diff --git a/modules/minio/client.ts b/modules/minio/client.ts new file mode 100644 index 0000000..5a0f29d --- /dev/null +++ b/modules/minio/client.ts @@ -0,0 +1,351 @@ +// 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, randomBytes } 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 and return its credential. 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 provisioner image + * bundles. + */ + async createAccessKey(bucket: string, accessKey: string): Promise { + await this.ensureAlias(); + const secretKey = randomBytes(20).toString("hex"); + 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; +} diff --git a/modules/minio/module.json b/modules/minio/module.json index 12c285a..98b5cfa 100644 --- a/modules/minio/module.json +++ b/modules/minio/module.json @@ -10,6 +10,10 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.minio.bucket.created", + "module.minio.bucket.removed" + ], "listens": [ { "port": 9000, @@ -31,7 +35,8 @@ "s3-bucket": "/var/lib/minio/grants" }, "own-secrets": { - "root": "/var/lib/minio/root.secret" + "root": "/var/lib/minio/root.secret", + "broker": "/var/lib/minio/broker" }, "resources": [ { @@ -94,9 +99,9 @@ "network": "minio", "env": { "GRANTS": "/var/lib/minio/grants", - "MESH_OBJECTSTORE_URL": "http://minio:9000", - "MESH_OBJECTSTORE_ROOT_USER": "meshroot", - "MESH_OBJECTSTORE_ROOT_PASSWORD_FILE": "/run/secrets/root" + "MESH_MINIO_ENDPOINT": "http://minio:9000", + "MESH_MINIO_ROOT_USER": "meshroot", + "MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root" }, "volumes": [ "/var/lib/minio/grants:/var/lib/minio/grants:ro", @@ -104,4 +109,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/modules/minio/package.json b/modules/minio/package.json new file mode 100644 index 0000000..c1839d6 --- /dev/null +++ b/modules/minio/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-minio", + "version": "0.1.0", + "description": "minio — S3-compatible object store. Its admin client, tools, provisioner and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/minio/provisioner/index.ts b/modules/minio/provisioner/index.ts new file mode 100644 index 0000000..fc999a1 --- /dev/null +++ b/modules/minio/provisioner/index.ts @@ -0,0 +1,66 @@ +// minio's provisioner — the adapter that makes minio a provider of the mesh `s3-bucket` interface +// (the name in module.json's `provides`). The reconcile loop, sealing and grant-file handling are the +// sdk harness's; this writes only the per-service half: how minio creates and removes a consumer's +// bucket and its scoped access key (novox/hq ADR 0044/0045). +// +// The `s3-bucket` interface: a consumer receives `{ endpoint, bucket, accessKey, secretKey, region }` +// — an S3 endpoint and a credential confined to its own bucket. It depends on `s3-bucket`, not on +// minio, so any S3-compatible provider could serve it. +// +// The bucket and access-key id are derived deterministically from the consumer's identity, because +// the harness hands `remove` only that identity (no stored values) — so teardown recomputes exactly +// what creation minted, with nothing to persist. The emits fire here, at the real provisioning +// points (novox/hq ADR 0046/0047); the module's events entrypoint (../index.ts) consumes them. + +import { runProvisioner, type Grant, type Credential } from "@novox/mesh-sdk/provisioner"; +import { emit } from "@novox/mesh-sdk/events"; +import { MinioClient, accessKeyFor, bucketFor } from "../client.js"; + +const minio = MinioClient.fromEnv(); + +runProvisioner("s3-bucket", { + async create(grant: Grant): Promise { + const bucket = bucketFor(grant.consumer); + const accessKeyId = accessKeyFor(grant.consumer); + + if (!(await minio.bucketExists(bucket))) await minio.createBucket(bucket); + // Re-mint the scoped key idempotently: drop any prior one under this id, then add fresh. + try { await minio.removeAccessKey(accessKeyId); } catch { /* none yet — first provision */ } + const key = await minio.createAccessKey(bucket, accessKeyId); + + await emit("module.minio.bucket.created", { + bucket, + consumer: grant.consumer, + node: grant.node, + accessKey: key.accessKey, // the secret is never put on the bus — only the credential file carries it + endpoint: minio.baseUrl, + }); + + return { + fields: { + endpoint: minio.baseUrl, + bucket, + accessKey: key.accessKey, + secretKey: key.secretKey, + region: minio.region, + }, + }; + }, + + async remove(grant: Grant): Promise { + const bucket = bucketFor(grant.consumer); + const accessKeyId = accessKeyFor(grant.consumer); + + // Revoking the key is what cuts the consumer's access. The bucket is emptied-then-dropped only if + // empty; a bucket that still holds objects is left for an operator rather than erroring on every + // reconcile tick — access is already gone, and silently deleting a consumer's data would be worse. + try { await minio.removeAccessKey(accessKeyId); } catch { /* already gone */ } + try { + await minio.removeBucket(bucket); + } catch (err) { + console.error(`[minio] bucket ${bucket} not removed (likely non-empty), access revoked: ${err}`); + } + + await emit("module.minio.bucket.removed", { bucket, consumer: grant.consumer, node: grant.node }); + }, +}); diff --git a/modules/minio/tools/index.ts b/modules/minio/tools/index.ts new file mode 100644 index 0000000..dc218b5 --- /dev/null +++ b/modules/minio/tools/index.ts @@ -0,0 +1,80 @@ +// minio's tools — ported here from the shared sdk (novox/hq ADR 0044), importing minio's own client. +// They return structured data; the mesh serves them through the sdk's tool harness. These are the +// read/inspect operations useful to an operator; creating storage for a consumer is the provisioner's +// job, not a tool's. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { MinioClient } from "../client.js"; + +export function getMinioTools(minio: MinioClient): ToolDefinition[] { + return [ + { + name: "minio_list_buckets", + description: "List every S3 bucket in the object store, with creation dates.", + input: {}, + run: async () => { + const buckets = await minio.listBuckets(); + return { + count: buckets.length, + buckets: buckets.map((b) => ({ name: b.name, createdAt: b.creationDate?.toISOString() })), + }; + }, + }, + { + name: "minio_list_objects", + description: "List objects in a bucket, optionally under a prefix.", + input: { + bucket: { type: "string", description: "the bucket name" }, + prefix: { type: "string", description: "only keys under this prefix (e.g. 'photos/')" }, + recursive: { type: "boolean", description: "descend into nested prefixes (default false)" }, + limit: { type: "number", description: "max keys to return (default 100)" }, + }, + run: async (args) => { + const bucket = String(args.bucket); + const objects = await minio.listObjects( + bucket, + args.prefix ? String(args.prefix) : "", + Boolean(args.recursive), + args.limit ? Number(args.limit) : 100, + ); + return { + bucket, + count: objects.length, + objects: objects.map((o) => ({ key: o.name, size: o.size, lastModified: o.lastModified.toISOString(), etag: o.etag })), + }; + }, + }, + { + name: "minio_bucket_info", + description: "Summary of one bucket: whether it exists, its region, and a sampled object count and size.", + input: { bucket: { type: "string", description: "the bucket name" } }, + run: async (args) => minio.bucketInfo(String(args.bucket)), + }, + { + name: "minio_presigned_url", + description: "A time-limited URL to download (GET) or upload (PUT) one object without credentials.", + input: { + bucket: { type: "string", description: "the bucket name" }, + object: { type: "string", description: "the object key" }, + method: { type: "string", description: "'GET' to download (default) or 'PUT' to upload" }, + expires: { type: "number", description: "seconds until the URL expires (default 86400 = 24h)" }, + }, + run: async (args) => { + const method = String(args.method ?? "GET").toUpperCase() === "PUT" ? "PUT" : "GET"; + const expires = args.expires ? Number(args.expires) : 86400; + const url = minio.presignedUrl(method, String(args.bucket), String(args.object), expires); + return { method, bucket: String(args.bucket), object: String(args.object), expiresInSeconds: expires, url }; + }, + }, + ]; +} + +// The tools exist only when the object store is configured and reachable; without it minio +// contributes none rather than failing the whole tool runtime. +registerModuleTools("minio", (env) => { + try { + return getMinioTools(MinioClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/minio/tsconfig.json b/modules/minio/tsconfig.json new file mode 100644 index 0000000..c2a8df0 --- /dev/null +++ b/modules/minio/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "client.ts", + "tools/index.ts", + "provisioner/index.ts" + ] +} \ No newline at end of file