Merge pull request 'audit-logger: the assigned-module manifest (ADR 0048)' (#2) from events/audit-logger-assigned into main
This commit was merged in pull request #2.
This commit is contained in:
@@ -11,15 +11,18 @@ export function auditLogPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return env.AUDIT_LOG ?? "/var/lib/audit-logger/audit.log";
|
||||
}
|
||||
|
||||
/** Append an event to the trail as one JSON line, keeping the metadata an audit needs first. */
|
||||
/** Append an event to the trail as one JSON line, keeping the metadata an audit needs first. The id
|
||||
* is the event's own x-event-id (ADR 0047) — the handle a reader dedups the at-least-once trail on. */
|
||||
export async function record(event: Event, path: string): Promise<void> {
|
||||
const line =
|
||||
JSON.stringify({
|
||||
id: event.type + "@" + event.at, // a stable-ish key until x-event-id headers land (ADR 0047)
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
source: event.source,
|
||||
node: event.node,
|
||||
at: event.at,
|
||||
...(event.causationId ? { causationId: event.causationId } : {}),
|
||||
...(event.schema ? { schema: event.schema } : {}),
|
||||
body: event.body,
|
||||
}) + "\n";
|
||||
await mkdir(dirname(path), { recursive: true }).catch(() => {});
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
{
|
||||
"module": "audit-logger",
|
||||
"version": "1",
|
||||
"consumes": [
|
||||
"#"
|
||||
],
|
||||
"consumes": ["#"],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/audit-logger/broker"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "log",
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/audit-logger",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "trail",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/audit-logger/trail",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "run",
|
||||
"type": "container",
|
||||
"name": "mesh-audit-logger",
|
||||
"image": "mesh-runtime-audit@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/audit-logger/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/audit-logger/trail:/trail"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"AUDIT_LOG": "/trail/audit.log"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// The Bazarr API client — bazarr's own code, living in the module (novox/hq ADR 0044). Bazarr
|
||||
// manages subtitles for a Sonarr/Radarr library: it tracks which episodes and movies are still
|
||||
// missing subtitles, searches providers for them, and records what it downloaded. This client
|
||||
// talks its /api surface (keyed by an X-API-KEY header); bazarr's tools and events import it.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface WantedSubtitle {
|
||||
kind: "episode" | "movie";
|
||||
title: string; // series + episode, or movie title
|
||||
path?: string;
|
||||
seriesId?: number; // sonarr series id (episodes)
|
||||
episodeId?: number; // sonarr episode id (episodes)
|
||||
radarrId?: number; // radarr movie id (movies)
|
||||
missing: string[]; // language names still missing
|
||||
}
|
||||
|
||||
export interface ProviderSubtitle {
|
||||
provider: string;
|
||||
language: string;
|
||||
hearingImpaired: boolean;
|
||||
forced: boolean;
|
||||
score?: number;
|
||||
release?: string;
|
||||
subtitle: string; // the opaque token Bazarr uses to download this exact result
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
kind: "episode" | "movie";
|
||||
id: string; // stable dedup key across polls
|
||||
title: string;
|
||||
language?: string;
|
||||
provider?: string;
|
||||
path?: string;
|
||||
timestamp?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
|
||||
function meshConfig(file?: string): Record<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
export class BazarrClient {
|
||||
readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly apiKey: string,
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/** Build from the module's resolved environment. Bazarr's API is keyed; without URL and key
|
||||
* there is nothing to talk to, so this throws rather than run half-configured. */
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): BazarrClient {
|
||||
const cfg = meshConfig(env.MESH_BAZARR_CONFIG_FILE);
|
||||
const url = cfg.url ?? env.MESH_BAZARR_URL;
|
||||
const apiKey = cfg.apiKey ?? env.MESH_BAZARR_API_KEY;
|
||||
if (!url) throw new Error("no Bazarr URL — set MESH_BAZARR_URL");
|
||||
if (!apiKey) throw new Error("no Bazarr API key — set MESH_BAZARR_API_KEY");
|
||||
return new BazarrClient(url, apiKey);
|
||||
}
|
||||
|
||||
private async request(method: string, path: string, params: Record<string, string> = {}): Promise<any> {
|
||||
const url = new URL(`${this.baseUrl}/api${path}`);
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
||||
const res = await fetch(url.toString(), { method, headers: { "X-API-KEY": this.apiKey, Accept: "application/json" } });
|
||||
if (!res.ok) throw new Error(`Bazarr API ${method} ${path}: ${res.status} ${await res.text()}`);
|
||||
// Downloads/patches return an empty body; only GETs carry JSON.
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : {};
|
||||
}
|
||||
|
||||
private get(path: string, params?: Record<string, string>): Promise<any> {
|
||||
return this.request("GET", path, params);
|
||||
}
|
||||
|
||||
private languageNames(missing: any[]): string[] {
|
||||
return (missing ?? []).map((m: any) => m?.name ?? m?.code2 ?? m?.code3).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Episodes and movies still missing subtitles — Bazarr's core "what's left to do" list. */
|
||||
async getWanted(limit = 50): Promise<WantedSubtitle[]> {
|
||||
const [eps, movies] = await Promise.all([
|
||||
this.get("/episodes/wanted", { start: "0", length: String(limit) }),
|
||||
this.get("/movies/wanted", { start: "0", length: String(limit) }),
|
||||
]);
|
||||
const episodes: WantedSubtitle[] = (eps?.data ?? []).map((e: any) => ({
|
||||
kind: "episode" as const,
|
||||
title: `${e.seriesTitle ?? e.series ?? "Unknown"} — ${e.episodeTitle ?? e.episode_title ?? ""}`.trim(),
|
||||
path: e.path,
|
||||
seriesId: e.sonarrSeriesId,
|
||||
episodeId: e.sonarrEpisodeId,
|
||||
missing: this.languageNames(e.missing_subtitles),
|
||||
}));
|
||||
const films: WantedSubtitle[] = (movies?.data ?? []).map((m: any) => ({
|
||||
kind: "movie" as const,
|
||||
title: m.title ?? "Unknown",
|
||||
path: m.path,
|
||||
radarrId: m.radarrId,
|
||||
missing: this.languageNames(m.missing_subtitles),
|
||||
}));
|
||||
return [...episodes, ...films];
|
||||
}
|
||||
|
||||
/** Ask providers what subtitles are available for one wanted episode — a manual search. */
|
||||
async searchEpisode(episodeId: number): Promise<ProviderSubtitle[]> {
|
||||
const raw = await this.get("/providers/episodes", { episodeid: String(episodeId) });
|
||||
return this.mapProviderResults(raw);
|
||||
}
|
||||
|
||||
/** Ask providers what subtitles are available for one movie — a manual search. */
|
||||
async searchMovie(radarrId: number): Promise<ProviderSubtitle[]> {
|
||||
const raw = await this.get("/providers/movies", { radarrid: String(radarrId) });
|
||||
return this.mapProviderResults(raw);
|
||||
}
|
||||
|
||||
private mapProviderResults(raw: any): ProviderSubtitle[] {
|
||||
const list = Array.isArray(raw) ? raw : (raw?.data ?? []);
|
||||
return list.map((r: any) => ({
|
||||
provider: r.provider,
|
||||
language: r.language?.name ?? r.language ?? "unknown",
|
||||
hearingImpaired: Boolean(r.hearing_impaired ?? r.hi),
|
||||
forced: Boolean(r.forced),
|
||||
score: r.score,
|
||||
release: r.release_info?.[0] ?? r.release_info,
|
||||
subtitle: r.subtitle,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Recent subtitle-download history, episodes and movies together, newest first. Each entry
|
||||
* carries a stable id so the events poller can tell a fresh download from one already seen. */
|
||||
async getHistory(limit = 40): Promise<HistoryEntry[]> {
|
||||
const [eps, movies] = await Promise.all([
|
||||
this.get("/episodes/history", { start: "0", length: String(limit) }),
|
||||
this.get("/movies/history", { start: "0", length: String(limit) }),
|
||||
]);
|
||||
const key = (kind: string, r: any): string =>
|
||||
`${kind}:${r.timestamp ?? r.parsed_timestamp ?? ""}:${r.subtitles_path ?? r.path ?? ""}:${r.language?.code3 ?? r.language ?? ""}`;
|
||||
const episodes: HistoryEntry[] = (eps?.data ?? []).map((r: any) => ({
|
||||
kind: "episode" as const,
|
||||
id: key("episode", r),
|
||||
title: `${r.seriesTitle ?? "Unknown"} — ${r.episodeTitle ?? ""}`.trim(),
|
||||
language: r.language?.name ?? r.language,
|
||||
provider: r.provider,
|
||||
path: r.subtitles_path,
|
||||
timestamp: r.timestamp,
|
||||
description: r.description,
|
||||
}));
|
||||
const films: HistoryEntry[] = (movies?.data ?? []).map((r: any) => ({
|
||||
kind: "movie" as const,
|
||||
id: key("movie", r),
|
||||
title: r.title ?? "Unknown",
|
||||
language: r.language?.name ?? r.language,
|
||||
provider: r.provider,
|
||||
path: r.subtitles_path,
|
||||
timestamp: r.timestamp,
|
||||
description: r.description,
|
||||
}));
|
||||
return [...episodes, ...films];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// bazarr's events. The tool runtime imports this once the broker is bound. Bazarr's one genuinely
|
||||
// observable thing is a subtitle arriving: it works away in the background, searching providers for
|
||||
// the missing-subtitle list, and when it succeeds a subtitle appears in its history. That is worth
|
||||
// announcing to the mesh.
|
||||
//
|
||||
// Emits (novox/hq ADR 0046/0047):
|
||||
// module.bazarr.subtitle.downloaded — a subtitle was fetched for an episode or movie
|
||||
//
|
||||
// Bazarr has nothing on the mesh it usefully reacts to (a download completing is Sonarr/Radarr's
|
||||
// business, and they trigger Bazarr directly), so it consumes nothing — a pure emitter.
|
||||
//
|
||||
// The event is observation-based: poll history and diff. Primed silently on the first look, or a
|
||||
// restart would re-announce the whole recent history as freshly downloaded.
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { BazarrClient } from "./client.js";
|
||||
|
||||
const bazarr = BazarrClient.fromEnv();
|
||||
|
||||
const seen = new Set<string>();
|
||||
let primed = false;
|
||||
async function pollHistory(): Promise<void> {
|
||||
const entries = await bazarr.getHistory(40);
|
||||
for (const entry of entries) {
|
||||
if (seen.has(entry.id)) continue;
|
||||
if (primed) {
|
||||
await emit("module.bazarr.subtitle.downloaded", {
|
||||
kind: entry.kind,
|
||||
title: entry.title,
|
||||
language: entry.language,
|
||||
provider: entry.provider,
|
||||
path: entry.path,
|
||||
});
|
||||
}
|
||||
seen.add(entry.id);
|
||||
}
|
||||
primed = true;
|
||||
}
|
||||
|
||||
const tick = (fn: () => Promise<void>, everyMs: number): void => {
|
||||
const run = (): void => void fn().catch((err) => console.error(`[bazarr] ${err}`));
|
||||
setInterval(run, everyMs);
|
||||
run();
|
||||
};
|
||||
tick(pollHistory, 60_000);
|
||||
|
||||
console.log("[bazarr] watching subtitle-download history");
|
||||
@@ -4,6 +4,12 @@
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.bazarr.subtitle.downloaded"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/bazarr/broker"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"port": 6767,
|
||||
@@ -13,6 +19,12 @@
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/bazarr",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "directory",
|
||||
@@ -68,6 +80,35 @@
|
||||
"/services/media/anime:/anime",
|
||||
"/services/media/downloads:/downloads"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/bazarr/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-bazarr",
|
||||
"image": "mesh-runtime-bazarr@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/bazarr/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/bazarr/config.json:/run/config/config.json:ro",
|
||||
"/services/bazarr/config:/var/lib/bazarr/config:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_BAZARR_URL": "http://127.0.0.1:6767",
|
||||
"MESH_BAZARR_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-bazarr",
|
||||
"version": "0.1.0",
|
||||
"description": "bazarr — subtitle management. Its API client, tools 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// bazarr's tools — its own code (novox/hq ADR 0044), importing bazarr's client. They return
|
||||
// structured data; the mesh serves them through the sdk's tool harness.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { BazarrClient } from "../client.js";
|
||||
|
||||
export function getBazarrTools(bazarr: BazarrClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "bazarr_wanted",
|
||||
description: "Episodes and movies still missing subtitles, with the languages each still needs.",
|
||||
input: { limit: { type: "number", description: "max items per kind (default 50)" } },
|
||||
run: async (args) => {
|
||||
const wanted = await bazarr.getWanted(args.limit ? Number(args.limit) : 50);
|
||||
return { count: wanted.length, wanted };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bazarr_search_subtitles",
|
||||
description: "Manually search subtitle providers for one wanted item — pass an episodeId or a radarrId.",
|
||||
input: {
|
||||
episodeId: { type: "number", description: "a Sonarr episode id (from bazarr_wanted)" },
|
||||
radarrId: { type: "number", description: "a Radarr movie id (from bazarr_wanted)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
if (args.episodeId !== undefined) {
|
||||
const results = await bazarr.searchEpisode(Number(args.episodeId));
|
||||
return { kind: "episode", episodeId: Number(args.episodeId), count: results.length, results };
|
||||
}
|
||||
if (args.radarrId !== undefined) {
|
||||
const results = await bazarr.searchMovie(Number(args.radarrId));
|
||||
return { kind: "movie", radarrId: Number(args.radarrId), count: results.length, results };
|
||||
}
|
||||
throw new Error("pass either episodeId or radarrId");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bazarr_history",
|
||||
description: "Recent subtitle-download history — what was downloaded, for which title, from which provider.",
|
||||
input: { limit: { type: "number", description: "max entries per kind (default 40)" } },
|
||||
run: async (args) => {
|
||||
const history = await bazarr.getHistory(args.limit ? Number(args.limit) : 40);
|
||||
return { count: history.length, history };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Exposed only when Bazarr is configured; otherwise bazarr contributes no tools rather than
|
||||
// failing the whole runtime.
|
||||
registerModuleTools("bazarr", (env) => {
|
||||
try {
|
||||
return getBazarrTools(BazarrClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// cloudflare-dns's own code (novox/hq ADR 0044). It provides the mesh `public-dns` interface
|
||||
// (ADR 0049): a public name that resolves to the mesh's public ingress. Cloudflare is one registrar
|
||||
// behind the neutral interface — a consumer names `public-dns`, never Cloudflare — so this file is
|
||||
// the only place Cloudflare's API appears, and swapping registrars swaps only this module.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface PublicRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export class CloudflareClient {
|
||||
constructor(
|
||||
private readonly token: string,
|
||||
private readonly zoneId: string,
|
||||
/** The zone this registers under, e.g. "example.com". */
|
||||
readonly domain: string,
|
||||
/** What every public name points at — the mesh's public ingress (the reverse proxy). */
|
||||
readonly ingress: string,
|
||||
) {}
|
||||
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): CloudflareClient {
|
||||
// Which zone, domain and ingress are a mesh's own facts, not this module's — so they are
|
||||
// settings, merged into a config file the mesh manages (novox/hq ADR 0051), read here. The
|
||||
// token is the one secret and stays an own-secret. Env is honoured as a fallback for a
|
||||
// hand-run instance, but the deployed path is the config file settings fill.
|
||||
const config = readConfig(env.MESH_CLOUDFLARE_CONFIG_FILE);
|
||||
const token = env.MESH_CLOUDFLARE_TOKEN ?? readSecret(env.MESH_CLOUDFLARE_TOKEN_FILE);
|
||||
const zoneId = config.zone ?? env.MESH_CLOUDFLARE_ZONE_ID;
|
||||
const domain = config.domain ?? env.MESH_PUBLIC_DOMAIN;
|
||||
const ingress = config.ingress ?? env.MESH_PUBLIC_INGRESS;
|
||||
if (!token || !zoneId || !domain || !ingress) {
|
||||
throw new Error(
|
||||
"cloudflare-dns is not configured — set its zone, domain and ingress in settings (and the " +
|
||||
"token as its own-secret); until then it registers nothing",
|
||||
);
|
||||
}
|
||||
return new CloudflareClient(token, zoneId, domain, ingress);
|
||||
}
|
||||
|
||||
/**
|
||||
* The public name a consumer gets: derived from its identity under the mesh's domain. Derived, not
|
||||
* contributed, for the same reason minio derives a bucket name — the harness hands `remove` only
|
||||
* the identity, so teardown must recompute exactly what creation made.
|
||||
*/
|
||||
nameFor(consumer: string): string {
|
||||
return `${consumer.replace(/[^A-Za-z0-9-]/g, "-").toLowerCase()}.${this.domain}`;
|
||||
}
|
||||
|
||||
/** An IP points at itself (A/AAAA); a hostname points through a CNAME. */
|
||||
private recordType(): "A" | "AAAA" | "CNAME" {
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(this.ingress)) return "A";
|
||||
if (this.ingress.includes(":")) return "AAAA";
|
||||
return "CNAME";
|
||||
}
|
||||
|
||||
private async api<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
|
||||
method,
|
||||
headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const json = (await res.json()) as { success?: boolean; result?: unknown; errors?: unknown };
|
||||
if (!res.ok || json.success === false) {
|
||||
throw new Error(`cloudflare ${method} ${path}: ${res.status} ${JSON.stringify(json.errors ?? json)}`);
|
||||
}
|
||||
return json.result as T;
|
||||
}
|
||||
|
||||
async findRecord(name: string): Promise<PublicRecord | undefined> {
|
||||
const records = await this.api<PublicRecord[]>(
|
||||
"GET",
|
||||
`/zones/${this.zoneId}/dns_records?name=${encodeURIComponent(name)}`,
|
||||
);
|
||||
return records[0];
|
||||
}
|
||||
|
||||
/** Point a public name at the mesh's ingress, idempotently — create it, or update one already there. */
|
||||
async upsert(name: string): Promise<PublicRecord> {
|
||||
const body = { type: this.recordType(), name, content: this.ingress, ttl: 300, proxied: false };
|
||||
const existing = await this.findRecord(name);
|
||||
if (existing) {
|
||||
return this.api<PublicRecord>("PUT", `/zones/${this.zoneId}/dns_records/${existing.id}`, body);
|
||||
}
|
||||
return this.api<PublicRecord>("POST", `/zones/${this.zoneId}/dns_records`, body);
|
||||
}
|
||||
|
||||
/** Remove a public name, idempotently — a record already gone is not an error on reconcile. */
|
||||
async remove(name: string): Promise<void> {
|
||||
const existing = await this.findRecord(name);
|
||||
if (existing) await this.api("DELETE", `/zones/${this.zoneId}/dns_records/${existing.id}`);
|
||||
}
|
||||
|
||||
/** Every record in the zone, for the diagnostic tool. */
|
||||
async records(): Promise<PublicRecord[]> {
|
||||
return this.api<PublicRecord[]>("GET", `/zones/${this.zoneId}/dns_records`);
|
||||
}
|
||||
}
|
||||
|
||||
function readSecret(path: string | undefined): string | undefined {
|
||||
if (!path) return undefined;
|
||||
try {
|
||||
return readFileSync(path, "utf8").trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
interface Config {
|
||||
zone?: string;
|
||||
domain?: string;
|
||||
ingress?: string;
|
||||
}
|
||||
|
||||
/** The settings-managed config file (a JSON document the mesh merges settings into). Absent or
|
||||
* unparseable yields an empty config, which fromEnv then reports as unconfigured. */
|
||||
function readConfig(path: string | undefined): Config {
|
||||
if (!path) return {};
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as Config;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"module": "cloudflare-dns",
|
||||
"version": "1",
|
||||
"provides": [
|
||||
{
|
||||
"name": "public-dns",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"serves": {
|
||||
"public-dns": {}
|
||||
},
|
||||
"grants": {
|
||||
"public-dns": "/var/lib/cloudflare-dns/grants"
|
||||
},
|
||||
"receives": {
|
||||
"public-dns": "/var/lib/cloudflare-dns/grants/mesh.json"
|
||||
},
|
||||
"own-secrets": {
|
||||
"token": "/var/lib/cloudflare-dns/token",
|
||||
"broker": "/var/lib/mesh/cloudflare-dns/broker"
|
||||
},
|
||||
"emits": [
|
||||
"module.cloudflare-dns.record.created",
|
||||
"module.cloudflare-dns.record.removed"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/cloudflare-dns",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/cloudflare-dns",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "grants",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/cloudflare-dns/grants",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/cloudflare-dns/config.json",
|
||||
"merge": "json",
|
||||
"content": "{}",
|
||||
"mode": "0600"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-cloudflare-dns",
|
||||
"image": "mesh-runtime-cloudflare-dns@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/cloudflare-dns/config.json:/run/config/config.json:ro",
|
||||
"/var/lib/cloudflare-dns/grants:/grants",
|
||||
"/var/lib/cloudflare-dns/token:/run/secrets/token:ro",
|
||||
"/var/lib/mesh/cloudflare-dns/broker:/run/secrets/broker:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_CLOUDFLARE_TOKEN_FILE": "/run/secrets/token",
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_CLOUDFLARE_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_RECEIVES": "/var/lib/cloudflare-dns/grants/mesh.json"
|
||||
}
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-cloudflare-dns",
|
||||
"version": "0.1.0",
|
||||
"description": "cloudflare-dns — a public-dns provider (ADR 0049): registers public names at Cloudflare.
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// cloudflare-dns's provisioner — the adapter making it a provider of the mesh `public-dns` interface
|
||||
// (novox/hq ADR 0049). The reconcile loop and the contributions file are the sdk harness's; this
|
||||
// writes only the per-registrar half: register a consumer's public name at Cloudflare, pointing it
|
||||
// at the mesh's ingress, and remove it when the consumer is withdrawn (ADR 0053).
|
||||
//
|
||||
// The `public-dns` interface hands a consumer { fqdn, target, ttl } — a name that resolves publicly
|
||||
// and what it resolves to. Like umami's analytics it is a *data* provision, not a credential one:
|
||||
// nothing the mesh mints is set here (a DNS record is public, and the only secret is this module's
|
||||
// own Cloudflare token, which never leaves). So the password the harness carries is unused; the name
|
||||
// is derived from the login the mesh gave the consumer, which the consumer can derive too. Delivering
|
||||
// the record back to the consumer is the data-provision return path ADR 0053 leaves out of scope.
|
||||
|
||||
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { CloudflareClient } from "../client.js";
|
||||
|
||||
const cloudflare = CloudflareClient.fromEnv();
|
||||
|
||||
runProvisioner("public-dns", {
|
||||
async create(p: Provision): Promise<void> {
|
||||
const fqdn = cloudflare.nameFor(p.as);
|
||||
await cloudflare.upsert(fqdn);
|
||||
await announce("module.cloudflare-dns.record.created", {
|
||||
name: fqdn,
|
||||
target: cloudflare.ingress,
|
||||
consumer: p.consumer ?? "",
|
||||
});
|
||||
},
|
||||
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
const fqdn = cloudflare.nameFor(p.as);
|
||||
await cloudflare.remove(fqdn);
|
||||
await announce("module.cloudflare-dns.record.removed", { name: fqdn, consumer: p.as });
|
||||
},
|
||||
});
|
||||
|
||||
/** Emit best-effort: a broker hiccup must never fail or reverse a DNS change that already happened. */
|
||||
async function announce(type: string, body: unknown): Promise<void> {
|
||||
try {
|
||||
await emit(type, body);
|
||||
} catch (err) {
|
||||
console.error(`[cloudflare-dns] could not emit ${type}: ${err}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// cloudflare-dns's tool — the diagnostic: what public names the mesh currently publishes here.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { CloudflareClient } from "../client.js";
|
||||
|
||||
export function getCloudflareDnsTools(cloudflare: CloudflareClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "cloudflare_dns_records",
|
||||
description: "The public DNS records in the mesh's zone — the names it currently publishes.",
|
||||
input: {},
|
||||
run: async () => ({ domain: cloudflare.domain, ingress: cloudflare.ingress, records: await cloudflare.records() }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
registerModuleTools("cloudflare-dns", (env) => {
|
||||
try {
|
||||
return getCloudflareDnsTools(CloudflareClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// dnsmasq's own code, living in the module (novox/hq ADR 0044). dnsmasq here is a pure resolver: it
|
||||
// answers the mesh's generated wildcard names (<service>.<node>.<suffix>) and forwards nothing. So
|
||||
// its code reads what it was told to answer, and can resolve through itself to prove that it does.
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { Resolver } from "node:dns/promises";
|
||||
|
||||
export interface AnsweredName {
|
||||
/** A machine's internal name, e.g. "anchor.internal" — it and everything under it resolve here. */
|
||||
name: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export class DnsmasqClient {
|
||||
constructor(
|
||||
private readonly resolverPath: string,
|
||||
private readonly address: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Build from the environment. Both values are node-local facts with mesh-chosen defaults — the
|
||||
* wildcard file the module is sent, and the loopback address its config listens on — so there is
|
||||
* nothing to be unconfigured about; it never throws.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): DnsmasqClient {
|
||||
return new DnsmasqClient(
|
||||
env.MESH_DNSMASQ_RESOLVER_PATH ?? "/etc/mesh-resolver/nodes.conf",
|
||||
env.MESH_DNSMASQ_ADDRESS ?? "127.0.0.55",
|
||||
);
|
||||
}
|
||||
|
||||
/** The names this resolver answers, read from the mesh-generated wildcard file. */
|
||||
async answeredNames(): Promise<AnsweredName[]> {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(this.resolverPath, "utf8");
|
||||
} catch {
|
||||
return []; // not yet on the network, or the file has not been written — no names, not an error
|
||||
}
|
||||
const names: AnsweredName[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
// dnsmasq wildcard syntax the mesh writes: address=/<node>.<suffix>/<address>
|
||||
const match = line.match(/^address=\/([^/]+)\/(.+)$/);
|
||||
if (match) names.push({ name: match[1], address: match[2] });
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Resolve a name through this node's own resolver — the check that wildcard-resolution answers. */
|
||||
async resolve(name: string): Promise<string[]> {
|
||||
const resolver = new Resolver();
|
||||
resolver.setServers([this.address]);
|
||||
try {
|
||||
return await resolver.resolve4(name);
|
||||
} catch {
|
||||
return await resolver.resolve6(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// dnsmasq's events. The resolver's answered set changes whenever the mesh rewrites the wildcard file
|
||||
// and restarts it — a machine joined the private network or left it. This watches that file and
|
||||
// announces, from the resolver's own vantage, that a name became (or stopped being) resolvable on
|
||||
// this node: DNS having actually propagated here, distinct from the mesh's own node.* events.
|
||||
//
|
||||
// Emits (novox/hq ADR 0046/0047):
|
||||
// module.dnsmasq.name.added — this node's resolver now answers a machine's name
|
||||
// module.dnsmasq.name.removed — it no longer does
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { DnsmasqClient } from "./client.js";
|
||||
|
||||
const dnsmasq = DnsmasqClient.fromEnv();
|
||||
|
||||
// name -> address, primed silently so a restart does not re-announce every name it already answered.
|
||||
const known = new Map<string, string>();
|
||||
let primed = false;
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
const now = new Map((await dnsmasq.answeredNames()).map((a) => [a.name, a.address]));
|
||||
if (primed) {
|
||||
for (const [name, address] of now) {
|
||||
if (!known.has(name)) await emit("module.dnsmasq.name.added", { name, address });
|
||||
}
|
||||
for (const [name] of known) {
|
||||
if (!now.has(name)) await emit("module.dnsmasq.name.removed", { name });
|
||||
}
|
||||
}
|
||||
known.clear();
|
||||
for (const [name, address] of now) known.set(name, address);
|
||||
primed = true;
|
||||
}
|
||||
|
||||
const run = (): void => void poll().catch((err) => console.error(`[dnsmasq] ${err}`));
|
||||
setInterval(run, 30_000);
|
||||
run();
|
||||
|
||||
console.log("[dnsmasq] watching the resolver's answered names");
|
||||
@@ -7,6 +7,13 @@
|
||||
"provides": [
|
||||
"wildcard-resolution"
|
||||
],
|
||||
"emits": [
|
||||
"module.dnsmasq.name.added",
|
||||
"module.dnsmasq.name.removed"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/dnsmasq/broker"
|
||||
},
|
||||
"claims": [
|
||||
{
|
||||
"name": "the-dns-port",
|
||||
@@ -23,6 +30,12 @@
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/dnsmasq",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@novox/module-dnsmasq",
|
||||
"version": "0.1.0",
|
||||
"description": "dnsmasq — the mesh's resolver: answers wildcard node names. Its tools and events live here.",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": { "@novox/mesh-sdk": "^0.1.0" },
|
||||
"devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// dnsmasq's tools — a resolver's two useful questions: what does it answer, and does it answer a
|
||||
// given name. Moved into the module (novox/hq ADR 0044); the mesh serves them through the sdk.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { DnsmasqClient } from "../client.js";
|
||||
|
||||
export function getDnsmasqTools(dnsmasq: DnsmasqClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "dnsmasq_names",
|
||||
description: "The mesh names this node's resolver answers — each a machine and everything under it.",
|
||||
input: {},
|
||||
run: async () => ({ names: await dnsmasq.answeredNames() }),
|
||||
},
|
||||
{
|
||||
name: "dnsmasq_resolve",
|
||||
description: "Resolve a mesh name through this node's own resolver — the check that wildcard-resolution answers.",
|
||||
input: { name: { type: "string", description: "a name to resolve, e.g. plex.anchor.internal" } },
|
||||
run: async (args) => {
|
||||
const name = String(args.name);
|
||||
try {
|
||||
return { name, addresses: await dnsmasq.resolve(name) };
|
||||
} catch (err) {
|
||||
return { name, addresses: [], error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
registerModuleTools("dnsmasq", (env) => getDnsmasqTools(DnsmasqClient.fromEnv(env)));
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// The firewall's own code, in the module (novox/hq ADR 0044). The mesh computes this node's whole
|
||||
// rule set from every module's `listens` and writes it to /etc/nftables.conf (novox/hq ADR 0050);
|
||||
// the module loads it (the nftables service, reloaded whenever the rules change). This code exists
|
||||
// only to read back what is actually enforced — the enforcement itself is declarative.
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
export class FirewallClient {
|
||||
static fromEnv(_env: NodeJS.ProcessEnv = process.env): FirewallClient {
|
||||
return new FirewallClient();
|
||||
}
|
||||
|
||||
/** The mesh's live table — exactly what is dropping and accepting on this node right now. */
|
||||
async ruleset(): Promise<string> {
|
||||
const { stdout } = await run("nft", ["list", "table", "inet", "mesh"]);
|
||||
return stdout;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"module": "firewall",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"firewall"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "the-packet-filter",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"filtering": {
|
||||
"into": "/etc/nftables.conf"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "nftables"
|
||||
},
|
||||
{
|
||||
"id": "load",
|
||||
"type": "service",
|
||||
"unit": "nftables.service",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"restart-on": [
|
||||
"filtering"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-firewall",
|
||||
"version": "0.1.0",
|
||||
"description": "firewall — applies the mesh-computed packet filter (ADR 0050). Its diagnostic tool lives here.
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// firewall's tools — one, and the useful one: what is actually enforced. The rules are the mesh's,
|
||||
// computed from every module's listens; this reads the live table so a declared scope can be checked
|
||||
// against what the packet filter is really doing.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { FirewallClient } from "../client.js";
|
||||
|
||||
export function getFirewallTools(firewall: FirewallClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "firewall_rules",
|
||||
description: "The mesh's live nftables rules on this node — what is actually accepting and dropping.",
|
||||
input: {},
|
||||
run: async () => ({ ruleset: await firewall.ruleset() }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
registerModuleTools("firewall", () => getFirewallTools(FirewallClient.fromEnv()));
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"client.ts",
|
||||
"tools/index.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// The Gitea API client — gitea's own code, living in the module (novox/hq ADR 0044). Moved out of
|
||||
// the shared hal sdk, where a change to Gitea's API rebuilt everything; here it rebuilds only
|
||||
// gitea. Both this module's tools and its events entrypoint import it, and nothing outside gitea
|
||||
// does.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
/** A repository, trimmed to what the mesh cares about. */
|
||||
export interface GiteaRepo {
|
||||
full_name: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
private: boolean;
|
||||
description?: string;
|
||||
html_url: string;
|
||||
default_branch?: string;
|
||||
}
|
||||
|
||||
/** An issue, with its labels flattened to names. */
|
||||
export interface GiteaIssue {
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
user?: string;
|
||||
labels: string[];
|
||||
html_url: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
/** A pull request, trimmed to the fields a reviewer or an event body needs. */
|
||||
export interface GiteaPull {
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
merged: boolean;
|
||||
user?: string;
|
||||
head?: string;
|
||||
base?: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
export interface GiteaLabel {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
|
||||
function meshConfig(file?: string): Record<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
export class GiteaClient {
|
||||
readonly baseUrl: string;
|
||||
private cachedUsername: string | null = null;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly token: string,
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build from the module's resolved environment. URL and token come from MESH_GITEA_URL /
|
||||
* MESH_GITEA_TOKEN (the mesh's own names), falling back to the bare GITEA_* names and, for the
|
||||
* URL, to the forge's loopback port. A token is required — without one there is no authenticated
|
||||
* call to make, so this throws rather than hand back a client that fails on first use.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaClient {
|
||||
const cfg = meshConfig(env.MESH_GITEA_CONFIG_FILE);
|
||||
const url = cfg.url ?? env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`;
|
||||
const token = cfg.token ?? env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN;
|
||||
if (!token) throw new Error("no Gitea token — set MESH_GITEA_TOKEN");
|
||||
return new GiteaClient(url, token);
|
||||
}
|
||||
|
||||
private async request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `token ${this.token}`,
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`);
|
||||
if (res.status === 204) return null as T;
|
||||
const text = await res.text();
|
||||
return (text ? JSON.parse(text) : null) as T;
|
||||
}
|
||||
|
||||
/** Generic authenticated API call — the escape hatch for endpoints without a dedicated method.
|
||||
* Path is relative to /api/v1. */
|
||||
async api<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
return this.request<T>(path, options);
|
||||
}
|
||||
|
||||
// ---- Repositories ----
|
||||
|
||||
async listRepos(page = 1, limit = 20): Promise<GiteaRepo[]> {
|
||||
const repos = await this.request<any[]>(`/user/repos?page=${page}&limit=${limit}`);
|
||||
return (repos ?? []).map(GiteaClient.mapRepo);
|
||||
}
|
||||
|
||||
async createRepo(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
private?: boolean;
|
||||
auto_init?: boolean;
|
||||
}): Promise<GiteaRepo> {
|
||||
return GiteaClient.mapRepo(await this.request<any>("/user/repos", { method: "POST", body: JSON.stringify(data) }));
|
||||
}
|
||||
|
||||
async deleteRepo(owner: string, repo: string): Promise<void> {
|
||||
await this.request(`/repos/${owner}/${repo}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ---- Issues ----
|
||||
|
||||
async listIssues(owner: string, repo: string, params: Record<string, string> = {}): Promise<GiteaIssue[]> {
|
||||
const qs = new URLSearchParams({ type: "issues", ...params }).toString();
|
||||
const issues = await this.request<any[]>(`/repos/${owner}/${repo}/issues?${qs}`);
|
||||
return (issues ?? []).map(GiteaClient.mapIssue);
|
||||
}
|
||||
|
||||
async getIssue(owner: string, repo: string, index: number): Promise<GiteaIssue> {
|
||||
return GiteaClient.mapIssue(await this.request<any>(`/repos/${owner}/${repo}/issues/${index}`));
|
||||
}
|
||||
|
||||
async createIssue(
|
||||
owner: string,
|
||||
repo: string,
|
||||
data: { title: string; body?: string; labels?: number[] },
|
||||
): Promise<GiteaIssue> {
|
||||
return GiteaClient.mapIssue(
|
||||
await this.request<any>(`/repos/${owner}/${repo}/issues`, { method: "POST", body: JSON.stringify(data) }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Patch an issue's state — the one edit the close tool needs. */
|
||||
async setIssueState(owner: string, repo: string, index: number, state: "open" | "closed"): Promise<GiteaIssue> {
|
||||
return GiteaClient.mapIssue(
|
||||
await this.request<any>(`/repos/${owner}/${repo}/issues/${index}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ state }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async addComment(owner: string, repo: string, index: number, body: string): Promise<{ id: number; html_url: string }> {
|
||||
const c = await this.request<any>(`/repos/${owner}/${repo}/issues/${index}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
return { id: c.id, html_url: c.html_url };
|
||||
}
|
||||
|
||||
// ---- Labels ----
|
||||
|
||||
async listLabels(owner: string, repo: string): Promise<GiteaLabel[]> {
|
||||
const labels = await this.request<any[]>(`/repos/${owner}/${repo}/labels`);
|
||||
return (labels ?? []).map((l: any) => ({ id: l.id, name: l.name }));
|
||||
}
|
||||
|
||||
async createLabel(
|
||||
owner: string,
|
||||
repo: string,
|
||||
data: { name: string; color: string; description?: string },
|
||||
): Promise<GiteaLabel> {
|
||||
const l = await this.request<any>(`/repos/${owner}/${repo}/labels`, { method: "POST", body: JSON.stringify(data) });
|
||||
return { id: l.id, name: l.name };
|
||||
}
|
||||
|
||||
/** Resolve a label name to its id, creating it if it does not exist — so create-issue can take
|
||||
* human label names and not numeric ids. */
|
||||
async getOrCreateLabel(owner: string, repo: string, name: string, color = "#0075ca"): Promise<number> {
|
||||
const existing = (await this.listLabels(owner, repo)).find((l) => l.name === name);
|
||||
if (existing) return existing.id;
|
||||
return (await this.createLabel(owner, repo, { name, color })).id;
|
||||
}
|
||||
|
||||
// ---- Pull requests ----
|
||||
|
||||
async listPullRequests(owner: string, repo: string, params: Record<string, string> = {}): Promise<GiteaPull[]> {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
const prs = await this.request<any[]>(`/repos/${owner}/${repo}/pulls?${qs}`);
|
||||
return (prs ?? []).map(GiteaClient.mapPull);
|
||||
}
|
||||
|
||||
async getPullRequest(owner: string, repo: string, index: number): Promise<GiteaPull> {
|
||||
return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`));
|
||||
}
|
||||
|
||||
async createPullRequest(
|
||||
owner: string,
|
||||
repo: string,
|
||||
data: { title: string; body?: string; head: string; base: string },
|
||||
): Promise<GiteaPull> {
|
||||
return GiteaClient.mapPull(
|
||||
await this.request<any>(`/repos/${owner}/${repo}/pulls`, { method: "POST", body: JSON.stringify(data) }),
|
||||
);
|
||||
}
|
||||
|
||||
async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false): Promise<void> {
|
||||
await this.request(`/repos/${owner}/${repo}/pulls/${index}/merge`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Mappers: the wire shape is broad and unstable; the mesh sees only these fields. ----
|
||||
|
||||
private static mapRepo(r: any): GiteaRepo {
|
||||
return {
|
||||
full_name: r.full_name,
|
||||
name: r.name,
|
||||
owner: r.owner?.login ?? r.full_name?.split("/")[0] ?? "unknown",
|
||||
private: Boolean(r.private),
|
||||
description: r.description || undefined,
|
||||
html_url: r.html_url,
|
||||
default_branch: r.default_branch,
|
||||
};
|
||||
}
|
||||
|
||||
private static mapIssue(i: any): GiteaIssue {
|
||||
return {
|
||||
number: i.number,
|
||||
title: i.title,
|
||||
state: i.state,
|
||||
user: i.user?.login,
|
||||
labels: (i.labels ?? []).map((l: any) => l.name),
|
||||
html_url: i.html_url,
|
||||
body: i.body || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private static mapPull(p: any): GiteaPull {
|
||||
return {
|
||||
number: p.number,
|
||||
title: p.title,
|
||||
state: p.state,
|
||||
merged: Boolean(p.merged),
|
||||
user: p.user?.login,
|
||||
head: p.head?.ref,
|
||||
base: p.base?.ref,
|
||||
html_url: p.html_url,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// gitea's events. The tool runtime imports this once the broker is bound. It watches the forge and
|
||||
// emits what appeared.
|
||||
//
|
||||
// Emits (novox/hq ADR 0046/0047):
|
||||
// module.gitea.repo.created — a repository appeared, however it was made (push, web UI, or tool)
|
||||
//
|
||||
// issue.opened and pull.merged are emitted from the tools (tools/index.ts), at the instant the mesh
|
||||
// takes that action — the natural point, and one process only. repo.created belongs here instead:
|
||||
// a repository is usually born from a `git push` or the web UI, which no tool sees, so polling the
|
||||
// repo list is the only way to catch every path — and keeping it out of the create-repo tool means
|
||||
// the fact is never announced twice from two processes.
|
||||
//
|
||||
// The polling is deliberately unhurried: an event a minute late is still an event, whereas hammering
|
||||
// the forge for an immediacy nobody asked for is not.
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { GiteaClient } from "./client.js";
|
||||
|
||||
// Without a token there is nothing to watch; log and stay quiet rather than crash the runtime.
|
||||
let gitea: GiteaClient | null = null;
|
||||
try {
|
||||
gitea = GiteaClient.fromEnv();
|
||||
} catch (err) {
|
||||
console.log(`[gitea] not watching — ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// New repositories, by diffing the repo list. Primed silently on the first look, or a restart would
|
||||
// re-announce every existing repository as freshly created.
|
||||
const seen = new Set<string>();
|
||||
let primed = false;
|
||||
async function pollRepos(client: GiteaClient): Promise<void> {
|
||||
const repos = await client.listRepos(1, 50);
|
||||
for (const repo of repos) {
|
||||
if (!seen.has(repo.full_name)) {
|
||||
if (primed) {
|
||||
await emit("module.gitea.repo.created", {
|
||||
full_name: repo.full_name,
|
||||
owner: repo.owner,
|
||||
name: repo.name,
|
||||
private: repo.private,
|
||||
html_url: repo.html_url,
|
||||
});
|
||||
}
|
||||
seen.add(repo.full_name);
|
||||
}
|
||||
}
|
||||
primed = true;
|
||||
}
|
||||
|
||||
if (gitea) {
|
||||
const client = gitea;
|
||||
const tick = (fn: () => Promise<void>, everyMs: number): void => {
|
||||
const run = (): void => void fn().catch((err) => console.error(`[gitea] ${err}`));
|
||||
setInterval(run, everyMs);
|
||||
run();
|
||||
};
|
||||
tick(() => pollRepos(client), 60_000);
|
||||
console.log("[gitea] watching for new repositories");
|
||||
}
|
||||
@@ -18,6 +18,11 @@
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.gitea.repo.created",
|
||||
"module.gitea.issue.opened",
|
||||
"module.gitea.pull.merged"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"port": 3000,
|
||||
@@ -33,9 +38,16 @@
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"internal-token": "/var/lib/gitea/internal-token.secret"
|
||||
"internal-token": "/var/lib/gitea/internal-token.secret",
|
||||
"broker": "/var/lib/mesh/gitea/broker"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/gitea",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -76,6 +88,33 @@
|
||||
"volumes": [
|
||||
"/services/gitea/gitea:/data"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/gitea/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-gitea",
|
||||
"image": "mesh-runtime-gitea@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/gitea/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/gitea/config.json:/run/config/config.json:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_GITEA_URL": "http://127.0.0.1:3000",
|
||||
"MESH_GITEA_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-gitea",
|
||||
"version": "0.1.0",
|
||||
"description": "gitea — git hosting. Its API client, tools 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// gitea's tools — moved here from the shared sdk (novox/hq ADR 0044), importing gitea's own client.
|
||||
// They return structured data; the mesh serves them through the sdk's tool harness.
|
||||
//
|
||||
// Two tools emit an event at the natural point of the action they take (novox/hq ADR 0046/0047):
|
||||
// create-issue emits issue.opened, merge-pull-request emits pull.merged — the mesh's own hand on
|
||||
// the forge, announced the instant it moves. repo.created is deliberately NOT emitted here: repos
|
||||
// are far more often born from a `git push` or the web UI than from this tool, so the events
|
||||
// entrypoint (index.ts) owns that one by polling, which catches every path without this tool and
|
||||
// the poll double-announcing the same repo from two processes.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { GiteaClient } from "../client.js";
|
||||
|
||||
/** Coerce a comma-separated label string into names; empty/absent yields none. */
|
||||
function parseLabels(raw: unknown): string[] {
|
||||
if (raw === undefined || raw === null || raw === "") return [];
|
||||
return String(raw)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
return [
|
||||
// ---- Repositories ----
|
||||
{
|
||||
name: "gitea_list_repos",
|
||||
description: "List repositories for the authenticated Gitea user.",
|
||||
input: {
|
||||
page: { type: "number", description: "page number (default 1)" },
|
||||
limit: { type: "number", description: "how many per page (default 20)" },
|
||||
},
|
||||
run: async (args) => ({
|
||||
repos: await gitea.listRepos(args.page ? Number(args.page) : 1, args.limit ? Number(args.limit) : 20),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "gitea_create_repo",
|
||||
description: "Create a repository owned by the authenticated user.",
|
||||
input: {
|
||||
name: { type: "string", description: "the repository name" },
|
||||
description: { type: "string", description: "an optional description" },
|
||||
private: { type: "boolean", description: "private repo (default true)" },
|
||||
auto_init: { type: "boolean", description: "initialise with a README (default true)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const repo = await gitea.createRepo({
|
||||
name: String(args.name),
|
||||
description: args.description ? String(args.description) : undefined,
|
||||
private: args.private === undefined ? true : Boolean(args.private),
|
||||
auto_init: args.auto_init === undefined ? true : Boolean(args.auto_init),
|
||||
});
|
||||
return { repo };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea_delete_repo",
|
||||
description: "Delete a repository. Destructive and irreversible — requires confirm=true.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
name: { type: "string", description: "the repository name" },
|
||||
confirm: { type: "boolean", description: "must be true to actually delete" },
|
||||
},
|
||||
run: async (args) => {
|
||||
if (!args.confirm) return { deleted: false, reason: "confirm must be true to delete a repository" };
|
||||
await gitea.deleteRepo(String(args.owner), String(args.name));
|
||||
return { deleted: true, repo: `${String(args.owner)}/${String(args.name)}` };
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Issues ----
|
||||
{
|
||||
name: "gitea_list_issues",
|
||||
description: "List issues for a repository, filterable by state and labels.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
state: { type: "string", description: "open | closed | all (default open)" },
|
||||
labels: { type: "string", description: "comma-separated label names to filter by" },
|
||||
page: { type: "number", description: "page number (default 1)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const params: Record<string, string> = {
|
||||
state: args.state ? String(args.state) : "open",
|
||||
page: String(args.page ? Number(args.page) : 1),
|
||||
};
|
||||
if (args.labels) params.labels = String(args.labels);
|
||||
return { issues: await gitea.listIssues(String(args.owner), String(args.repo), params) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea_get_issue",
|
||||
description: "Get a single issue by its number.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the issue number" },
|
||||
},
|
||||
run: async (args) => ({
|
||||
issue: await gitea.getIssue(String(args.owner), String(args.repo), Number(args.number)),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "gitea_create_issue",
|
||||
description: "Open a new issue. Label names are resolved to ids, creating any that are missing.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
title: { type: "string", description: "the issue title" },
|
||||
body: { type: "string", description: "the issue body (markdown)" },
|
||||
labels: { type: "string", description: "comma-separated label names" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner);
|
||||
const repo = String(args.repo);
|
||||
const names = parseLabels(args.labels);
|
||||
const labelIds = names.length
|
||||
? await Promise.all(names.map((n) => gitea.getOrCreateLabel(owner, repo, n)))
|
||||
: undefined;
|
||||
const issue = await gitea.createIssue(owner, repo, {
|
||||
title: String(args.title),
|
||||
body: args.body ? String(args.body) : undefined,
|
||||
labels: labelIds,
|
||||
});
|
||||
// The mesh just opened an issue — announce it the moment it exists.
|
||||
await emit("module.gitea.issue.opened", {
|
||||
owner,
|
||||
repo,
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
user: issue.user,
|
||||
html_url: issue.html_url,
|
||||
});
|
||||
return { issue };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea_close_issue",
|
||||
description: "Close an open issue.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the issue number" },
|
||||
},
|
||||
run: async (args) => ({
|
||||
issue: await gitea.setIssueState(String(args.owner), String(args.repo), Number(args.number), "closed"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "gitea_add_comment",
|
||||
description: "Add a comment to an issue or pull request.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the issue or PR number" },
|
||||
body: { type: "string", description: "the comment body (markdown)" },
|
||||
},
|
||||
run: async (args) => ({
|
||||
comment: await gitea.addComment(String(args.owner), String(args.repo), Number(args.number), String(args.body)),
|
||||
}),
|
||||
},
|
||||
|
||||
// ---- Pull requests ----
|
||||
{
|
||||
name: "gitea_list_pull_requests",
|
||||
description: "List pull requests for a repository.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
state: { type: "string", description: "open | closed | all (default open)" },
|
||||
page: { type: "number", description: "page number (default 1)" },
|
||||
limit: { type: "number", description: "how many per page (default 20)" },
|
||||
},
|
||||
run: async (args) => ({
|
||||
pulls: await gitea.listPullRequests(String(args.owner), String(args.repo), {
|
||||
state: args.state ? String(args.state) : "open",
|
||||
page: String(args.page ? Number(args.page) : 1),
|
||||
limit: String(args.limit ? Number(args.limit) : 20),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "gitea_get_pull_request",
|
||||
description: "Get a single pull request by its number.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the PR number" },
|
||||
},
|
||||
run: async (args) => ({
|
||||
pull: await gitea.getPullRequest(String(args.owner), String(args.repo), Number(args.number)),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "gitea_create_pull_request",
|
||||
description: "Open a pull request from a head branch into a base branch.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
title: { type: "string", description: "the PR title" },
|
||||
body: { type: "string", description: "the PR body (markdown)" },
|
||||
head: { type: "string", description: "the source branch" },
|
||||
base: { type: "string", description: "the target branch (default main)" },
|
||||
},
|
||||
run: async (args) => ({
|
||||
pull: await gitea.createPullRequest(String(args.owner), String(args.repo), {
|
||||
title: String(args.title),
|
||||
body: args.body ? String(args.body) : undefined,
|
||||
head: String(args.head),
|
||||
base: args.base ? String(args.base) : "main",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "gitea_merge_pull_request",
|
||||
description: "Merge a pull request, optionally deleting the source branch afterwards.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the PR number" },
|
||||
method: { type: "string", description: "merge | rebase | squash (default merge)" },
|
||||
delete_branch: { type: "boolean", description: "delete the source branch after merge (default true)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner);
|
||||
const repo = String(args.repo);
|
||||
const number = Number(args.number);
|
||||
const method = args.method ? String(args.method) : "merge";
|
||||
const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch);
|
||||
// Read the PR first, so the merged event carries a title and branches, not just a number.
|
||||
const pull = await gitea.getPullRequest(owner, repo, number);
|
||||
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
|
||||
await emit("module.gitea.pull.merged", {
|
||||
owner,
|
||||
repo,
|
||||
number,
|
||||
title: pull.title,
|
||||
head: pull.head,
|
||||
base: pull.base,
|
||||
method,
|
||||
html_url: pull.html_url,
|
||||
});
|
||||
return { merged: true, number, method, deleted_branch: deleteBranch };
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Labels ----
|
||||
{
|
||||
name: "gitea_list_labels",
|
||||
description: "List every label defined in a repository.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
},
|
||||
run: async (args) => ({ labels: await gitea.listLabels(String(args.owner), String(args.repo)) }),
|
||||
},
|
||||
{
|
||||
name: "gitea_create_label",
|
||||
description: "Create a label in a repository.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
name: { type: "string", description: "the label name" },
|
||||
color: { type: "string", description: "hex colour, e.g. #0075ca" },
|
||||
description: { type: "string", description: "an optional description" },
|
||||
},
|
||||
run: async (args) => ({
|
||||
label: await gitea.createLabel(String(args.owner), String(args.repo), {
|
||||
name: String(args.name),
|
||||
color: String(args.color),
|
||||
description: args.description ? String(args.description) : undefined,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
|
||||
// ---- Escape hatch ----
|
||||
{
|
||||
name: "gitea_api",
|
||||
description: "Make an authenticated Gitea API call for any endpoint without a dedicated tool. Path is relative to /api/v1.",
|
||||
input: {
|
||||
path: { type: "string", description: "API path relative to /api/v1, e.g. /repos/owner/repo/branches" },
|
||||
method: { type: "string", description: "GET | POST | PUT | PATCH | DELETE (default GET)" },
|
||||
body: { type: "object", description: "JSON request body for POST/PUT/PATCH" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const method = args.method ? String(args.method) : "GET";
|
||||
const result = await gitea.api(String(args.path), {
|
||||
method,
|
||||
...(args.body ? { body: JSON.stringify(args.body) } : {}),
|
||||
});
|
||||
return { result };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The tools exist only when a token can be found; without one, gitea contributes none rather than
|
||||
// failing the whole runtime.
|
||||
registerModuleTools("gitea", (env) => {
|
||||
try {
|
||||
return getGiteaTools(GiteaClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Grafana's API client — grafana's own code, living in the module (novox/hq ADR 0044). Ported from
|
||||
// the shared hal sdk, where a change here rebuilt everything; here it rebuilds only grafana. Both
|
||||
// this module's tools and its events entrypoint import it, and nothing outside grafana does.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface GrafanaHealth {
|
||||
database: string;
|
||||
version: string;
|
||||
commit: string;
|
||||
}
|
||||
|
||||
export interface GrafanaDatasource {
|
||||
id: number;
|
||||
uid: string;
|
||||
name: string;
|
||||
type: string;
|
||||
url: string;
|
||||
isDefault: boolean;
|
||||
database?: string;
|
||||
}
|
||||
|
||||
export interface GrafanaDashboard {
|
||||
uid: string;
|
||||
title: string;
|
||||
url: string;
|
||||
tags: string[];
|
||||
folderTitle?: string;
|
||||
}
|
||||
|
||||
export interface GrafanaAlert {
|
||||
/** The rule name (labels.alertname), the stable identity a firing alert is diffed on. */
|
||||
name: string;
|
||||
/** Grafana unified-alerting state: "Normal" | "Pending" | "Alerting". */
|
||||
state: string;
|
||||
labels: Record<string, string>;
|
||||
activeAt?: string;
|
||||
}
|
||||
|
||||
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
|
||||
function meshConfig(file?: string): Record<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
export class GrafanaClient {
|
||||
readonly baseUrl: string;
|
||||
private readonly authHeader: string;
|
||||
|
||||
constructor(url: string, authHeader: string) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
this.authHeader = authHeader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build from the module's resolved environment. Auth is a service-account/API token
|
||||
* (MESH_GRAFANA_TOKEN, sent as Bearer) when present, else HTTP basic with the admin password the
|
||||
* module keeps as its own secret (MESH_GRAFANA_PASSWORD, user MESH_GRAFANA_USER, default admin).
|
||||
* Throws when neither is configured — the module then contributes nothing rather than failing.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): GrafanaClient {
|
||||
const cfg = meshConfig(env.MESH_GRAFANA_CONFIG_FILE);
|
||||
const url = cfg.url ?? env.MESH_GRAFANA_URL ?? `http://127.0.0.1:${env.GRAFANA_PORT ?? "3000"}`;
|
||||
const token = cfg.token ?? env.MESH_GRAFANA_TOKEN;
|
||||
if (token) return new GrafanaClient(url, `Bearer ${token}`);
|
||||
const password = cfg.password ?? env.MESH_GRAFANA_PASSWORD;
|
||||
if (password) {
|
||||
const user = cfg.user ?? env.MESH_GRAFANA_USER ?? "admin";
|
||||
return new GrafanaClient(url, `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`);
|
||||
}
|
||||
throw new Error("no Grafana auth — set MESH_GRAFANA_TOKEN or MESH_GRAFANA_PASSWORD");
|
||||
}
|
||||
|
||||
private async get(path: string): Promise<any> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
headers: { Authorization: this.authHeader, Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Grafana API ${path}: ${res.status} ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async health(): Promise<GrafanaHealth> {
|
||||
const h = await this.get("/api/health");
|
||||
return { database: h.database ?? "unknown", version: h.version ?? "unknown", commit: h.commit ?? "unknown" };
|
||||
}
|
||||
|
||||
async listDatasources(): Promise<GrafanaDatasource[]> {
|
||||
const arr = (await this.get("/api/datasources")) as any[];
|
||||
return arr.map((d) => ({
|
||||
id: d.id, uid: d.uid, name: d.name, type: d.type, url: d.url,
|
||||
isDefault: !!d.isDefault, database: d.database || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async listDashboards(query?: string): Promise<GrafanaDashboard[]> {
|
||||
const params = new URLSearchParams({ type: "dash-db" });
|
||||
if (query) params.set("query", query);
|
||||
const arr = (await this.get(`/api/search?${params.toString()}`)) as any[];
|
||||
return arr.map((d) => ({
|
||||
uid: d.uid, title: d.title, url: d.url, tags: d.tags ?? [], folderTitle: d.folderTitle || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Active alert instances from unified alerting's Prometheus-compatible surface. Grafana without
|
||||
* alerting configured answers this with an empty set (or a 404, surfaced by get) — callers treat
|
||||
* "no alerts" and "no alerting" alike.
|
||||
*/
|
||||
async listAlerts(): Promise<GrafanaAlert[]> {
|
||||
const data = (await this.get("/api/prometheus/grafana/api/v1/alerts")).data ?? {};
|
||||
const alerts = (data.alerts ?? []) as any[];
|
||||
return alerts.map((a) => ({
|
||||
name: a.labels?.alertname ?? "unknown",
|
||||
state: a.state ?? "unknown",
|
||||
labels: a.labels ?? {},
|
||||
activeAt: a.activeAt || undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// grafana's events. The tool runtime imports this once the broker is bound. It watches unified
|
||||
// alerting and announces when an alert instance starts firing.
|
||||
//
|
||||
// Emits (novox/hq ADR 0046/0047):
|
||||
// module.grafana.alert.firing — an alert instance entered the Alerting state
|
||||
//
|
||||
// A Grafana with no alerting configured simply never has a firing alert, so this observes nothing
|
||||
// and emits nothing — no error, no noise.
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { GrafanaClient, type GrafanaAlert } from "./client.js";
|
||||
|
||||
// Constructed lazily so an unconfigured node (no auth) loads this entrypoint without crashing the
|
||||
// events host — it simply watches nothing.
|
||||
let grafana: GrafanaClient | undefined;
|
||||
try {
|
||||
grafana = GrafanaClient.fromEnv();
|
||||
} catch (err) {
|
||||
console.log(`[grafana] not configured, not watching alerts: ${err}`);
|
||||
}
|
||||
|
||||
// Firing alerts, by diffing the set currently in the Alerting state. Primed silently on the first
|
||||
// look so alerts already firing when this started are not announced as freshly firing.
|
||||
const firing = new Set<string>();
|
||||
let primed = false;
|
||||
|
||||
const alertKey = (a: GrafanaAlert): string =>
|
||||
`${a.name}:${Object.entries(a.labels).sort().map(([k, v]) => `${k}=${v}`).join(",")}`;
|
||||
|
||||
async function pollAlerts(client: GrafanaClient): Promise<void> {
|
||||
const now = new Set<string>();
|
||||
const byKey = new Map<string, GrafanaAlert>();
|
||||
for (const a of await client.listAlerts()) {
|
||||
if (a.state.toLowerCase() !== "alerting") continue;
|
||||
const key = alertKey(a);
|
||||
now.add(key);
|
||||
byKey.set(key, a);
|
||||
}
|
||||
if (primed) {
|
||||
for (const key of now) {
|
||||
if (!firing.has(key)) {
|
||||
const a = byKey.get(key)!;
|
||||
await emit("module.grafana.alert.firing", { name: a.name, labels: a.labels, activeAt: a.activeAt });
|
||||
}
|
||||
}
|
||||
}
|
||||
firing.clear();
|
||||
for (const key of now) firing.add(key);
|
||||
primed = true;
|
||||
}
|
||||
|
||||
if (grafana) {
|
||||
const client = grafana;
|
||||
const run = (): void => void pollAlerts(client).catch((err) => console.error(`[grafana] ${err}`));
|
||||
setInterval(run, 30_000);
|
||||
run();
|
||||
console.log("[grafana] watching for firing alerts");
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
{
|
||||
"module": "grafana",
|
||||
"version": "1",
|
||||
"emits": [
|
||||
"module.grafana.alert.firing"
|
||||
],
|
||||
"own-secrets": {
|
||||
"admin": "/var/lib/grafana-module/admin.secret"
|
||||
"admin": "/var/lib/grafana-module/admin.secret",
|
||||
"broker": "/var/lib/mesh/grafana/broker"
|
||||
},
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
@@ -16,6 +20,12 @@
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/grafana",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -50,6 +60,33 @@
|
||||
"volumes": [
|
||||
"/services/grafana/data:/var/lib/grafana"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/grafana/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-grafana",
|
||||
"image": "mesh-runtime-grafana@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/grafana/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/grafana/config.json:/run/config/config.json:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_GRAFANA_URL": "http://127.0.0.1:3000",
|
||||
"MESH_GRAFANA_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-grafana",
|
||||
"version": "0.1.0",
|
||||
"description": "grafana — monitoring dashboards. Its API client, tools 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// grafana's tools — moved here from the shared hal sdk (novox/hq ADR 0044), importing grafana's own
|
||||
// client. They return structured data; the mesh serves them through the sdk's tool harness.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { GrafanaClient } from "../client.js";
|
||||
|
||||
export function getGrafanaTools(grafana: GrafanaClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "grafana_status",
|
||||
description: "Grafana server health — database state, version, build commit.",
|
||||
input: {},
|
||||
run: async () => grafana.health(),
|
||||
},
|
||||
{
|
||||
name: "grafana_list_datasources",
|
||||
description: "List Grafana data sources — name, type, backing URL, which is default.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const datasources = await grafana.listDatasources();
|
||||
return { count: datasources.length, datasources };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "grafana_list_dashboards",
|
||||
description: "List Grafana dashboards, optionally filtered by a name query.",
|
||||
input: { query: { type: "string", description: "filter dashboards by name (optional)" } },
|
||||
run: async (args) => {
|
||||
const query = args.query ? String(args.query) : undefined;
|
||||
const dashboards = await grafana.listDashboards(query);
|
||||
return { count: dashboards.length, dashboards };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "grafana_alerts",
|
||||
description: "Active Grafana alert instances and their state (Alerting, Pending, Normal).",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const alerts = await grafana.listAlerts();
|
||||
const firing = alerts.filter((a) => a.state.toLowerCase() === "alerting");
|
||||
return { count: alerts.length, firing: firing.length, alerts };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The tools exist only when Grafana auth can be resolved; without it, grafana contributes none
|
||||
// rather than failing the whole tool runtime.
|
||||
registerModuleTools("grafana", (env) => {
|
||||
try {
|
||||
return getGrafanaTools(GrafanaClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// The Home Assistant API client — home-assistant's own code, living in the module (novox/hq
|
||||
// ADR 0044). Both this module's tools and its events entrypoint import it, and nothing outside
|
||||
// home-assistant does. Talks to the HA REST API (/api) with a long-lived access token.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface HAEntityState {
|
||||
entity_id: string;
|
||||
state: string;
|
||||
attributes: Record<string, unknown>;
|
||||
last_changed?: string;
|
||||
last_updated?: string;
|
||||
}
|
||||
|
||||
export interface HAConfig {
|
||||
location_name?: string;
|
||||
version?: string;
|
||||
components?: string[];
|
||||
time_zone?: string;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
|
||||
function meshConfig(file?: string): Record<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
export class HomeAssistantClient {
|
||||
readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly token: string,
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build from the module's resolved environment. The URL defaults to the local server (HA runs on
|
||||
* the node); the token is the long-lived access token minted in HA's profile — required, since
|
||||
* every API call is Bearer-authenticated and there is nowhere to discover it from.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): HomeAssistantClient {
|
||||
const cfg = meshConfig(env.MESH_HOMEASSISTANT_CONFIG_FILE);
|
||||
const url = cfg.url ?? env.MESH_HOMEASSISTANT_URL ?? `http://127.0.0.1:${env.HOMEASSISTANT_PORT ?? "8123"}`;
|
||||
const token = cfg.token ?? env.MESH_HOMEASSISTANT_TOKEN;
|
||||
if (!token) throw new Error("no Home Assistant token — set MESH_HOMEASSISTANT_TOKEN");
|
||||
return new HomeAssistantClient(url, token);
|
||||
}
|
||||
|
||||
private async request(path: string, init?: RequestInit): Promise<unknown> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`Home Assistant API ${path}: ${res.status} ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async getConfig(): Promise<HAConfig> {
|
||||
return (await this.request("/api/config")) as HAConfig;
|
||||
}
|
||||
|
||||
/** All entity states, or one entity when an id is given. */
|
||||
async getStates(): Promise<HAEntityState[]> {
|
||||
return (await this.request("/api/states")) as HAEntityState[];
|
||||
}
|
||||
|
||||
async getState(entityId: string): Promise<HAEntityState> {
|
||||
return (await this.request(`/api/states/${encodeURIComponent(entityId)}`)) as HAEntityState;
|
||||
}
|
||||
|
||||
/** Call a service (e.g. switch.turn_on) — how "turn the light on" reaches HA. Returns the states
|
||||
* the call changed. */
|
||||
async callService(domain: string, service: string, data: Record<string, unknown> = {}): Promise<HAEntityState[]> {
|
||||
return (await this.request(`/api/services/${encodeURIComponent(domain)}/${encodeURIComponent(service)}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
})) as HAEntityState[];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// home-assistant's events. The tool runtime imports this once the broker is bound. It watches the
|
||||
// entities whose state changing is a real signal — a door opening, a lock turning, a switch
|
||||
// flipping — and emits when one does.
|
||||
//
|
||||
// Emits (novox/hq ADR 0046/0047):
|
||||
// module.home-assistant.state.changed — a watched entity's state value changed
|
||||
//
|
||||
// Bounded on purpose. Home Assistant has hundreds of entities and many (temperature, humidity,
|
||||
// power draw) tick constantly; emitting every tick would be noise, not signal. So the watch is
|
||||
// limited to actuator/contact domains where a change is an event a human would care about, and
|
||||
// only the discrete `state` value is diffed — not the attribute bag. The set is overridable with
|
||||
// MESH_HOMEASSISTANT_WATCH (comma-separated entity ids) for a node that wants a specific few.
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { HomeAssistantClient, type HAEntityState } from "./client.js";
|
||||
|
||||
const ha = HomeAssistantClient.fromEnv();
|
||||
|
||||
// Domains whose state changing is meaningful rather than a continuous reading.
|
||||
const WATCH_DOMAINS = new Set(["binary_sensor", "lock", "cover", "switch", "input_boolean", "light", "alarm_control_panel"]);
|
||||
|
||||
// An explicit allowlist of entity ids, if the node set one; otherwise fall back to the domain filter.
|
||||
const watchList = (process.env.MESH_HOMEASSISTANT_WATCH ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const watchSet = watchList.length ? new Set(watchList) : null;
|
||||
|
||||
function isWatched(s: HAEntityState): boolean {
|
||||
if (watchSet) return watchSet.has(s.entity_id);
|
||||
return WATCH_DOMAINS.has(s.entity_id.split(".")[0] ?? "");
|
||||
}
|
||||
|
||||
const nameOf = (s: HAEntityState): string | undefined =>
|
||||
typeof s.attributes.friendly_name === "string" ? s.attributes.friendly_name : undefined;
|
||||
|
||||
// Last seen state per watched entity. Primed silently on the first poll so a restart does not
|
||||
// re-announce the current state of everything as a fresh change.
|
||||
const lastState = new Map<string, string>();
|
||||
let primed = false;
|
||||
|
||||
async function pollStates(): Promise<void> {
|
||||
const states = (await ha.getStates()).filter(isWatched);
|
||||
for (const s of states) {
|
||||
const prev = lastState.get(s.entity_id);
|
||||
if (primed && prev !== undefined && prev !== s.state) {
|
||||
await emit("module.home-assistant.state.changed", {
|
||||
entity: s.entity_id,
|
||||
name: nameOf(s),
|
||||
from: prev,
|
||||
to: s.state,
|
||||
});
|
||||
}
|
||||
lastState.set(s.entity_id, s.state);
|
||||
}
|
||||
primed = true;
|
||||
}
|
||||
|
||||
const tick = (fn: () => Promise<void>, everyMs: number): void => {
|
||||
const run = (): void => void fn().catch((err) => console.error(`[home-assistant] ${err}`));
|
||||
setInterval(run, everyMs);
|
||||
run();
|
||||
};
|
||||
tick(pollStates, 15_000);
|
||||
|
||||
console.log("[home-assistant] watching entity states, emitting on change");
|
||||
@@ -4,6 +4,12 @@
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.home-assistant.state.changed"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/home-assistant/broker"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"port": 8123,
|
||||
@@ -13,6 +19,12 @@
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/home-assistant",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "directory",
|
||||
@@ -32,6 +44,35 @@
|
||||
"volumes": [
|
||||
"/services/home-assistant/config:/config"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/home-assistant/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-home-assistant",
|
||||
"image": "mesh-runtime-home-assistant@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/home-assistant/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/home-assistant/config.json:/run/config/config.json:ro",
|
||||
"/services/home-assistant/config:/var/lib/home-assistant/config:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:8123",
|
||||
"MESH_HOMEASSISTANT_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_HOMEASSISTANT_CONFIG_DIR": "/var/lib/home-assistant/config"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-home-assistant",
|
||||
"version": "0.1.0",
|
||||
"description": "home-assistant — home automation platform. Its API client, tools 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// home-assistant's tools — its own code (novox/hq ADR 0044), importing its own client. They return
|
||||
// structured data; the mesh serves them through the sdk's tool harness.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { HomeAssistantClient, type HAEntityState } from "../client.js";
|
||||
|
||||
/** Trim an entity to the fields worth returning — the full attribute bag is large and mostly noise. */
|
||||
function summarize(s: HAEntityState): { entity_id: string; state: string; name?: string; last_changed?: string } {
|
||||
return {
|
||||
entity_id: s.entity_id,
|
||||
state: s.state,
|
||||
name: typeof s.attributes.friendly_name === "string" ? s.attributes.friendly_name : undefined,
|
||||
last_changed: s.last_changed,
|
||||
};
|
||||
}
|
||||
|
||||
export function getHomeAssistantTools(ha: HomeAssistantClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "homeassistant_states",
|
||||
description:
|
||||
"Entity states from Home Assistant. With no argument, lists every entity; with `entity` (e.g. light.kitchen), returns just that one with its full attributes.",
|
||||
input: { entity: { type: "string", description: "an entity id to fetch one entity; omitted lists all" } },
|
||||
run: async (args) => {
|
||||
if (args.entity) {
|
||||
const s = await ha.getState(String(args.entity));
|
||||
return { entity_id: s.entity_id, state: s.state, attributes: s.attributes, last_changed: s.last_changed };
|
||||
}
|
||||
const states = await ha.getStates();
|
||||
return { count: states.length, entities: states.map(summarize) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "homeassistant_call_service",
|
||||
description:
|
||||
"Call a Home Assistant service — turn a switch/light on or off, lock a door, etc. Give the domain (e.g. switch), the service (e.g. turn_on), and optionally a target entity and extra data.",
|
||||
input: {
|
||||
domain: { type: "string", description: "the service domain, e.g. light, switch, lock" },
|
||||
service: { type: "string", description: "the service, e.g. turn_on, turn_off, toggle" },
|
||||
entity: { type: "string", description: "the entity id to target (optional)" },
|
||||
data: { type: "object", description: "extra service data merged into the call (optional)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const data: Record<string, unknown> = { ...(args.data as Record<string, unknown> | undefined) };
|
||||
if (args.entity) data.entity_id = String(args.entity);
|
||||
const changed = await ha.callService(String(args.domain), String(args.service), data);
|
||||
return { changed: changed.map(summarize) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "homeassistant_config",
|
||||
description: "Home Assistant instance config: version, location, timezone, loaded components.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const c = await ha.getConfig();
|
||||
return {
|
||||
location: c.location_name,
|
||||
version: c.version,
|
||||
time_zone: c.time_zone,
|
||||
state: c.state,
|
||||
components: c.components?.length ?? 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The tools exist only when a token is configured; without one, home-assistant contributes none
|
||||
// rather than failing the whole runtime.
|
||||
registerModuleTools("home-assistant", (env) => {
|
||||
try {
|
||||
return getHomeAssistantTools(HomeAssistantClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Icecast's API client — icecast's own code, living in the module (novox/hq ADR 0044). Icecast is an
|
||||
// audio streaming server: sources push mountpoints in, listeners pull them out. Its `/status-json.xsl`
|
||||
// endpoint reports the live mountpoints and their listener counts — the one thing worth watching, and
|
||||
// the basis for both the status tool and the stream started/stopped events.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface IcecastMount {
|
||||
/** The mountpoint path, e.g. "/stream.mp3", derived from the source's listen URL. */
|
||||
mount: string;
|
||||
listeners: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
streamStart?: string;
|
||||
bitrate?: number;
|
||||
serverType?: string;
|
||||
}
|
||||
|
||||
export interface IcecastStatus {
|
||||
mounts: IcecastMount[];
|
||||
totalListeners: number;
|
||||
mountCount: number;
|
||||
}
|
||||
|
||||
// The raw shape of one <source> in status-json.xsl. `source` is absent with no mounts, a lone object
|
||||
// with one, and an array with several — normalised below.
|
||||
interface RawSource {
|
||||
listenurl?: string;
|
||||
listeners?: number;
|
||||
server_name?: string;
|
||||
server_description?: string;
|
||||
stream_start_iso8601?: string;
|
||||
stream_start?: string;
|
||||
bitrate?: number;
|
||||
server_type?: string;
|
||||
}
|
||||
|
||||
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
|
||||
function meshConfig(file?: string): Record<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
export class IcecastClient {
|
||||
readonly baseUrl: string;
|
||||
private readonly authHeader?: string;
|
||||
|
||||
constructor(url: string, adminUser?: string, adminPassword?: string) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
// status-json.xsl is public on most instances; basic auth is used only where admin locked it down.
|
||||
if (adminUser && adminPassword) {
|
||||
this.authHeader = "Basic " + Buffer.from(`${adminUser}:${adminPassword}`).toString("base64");
|
||||
}
|
||||
}
|
||||
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): IcecastClient {
|
||||
const cfg = meshConfig(env.MESH_ICECAST_CONFIG_FILE);
|
||||
const url = cfg.url ?? (env.MESH_ICECAST_URL ?? `http://127.0.0.1:${env.ICECAST_PORT ?? "8000"}`);
|
||||
return new IcecastClient(url, cfg.user ?? env.MESH_ICECAST_ADMIN_USER, cfg.password ?? env.MESH_ICECAST_ADMIN_PASSWORD);
|
||||
}
|
||||
|
||||
async getStatus(): Promise<IcecastStatus> {
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (this.authHeader) headers.Authorization = this.authHeader;
|
||||
const res = await fetch(`${this.baseUrl}/status-json.xsl`, { headers });
|
||||
if (!res.ok) throw new Error(`Icecast status: ${res.status} ${await res.text()}`);
|
||||
const data = (await res.json()) as { icestats?: { source?: RawSource | RawSource[] } };
|
||||
|
||||
const raw = data.icestats?.source;
|
||||
const sources: RawSource[] = raw == null ? [] : Array.isArray(raw) ? raw : [raw];
|
||||
const mounts = sources.map((s) => ({
|
||||
mount: this.mountFromUrl(s.listenurl),
|
||||
listeners: s.listeners ?? 0,
|
||||
name: s.server_name,
|
||||
description: s.server_description,
|
||||
streamStart: s.stream_start_iso8601 ?? s.stream_start,
|
||||
bitrate: s.bitrate,
|
||||
serverType: s.server_type,
|
||||
}));
|
||||
return {
|
||||
mounts,
|
||||
totalListeners: mounts.reduce((n, m) => n + m.listeners, 0),
|
||||
mountCount: mounts.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Icecast names the mount only inside the listen URL's path; pull it back out (fall back to raw). */
|
||||
private mountFromUrl(listenurl?: string): string {
|
||||
if (!listenurl) return "unknown";
|
||||
try {
|
||||
return new URL(listenurl).pathname;
|
||||
} catch {
|
||||
return listenurl;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// icecast's events. The tool runtime imports this once the broker is bound. It watches the streaming
|
||||
// server and announces when a mountpoint goes live or drops.
|
||||
//
|
||||
// Emits (novox/hq ADR 0046/0047):
|
||||
// module.icecast.stream.started / .stopped — a mountpoint appeared or disappeared
|
||||
//
|
||||
// A mountpoint exists only while a source is connected, so the set of mounts diffed over time is
|
||||
// exactly the set of live streams. Primed silently on the first look, so streams already running when
|
||||
// this starts are not announced as freshly begun. Polling is unhurried — a stream a few seconds late
|
||||
// is still the event, and hammering the status endpoint buys immediacy nobody asked for.
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { IcecastClient, type IcecastMount } from "./client.js";
|
||||
|
||||
const icecast = IcecastClient.fromEnv();
|
||||
|
||||
const live = new Map<string, IcecastMount>();
|
||||
let primed = false;
|
||||
|
||||
async function pollMounts(): Promise<void> {
|
||||
const { mounts } = await icecast.getStatus();
|
||||
const now = new Map(mounts.map((m) => [m.mount, m]));
|
||||
if (primed) {
|
||||
for (const [mount, m] of now) {
|
||||
if (!live.has(mount)) {
|
||||
await emit("module.icecast.stream.started", {
|
||||
mount,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
bitrate: m.bitrate,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [mount, m] of live) {
|
||||
if (!now.has(mount)) {
|
||||
await emit("module.icecast.stream.stopped", { mount, name: m.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
live.clear();
|
||||
for (const [mount, m] of now) live.set(mount, m);
|
||||
primed = true;
|
||||
}
|
||||
|
||||
const run = (): void => void pollMounts().catch((err) => console.error(`[icecast] ${err}`));
|
||||
setInterval(run, 15_000);
|
||||
run();
|
||||
|
||||
console.log("[icecast] watching mountpoints for streams starting and stopping");
|
||||
@@ -4,10 +4,15 @@
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.icecast.stream.started",
|
||||
"module.icecast.stream.stopped"
|
||||
],
|
||||
"own-secrets": {
|
||||
"source": "/var/lib/icecast-module/source.secret",
|
||||
"admin": "/var/lib/icecast-module/admin.secret",
|
||||
"relay": "/var/lib/icecast-module/relay.secret"
|
||||
"relay": "/var/lib/icecast-module/relay.secret",
|
||||
"broker": "/var/lib/mesh/icecast/broker"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
@@ -18,6 +23,12 @@
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/icecast",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -42,6 +53,33 @@
|
||||
"ports": [
|
||||
"8000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/icecast/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-icecast",
|
||||
"image": "mesh-runtime-icecast@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/icecast/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/icecast/config.json:/run/config/config.json:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_ICECAST_URL": "http://127.0.0.1:8000",
|
||||
"MESH_ICECAST_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-icecast",
|
||||
"version": "0.1.0",
|
||||
"description": "icecast — audio streaming server. Its API client, tools 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// icecast's tools (novox/hq ADR 0044), importing icecast's own client.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { IcecastClient } from "../client.js";
|
||||
|
||||
export function getIcecastTools(icecast: IcecastClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "icecast_status",
|
||||
description: "Icecast streaming status: live mountpoints, each with its listener count, plus the total.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const status = await icecast.getStatus();
|
||||
return status;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
registerModuleTools("icecast", (env) => {
|
||||
try {
|
||||
return getIcecastTools(IcecastClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// The InfluxDB API client — influxdb's own code, living in the module (novox/hq ADR 0044). Only
|
||||
// this module's tools import it. Talks to the InfluxDB 2.x HTTP API (/api/v2) with a token.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface InfluxHealth {
|
||||
name?: string;
|
||||
status?: string;
|
||||
message?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface InfluxBucket {
|
||||
id: string;
|
||||
name: string;
|
||||
orgID?: string;
|
||||
retentionSeconds?: number;
|
||||
}
|
||||
|
||||
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
|
||||
function meshConfig(file?: string): Record<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
export class InfluxDBClient {
|
||||
readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly token: string,
|
||||
private readonly org: string,
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build from the module's resolved environment. The token is the InfluxDB API token (the admin
|
||||
* token the server was initialised with, or a scoped one) — required, since every /api/v2 call
|
||||
* is token-authenticated. The org scopes bucket listing and queries.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): InfluxDBClient {
|
||||
const cfg = meshConfig(env.MESH_INFLUXDB_CONFIG_FILE);
|
||||
const url = cfg.url ?? env.MESH_INFLUXDB_URL ?? `http://127.0.0.1:${env.INFLUXDB_PORT ?? "8086"}`;
|
||||
const token = cfg.token ?? env.MESH_INFLUXDB_TOKEN;
|
||||
if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN");
|
||||
const org = cfg.org ?? env.MESH_INFLUXDB_ORG ?? "mesh";
|
||||
return new InfluxDBClient(url, token, org);
|
||||
}
|
||||
|
||||
private async request(path: string, init?: RequestInit): Promise<Response> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Token ${this.token}`,
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`InfluxDB API ${path}: ${res.status} ${await res.text()}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Server health — the one endpoint that needs no token, but we send it anyway. */
|
||||
async health(): Promise<InfluxHealth> {
|
||||
return (await (await this.request("/health")).json()) as InfluxHealth;
|
||||
}
|
||||
|
||||
async listBuckets(): Promise<InfluxBucket[]> {
|
||||
const body = (await (await this.request("/api/v2/buckets")).json()) as { buckets?: unknown[] };
|
||||
return (body.buckets ?? []).map((b) => {
|
||||
const bucket = b as Record<string, unknown>;
|
||||
const rules = (bucket.retentionRules as { everySeconds?: number }[] | undefined) ?? [];
|
||||
return {
|
||||
id: String(bucket.id),
|
||||
name: String(bucket.name),
|
||||
orgID: bucket.orgID ? String(bucket.orgID) : undefined,
|
||||
retentionSeconds: rules[0]?.everySeconds,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a read-only Flux query and return the raw CSV InfluxDB answers with, plus a light parse
|
||||
* into rows. Read-only: Flux has no write verb, and the token's own permissions bound the rest —
|
||||
* this client never calls the write endpoint.
|
||||
*/
|
||||
async query(flux: string): Promise<{ csv: string; rows: Record<string, string>[] }> {
|
||||
const res = await this.request(`/api/v2/query?org=${encodeURIComponent(this.org)}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.flux",
|
||||
Accept: "application/csv",
|
||||
},
|
||||
body: flux,
|
||||
});
|
||||
const csv = await res.text();
|
||||
return { csv, rows: parseAnnotatedCsv(csv) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse InfluxDB's annotated CSV into rows keyed by column header. Annotation lines (starting
|
||||
* with #) and blanks are skipped; the first non-annotation line is the header. */
|
||||
function parseAnnotatedCsv(csv: string): Record<string, string>[] {
|
||||
const lines = csv.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
||||
if (lines.length < 2) return [];
|
||||
const header = lines[0].split(",");
|
||||
return lines.slice(1).map((line) => {
|
||||
const cells = line.split(",");
|
||||
const row: Record<string, string> = {};
|
||||
header.forEach((h, i) => {
|
||||
if (h) row[h] = cells[i] ?? "";
|
||||
});
|
||||
return row;
|
||||
});
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
],
|
||||
"own-secrets": {
|
||||
"admin": "/var/lib/influxdb-module/admin.secret",
|
||||
"admin-token": "/var/lib/influxdb-module/admin-token.secret"
|
||||
"admin-token": "/var/lib/influxdb-module/admin-token.secret",
|
||||
"broker": "/var/lib/mesh/influxdb/broker"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
@@ -17,6 +18,12 @@
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/influxdb",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -59,6 +66,35 @@
|
||||
"/services/influxdb/data:/var/lib/influxdb2",
|
||||
"/services/influxdb/config:/etc/influxdb2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/influxdb/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-influxdb",
|
||||
"image": "mesh-runtime-influxdb@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/influxdb/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/influxdb/config.json:/run/config/config.json:ro",
|
||||
"/services/influxdb/config:/var/lib/influxdb/config:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_INFLUXDB_URL": "http://127.0.0.1:8086",
|
||||
"MESH_INFLUXDB_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_INFLUXDB_CONFIG_DIR": "/var/lib/influxdb/config"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-influxdb",
|
||||
"version": "0.1.0",
|
||||
"description": "influxdb — time-series database. Its API client and tools 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// influxdb's tools — its own code (novox/hq ADR 0044), importing its own client. They return
|
||||
// structured data; the mesh serves them through the sdk's tool harness. Read-only: health, bucket
|
||||
// listing, and Flux queries — no write path is exposed.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { InfluxDBClient } from "../client.js";
|
||||
|
||||
export function getInfluxDBTools(influx: InfluxDBClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "influxdb_health",
|
||||
description: "InfluxDB server health and version.",
|
||||
input: {},
|
||||
run: async () => influx.health(),
|
||||
},
|
||||
{
|
||||
name: "influxdb_list_buckets",
|
||||
description: "List InfluxDB buckets in the org, with their retention.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const buckets = await influx.listBuckets();
|
||||
return { count: buckets.length, buckets };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "influxdb_query",
|
||||
description:
|
||||
"Run a read-only Flux query against InfluxDB and return the parsed rows (plus raw CSV). The query is Flux, e.g. from(bucket:\"default\") |> range(start:-1h).",
|
||||
input: { flux: { type: "string", description: "the Flux query to run" } },
|
||||
run: async (args) => {
|
||||
const { csv, rows } = await influx.query(String(args.flux));
|
||||
return { rowCount: rows.length, rows, csv };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The tools exist only when a token is configured; without one, influxdb contributes none rather
|
||||
// than failing the whole runtime.
|
||||
registerModuleTools("influxdb", (env) => {
|
||||
try {
|
||||
return getInfluxDBTools(InfluxDBClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "tools/index.ts"]
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// The Jackett API client — jackett's own code, living in the module (novox/hq ADR 0044). Jackett is
|
||||
// an indexer proxy: it normalises many torrent trackers behind one Torznab surface. This client
|
||||
// talks its /api/v2.0 REST API, and only jackett's tools import it.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface JackettIndexer {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string; // "public" | "private" | "semi-public"
|
||||
configured: boolean;
|
||||
siteLink?: string;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface JackettResult {
|
||||
title: string;
|
||||
tracker: string;
|
||||
category?: string;
|
||||
size: number;
|
||||
seeders?: number;
|
||||
peers?: number;
|
||||
publishDate?: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
|
||||
function meshConfig(file?: string): Record<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
export class JackettClient {
|
||||
readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly apiKey: string,
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build from the module's resolved environment. Jackett's REST API is keyed, so both the URL and
|
||||
* the key must be present — without them there is nothing to talk to, so this throws and the
|
||||
* module contributes no tools rather than failing half-configured.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient {
|
||||
const cfg = meshConfig(env.MESH_JACKETT_CONFIG_FILE);
|
||||
const url = cfg.url ?? env.MESH_JACKETT_URL;
|
||||
const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY;
|
||||
if (!url) throw new Error("no Jackett URL — set MESH_JACKETT_URL");
|
||||
if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY");
|
||||
return new JackettClient(url, apiKey);
|
||||
}
|
||||
|
||||
private async get(path: string, params: Record<string, string> = {}): Promise<any> {
|
||||
const url = new URL(`${this.baseUrl}${path}`);
|
||||
url.searchParams.set("apikey", this.apiKey);
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
||||
const res = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!res.ok) throw new Error(`Jackett API ${path}: ${res.status} ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** The configured indexers Jackett proxies. `configured=false` also lists the ones not set up. */
|
||||
async getIndexers(configuredOnly = true): Promise<JackettIndexer[]> {
|
||||
const raw = await this.get("/api/v2.0/indexers", { configured: configuredOnly ? "true" : "false" });
|
||||
const list = Array.isArray(raw) ? raw : [];
|
||||
return list.map((i: any) => ({
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
type: i.type,
|
||||
configured: i.configured ?? false,
|
||||
siteLink: i.site_link,
|
||||
lastError: i.last_error || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A Torznab search across one indexer, or the "all" aggregate. Jackett returns a normalised JSON
|
||||
* result set regardless of the underlying tracker, which is the whole point of the proxy.
|
||||
*/
|
||||
async search(query: string, indexer = "all", limit = 25): Promise<JackettResult[]> {
|
||||
const raw = await this.get(`/api/v2.0/indexers/${encodeURIComponent(indexer)}/results`, { Query: query });
|
||||
const results = Array.isArray(raw?.Results) ? raw.Results : [];
|
||||
return results.slice(0, limit).map((r: any) => ({
|
||||
title: r.Title,
|
||||
tracker: r.Tracker ?? r.TrackerId ?? "unknown",
|
||||
category: Array.isArray(r.CategoryDesc) ? r.CategoryDesc.join(", ") : r.CategoryDesc,
|
||||
size: r.Size ?? 0,
|
||||
seeders: r.Seeders,
|
||||
peers: r.Peers,
|
||||
publishDate: r.PublishDate,
|
||||
link: r.Link ?? r.Details,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,12 @@
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/jackett",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "directory",
|
||||
@@ -36,6 +42,38 @@
|
||||
"volumes": [
|
||||
"/services/jackett/config:/config"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/jackett/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-jackett",
|
||||
"image": "mesh-runtime-jackett@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/jackett/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/jackett/config.json:/run/config/config.json:ro",
|
||||
"/services/jackett/config:/var/lib/jackett/config:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_JACKETT_URL": "http://127.0.0.1:9117",
|
||||
"MESH_JACKETT_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/jackett/broker"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-jackett",
|
||||
"version": "0.1.0",
|
||||
"description": "jackett — indexer proxy. Its API client and tools 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// jackett's tools — its own code (novox/hq ADR 0044), importing jackett's client. Jackett has
|
||||
// nothing worth watching (an indexer proxy answers queries; it has no timeline of its own), so it
|
||||
// is a tools-only module: no events entrypoint, no broker. What is useful is asking it things.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { JackettClient } from "../client.js";
|
||||
|
||||
export function getJackettTools(jackett: JackettClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "jackett_indexers",
|
||||
description: "List the indexers Jackett proxies, with their type and any last error.",
|
||||
input: { all: { type: "boolean", description: "include indexers not yet configured (default false)" } },
|
||||
run: async (args) => {
|
||||
const indexers = await jackett.getIndexers(!args.all);
|
||||
return { count: indexers.length, indexers };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "jackett_search",
|
||||
description: "Torznab search across Jackett's indexers, returning normalised torrent results.",
|
||||
input: {
|
||||
query: { type: "string", description: "the search query" },
|
||||
indexer: { type: "string", description: 'an indexer id, or "all" to aggregate (default "all")' },
|
||||
limit: { type: "number", description: "max results (default 25)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const query = String(args.query);
|
||||
const results = await jackett.search(
|
||||
query,
|
||||
args.indexer ? String(args.indexer) : "all",
|
||||
args.limit ? Number(args.limit) : 25,
|
||||
);
|
||||
return { query, count: results.length, results };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Only exposed when Jackett is configured; otherwise jackett contributes no tools rather than
|
||||
// failing the whole runtime.
|
||||
registerModuleTools("jackett", (env) => {
|
||||
try {
|
||||
return getJackettTools(JackettClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "tools/index.ts"]
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// The Keycloak admin API client — keycloak's own code, living in the module (novox/hq ADR 0044).
|
||||
// Moved out of the shared hal sdk, where a change to Keycloak's admin API rebuilt everything; here
|
||||
// it rebuilds only keycloak. Both this module's tools and its events entrypoint import it, and
|
||||
// nothing outside keycloak does.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
|
||||
function meshConfig(file?: string): Record<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
export class KeycloakClient {
|
||||
readonly baseUrl: string;
|
||||
readonly defaultRealm: string;
|
||||
// The admin token is short-lived; caching it (minus a safety margin) spares every call a fresh
|
||||
// password grant, and a 401 mid-flight refreshes it once rather than failing the request.
|
||||
private tokenCache: { token: string; expiresAt: number } | null = null;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly adminUser: string,
|
||||
private readonly adminPass: string,
|
||||
defaultRealm = "master",
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/+$/, "");
|
||||
this.defaultRealm = defaultRealm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build from the module's resolved environment. Admin URL, credentials and the fallback realm are
|
||||
* read from MESH_KEYCLOAK_* — the names the mesh sets — falling back to the container's own
|
||||
* KEYCLOAK_ADMIN/KEYCLOAK_ADMIN_PASSWORD so a co-located server needs nothing configured twice.
|
||||
* Throws when no admin password can be found: without it the client can do nothing, so failing
|
||||
* here lets the tool runtime expose no keycloak tools rather than tools that always error.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): KeycloakClient {
|
||||
const cfg = meshConfig(env.MESH_KEYCLOAK_CONFIG_FILE);
|
||||
const url = cfg.url ?? env.MESH_KEYCLOAK_URL ?? `http://127.0.0.1:${env.KEYCLOAK_PORT ?? "8080"}`;
|
||||
const adminUser = cfg.user ?? env.MESH_KEYCLOAK_ADMIN ?? env.KEYCLOAK_ADMIN ?? "admin";
|
||||
const adminPass = cfg.password ?? env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD;
|
||||
if (!adminPass) throw new Error("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD");
|
||||
const realm = cfg.realm ?? env.MESH_KEYCLOAK_REALM ?? "master";
|
||||
return new KeycloakClient(url, adminUser, adminPass, realm);
|
||||
}
|
||||
|
||||
private async getToken(): Promise<string> {
|
||||
if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) return this.tokenCache.token;
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/realms/master/protocol/openid-connect/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "password",
|
||||
client_id: "admin-cli",
|
||||
username: this.adminUser,
|
||||
password: this.adminPass,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Keycloak token request failed: ${res.status} ${await res.text()}`);
|
||||
|
||||
const data = (await res.json()) as { access_token: string; expires_in: number };
|
||||
this.tokenCache = { token: data.access_token, expiresAt: Date.now() + (data.expires_in - 30) * 1000 };
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
private async request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const doRequest = async (token: string): Promise<Response> =>
|
||||
fetch(`${this.baseUrl}/admin/realms${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(options.headers as Record<string, string>),
|
||||
},
|
||||
});
|
||||
|
||||
let res = await doRequest(await this.getToken());
|
||||
// A cached token that expired against the server's clock reads as 401; drop it and retry once.
|
||||
if (res.status === 401) {
|
||||
this.tokenCache = null;
|
||||
res = await doRequest(await this.getToken());
|
||||
}
|
||||
if (!res.ok) throw new Error(`Keycloak API error ${res.status}: ${await res.text()}`);
|
||||
// 201/204 carry no body — the admin API's create/update/delete answer with an empty response.
|
||||
if (res.status === 201 || res.status === 204) return null as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// Realms
|
||||
async listRealms(): Promise<Array<{ id: string; realm: string; displayName?: string; enabled: boolean }>> {
|
||||
return this.request("/");
|
||||
}
|
||||
|
||||
// Users
|
||||
async listUsers(realm: string, params: { search?: string; max?: number } = {}): Promise<unknown[]> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.max) qs.set("max", String(params.max));
|
||||
const query = qs.toString();
|
||||
return this.request(`/${realm}/users${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
async createUser(realm: string, data: {
|
||||
username: string;
|
||||
email?: string;
|
||||
enabled?: boolean;
|
||||
credentials?: Array<{ type: string; value: string; temporary: boolean }>;
|
||||
}): Promise<void> {
|
||||
await this.request(`/${realm}/users`, { method: "POST", body: JSON.stringify({ enabled: true, ...data }) });
|
||||
}
|
||||
|
||||
async updateUser(realm: string, userId: string, data: Record<string, unknown>): Promise<void> {
|
||||
await this.request(`/${realm}/users/${userId}`, { method: "PUT", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async deleteUser(realm: string, userId: string): Promise<void> {
|
||||
await this.request(`/${realm}/users/${userId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async resetPassword(realm: string, userId: string, password: string, temporary = false): Promise<void> {
|
||||
await this.request(`/${realm}/users/${userId}/reset-password`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ type: "password", value: password, temporary }),
|
||||
});
|
||||
}
|
||||
|
||||
async getUserSessions(realm: string, userId: string): Promise<unknown[]> {
|
||||
return this.request(`/${realm}/users/${userId}/sessions`);
|
||||
}
|
||||
|
||||
// Clients
|
||||
async listClients(realm: string): Promise<unknown[]> {
|
||||
return this.request(`/${realm}/clients`);
|
||||
}
|
||||
|
||||
async createClient(realm: string, data: {
|
||||
clientId: string;
|
||||
name?: string;
|
||||
rootUrl?: string;
|
||||
redirectUris?: string[];
|
||||
publicClient?: boolean;
|
||||
protocol?: string;
|
||||
}): Promise<void> {
|
||||
await this.request(`/${realm}/clients`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ protocol: "openid-connect", enabled: true, ...data }),
|
||||
});
|
||||
}
|
||||
|
||||
// The admin API addresses a client by its internal UUID, not the human clientId a caller knows;
|
||||
// every client-scoped call resolves the one to the other first.
|
||||
private async resolveClientId(realm: string, clientId: string): Promise<string> {
|
||||
const clients = (await this.listClients(realm)) as Array<Record<string, unknown>>;
|
||||
const client = clients.find((c) => c.clientId === clientId);
|
||||
if (!client) throw new Error(`Client '${clientId}' not found in realm '${realm}'`);
|
||||
return client.id as string;
|
||||
}
|
||||
|
||||
async deleteClient(realm: string, clientId: string): Promise<void> {
|
||||
await this.request(`/${realm}/clients/${await this.resolveClientId(realm, clientId)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async getClientSecret(realm: string, clientId: string): Promise<string> {
|
||||
const id = await this.resolveClientId(realm, clientId);
|
||||
const result = await this.request<{ value: string }>(`/${realm}/clients/${id}/client-secret`);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
async addProtocolMapper(realm: string, clientId: string, mapper: {
|
||||
name: string;
|
||||
protocolMapper: string;
|
||||
config: Record<string, string>;
|
||||
}): Promise<void> {
|
||||
const id = await this.resolveClientId(realm, clientId);
|
||||
await this.request(`/${realm}/clients/${id}/protocol-mappers/models`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ protocol: "openid-connect", ...mapper }),
|
||||
});
|
||||
}
|
||||
|
||||
// Roles
|
||||
async listRealmRoles(realm: string): Promise<Array<{ id: string; name: string; description?: string; composite: boolean }>> {
|
||||
return this.request(`/${realm}/roles`);
|
||||
}
|
||||
|
||||
async createRealmRole(realm: string, data: { name: string; description?: string }): Promise<void> {
|
||||
await this.request(`/${realm}/roles`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async getUserRealmRoles(realm: string, userId: string): Promise<Array<{ id: string; name: string; description?: string }>> {
|
||||
return this.request(`/${realm}/users/${userId}/role-mappings/realm`);
|
||||
}
|
||||
|
||||
async getAvailableRealmRoles(realm: string, userId: string): Promise<Array<{ id: string; name: string; description?: string }>> {
|
||||
return this.request(`/${realm}/users/${userId}/role-mappings/realm/available`);
|
||||
}
|
||||
|
||||
async assignRealmRoles(realm: string, userId: string, roles: Array<{ id: string; name: string }>): Promise<void> {
|
||||
await this.request(`/${realm}/users/${userId}/role-mappings/realm`, { method: "POST", body: JSON.stringify(roles) });
|
||||
}
|
||||
|
||||
async removeRealmRoles(realm: string, userId: string, roles: Array<{ id: string; name: string }>): Promise<void> {
|
||||
await this.request(`/${realm}/users/${userId}/role-mappings/realm`, { method: "DELETE", body: JSON.stringify(roles) });
|
||||
}
|
||||
|
||||
// Groups
|
||||
async listGroups(realm: string): Promise<Array<{ id: string; name: string; path: string; subGroupCount?: number }>> {
|
||||
return this.request(`/${realm}/groups`);
|
||||
}
|
||||
|
||||
async createGroup(realm: string, name: string): Promise<void> {
|
||||
await this.request(`/${realm}/groups`, { method: "POST", body: JSON.stringify({ name }) });
|
||||
}
|
||||
|
||||
async getUserGroups(realm: string, userId: string): Promise<Array<{ id: string; name: string; path: string }>> {
|
||||
return this.request(`/${realm}/users/${userId}/groups`);
|
||||
}
|
||||
|
||||
async addUserToGroup(realm: string, userId: string, groupId: string): Promise<void> {
|
||||
await this.request(`/${realm}/users/${userId}/groups/${groupId}`, { method: "PUT" });
|
||||
}
|
||||
|
||||
async removeUserFromGroup(realm: string, userId: string, groupId: string): Promise<void> {
|
||||
await this.request(`/${realm}/users/${userId}/groups/${groupId}`, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// keycloak's events. Keycloak's worth to the mesh is in what it changes — an identity created, a
|
||||
// client registered, a password reset — so its events are emitted from the admin actions themselves
|
||||
// (novox/hq ADR 0046/0047), not scraped back by polling. This module is the single vocabulary for
|
||||
// them: every keycloak event goes through one of the helpers here, and the tools call them at the
|
||||
// point the change succeeds.
|
||||
//
|
||||
// Emits:
|
||||
// module.keycloak.user.created / .deleted — an identity appeared or was removed
|
||||
// module.keycloak.password.reset — a user's credential was reset (no secret in the body)
|
||||
// module.keycloak.client.created — an OIDC client was registered
|
||||
// module.keycloak.group.created — a group was created
|
||||
// module.keycloak.role.created — a realm role was created
|
||||
// Consumes:
|
||||
// nothing — Keycloak is upstream of the things that authenticate against it; it reacts to none of
|
||||
// their events. There is no honest `on(...)` to write, so there is none.
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
|
||||
// A completed admin action must not be undone by a flaky broker: the change already happened in
|
||||
// Keycloak, so a failed emit is logged and swallowed rather than thrown back through the tool.
|
||||
async function announce(type: string, body: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
await emit(type, body);
|
||||
} catch (err) {
|
||||
console.error(`[keycloak] emit ${type} failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const events = {
|
||||
userCreated: (realm: string, username: string, email?: string) =>
|
||||
announce("module.keycloak.user.created", { realm, username, ...(email ? { email } : {}) }),
|
||||
userDeleted: (realm: string, userId: string) =>
|
||||
announce("module.keycloak.user.deleted", { realm, userId }),
|
||||
passwordReset: (realm: string, userId: string) =>
|
||||
announce("module.keycloak.password.reset", { realm, userId }),
|
||||
clientCreated: (realm: string, clientId: string, name?: string) =>
|
||||
announce("module.keycloak.client.created", { realm, clientId, ...(name ? { name } : {}) }),
|
||||
groupCreated: (realm: string, name: string) =>
|
||||
announce("module.keycloak.group.created", { realm, name }),
|
||||
roleCreated: (realm: string, name: string) =>
|
||||
announce("module.keycloak.role.created", { realm, name }),
|
||||
};
|
||||
|
||||
console.log("[keycloak] event surface ready — identity, client, group and role changes are announced");
|
||||
@@ -18,6 +18,14 @@
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.keycloak.user.created",
|
||||
"module.keycloak.user.deleted",
|
||||
"module.keycloak.password.reset",
|
||||
"module.keycloak.client.created",
|
||||
"module.keycloak.group.created",
|
||||
"module.keycloak.role.created"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"port": 8080,
|
||||
@@ -27,9 +35,16 @@
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"admin": "/var/lib/keycloak/admin.secret"
|
||||
"admin": "/var/lib/keycloak/admin.secret",
|
||||
"broker": "/var/lib/mesh/keycloak/broker"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/keycloak",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -76,6 +91,33 @@
|
||||
"ports": [
|
||||
"8080"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/keycloak/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-keycloak",
|
||||
"image": "mesh-runtime-keycloak@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/keycloak/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/keycloak/config.json:/run/config/config.json:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_KEYCLOAK_URL": "http://127.0.0.1:8080",
|
||||
"MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-keycloak",
|
||||
"version": "0.1.0",
|
||||
"description": "keycloak — identity and access. Its admin API client, tools 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
// keycloak's tools — moved here from the shared sdk (novox/hq ADR 0044), importing keycloak's own
|
||||
// client. They return structured data (not the hal MCP `{content:[...]}` shape); the mesh serves
|
||||
// them through the sdk's tool harness. Write actions announce themselves through the module's event
|
||||
// surface at the point they succeed.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { KeycloakClient } from "../client.js";
|
||||
import { events } from "../index.js";
|
||||
|
||||
export function getKeycloakTools(kc: KeycloakClient): ToolDefinition[] {
|
||||
// Almost every tool is realm-scoped; an omitted realm falls back to the one the module resolved
|
||||
// from its environment, so the common single-realm case needs no argument.
|
||||
const realmOf = (args: Readonly<Record<string, unknown>>): string =>
|
||||
args.realm ? String(args.realm) : kc.defaultRealm;
|
||||
|
||||
return [
|
||||
// Realms & sessions
|
||||
{
|
||||
name: "keycloak_list_realms",
|
||||
description: "List all Keycloak realms.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const realms = await kc.listRealms();
|
||||
return { realms: realms.map((r) => ({ id: r.id, realm: r.realm, displayName: r.displayName, enabled: r.enabled })) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keycloak_list_sessions",
|
||||
description: "List active sessions for a user in a Keycloak realm.",
|
||||
input: {
|
||||
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
|
||||
user_id: { type: "string", description: "user ID (UUID)" },
|
||||
},
|
||||
run: async (args) => ({ sessions: await kc.getUserSessions(realmOf(args), String(args.user_id)) }),
|
||||
},
|
||||
|
||||
// Users
|
||||
{
|
||||
name: "keycloak_list_users",
|
||||
description: "List users in a Keycloak realm.",
|
||||
input: {
|
||||
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
|
||||
search: { type: "string", description: "search by username, email, first/last name" },
|
||||
max: { type: "number", description: "maximum number of results" },
|
||||
},
|
||||
run: async (args) => ({
|
||||
users: await kc.listUsers(realmOf(args), {
|
||||
search: args.search ? String(args.search) : undefined,
|
||||
max: args.max ? Number(args.max) : undefined,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "keycloak_create_user",
|
||||
description: "Create a user in a Keycloak realm.",
|
||||
input: {
|
||||
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
|
||||
username: { type: "string", description: "username" },
|
||||
email: { type: "string", description: "email address" },
|
||||
password: { type: "string", description: "initial password" },
|
||||
temporary_password: { type: "boolean", description: "require a password change on first login (default true)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const realm = realmOf(args);
|
||||
const username = String(args.username);
|
||||
const email = args.email ? String(args.email) : undefined;
|
||||
const credentials = args.password
|
||||
? [{ type: "password", value: String(args.password), temporary: args.temporary_password !== false }]
|
||||
: undefined;
|
||||
await kc.createUser(realm, { username, email, credentials });
|
||||
await events.userCreated(realm, username, email);
|
||||
return { created: { realm, username, email } };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keycloak_delete_user",
|
||||
description: "Delete a user from a Keycloak realm (requires confirm).",
|
||||
input: {
|
||||
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
|
||||
user_id: { type: "string", description: "user ID (UUID)" },
|
||||
confirm: { type: "boolean", description: "must be true to confirm deletion" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const realm = realmOf(args);
|
||||
const userId = String(args.user_id);
|
||||
if (args.confirm !== true) return { aborted: "confirm must be true to delete a user" };
|
||||
await kc.deleteUser(realm, userId);
|
||||
await events.userDeleted(realm, userId);
|
||||
return { deleted: { realm, userId } };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keycloak_update_user",
|
||||
description: "Update a user's attributes in a Keycloak realm (enable/disable, change email, name).",
|
||||
input: {
|
||||
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
|
||||
user_id: { type: "string", description: "user ID (UUID)" },
|
||||
enabled: { type: "boolean", description: "enable or disable the user" },
|
||||
email: { type: "string", description: "new email address" },
|
||||
firstName: { type: "string", description: "new first name" },
|
||||
lastName: { type: "string", description: "new last name" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const realm = realmOf(args);
|
||||
const userId = String(args.user_id);
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (args.enabled !== undefined) updates.enabled = args.enabled === true;
|
||||
if (args.email !== undefined) updates.email = String(args.email);
|
||||
if (args.firstName !== undefined) updates.firstName = String(args.firstName);
|
||||
if (args.lastName !== undefined) updates.lastName = String(args.lastName);
|
||||
if (Object.keys(updates).length === 0) return { aborted: "no updates provided" };
|
||||
await kc.updateUser(realm, userId, updates);
|
||||
return { updated: { realm, userId, fields: Object.keys(updates) } };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keycloak_reset_password",
|
||||
description: "Reset a user's password in a Keycloak realm.",
|
||||
input: {
|
||||
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
|
||||
user_id: { type: "string", description: "user ID (UUID)" },
|
||||
password: { type: "string", description: "new password" },
|
||||
temporary: { type: "boolean", description: "require a password change on next login (default false)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const realm = realmOf(args);
|
||||
const userId = String(args.user_id);
|
||||
await kc.resetPassword(realm, userId, String(args.password), args.temporary === true);
|
||||
await events.passwordReset(realm, userId);
|
||||
return { reset: { realm, userId } };
|
||||
},
|
||||
},
|
||||
|
||||
// Clients
|
||||
{
|
||||
name: "keycloak_list_clients",
|
||||
description: "List OIDC clients in a Keycloak realm.",
|
||||
input: { realm: { type: "string", description: "realm name (defaults to the module's realm)" } },
|
||||
run: async (args) => {
|
||||
const clients = (await kc.listClients(realmOf(args))) as Array<Record<string, unknown>>;
|
||||
return {
|
||||
clients: clients.map((c) => ({
|
||||
id: c.id, clientId: c.clientId, name: c.name, enabled: c.enabled,
|
||||
protocol: c.protocol, publicClient: c.publicClient, rootUrl: c.rootUrl,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keycloak_create_client",
|
||||
description: "Create an OIDC client in a Keycloak realm.",
|
||||
input: {
|
||||
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
|
||||
client_id: { type: "string", description: "client ID (e.g. 'my-app')" },
|
||||
name: { type: "string", description: "display name" },
|
||||
root_url: { type: "string", description: "root URL of the application" },
|
||||
redirect_uris: { type: "array", description: "allowed redirect URIs" },
|
||||
public_client: { type: "boolean", description: "public client, no client secret (default true)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const realm = realmOf(args);
|
||||
const clientId = String(args.client_id);
|
||||
const name = args.name ? String(args.name) : undefined;
|
||||
await kc.createClient(realm, {
|
||||
clientId,
|
||||
name,
|
||||
rootUrl: args.root_url ? String(args.root_url) : undefined,
|
||||
redirectUris: Array.isArray(args.redirect_uris) ? args.redirect_uris.map(String) : undefined,
|
||||
publicClient: args.public_client !== false,
|
||||
});
|
||||
await events.clientCreated(realm, clientId, name);
|
||||
return { created: { realm, clientId, name } };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keycloak_delete_client",
|
||||
description: "Delete an OIDC client from a Keycloak realm (requires confirm).",
|
||||
input: {
|
||||
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
|
||||
client_id: { type: "string", description: "client ID (e.g. 'my-app')" },
|
||||
confirm: { type: "boolean", description: "must be true to confirm deletion" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const realm = realmOf(args);
|
||||
const clientId = String(args.client_id);
|
||||
if (args.confirm !== true) return { aborted: "confirm must be true to delete a client" };
|
||||
await kc.deleteClient(realm, clientId);
|
||||
return { deleted: { realm, clientId } };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keycloak_get_client_secret",
|
||||
description: "Get the client secret for a confidential OIDC client.",
|
||||
input: {
|
||||
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
|
||||
client_id: { type: "string", description: "client ID" },
|
||||
},
|
||||
run: async (args) => ({ secret: await kc.getClientSecret(realmOf(args), String(args.client_id)) }),
|
||||
},
|
||||
{
|
||||
name: "keycloak_add_protocol_mapper",
|
||||
description:
|
||||
"Add a protocol mapper to an OIDC client. Common types: oidc-usermodel-realm-role-mapper " +
|
||||
"(realm roles), oidc-usermodel-attribute-mapper (user attributes), oidc-audience-mapper.",
|
||||
input: {
|
||||
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
|
||||
client_id: { type: "string", description: "client ID (e.g. 'grafana')" },
|
||||
name: { type: "string", description: "mapper name (e.g. 'realm roles')" },
|
||||
mapper_type: { type: "string", description: "protocol mapper type (e.g. 'oidc-usermodel-realm-role-mapper')" },
|
||||
claim_name: { type: "string", description: "token claim name (e.g. 'realm_access.roles')" },
|
||||
claim_type: { type: "string", description: "JSON type: String, long, int, boolean (default String)" },
|
||||
multivalued: { type: "boolean", description: "whether the claim has multiple values (default false)" },
|
||||
id_token: { type: "boolean", description: "include in ID token (default true)" },
|
||||
access_token: { type: "boolean", description: "include in access token (default true)" },
|
||||
userinfo: { type: "boolean", description: "include in userinfo response (default true)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const realm = realmOf(args);
|
||||
const clientId = String(args.client_id);
|
||||
const name = String(args.name);
|
||||
await kc.addProtocolMapper(realm, clientId, {
|
||||
name,
|
||||
protocolMapper: String(args.mapper_type),
|
||||
config: {
|
||||
"claim.name": String(args.claim_name),
|
||||
"jsonType.label": args.claim_type ? String(args.claim_type) : "String",
|
||||
"multivalued": String(args.multivalued === true),
|
||||
"id.token.claim": String(args.id_token !== false),
|
||||
"access.token.claim": String(args.access_token !== false),
|
||||
"userinfo.token.claim": String(args.userinfo !== false),
|
||||
},
|
||||
});
|
||||
return { added: { realm, clientId, mapper: name } };
|
||||
},
|
||||
},
|
||||
|
||||
// Groups
|
||||
{
|
||||
name: "keycloak_list_groups",
|
||||
description: "List groups in a Keycloak realm.",
|
||||
input: { realm: { type: "string", description: "realm name (defaults to the module's realm)" } },
|
||||
run: async (args) => ({ groups: await kc.listGroups(realmOf(args)) }),
|
||||
},
|
||||
{
|
||||
name: "keycloak_create_group",
|
||||
description: "Create a group in a Keycloak realm.",
|
||||
input: {
|
||||
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
|
||||
name: { type: "string", description: "group name" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const realm = realmOf(args);
|
||||
const name = String(args.name);
|
||||
await kc.createGroup(realm, name);
|
||||
await events.groupCreated(realm, name);
|
||||
return { created: { realm, group: name } };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keycloak_get_user_groups",
|
||||
description: "List the groups a user belongs to in a Keycloak realm.",
|
||||
input: {
|
||||
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
|
||||
user_id: { type: "string", description: "user ID (UUID)" },
|
||||
},
|
||||
run: async (args) => ({ groups: await kc.getUserGroups(realmOf(args), String(args.user_id)) }),
|
||||
},
|
||||
{
|
||||
name: "keycloak_add_user_to_group",
|
||||
description: "Add a user to a group in a Keycloak realm.",
|
||||
input: {
|
||||
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
|
||||
user_id: { type: "string", description: "user ID (UUID)" },
|
||||
group_id: { type: "string", description: "group ID (UUID)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const realm = realmOf(args);
|
||||
await kc.addUserToGroup(realm, String(args.user_id), String(args.group_id));
|
||||
return { added: { realm, userId: String(args.user_id), groupId: String(args.group_id) } };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keycloak_remove_user_from_group",
|
||||
description: "Remove a user from a group in a Keycloak realm.",
|
||||
input: {
|
||||
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
|
||||
user_id: { type: "string", description: "user ID (UUID)" },
|
||||
group_id: { type: "string", description: "group ID (UUID)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const realm = realmOf(args);
|
||||
await kc.removeUserFromGroup(realm, String(args.user_id), String(args.group_id));
|
||||
return { removed: { realm, userId: String(args.user_id), groupId: String(args.group_id) } };
|
||||
},
|
||||
},
|
||||
|
||||
// Roles
|
||||
{
|
||||
name: "keycloak_get_user_roles",
|
||||
description: "List the realm roles assigned to a user in a Keycloak realm.",
|
||||
input: {
|
||||
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
|
||||
user_id: { type: "string", description: "user ID (UUID)" },
|
||||
},
|
||||
run: async (args) => ({ roles: await kc.getUserRealmRoles(realmOf(args), String(args.user_id)) }),
|
||||
},
|
||||
{
|
||||
name: "keycloak_create_role",
|
||||
description: "Create a realm role in a Keycloak realm.",
|
||||
input: {
|
||||
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
|
||||
role_name: { type: "string", description: "role name" },
|
||||
description: { type: "string", description: "role description" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const realm = realmOf(args);
|
||||
const name = String(args.role_name);
|
||||
await kc.createRealmRole(realm, { name, description: args.description ? String(args.description) : undefined });
|
||||
await events.roleCreated(realm, name);
|
||||
return { created: { realm, role: name } };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keycloak_assign_user_role",
|
||||
description: "Assign an existing realm role to a user. Create it first with keycloak_create_role if needed.",
|
||||
input: {
|
||||
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
|
||||
user_id: { type: "string", description: "user ID (UUID)" },
|
||||
role_name: { type: "string", description: "role name to assign" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const realm = realmOf(args);
|
||||
const userId = String(args.user_id);
|
||||
const roleName = String(args.role_name);
|
||||
// The mapping API needs the role's UUID, which only the "available" list carries; if the
|
||||
// role is neither available nor already assigned it does not exist in this realm.
|
||||
const available = await kc.getAvailableRealmRoles(realm, userId);
|
||||
const role = available.find((r) => r.name === roleName);
|
||||
if (!role) {
|
||||
const assigned = await kc.getUserRealmRoles(realm, userId);
|
||||
if (assigned.find((r) => r.name === roleName)) return { alreadyAssigned: { realm, userId, role: roleName } };
|
||||
return { notFound: { realm, role: roleName } };
|
||||
}
|
||||
await kc.assignRealmRoles(realm, userId, [{ id: role.id, name: role.name }]);
|
||||
return { assigned: { realm, userId, role: roleName } };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keycloak_remove_user_role",
|
||||
description: "Remove a realm role from a user in a Keycloak realm.",
|
||||
input: {
|
||||
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
|
||||
user_id: { type: "string", description: "user ID (UUID)" },
|
||||
role_name: { type: "string", description: "role name to remove" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const realm = realmOf(args);
|
||||
const userId = String(args.user_id);
|
||||
const roleName = String(args.role_name);
|
||||
const assigned = await kc.getUserRealmRoles(realm, userId);
|
||||
const role = assigned.find((r) => r.name === roleName);
|
||||
if (!role) return { notAssigned: { realm, userId, role: roleName } };
|
||||
await kc.removeRealmRoles(realm, userId, [{ id: role.id, name: role.name }]);
|
||||
return { removed: { realm, userId, role: roleName } };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The tools exist only when the client can be configured; without an admin password, keycloak
|
||||
// contributes none rather than failing the whole runtime.
|
||||
registerModuleTools("keycloak", (env) => {
|
||||
try {
|
||||
return getKeycloakTools(KeycloakClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// The Mailu API client — mailu's own code, living in the module (novox/hq ADR 0044). Moved out of
|
||||
// the shared hal sdk, where a change to Mailu's surface rebuilt everything; here it rebuilds only
|
||||
// mailu. Both this module's tools and its events entrypoint import it, and nothing outside mailu does.
|
||||
//
|
||||
// hal drove Mailu through its flask CLI over `docker compose exec` into the admin container. That
|
||||
// coupling was to the container, not to Mailu: it needed a shell on the box. The module's real
|
||||
// coupling is the admin REST API, so that is what this client speaks — a token and a URL, no shell.
|
||||
// The one exception is reading a mailbox: the admin API exposes no message reads, so that alone
|
||||
// falls back to `doveadm` inside the imap container, the operation the HTTP surface cannot serve.
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
export interface MailuUser {
|
||||
email: string;
|
||||
displayed_name?: string;
|
||||
global_admin?: boolean;
|
||||
enabled?: boolean;
|
||||
forward_enabled?: boolean;
|
||||
forward_destination?: string[];
|
||||
quota_bytes?: number;
|
||||
}
|
||||
|
||||
export interface MailuAlias {
|
||||
email: string;
|
||||
destination: string[];
|
||||
wildcard?: boolean;
|
||||
}
|
||||
|
||||
export interface MailuDomain {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** One parsed message from a doveadm fetch — the subset the read/search tools surface. */
|
||||
export interface MailMessage {
|
||||
date?: string;
|
||||
from?: string;
|
||||
subject?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
// The fields we ask doveadm for, once — kept together so read and search stay identical in shape.
|
||||
const FETCH_FIELDS = "date.received hdr.subject hdr.from body.snippet";
|
||||
|
||||
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
|
||||
function meshConfig(file?: string): Record<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
export class MailuClient {
|
||||
readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly apiKey: string,
|
||||
/** The container name doveadm runs in — reads bypass the API, so they need the box, not a token. */
|
||||
private readonly imapContainer: string,
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build from the module's resolved environment. MESH_MAILU_URL points at the admin API (e.g. the
|
||||
* admin container's /api/v1), MESH_MAILU_API_KEY authenticates against it. Both are required — a
|
||||
* client with neither would only fail later, one call at a time, so it fails here instead.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): MailuClient {
|
||||
const cfg = meshConfig(env.MESH_MAILU_CONFIG_FILE);
|
||||
const url = cfg.url ?? env.MESH_MAILU_URL;
|
||||
const apiKey = cfg.apiKey ?? env.MESH_MAILU_API_KEY;
|
||||
if (!url || !apiKey) {
|
||||
throw new Error("Mailu is not configured — set MESH_MAILU_URL and MESH_MAILU_API_KEY");
|
||||
}
|
||||
const imapContainer = cfg.container ?? env.MESH_MAILU_IMAP_CONTAINER ?? "mailu-imap";
|
||||
return new MailuClient(url, apiKey, imapContainer);
|
||||
}
|
||||
|
||||
// --- The admin REST API: users, aliases, domains. ---------------------------------------------
|
||||
|
||||
private async api<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
// Mailu's admin API takes the token directly in Authorization, no scheme prefix.
|
||||
Authorization: this.apiKey,
|
||||
Accept: "application/json",
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Mailu API ${method} ${path}: ${res.status} ${await res.text()}`);
|
||||
// DELETE and some writes answer with an empty body or a bare string; guard the JSON parse.
|
||||
const text = await res.text();
|
||||
return (text ? JSON.parse(text) : undefined) as T;
|
||||
}
|
||||
|
||||
async listUsers(): Promise<MailuUser[]> {
|
||||
const users = await this.api<any[]>("GET", "/user");
|
||||
return (users ?? []).map((u) => ({
|
||||
email: u.email,
|
||||
displayed_name: u.displayed_name,
|
||||
global_admin: u.global_admin,
|
||||
enabled: u.enabled,
|
||||
forward_enabled: u.forward_enabled,
|
||||
forward_destination: u.forward_destination,
|
||||
quota_bytes: u.quota_bytes,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Create a mailbox. Mailu wants the full address and the plaintext password it will hash. */
|
||||
async createUser(email: string, password: string): Promise<void> {
|
||||
await this.api("POST", "/user", { email, raw_password: password });
|
||||
}
|
||||
|
||||
async changePassword(email: string, password: string): Promise<void> {
|
||||
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password });
|
||||
}
|
||||
|
||||
async deleteUser(email: string): Promise<void> {
|
||||
await this.api("DELETE", `/user/${encodeURIComponent(email)}`);
|
||||
}
|
||||
|
||||
async listAliases(): Promise<MailuAlias[]> {
|
||||
const aliases = await this.api<any[]>("GET", "/alias");
|
||||
return (aliases ?? []).map((a) => ({
|
||||
email: a.email,
|
||||
// The API returns destination as a comma-joined string on some versions, a list on others.
|
||||
destination: Array.isArray(a.destination)
|
||||
? a.destination
|
||||
: String(a.destination ?? "").split(",").map((d: string) => d.trim()).filter(Boolean),
|
||||
wildcard: a.wildcard,
|
||||
}));
|
||||
}
|
||||
|
||||
async createAlias(email: string, destination: string[], wildcard = false): Promise<void> {
|
||||
await this.api("POST", "/alias", { email, destination, wildcard });
|
||||
}
|
||||
|
||||
async deleteAlias(email: string): Promise<void> {
|
||||
await this.api("DELETE", `/alias/${encodeURIComponent(email)}`);
|
||||
}
|
||||
|
||||
async listDomains(): Promise<MailuDomain[]> {
|
||||
const domains = await this.api<any[]>("GET", "/domain");
|
||||
return (domains ?? []).map((d) => ({ name: d.name }));
|
||||
}
|
||||
|
||||
// --- Reading mail: doveadm, because the admin API has no message reads. -----------------------
|
||||
|
||||
/** Recent messages in a mailbox, newest last, capped to `limit`. */
|
||||
async readMail(user: string, mailbox = "INBOX", limit = 10): Promise<MailMessage[]> {
|
||||
const messages = await this.doveadmFetch(user, ["mailbox", mailbox]);
|
||||
return messages.slice(-limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search a mailbox by subject, sender and/or date. doveadm fetch takes a search query directly,
|
||||
* so we build one from whichever criteria were given — `all` when none were, to avoid an empty
|
||||
* query that would match nothing.
|
||||
*/
|
||||
async searchMail(
|
||||
user: string,
|
||||
criteria: { subject?: string; from?: string; since?: string },
|
||||
limit = 10,
|
||||
): Promise<MailMessage[]> {
|
||||
const query: string[] = [];
|
||||
if (criteria.subject) query.push("subject", criteria.subject);
|
||||
if (criteria.from) query.push("from", criteria.from);
|
||||
if (criteria.since) query.push("since", criteria.since);
|
||||
if (query.length === 0) query.push("all");
|
||||
const messages = await this.doveadmFetch(user, query);
|
||||
return messages.slice(-limit);
|
||||
}
|
||||
|
||||
private async doveadmFetch(user: string, query: string[]): Promise<MailMessage[]> {
|
||||
const output = await run(
|
||||
"docker",
|
||||
["exec", "-i", this.imapContainer, "doveadm", "fetch", "-u", user, FETCH_FIELDS, ...query],
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.then((r) => r.stdout)
|
||||
// An empty mailbox is not an error; doveadm says so on stderr and exits non-zero.
|
||||
.catch((e: { stderr?: string; message?: string }) => {
|
||||
const text = `${e.stderr ?? ""}${e.message ?? ""}`;
|
||||
if (text.includes("no matching mails")) return "";
|
||||
throw e;
|
||||
});
|
||||
return parseDoveadmFetch(output).map(toMailMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// doveadm fetch prints one record per message, records separated by a blank line (a form feed in
|
||||
// some builds), each field on its own `name: value` line. A folded value continues on later lines.
|
||||
function parseDoveadmFetch(output: string): Array<Record<string, string>> {
|
||||
const messages: Array<Record<string, string>> = [];
|
||||
let current: Record<string, string> = {};
|
||||
for (const line of output.split("\n")) {
|
||||
if (line === "" || line === "\f") {
|
||||
if (Object.keys(current).length) {
|
||||
messages.push(current);
|
||||
current = {};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const colonIdx = line.indexOf(": ");
|
||||
if (colonIdx > 0) {
|
||||
const key = line.slice(0, colonIdx);
|
||||
const value = line.slice(colonIdx + 2);
|
||||
current[key] = current[key] ? `${current[key]}\n${value}` : value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(current).length) messages.push(current);
|
||||
return messages;
|
||||
}
|
||||
|
||||
function toMailMessage(m: Record<string, string>): MailMessage {
|
||||
const preview = m["body.snippet"];
|
||||
return {
|
||||
date: m["date.received"]?.trim(),
|
||||
from: m["hdr.from"]?.trim(),
|
||||
subject: m["hdr.subject"]?.trim(),
|
||||
preview: preview ? preview.trim().slice(0, 200) : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// mailu's events. The tool runtime imports this once the broker is bound. It watches the mail
|
||||
// server's accounts and announces what changed, so the rest of the mesh can react to a mailbox
|
||||
// appearing or an alias being pointed somewhere new.
|
||||
//
|
||||
// Emits (novox/hq ADR 0046/0047):
|
||||
// module.mailu.user.created / .deleted — a mailbox appeared or was removed
|
||||
// module.mailu.alias.created / .deleted — an alias was added or removed
|
||||
//
|
||||
// Consumes: nothing. Mail accounts are not something the mesh should mutate in the background off
|
||||
// another module's event — a wrong reaction here silently loses mail. mailu observes and announces;
|
||||
// it does not act on what others do. If a real consumer is ever wanted, it is a deliberate addition.
|
||||
//
|
||||
// Detection is by polling the admin API and diffing, exactly as plex diffs its sessions: the change
|
||||
// may have come from the web admin as easily as from a tool, and a diff catches both. The first
|
||||
// look primes silently, or a restart would re-announce every existing account as freshly created.
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { MailuClient } from "./client.js";
|
||||
|
||||
const mailu = MailuClient.fromEnv();
|
||||
|
||||
// A generic diff over a keyed set: emit `created` for keys that appeared, `deleted` for keys that
|
||||
// went away, and stay silent until primed. Users and aliases are the same shape of watch.
|
||||
function watcher(created: string, deleted: string): (keys: string[]) => Promise<void> {
|
||||
const known = new Set<string>();
|
||||
let primed = false;
|
||||
return async (keys: string[]) => {
|
||||
const now = new Set(keys);
|
||||
if (primed) {
|
||||
for (const key of now) if (!known.has(key)) await emit(created, { email: key });
|
||||
for (const key of known) if (!now.has(key)) await emit(deleted, { email: key });
|
||||
}
|
||||
known.clear();
|
||||
for (const key of now) known.add(key);
|
||||
primed = true;
|
||||
};
|
||||
}
|
||||
|
||||
const watchUsers = watcher("module.mailu.user.created", "module.mailu.user.deleted");
|
||||
const watchAliases = watcher("module.mailu.alias.created", "module.mailu.alias.deleted");
|
||||
|
||||
async function pollUsers(): Promise<void> {
|
||||
await watchUsers((await mailu.listUsers()).map((u) => u.email));
|
||||
}
|
||||
|
||||
async function pollAliases(): Promise<void> {
|
||||
await watchAliases((await mailu.listAliases()).map((a) => a.email));
|
||||
}
|
||||
|
||||
const tick = (fn: () => Promise<void>, everyMs: number): void => {
|
||||
const runOnce = (): void => void fn().catch((err) => console.error(`[mailu] ${err}`));
|
||||
setInterval(runOnce, everyMs);
|
||||
runOnce();
|
||||
};
|
||||
|
||||
// Accounts and aliases change on human time, not machine time — a minute's latency is fine, and
|
||||
// polling the admin API harder buys immediacy nobody asked for.
|
||||
tick(pollUsers, 60_000);
|
||||
tick(pollAliases, 60_000);
|
||||
|
||||
console.log("[mailu] watching users and aliases");
|
||||
@@ -4,6 +4,12 @@
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.mailu.user.created",
|
||||
"module.mailu.user.deleted",
|
||||
"module.mailu.alias.created",
|
||||
"module.mailu.alias.deleted"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"port": 25,
|
||||
@@ -43,9 +49,16 @@
|
||||
"own-secrets": {
|
||||
"secret-key": "/var/lib/mailu/secret-key.secret",
|
||||
"database": "/var/lib/mailu/database.secret",
|
||||
"admin": "/var/lib/mailu/admin.secret"
|
||||
"admin": "/var/lib/mailu/admin.secret",
|
||||
"broker": "/var/lib/mesh/mailu/broker"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/mailu",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -269,6 +282,34 @@
|
||||
"/services/mailu/data/certs:/certs",
|
||||
"/services/mailu/data/overrides/nginx:/overrides:ro"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/mailu/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-mailu",
|
||||
"image": "mesh-runtime-mailu@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/mailu/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/mailu/config.json:/run/config/config.json:ro",
|
||||
"/var/run/docker.sock:/var/run/docker.sock"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_MAILU_URL": "http://127.0.0.1:80",
|
||||
"MESH_MAILU_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-mailu",
|
||||
"version": "0.1.0",
|
||||
"description": "mailu — mail server. Its API client, tools 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// mailu's tools — moved here from the shared sdk (novox/hq ADR 0044), importing mailu's own client.
|
||||
// They return structured data; the mesh serves them through the sdk's tool harness. Deletions are
|
||||
// guarded by an explicit `confirm`, since removing a mailbox destroys its mail and cannot be undone.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { MailuClient } from "../client.js";
|
||||
|
||||
export function getMailuTools(mailu: MailuClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "mailu_list_users",
|
||||
description: "List all email accounts on the mail server.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const users = await mailu.listUsers();
|
||||
return { count: users.length, users };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mailu_create_user",
|
||||
description: "Create a new email account.",
|
||||
input: {
|
||||
email: { type: "string", description: "full address, e.g. user@example.com" },
|
||||
password: { type: "string", description: "the account's initial password" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const email = String(args.email);
|
||||
await mailu.createUser(email, String(args.password));
|
||||
return { created: email };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mailu_change_password",
|
||||
description: "Change the password of an email account.",
|
||||
input: {
|
||||
email: { type: "string", description: "full address of the account" },
|
||||
password: { type: "string", description: "the new password" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const email = String(args.email);
|
||||
await mailu.changePassword(email, String(args.password));
|
||||
return { changed: email };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mailu_delete_user",
|
||||
description: "Delete an email account. DESTRUCTIVE — removes the mailbox and all its mail.",
|
||||
input: {
|
||||
email: { type: "string", description: "full address of the account to delete" },
|
||||
confirm: { type: "boolean", description: "must be true — deletion is irreversible" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const email = String(args.email);
|
||||
if (args.confirm !== true) return { aborted: "confirm must be true to delete a user", email };
|
||||
await mailu.deleteUser(email);
|
||||
return { deleted: email };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mailu_list_aliases",
|
||||
description: "List all email aliases and where they forward.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const aliases = await mailu.listAliases();
|
||||
return { count: aliases.length, aliases };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mailu_create_alias",
|
||||
description: "Create an email alias forwarding to one or more destinations.",
|
||||
input: {
|
||||
localpart: { type: "string", description: "the part before @, e.g. 'sales'" },
|
||||
domain: { type: "string", description: "the domain, e.g. example.com" },
|
||||
destination: { type: "string", description: "destination address(es), comma-separated" },
|
||||
wildcard: { type: "boolean", description: "match any localpart under the domain (optional)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const email = `${String(args.localpart)}@${String(args.domain)}`;
|
||||
const destination = String(args.destination).split(",").map((d) => d.trim()).filter(Boolean);
|
||||
await mailu.createAlias(email, destination, args.wildcard === true);
|
||||
return { created: email, destination };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mailu_delete_alias",
|
||||
description: "Delete an email alias.",
|
||||
input: {
|
||||
email: { type: "string", description: "the alias address to delete" },
|
||||
confirm: { type: "boolean", description: "must be true to confirm deletion" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const email = String(args.email);
|
||||
if (args.confirm !== true) return { aborted: "confirm must be true to delete an alias", email };
|
||||
await mailu.deleteAlias(email);
|
||||
return { deleted: email };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mailu_list_domains",
|
||||
description: "List the mail domains the server handles.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const domains = await mailu.listDomains();
|
||||
return { count: domains.length, domains };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mailu_read_mail",
|
||||
description: "Read recent messages in a user's mailbox — date, from, subject and a preview.",
|
||||
input: {
|
||||
user: { type: "string", description: "the mailbox owner's address" },
|
||||
mailbox: { type: "string", description: "which mailbox (default INBOX)" },
|
||||
limit: { type: "number", description: "how many recent messages (default 10)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const user = String(args.user);
|
||||
const mailbox = args.mailbox ? String(args.mailbox) : "INBOX";
|
||||
const messages = await mailu.readMail(user, mailbox, args.limit ? Number(args.limit) : 10);
|
||||
return { user, mailbox, count: messages.length, messages };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mailu_search_mail",
|
||||
description: "Search a user's mailbox by subject, sender, and/or date.",
|
||||
input: {
|
||||
user: { type: "string", description: "the mailbox owner's address" },
|
||||
subject: { type: "string", description: "substring to match in the subject (optional)" },
|
||||
from: { type: "string", description: "sender address or name to match (optional)" },
|
||||
since: { type: "string", description: "only messages since a date, e.g. 01-Jan-2026 (optional)" },
|
||||
limit: { type: "number", description: "maximum results (default 10)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const user = String(args.user);
|
||||
const criteria = {
|
||||
subject: args.subject ? String(args.subject) : undefined,
|
||||
from: args.from ? String(args.from) : undefined,
|
||||
since: args.since ? String(args.since) : undefined,
|
||||
};
|
||||
const messages = await mailu.searchMail(user, criteria, args.limit ? Number(args.limit) : 10);
|
||||
return { user, count: messages.length, messages };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The tools exist only when the admin API is configured; without it, mailu contributes none rather
|
||||
// than failing the whole runtime.
|
||||
registerModuleTools("mailu", (env) => {
|
||||
try {
|
||||
return getMailuTools(MailuClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
// 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<MinioBucket[]> {
|
||||
const { status, text } = await this.request("GET", "/");
|
||||
if (status !== 200) throw new Error(`minio listBuckets: ${status} ${text}`);
|
||||
const out: MinioBucket[] = [];
|
||||
const re = /<Bucket>\s*<Name>([^<]+)<\/Name>\s*<CreationDate>([^<]*)<\/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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<MinioObject[]> {
|
||||
const query: Record<string, string> = { "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("<Contents>").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<MinioObjectStat> {
|
||||
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<MinioBucketInfo> {
|
||||
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<string, string> = {
|
||||
"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<AccessKey> {
|
||||
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<void> {
|
||||
await this.ensureAlias();
|
||||
await this.mc("admin", "user", "svcacct", "rm", "mesh", accessKey);
|
||||
}
|
||||
|
||||
private async ensureAlias(): Promise<void> {
|
||||
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<string> {
|
||||
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<string, string> = {},
|
||||
): 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}>([^<]*)</${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, string>): 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;
|
||||
}
|
||||
+24
-11
@@ -10,6 +10,10 @@
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.minio.bucket.created",
|
||||
"module.minio.bucket.removed"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"port": 9000,
|
||||
@@ -31,9 +35,16 @@
|
||||
"s3-bucket": "/var/lib/minio/grants"
|
||||
},
|
||||
"own-secrets": {
|
||||
"root": "/var/lib/minio/root.secret"
|
||||
"root": "/var/lib/minio/root.secret",
|
||||
"broker": "/var/lib/mesh/minio/broker"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/minio",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -87,21 +98,23 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "provisioner",
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-provision-objectstore",
|
||||
"image": "mesh-provision-objectstore@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"name": "mesh-minio",
|
||||
"image": "mesh-runtime-minio@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"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"
|
||||
},
|
||||
"volumes": [
|
||||
"/var/lib/mesh/minio/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/minio/grants:/var/lib/minio/grants:ro",
|
||||
"/var/lib/minio/root.secret:/run/secrets/root:ro"
|
||||
]
|
||||
],
|
||||
"env": {
|
||||
"MESH_MINIO_ENDPOINT": "http://minio:9000",
|
||||
"MESH_MINIO_ROOT_USER": "meshroot",
|
||||
"MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root",
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_RECEIVES": "/var/lib/minio/grants/mesh.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"
|
||||
}
|
||||
}
|
||||
@@ -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, the contributions file, and reading
|
||||
// the mesh's minted secret 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/0053).
|
||||
//
|
||||
// The `s3-bucket` interface: a consumer connects to an S3 endpoint with an access key confined to
|
||||
// its own bucket. It depends on `s3-bucket`, not on minio, so any S3-compatible provider could serve
|
||||
// it.
|
||||
//
|
||||
// **The access key and its secret are the mesh's, not the provisioner's (ADR 0053).** The mesh
|
||||
// derives the login (the access-key id) and hands it to both ends, and mints the secret key. minio
|
||||
// creates the service account under exactly that access key with exactly that secret — a credential
|
||||
// the provisioner invented is one the consumer could never present. The bucket is derived from the
|
||||
// login, so teardown recomputes it with nothing to persist.
|
||||
|
||||
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { MinioClient, bucketFor } from "../client.js";
|
||||
|
||||
const minio = MinioClient.fromEnv();
|
||||
|
||||
runProvisioner("s3-bucket", {
|
||||
async create(p: Provision): Promise<void> {
|
||||
const bucket = bucketFor(p.as);
|
||||
const accessKeyId = p.as;
|
||||
|
||||
if (!(await minio.bucketExists(bucket))) await minio.createBucket(bucket);
|
||||
// Re-mint the scoped key idempotently: drop any prior one under this id, then add it back with
|
||||
// the mesh's secret.
|
||||
try { await minio.removeAccessKey(accessKeyId); } catch { /* none yet — first provision */ }
|
||||
await minio.createAccessKey(bucket, accessKeyId, p.password);
|
||||
|
||||
await announce("module.minio.bucket.created", {
|
||||
bucket,
|
||||
consumer: p.consumer ?? "",
|
||||
accessKey: accessKeyId,
|
||||
endpoint: minio.baseUrl,
|
||||
});
|
||||
},
|
||||
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
const bucket = bucketFor(p.as);
|
||||
|
||||
// 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(p.as); } catch { /* already gone */ }
|
||||
try {
|
||||
await minio.removeBucket(bucket);
|
||||
} catch (err) {
|
||||
console.error(`[minio] bucket ${bucket} not removed (likely non-empty), access revoked: ${err}`);
|
||||
}
|
||||
|
||||
await announce("module.minio.bucket.removed", { bucket, accessKey: p.as });
|
||||
},
|
||||
});
|
||||
|
||||
/** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a
|
||||
* bucket that was made. */
|
||||
async function announce(type: string, body: unknown): Promise<void> {
|
||||
try {
|
||||
await emit(type, body);
|
||||
} catch (err) {
|
||||
console.error(`[minio] could not emit ${type}: ${err}`);
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Nextcloud's client — nextcloud's own code, living in the module (novox/hq ADR 0044). Both this
|
||||
// module's tools and its events entrypoint import it, and nothing outside nextcloud does.
|
||||
//
|
||||
// Nextcloud is administered two ways, and this client speaks both:
|
||||
// - occ, its admin CLI, is a PHP script inside the container runnable only as the web user. We
|
||||
// reach it with `docker exec`, the same side channel an operator would use by hand — turned
|
||||
// into something the mesh can call. Users and apps come from here.
|
||||
// - the OCS Sharing API answers over HTTP with the admin credentials. Shares come from here,
|
||||
// because occ has no version-stable "list every share" across the releases we run.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface NextcloudUser {
|
||||
uid: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface NextcloudShare {
|
||||
/** The OCS share id — the stable identity a new share is diffed on. */
|
||||
id: string;
|
||||
path: string;
|
||||
shareType: number;
|
||||
shareWith?: string;
|
||||
owner: string;
|
||||
}
|
||||
|
||||
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
|
||||
function meshConfig(file?: string): Record<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
export class NextcloudClient {
|
||||
constructor(
|
||||
private readonly container: string,
|
||||
private readonly ocsUrl: string,
|
||||
private readonly adminUser: string,
|
||||
private readonly adminPassword: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Build from the module's resolved environment. occ needs only the container name (default
|
||||
* "nextcloud"); the OCS surface needs the admin password the module keeps as its own secret
|
||||
* (MESH_NEXTCLOUD_ADMIN_PASSWORD, user MESH_NEXTCLOUD_ADMIN_USER default admin, URL the local
|
||||
* container). The admin password is treated as the "configured for mesh administration" signal:
|
||||
* throws without it, and the module then contributes nothing rather than failing.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): NextcloudClient {
|
||||
const cfg = meshConfig(env.MESH_NEXTCLOUD_CONFIG_FILE);
|
||||
const container = cfg.container ?? env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud";
|
||||
const ocsUrl = cfg.url ?? env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`;
|
||||
const adminUser = cfg.user ?? env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin";
|
||||
const adminPassword = cfg.password ?? env.MESH_NEXTCLOUD_ADMIN_PASSWORD;
|
||||
if (!adminPassword) throw new Error("no Nextcloud admin password — set MESH_NEXTCLOUD_ADMIN_PASSWORD");
|
||||
return new NextcloudClient(container, ocsUrl.replace(/\/$/, ""), adminUser, adminPassword);
|
||||
}
|
||||
|
||||
/** Run occ inside the container as the web user, returning its stdout, throwing its own message. */
|
||||
occ(args: string[]): string {
|
||||
try {
|
||||
return execFileSync("docker", ["exec", "-u", "www-data", this.container, "php", "occ", ...args], {
|
||||
encoding: "utf8", timeout: 60_000,
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
const detail = String(err?.stderr ?? err?.stdout ?? err?.message ?? "").trim();
|
||||
throw new Error(detail || `occ produced no output — is the ${this.container} container running?`);
|
||||
}
|
||||
}
|
||||
|
||||
listUsers(): NextcloudUser[] {
|
||||
// user:list --output=json answers an object of uid → display name.
|
||||
const raw = this.occ(["user:list", "--output=json"]);
|
||||
const map = JSON.parse(raw || "{}") as Record<string, string>;
|
||||
return Object.entries(map).map(([uid, displayName]) => ({ uid, displayName }));
|
||||
}
|
||||
|
||||
listApps(): { enabled: string[]; disabled: string[] } {
|
||||
const raw = this.occ(["app:list", "--output=json"]);
|
||||
const parsed = JSON.parse(raw || "{}") as { enabled?: Record<string, unknown>; disabled?: Record<string, unknown> };
|
||||
return { enabled: Object.keys(parsed.enabled ?? {}), disabled: Object.keys(parsed.disabled ?? {}) };
|
||||
}
|
||||
|
||||
/** List every share, over the OCS Sharing API with the admin credentials. */
|
||||
async listShares(): Promise<NextcloudShare[]> {
|
||||
const auth = Buffer.from(`${this.adminUser}:${this.adminPassword}`).toString("base64");
|
||||
const res = await fetch(
|
||||
`${this.ocsUrl}/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json`,
|
||||
{ headers: { Authorization: `Basic ${auth}`, "OCS-APIRequest": "true", Accept: "application/json" } },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Nextcloud OCS shares: ${res.status} ${await res.text()}`);
|
||||
const rows = ((await res.json())?.ocs?.data ?? []) as any[];
|
||||
return rows.map((s) => ({
|
||||
id: String(s.id),
|
||||
path: s.path ?? "",
|
||||
shareType: Number(s.share_type ?? -1),
|
||||
shareWith: s.share_with || undefined,
|
||||
owner: s.uid_owner ?? "unknown",
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// nextcloud's events. The tool runtime imports this once the broker is bound. It watches the user
|
||||
// list and the share list and announces new arrivals.
|
||||
//
|
||||
// Emits (novox/hq ADR 0046/0047):
|
||||
// module.nextcloud.user.created — a user account appeared (occ user:list)
|
||||
// module.nextcloud.share.created — a share appeared (OCS shares)
|
||||
//
|
||||
// Both are diffed and primed silently on the first look, so a restart does not re-announce every
|
||||
// existing user and share as freshly created.
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { NextcloudClient } from "./client.js";
|
||||
|
||||
// Constructed lazily so an unconfigured node (no admin password) loads this entrypoint without
|
||||
// crashing the events host — it simply watches nothing.
|
||||
let nextcloud: NextcloudClient | undefined;
|
||||
try {
|
||||
nextcloud = NextcloudClient.fromEnv();
|
||||
} catch (err) {
|
||||
console.log(`[nextcloud] not configured, not watching: ${err}`);
|
||||
}
|
||||
|
||||
const knownUsers = new Set<string>();
|
||||
let usersPrimed = false;
|
||||
async function pollUsers(client: NextcloudClient): Promise<void> {
|
||||
const users = client.listUsers();
|
||||
for (const u of users) {
|
||||
if (knownUsers.has(u.uid)) continue;
|
||||
if (usersPrimed) await emit("module.nextcloud.user.created", { uid: u.uid, displayName: u.displayName });
|
||||
knownUsers.add(u.uid);
|
||||
}
|
||||
usersPrimed = true;
|
||||
}
|
||||
|
||||
const knownShares = new Set<string>();
|
||||
let sharesPrimed = false;
|
||||
async function pollShares(client: NextcloudClient): Promise<void> {
|
||||
const shares = await client.listShares();
|
||||
for (const s of shares) {
|
||||
if (knownShares.has(s.id)) continue;
|
||||
if (sharesPrimed) await emit("module.nextcloud.share.created", { id: s.id, path: s.path, shareType: s.shareType, shareWith: s.shareWith, owner: s.owner });
|
||||
knownShares.add(s.id);
|
||||
}
|
||||
sharesPrimed = true;
|
||||
}
|
||||
|
||||
if (nextcloud) {
|
||||
const client = nextcloud;
|
||||
const tick = (fn: (c: NextcloudClient) => Promise<void>): void => {
|
||||
const run = (): void => void fn(client).catch((err) => console.error(`[nextcloud] ${err}`));
|
||||
setInterval(run, 60_000);
|
||||
run();
|
||||
};
|
||||
tick(pollUsers);
|
||||
tick(pollShares);
|
||||
console.log("[nextcloud] watching users and shares");
|
||||
}
|
||||
@@ -21,8 +21,13 @@
|
||||
"postgres-database": "/var/lib/nextcloud-module/database.secret",
|
||||
"s3-bucket": "/var/lib/nextcloud-module/store.secret"
|
||||
},
|
||||
"emits": [
|
||||
"module.nextcloud.user.created",
|
||||
"module.nextcloud.share.created"
|
||||
],
|
||||
"own-secrets": {
|
||||
"admin": "/var/lib/nextcloud-module/admin.secret"
|
||||
"admin": "/var/lib/nextcloud-module/admin.secret",
|
||||
"broker": "/var/lib/mesh/nextcloud/broker"
|
||||
},
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
@@ -36,6 +41,12 @@
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/nextcloud",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -70,6 +81,34 @@
|
||||
"volumes": [
|
||||
"/services/nextcloud/html:/var/www/html"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/nextcloud/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-nextcloud",
|
||||
"image": "mesh-runtime-nextcloud@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/nextcloud/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/nextcloud/config.json:/run/config/config.json:ro",
|
||||
"/var/run/docker.sock:/var/run/docker.sock"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_NEXTCLOUD_URL": "http://127.0.0.1:80",
|
||||
"MESH_NEXTCLOUD_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-nextcloud",
|
||||
"version": "0.1.0",
|
||||
"description": "nextcloud — file sync and share. Its client, tools 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// nextcloud's tools — importing nextcloud's own client (novox/hq ADR 0044). occ runs inside the
|
||||
// container; shares come over OCS. They return structured data; the mesh serves them through the
|
||||
// sdk's tool harness.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { NextcloudClient } from "../client.js";
|
||||
|
||||
export function getNextcloudTools(nextcloud: NextcloudClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "nextcloud_users",
|
||||
description: "List Nextcloud user accounts — uid and display name — via occ.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const users = nextcloud.listUsers();
|
||||
return { count: users.length, users };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nextcloud_shares",
|
||||
description: "List Nextcloud shares — path, type, who it is shared with — via the OCS API.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const shares = await nextcloud.listShares();
|
||||
return { count: shares.length, shares };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nextcloud_apps",
|
||||
description: "List Nextcloud apps, split into enabled and disabled, via occ.",
|
||||
input: {},
|
||||
run: async () => nextcloud.listApps(),
|
||||
},
|
||||
{
|
||||
name: "nextcloud_occ",
|
||||
description:
|
||||
"Run an arbitrary occ admin command, e.g. status, 'config:system:get trusted_domains', " +
|
||||
"user:list. occ is Nextcloud's CLI inside the container, run as the web user.",
|
||||
input: { args: { type: "array", description: 'occ arguments, e.g. ["config:system:get","trusted_domains"]' } },
|
||||
run: async (args) => {
|
||||
const occArgs = (args.args ?? []) as unknown[];
|
||||
if (!Array.isArray(occArgs) || occArgs.length === 0) throw new Error('args must be a non-empty array, e.g. ["status"]');
|
||||
return { output: nextcloud.occ(occArgs.map(String)) || "(no output)" };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The tools exist only when the admin password can be resolved; without it, nextcloud contributes
|
||||
// none rather than failing the whole tool runtime.
|
||||
registerModuleTools("nextcloud", (env) => {
|
||||
try {
|
||||
return getNextcloudTools(NextcloudClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Node-RED's admin-API client — nodered's own code, living in the module (novox/hq ADR 0044). Only
|
||||
// this module's tools import it; nodered has nothing to poll, so there is no events entrypoint.
|
||||
//
|
||||
// Node-RED exposes a runtime admin API under its base URL: GET/POST /flows for the whole flow
|
||||
// configuration, GET /nodes for installed node modules. A default install has no auth; when
|
||||
// adminAuth is on, a bearer token (minted at /auth/token) is required.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface NodeRedFlow {
|
||||
/** The tab (flow) node id. */
|
||||
id: string;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface NodeRedNodeModule {
|
||||
name: string;
|
||||
version: string;
|
||||
types: string[];
|
||||
}
|
||||
|
||||
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
|
||||
function meshConfig(file?: string): Record<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
export class NodeRedClient {
|
||||
readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly token?: string,
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build from the module's resolved environment. MESH_NODERED_URL locates the admin API and is the
|
||||
* "this node runs Node-RED" signal — throws when unset, and the module then contributes nothing
|
||||
* rather than failing on every node. MESH_NODERED_TOKEN is the bearer token when adminAuth is on;
|
||||
* a default install needs none.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): NodeRedClient {
|
||||
const cfg = meshConfig(env.MESH_NODERED_CONFIG_FILE);
|
||||
const url = cfg.url ?? env.MESH_NODERED_URL;
|
||||
if (!url) throw new Error("no Node-RED URL — set MESH_NODERED_URL");
|
||||
return new NodeRedClient(url, cfg.token ?? env.MESH_NODERED_TOKEN);
|
||||
}
|
||||
|
||||
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
||||
return { Accept: "application/json", ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), ...extra };
|
||||
}
|
||||
|
||||
private async req(path: string, init: RequestInit = {}): Promise<any> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, init);
|
||||
if (!res.ok) throw new Error(`Node-RED ${path}: ${res.status} ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** The full flow configuration — the flat array of every node across every tab. */
|
||||
async getConfig(): Promise<any[]> {
|
||||
const body = await this.req("/flows", { headers: this.headers() });
|
||||
// /flows answers a bare array by default, or { rev, flows } to a v2-aware client.
|
||||
return Array.isArray(body) ? body : (body.flows ?? []);
|
||||
}
|
||||
|
||||
/** The tabs (flows), each a node of type "tab" in the configuration. */
|
||||
async listFlows(): Promise<{ flows: NodeRedFlow[]; nodeCount: number }> {
|
||||
const config = await this.getConfig();
|
||||
const flows = config
|
||||
.filter((n) => n.type === "tab")
|
||||
.map((n) => ({ id: n.id, label: n.label ?? "(unnamed)", disabled: !!n.disabled }));
|
||||
return { flows, nodeCount: config.length };
|
||||
}
|
||||
|
||||
async listNodes(): Promise<NodeRedNodeModule[]> {
|
||||
const modules = (await this.req("/nodes", { headers: this.headers() })) as any[];
|
||||
return modules.map((m) => ({ name: m.name, version: m.version, types: m.types ?? [] }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the whole flow configuration and deploy. Returns the new revision. `type` maps to
|
||||
* Node-RED's deployment types — "full" (default), "nodes", or "flows".
|
||||
*/
|
||||
async deployFlows(config: any[], type = "full"): Promise<{ rev?: string; nodeCount: number }> {
|
||||
const body = await this.req("/flows", {
|
||||
method: "POST",
|
||||
headers: this.headers({ "Content-Type": "application/json", "Node-RED-Deployment-Type": type }),
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
return { rev: body?.rev, nodeCount: config.length };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"module": "nodered",
|
||||
"version": "1",
|
||||
"emits": [
|
||||
"module.nodered.flows.deployed"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/nodered/broker"
|
||||
},
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
@@ -13,6 +19,12 @@
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/nodered",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
@@ -34,6 +46,33 @@
|
||||
"volumes": [
|
||||
"/services/nodered/data:/data"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/nodered/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-nodered",
|
||||
"image": "mesh-runtime-nodered@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/nodered/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/nodered/config.json:/run/config/config.json:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_NODERED_URL": "http://127.0.0.1:1880",
|
||||
"MESH_NODERED_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-nodered",
|
||||
"version": "0.1.0",
|
||||
"description": "nodered — flow-based automation. Its client and tools 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// nodered's tools — importing nodered's own admin-API client (novox/hq ADR 0044). They return
|
||||
// structured data; the mesh serves them through the sdk's tool harness.
|
||||
//
|
||||
// The deploy tool is nodered's one event source (novox/hq ADR 0046/0047): a successful deploy
|
||||
// emits module.nodered.flows.deployed. nodered has nothing to observe on a timer, so there is no
|
||||
// separate events entrypoint — the emit rides the action that causes it. The emit is best-effort:
|
||||
// if no broker is bound, the deploy still succeeds and the announcement is simply skipped.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { NodeRedClient } from "../client.js";
|
||||
|
||||
export function getNodeRedTools(nodered: NodeRedClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "nodered_list_flows",
|
||||
description: "List Node-RED flows (tabs) — id, label, disabled state — and the total node count.",
|
||||
input: {},
|
||||
run: async () => nodered.listFlows(),
|
||||
},
|
||||
{
|
||||
name: "nodered_list_nodes",
|
||||
description: "List the Node-RED node modules installed in the runtime and their versions.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const nodes = await nodered.listNodes();
|
||||
return { count: nodes.length, nodes };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nodered_deploy",
|
||||
description:
|
||||
"Replace the whole Node-RED flow configuration and deploy it. `flows` is the full node " +
|
||||
"array (as GET /flows returns). Emits module.nodered.flows.deployed on success.",
|
||||
input: {
|
||||
flows: { type: "array", description: "the full flow configuration — every node across every tab" },
|
||||
type: { type: "string", description: "deployment type: full (default), nodes, or flows" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const flows = args.flows as unknown[];
|
||||
if (!Array.isArray(flows)) throw new Error("flows must be an array of Node-RED nodes");
|
||||
const type = args.type ? String(args.type) : "full";
|
||||
const result = await nodered.deployFlows(flows, type);
|
||||
// Best-effort announcement — a deploy must not fail because the broker is unbound here.
|
||||
try {
|
||||
await emit("module.nodered.flows.deployed", { rev: result.rev, nodeCount: result.nodeCount, type });
|
||||
} catch (err) {
|
||||
console.error(`[nodered] deployed but could not emit: ${err}`);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The tools exist only when a Node-RED URL is configured; without one, nodered contributes none
|
||||
// rather than failing the whole tool runtime.
|
||||
registerModuleTools("nodered", (env) => {
|
||||
try {
|
||||
return getNodeRedTools(NodeRedClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "tools/index.ts"]
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// The NZBGet API client — nzbget's own code, living in the module (novox/hq ADR 0044). Ported from
|
||||
// hal's shared nzbget tools, but self-contained: a change to NZBGet's JSON-RPC now rebuilds only
|
||||
// nzbget and nothing else. Both this module's tools and its events entrypoint import it, and
|
||||
// nothing outside nzbget does. NZBGet speaks JSON-RPC at /jsonrpc, behind HTTP Basic auth.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface NzbgetStatus {
|
||||
/** Bytes/sec — NZBGet reports it split across two 32-bit halves, rejoined here. */
|
||||
speedBytesPerSec: number;
|
||||
remainingMB: number;
|
||||
downloadedTodayMB: number;
|
||||
downloadedMonthMB: number;
|
||||
freeDiskMB: number;
|
||||
paused: boolean;
|
||||
postJobs: number;
|
||||
uptimeSec: number;
|
||||
}
|
||||
|
||||
export interface NzbgetQueueItem {
|
||||
/** The NZBID — stable while the item is queued, so events can diff on it. */
|
||||
id: number;
|
||||
name: string;
|
||||
status: string;
|
||||
category: string;
|
||||
sizeMB: number;
|
||||
remainingMB: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface NzbgetHistoryItem {
|
||||
/** The NZBID — the same id the item carried in the queue. */
|
||||
id: number;
|
||||
name: string;
|
||||
/** NZBGet's own status string, e.g. "SUCCESS/ALL", "FAILURE/PAR", "DELETED/MANUAL". */
|
||||
status: string;
|
||||
category: string;
|
||||
sizeMB: number;
|
||||
/** A genuine completion (status starts "SUCCESS") vs a failed or deleted entry — the difference
|
||||
* between something to announce as done and something that merely left the queue. */
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
|
||||
function meshConfig(file?: string): Record<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
export class NzbgetClient {
|
||||
readonly rpcUrl: string;
|
||||
private readonly auth: string;
|
||||
|
||||
constructor(url: string, user: string, password: string) {
|
||||
this.rpcUrl = `${url.replace(/\/$/, "")}/jsonrpc`;
|
||||
this.auth = Buffer.from(`${user}:${password}`).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build from the module's resolved environment. URL and password are read from MESH_NZBGET_URL
|
||||
* and MESH_NZBGET_PASSWORD; both must be present — an unconfigured NZBGet throws rather than
|
||||
* pretend to be reachable, so the tools/events simply do not load (the harness treats the throw
|
||||
* as "exposes nothing"). The control username defaults to "nzbget", NZBGet's own default.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): NzbgetClient {
|
||||
const cfg = meshConfig(env.MESH_NZBGET_CONFIG_FILE);
|
||||
const url = cfg.url ?? env.MESH_NZBGET_URL;
|
||||
const password = cfg.password ?? env.MESH_NZBGET_PASSWORD;
|
||||
if (!url || !password) {
|
||||
throw new Error("NZBGet not configured — set MESH_NZBGET_URL and MESH_NZBGET_PASSWORD");
|
||||
}
|
||||
const user = cfg.user ?? env.MESH_NZBGET_USER ?? "nzbget";
|
||||
return new NzbgetClient(url, user, password);
|
||||
}
|
||||
|
||||
private async rpc<T>(method: string, params: unknown[] = []): Promise<T> {
|
||||
const res = await fetch(this.rpcUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Basic ${this.auth}` },
|
||||
body: JSON.stringify({ method, params, id: 1 }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`NZBGet API ${method}: ${res.status} ${await res.text()}`);
|
||||
const data = (await res.json()) as { result?: T; error?: unknown };
|
||||
if (data.error) throw new Error(`NZBGet RPC ${method}: ${JSON.stringify(data.error)}`);
|
||||
return data.result as T;
|
||||
}
|
||||
|
||||
async getVersion(): Promise<string> {
|
||||
return this.rpc<string>("version");
|
||||
}
|
||||
|
||||
async getStatus(): Promise<NzbgetStatus> {
|
||||
const s = await this.rpc<Record<string, number | boolean>>("status");
|
||||
const lo = Number(s.DownloadRateLo ?? 0);
|
||||
const hi = Number(s.DownloadRateHi ?? 0);
|
||||
return {
|
||||
speedBytesPerSec: lo + hi * 4294967296,
|
||||
remainingMB: Number(s.RemainingSizeMB ?? 0),
|
||||
downloadedTodayMB: Number(s.DaySizeMB ?? 0),
|
||||
downloadedMonthMB: Number(s.MonthSizeMB ?? 0),
|
||||
freeDiskMB: Number(s.FreeDiskSpaceMB ?? 0),
|
||||
paused: Boolean(s.DownloadPaused),
|
||||
postJobs: Number(s.PostJobCount ?? 0),
|
||||
uptimeSec: Number(s.UpTimeSec ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async getQueue(): Promise<NzbgetQueueItem[]> {
|
||||
const groups = await this.rpc<Record<string, any>[]>("listgroups", [0]);
|
||||
return groups.map((g) => {
|
||||
const size = Number(g.FileSizeMB ?? 0);
|
||||
const remaining = Number(g.RemainingSizeMB ?? 0);
|
||||
return {
|
||||
id: Number(g.NZBID),
|
||||
name: String(g.NZBName ?? "Unknown"),
|
||||
status: String(g.Status ?? "unknown"),
|
||||
category: String(g.Category ?? ""),
|
||||
sizeMB: size,
|
||||
remainingMB: remaining,
|
||||
percent: size > 0 ? Math.round(((size - remaining) / size) * 100) : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getHistory(limit = 20): Promise<NzbgetHistoryItem[]> {
|
||||
const history = await this.rpc<Record<string, any>[]>("history", [false]);
|
||||
return history.slice(0, limit).map((h) => {
|
||||
const status = String(h.Status ?? "");
|
||||
return {
|
||||
id: Number(h.NZBID),
|
||||
name: String(h.Name ?? "Unknown"),
|
||||
status,
|
||||
category: String(h.Category ?? ""),
|
||||
sizeMB: Number(h.FileSizeMB ?? 0),
|
||||
success: status.startsWith("SUCCESS"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Queue an NZB by URL. Returns the new NZBID; a non-positive id means NZBGet refused it. */
|
||||
async add(url: string, category = "", priority = 0, paused = false): Promise<number> {
|
||||
const id = await this.rpc<number>("append", [
|
||||
"", url, category, priority, false, paused, "", 0, "SCORE", false, [],
|
||||
]);
|
||||
if (!id || id <= 0) throw new Error("NZBGet refused the NZB (append returned 0)");
|
||||
return id;
|
||||
}
|
||||
|
||||
async pauseAll(): Promise<void> {
|
||||
await this.rpc("pausedownload");
|
||||
}
|
||||
|
||||
async resumeAll(): Promise<void> {
|
||||
await this.rpc("resumedownload");
|
||||
}
|
||||
|
||||
async pauseItem(id: number): Promise<void> {
|
||||
await this.rpc("editqueue", ["GroupPause", "", [id]]);
|
||||
}
|
||||
|
||||
async resumeItem(id: number): Promise<void> {
|
||||
await this.rpc("editqueue", ["GroupResume", "", [id]]);
|
||||
}
|
||||
|
||||
/** Delete an item from the queue or from history. */
|
||||
async delete(id: number, from: "queue" | "history" = "queue"): Promise<void> {
|
||||
await this.rpc("editqueue", [from === "history" ? "HistoryDelete" : "GroupDelete", "", [id]]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// nzbget's events. The tool runtime imports this once the broker is bound. It watches the download
|
||||
// queue and the history and turns their comings and goings into mesh events.
|
||||
//
|
||||
// Emits (novox/hq ADR 0046/0047):
|
||||
// module.nzbget.download.added — an NZB entered the queue
|
||||
// module.nzbget.download.completed — an NZB finished successfully (left the queue, landed in
|
||||
// history as SUCCESS). This exact routing key is what the
|
||||
// plex module consumes (module.*.download.completed) to
|
||||
// rescan, so the new file becomes a visible item.
|
||||
// Consumes: none.
|
||||
//
|
||||
// Two diffs, each primed silently on the first look (like plex's and sonarr's index.ts) so a
|
||||
// restart mid-download does not re-announce everything already in flight or already finished. The
|
||||
// queue tells us what was grabbed; history — not the queue's disappearance — tells us what actually
|
||||
// succeeded, since a failed or deleted download also leaves the queue.
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { NzbgetClient } from "./client.js";
|
||||
|
||||
const nzbget = NzbgetClient.fromEnv();
|
||||
|
||||
const inQueue = new Set<number>();
|
||||
let queuePrimed = false;
|
||||
async function pollQueue(): Promise<void> {
|
||||
const items = await nzbget.getQueue();
|
||||
const now = new Set(items.map((i) => i.id));
|
||||
if (queuePrimed) {
|
||||
for (const item of items) {
|
||||
if (!inQueue.has(item.id)) {
|
||||
await emit("module.nzbget.download.added", { name: item.name, category: item.category, sizeMB: item.sizeMB });
|
||||
}
|
||||
}
|
||||
}
|
||||
inQueue.clear();
|
||||
for (const id of now) inQueue.add(id);
|
||||
queuePrimed = true;
|
||||
}
|
||||
|
||||
const seenHistory = new Set<number>();
|
||||
let historyPrimed = false;
|
||||
async function pollHistory(): Promise<void> {
|
||||
const items = await nzbget.getHistory(50);
|
||||
for (const item of items) {
|
||||
if (!seenHistory.has(item.id)) {
|
||||
// A newly-appeared history entry is a completion only if it actually succeeded; a failure or
|
||||
// a manual delete lands in history too, and neither is a "download.completed".
|
||||
if (historyPrimed && item.success) {
|
||||
await emit("module.nzbget.download.completed", { name: item.name, category: item.category, sizeMB: item.sizeMB });
|
||||
}
|
||||
seenHistory.add(item.id);
|
||||
}
|
||||
}
|
||||
historyPrimed = true;
|
||||
}
|
||||
|
||||
const tick = (fn: () => Promise<void>, everyMs: number): void => {
|
||||
const run = (): void => void fn().catch((err) => console.error(`[nzbget] ${err}`));
|
||||
setInterval(run, everyMs);
|
||||
run();
|
||||
};
|
||||
tick(pollQueue, 20_000);
|
||||
tick(pollHistory, 30_000);
|
||||
|
||||
console.log("[nzbget] watching the queue and history, emitting adds and completions");
|
||||
@@ -4,6 +4,14 @@
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.nzbget.download.added",
|
||||
"module.nzbget.download.completed"
|
||||
],
|
||||
"consumes": [],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/nzbget/broker"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"port": 6789,
|
||||
@@ -13,6 +21,12 @@
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/nzbget",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "directory",
|
||||
@@ -44,6 +58,35 @@
|
||||
"/services/nzbget/config:/config",
|
||||
"/services/media/downloads:/downloads"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/nzbget/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-nzbget",
|
||||
"image": "mesh-runtime-nzbget@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/nzbget/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/nzbget/config.json:/run/config/config.json:ro",
|
||||
"/services/nzbget/config:/var/lib/nzbget/config:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_NZBGET_URL": "http://127.0.0.1:6789",
|
||||
"MESH_NZBGET_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_NZBGET_CONFIG_DIR": "/var/lib/nzbget/config"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-nzbget",
|
||||
"version": "0.1.0",
|
||||
"description": "nzbget — Usenet download client. Its API client, tools 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// nzbget's tools — ported from the shared hal sdk (novox/hq ADR 0044), importing nzbget's own
|
||||
// client. They return structured data (not the pre-formatted text hal returned); the mesh serves
|
||||
// them through the sdk's tool harness.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { NzbgetClient } from "../client.js";
|
||||
|
||||
export function getNzbgetTools(nzbget: NzbgetClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "nzbget_status",
|
||||
description: "NZBGet server status: download speed, queue remaining, disk free, paused state.",
|
||||
input: {},
|
||||
run: async () => nzbget.getStatus(),
|
||||
},
|
||||
{
|
||||
name: "nzbget_queue",
|
||||
description: "List the current NZBGet download queue — what is downloading and how far along.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const items = await nzbget.getQueue();
|
||||
return { count: items.length, items };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nzbget_history",
|
||||
description: "Recent NZBGet download history, newest first — completed, failed and deleted items.",
|
||||
input: { limit: { type: "number", description: "how many entries (default 20)" } },
|
||||
run: async (args) => {
|
||||
const items = await nzbget.getHistory(args.limit ? Number(args.limit) : 20);
|
||||
return { count: items.length, items };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nzbget_add",
|
||||
description: "Queue an NZB download by URL, optionally into a category.",
|
||||
input: {
|
||||
url: { type: "string", description: "URL to the NZB file" },
|
||||
category: { type: "string", description: "category name (determines download directory)" },
|
||||
priority: { type: "number", description: "-100 very low … 0 normal … 100 very high (default 0)" },
|
||||
paused: { type: "boolean", description: "add in paused state (default false)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const id = await nzbget.add(
|
||||
String(args.url),
|
||||
args.category ? String(args.category) : "",
|
||||
args.priority ? Number(args.priority) : 0,
|
||||
args.paused === true || args.paused === "true",
|
||||
);
|
||||
return { added: id, category: args.category ? String(args.category) : null };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nzbget_pause",
|
||||
description: "Pause or resume all NZBGet downloads.",
|
||||
input: { resume: { type: "boolean", description: "true to resume, false to pause (default false)" } },
|
||||
run: async (args) => {
|
||||
const resume = args.resume === true || args.resume === "true";
|
||||
if (resume) await nzbget.resumeAll();
|
||||
else await nzbget.pauseAll();
|
||||
return { paused: !resume };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nzbget_delete",
|
||||
description: "Delete an NZB from the queue or from history by its NZBID.",
|
||||
input: {
|
||||
id: { type: "number", description: "the NZBID to delete" },
|
||||
from: { type: "string", description: "'queue' (default) or 'history'" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const from = args.from === "history" ? "history" : "queue";
|
||||
await nzbget.delete(Number(args.id), from);
|
||||
return { deleted: Number(args.id), from };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The tools exist only when NZBGet is configured; without a URL and password, nzbget contributes
|
||||
// none rather than failing the whole runtime.
|
||||
registerModuleTools("nzbget", (env) => {
|
||||
try {
|
||||
return getNzbgetTools(NzbgetClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// The Ombi API client — ombi's own code, living in the module (novox/hq ADR 0044). Ombi is the
|
||||
// request front-end: viewers ask for movies and shows, and an operator approves them. This client
|
||||
// talks its /api/v1 REST API (keyed by an ApiKey header); ombi's tools and events import it.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface OmbiRequest {
|
||||
kind: "movie" | "tv";
|
||||
id: number;
|
||||
title: string;
|
||||
requestedBy?: string;
|
||||
requestedDate?: string;
|
||||
approved: boolean;
|
||||
available: boolean;
|
||||
denied: boolean;
|
||||
tmdbId?: number;
|
||||
}
|
||||
|
||||
export interface RequestCounts {
|
||||
pending: number;
|
||||
approved: number;
|
||||
available: number;
|
||||
}
|
||||
|
||||
/** The settings-merged config the mesh delivers (novox/hq ADR 0051): { url, apiKey, token, password, user, ... }. */
|
||||
function meshConfig(file?: string): Record<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
export class OmbiClient {
|
||||
readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly apiKey: string,
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/** Build from the module's resolved environment. Ombi's API is keyed; without URL and key there
|
||||
* is nothing to talk to, so this throws rather than run half-configured. */
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): OmbiClient {
|
||||
const cfg = meshConfig(env.MESH_OMBI_CONFIG_FILE);
|
||||
const url = cfg.url ?? env.MESH_OMBI_URL;
|
||||
const apiKey = cfg.apiKey ?? env.MESH_OMBI_API_KEY;
|
||||
if (!url) throw new Error("no Ombi URL — set MESH_OMBI_URL");
|
||||
if (!apiKey) throw new Error("no Ombi API key — set MESH_OMBI_API_KEY");
|
||||
return new OmbiClient(url, apiKey);
|
||||
}
|
||||
|
||||
private async request(method: string, path: string, body?: unknown): Promise<any> {
|
||||
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
ApiKey: this.apiKey,
|
||||
Accept: "application/json",
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Ombi API ${method} ${path}: ${res.status} ${await res.text()}`);
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : {};
|
||||
}
|
||||
|
||||
/** All requests, movies and TV together — who asked for what, and where each stands. */
|
||||
async getRequests(): Promise<OmbiRequest[]> {
|
||||
const [movies, tv] = await Promise.all([
|
||||
this.request("GET", "/Request/movie"),
|
||||
this.request("GET", "/Request/tv"),
|
||||
]);
|
||||
const films: OmbiRequest[] = (Array.isArray(movies) ? movies : []).map((r: any) => ({
|
||||
kind: "movie" as const,
|
||||
id: r.id,
|
||||
title: r.title ?? "Unknown",
|
||||
requestedBy: r.requestedUser?.userName ?? r.requestedUser?.userAlias,
|
||||
requestedDate: r.requestedDate,
|
||||
approved: Boolean(r.approved),
|
||||
available: Boolean(r.available),
|
||||
denied: Boolean(r.denied),
|
||||
tmdbId: r.theMovieDbId,
|
||||
}));
|
||||
// TV requests carry per-season child requests; the top-level record is approved when all its
|
||||
// children are, which is the grain an operator acts on.
|
||||
const shows: OmbiRequest[] = (Array.isArray(tv) ? tv : []).map((r: any) => {
|
||||
const children: any[] = r.childRequests ?? [];
|
||||
return {
|
||||
kind: "tv" as const,
|
||||
id: r.id,
|
||||
title: r.title ?? "Unknown",
|
||||
requestedBy: children[0]?.requestedUser?.userName,
|
||||
requestedDate: children[0]?.requestedDate,
|
||||
approved: children.length > 0 && children.every((c) => c.approved),
|
||||
available: children.length > 0 && children.every((c) => c.available),
|
||||
denied: children.some((c) => c.denied),
|
||||
tmdbId: r.theMovieDbId,
|
||||
};
|
||||
});
|
||||
return [...films, ...shows];
|
||||
}
|
||||
|
||||
/** Live pending/approved/available counts — a one-line health read without listing everything. */
|
||||
async getCounts(): Promise<RequestCounts> {
|
||||
const c = await this.request("GET", "/Request/count");
|
||||
return { pending: c.pending ?? 0, approved: c.approved ?? 0, available: c.available ?? 0 };
|
||||
}
|
||||
|
||||
/** Approve a request. TV approval fans out to the request's child (per-season) requests. */
|
||||
async approve(kind: "movie" | "tv", id: number): Promise<void> {
|
||||
await this.request("POST", `/Request/${kind}/approve`, { id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// ombi's events. The tool runtime imports this once the broker is bound. Ombi's timeline is the
|
||||
// request lifecycle: a viewer files a request, and later an operator approves it. Both transitions
|
||||
// are worth announcing — the mesh can notify on a new request, and act on an approval (that is when
|
||||
// a downloader should start looking).
|
||||
//
|
||||
// Emits (novox/hq ADR 0046/0047):
|
||||
// module.ombi.request.created — a viewer filed a new request
|
||||
// module.ombi.request.approved — a request was approved
|
||||
//
|
||||
// Ombi is the origin of these decisions, not a reactor to the mesh, so it consumes nothing.
|
||||
//
|
||||
// Both events are observation-based: poll the request list and diff. Creation is diffed on the set
|
||||
// of request ids; approval on each request's approved flag flipping true. Primed silently on the
|
||||
// first look, or a restart would re-announce every existing request and approval.
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { OmbiClient, type OmbiRequest } from "./client.js";
|
||||
|
||||
const ombi = OmbiClient.fromEnv();
|
||||
|
||||
// Remember each seen request and whether it was approved last time, keyed by kind+id (ids are only
|
||||
// unique within a kind).
|
||||
const approvedState = new Map<string, boolean>();
|
||||
let primed = false;
|
||||
|
||||
const keyOf = (r: OmbiRequest): string => `${r.kind}:${r.id}`;
|
||||
|
||||
async function pollRequests(): Promise<void> {
|
||||
const requests = await ombi.getRequests();
|
||||
for (const r of requests) {
|
||||
const key = keyOf(r);
|
||||
const known = approvedState.has(key);
|
||||
if (primed && !known) {
|
||||
await emit("module.ombi.request.created", {
|
||||
kind: r.kind,
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
requestedBy: r.requestedBy,
|
||||
tmdbId: r.tmdbId,
|
||||
});
|
||||
}
|
||||
// Approval: the flag went from false to true for a request we already knew about.
|
||||
if (primed && known && r.approved && approvedState.get(key) === false) {
|
||||
await emit("module.ombi.request.approved", { kind: r.kind, id: r.id, title: r.title, tmdbId: r.tmdbId });
|
||||
}
|
||||
approvedState.set(key, r.approved);
|
||||
}
|
||||
primed = true;
|
||||
}
|
||||
|
||||
const tick = (fn: () => Promise<void>, everyMs: number): void => {
|
||||
const run = (): void => void fn().catch((err) => console.error(`[ombi] ${err}`));
|
||||
setInterval(run, everyMs);
|
||||
run();
|
||||
};
|
||||
tick(pollRequests, 30_000);
|
||||
|
||||
console.log("[ombi] watching requests for new filings and approvals");
|
||||
@@ -4,6 +4,13 @@
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.ombi.request.created",
|
||||
"module.ombi.request.approved"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/ombi/broker"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"port": 3579,
|
||||
@@ -13,6 +20,12 @@
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/ombi",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "directory",
|
||||
@@ -36,6 +49,35 @@
|
||||
"volumes": [
|
||||
"/services/ombi/config:/config"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/ombi/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-ombi",
|
||||
"image": "mesh-runtime-ombi@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/ombi/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/ombi/config.json:/run/config/config.json:ro",
|
||||
"/services/ombi/config:/var/lib/ombi/config:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_OMBI_URL": "http://127.0.0.1:3579",
|
||||
"MESH_OMBI_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_OMBI_CONFIG_DIR": "/var/lib/ombi/config"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-ombi",
|
||||
"version": "0.1.0",
|
||||
"description": "ombi — media requests. Its API client, tools 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// ombi's tools — its own code (novox/hq ADR 0044), importing ombi's client. They return structured
|
||||
// data; the mesh serves them through the sdk's tool harness.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { OmbiClient } from "../client.js";
|
||||
|
||||
export function getOmbiTools(ombi: OmbiClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "ombi_requests",
|
||||
description: "List media requests — movies and shows — with who asked and whether each is approved or available.",
|
||||
input: { pending: { type: "boolean", description: "only requests not yet approved (default false)" } },
|
||||
run: async (args) => {
|
||||
let requests = await ombi.getRequests();
|
||||
if (args.pending) requests = requests.filter((r) => !r.approved && !r.denied);
|
||||
const counts = await ombi.getCounts();
|
||||
return { counts, count: requests.length, requests };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ombi_approve",
|
||||
description: "Approve a media request by its kind and id (from ombi_requests).",
|
||||
input: {
|
||||
kind: { type: "string", description: '"movie" or "tv"' },
|
||||
id: { type: "number", description: "the request id" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const kind = String(args.kind);
|
||||
if (kind !== "movie" && kind !== "tv") throw new Error('kind must be "movie" or "tv"');
|
||||
const id = Number(args.id);
|
||||
await ombi.approve(kind, id);
|
||||
return { approved: { kind, id } };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Exposed only when Ombi is configured; otherwise ombi contributes no tools rather than failing the
|
||||
// whole runtime.
|
||||
registerModuleTools("ombi", (env) => {
|
||||
try {
|
||||
return getOmbiTools(OmbiClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user