@thotischner/observability-mcp 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseArgs, pickFreePort, composeOverride, resolveCatalogSource, formatPluginList, formatPluginInfo, DEFAULT_CATALOG_URL, } from "./lib.js";
4
+ const CAT = {
5
+ catalogVersion: 1,
6
+ connectors: [
7
+ {
8
+ name: "prometheus",
9
+ displayName: "Prometheus",
10
+ description: "PromQL metrics.",
11
+ tier: "official",
12
+ builtin: true,
13
+ signalTypes: ["metrics"],
14
+ latest: "1.4.0",
15
+ versions: [{ version: "1.4.0", releasedAt: "2026-05-15", serverCompat: ">=1.4.0" }],
16
+ },
17
+ {
18
+ name: "tempo",
19
+ displayName: "Grafana Tempo",
20
+ description: "TraceQL.",
21
+ tier: "third-party",
22
+ signalTypes: ["traces"],
23
+ versions: [
24
+ { version: "1.0.0", releasedAt: "2026-05-15", integrity: "sha256-AAAA=", signatureUrl: "https://x/s.sig" },
25
+ ],
26
+ },
27
+ ],
28
+ };
29
+ test("parseArgs: command, sub, positionals", () => {
30
+ const p = parseArgs(["demo", "up", "extra"]);
31
+ assert.equal(p.command, "demo");
32
+ assert.equal(p.sub, "up");
33
+ assert.deepEqual(p.positionals, ["extra"]);
34
+ });
35
+ test("parseArgs: --flag=val, --flag val, boolean, -f val", () => {
36
+ const p = parseArgs(["plugin", "install", "loki", "--from=/m", "--ver", "1.2.0", "--json", "-f", "x"]);
37
+ assert.equal(p.command, "plugin");
38
+ assert.equal(p.sub, "install");
39
+ assert.deepEqual(p.positionals, ["loki"]);
40
+ assert.equal(p.flags.from, "/m");
41
+ assert.equal(p.flags.ver, "1.2.0");
42
+ assert.equal(p.flags.json, true);
43
+ assert.equal(p.flags.f, "x");
44
+ });
45
+ test("parseArgs: empty argv", () => {
46
+ const p = parseArgs([]);
47
+ assert.equal(p.command, "");
48
+ assert.equal(p.sub, undefined);
49
+ });
50
+ test("pickFreePort returns desired when free", () => {
51
+ assert.equal(pickFreePort(3000, () => false), 3000);
52
+ });
53
+ test("pickFreePort skips used ports", () => {
54
+ const used = new Set([3000, 3001, 3002]);
55
+ assert.equal(pickFreePort(3000, (p) => used.has(p)), 3003);
56
+ });
57
+ test("pickFreePort throws when span exhausted", () => {
58
+ assert.throws(() => pickFreePort(3000, () => true, 5), /no free port/);
59
+ });
60
+ test("composeOverride emits !override port mappings", () => {
61
+ const y = composeOverride([
62
+ { service: "mcp-server", host: 3001, container: 3000 },
63
+ { service: "loki", host: 3101, container: 3100 },
64
+ ]);
65
+ assert.match(y, /^services:\n/);
66
+ assert.match(y, / mcp-server:\n ports: !override\n - "3001:3000"/);
67
+ assert.match(y, / loki:\n ports: !override\n - "3101:3100"/);
68
+ });
69
+ test("resolveCatalogSource: explicit url, explicit path, local, default", () => {
70
+ assert.deepEqual(resolveCatalogSource("https://h/x.json", null), { kind: "url", location: "https://h/x.json" });
71
+ assert.deepEqual(resolveCatalogSource("/tmp/c.json", null), { kind: "file", location: "/tmp/c.json" });
72
+ assert.deepEqual(resolveCatalogSource(undefined, "/repo/hub/catalog/index.json"), { kind: "file", location: "/repo/hub/catalog/index.json" });
73
+ assert.deepEqual(resolveCatalogSource(undefined, null), { kind: "url", location: DEFAULT_CATALOG_URL });
74
+ });
75
+ test("formatPluginList: header, sorted rows, builtin+tier flags", () => {
76
+ const out = formatPluginList(CAT);
77
+ const lines = out.split("\n");
78
+ assert.match(lines[0], /^NAME\s+LATEST\s+SIGNALS\s+TIER$/);
79
+ // sorted: prometheus before tempo
80
+ assert.ok(lines[1].startsWith("prometheus"));
81
+ assert.match(lines[1], /builtin,official/);
82
+ assert.ok(lines[2].startsWith("tempo"));
83
+ assert.match(lines[2], /third-party/);
84
+ });
85
+ test("formatPluginInfo: versions + integrity/signature surfaced", () => {
86
+ const info = formatPluginInfo(CAT.connectors[1]);
87
+ assert.match(info, /Grafana Tempo {2}\(tempo\)/);
88
+ assert.match(info, /tier: {6}third-party/);
89
+ assert.match(info, /1\.0\.0 \(2026-05-15\)/);
90
+ assert.match(info, /integrity: sha256-AAAA=/);
91
+ assert.match(info, /signature: https:\/\/x\/s\.sig/);
92
+ });
93
+ import { parsePluginRef, resolveInstall } from "./lib.js";
94
+ test("parsePluginRef: name and name@version, rejects junk", () => {
95
+ assert.deepEqual(parsePluginRef("tempo"), { name: "tempo", version: undefined });
96
+ assert.deepEqual(parsePluginRef("tempo@1.2.3"), { name: "tempo", version: "1.2.3" });
97
+ assert.deepEqual(parsePluginRef("x@1.0.0-rc.1"), { name: "x", version: "1.0.0-rc.1" });
98
+ assert.throws(() => parsePluginRef("Bad Name"), /invalid plugin ref/);
99
+ assert.throws(() => parsePluginRef("tempo@v1"), /invalid plugin ref/);
100
+ });
101
+ test("resolveInstall: builtin short-circuits", () => {
102
+ const r = resolveInstall(CAT, "prometheus");
103
+ assert.equal(r.builtin, true);
104
+ assert.equal(r.name, "prometheus");
105
+ });
106
+ test("resolveInstall: picks latest then specific version", () => {
107
+ const def = resolveInstall(CAT, "tempo");
108
+ assert.equal(def.version, "1.0.0");
109
+ assert.equal(def.builtin, false);
110
+ assert.equal(def.integrity, "sha256-AAAA=");
111
+ const pinned = resolveInstall(CAT, "tempo@1.0.0");
112
+ assert.equal(pinned.version, "1.0.0");
113
+ assert.throws(() => resolveInstall(CAT, "tempo@9.9.9"), /not found/);
114
+ assert.throws(() => resolveInstall(CAT, "ghost"), /no connector/);
115
+ });
116
+ import { splitPassthrough, helmReleaseArgs, HELM_CHART } from "./lib.js";
117
+ test("splitPassthrough: splits at first -- ", () => {
118
+ assert.deepEqual(splitPassthrough(["helm", "install", "obs"]), {
119
+ argv: ["helm", "install", "obs"],
120
+ passthrough: [],
121
+ });
122
+ assert.deepEqual(splitPassthrough(["helm", "upgrade", "obs", "--", "-n", "mon", "--set", "a=b"]), { argv: ["helm", "upgrade", "obs"], passthrough: ["-n", "mon", "--set", "a=b"] });
123
+ });
124
+ test("helmReleaseArgs: install vs upgrade --install, passthrough appended", () => {
125
+ assert.deepEqual(helmReleaseArgs("install", "obs", []), ["install", "obs", HELM_CHART]);
126
+ assert.deepEqual(helmReleaseArgs("upgrade", "obs", ["-n", "mon"]), [
127
+ "upgrade",
128
+ "--install",
129
+ "obs",
130
+ HELM_CHART,
131
+ "-n",
132
+ "mon",
133
+ ]);
134
+ });
@@ -0,0 +1,48 @@
1
+ import type { LoadedConnector } from "./loader.js";
2
+ export declare const DEFAULT_HUB_CATALOG_URL = "https://thotischner.github.io/observability-mcp/hub/index.json";
3
+ /** Catalog source: env override wins (airgapped mirror / private hub). */
4
+ export declare function resolveHubCatalogUrl(env?: NodeJS.ProcessEnv): string;
5
+ export interface InstalledConnector {
6
+ name: string;
7
+ source: "builtin" | "filesystem" | "config";
8
+ displayName: string;
9
+ description: string;
10
+ version: string | null;
11
+ signalTypes: string[];
12
+ capabilities: Record<string, boolean>;
13
+ }
14
+ /** Shape the loader's entries into the UI's installed list. */
15
+ export declare function describeInstalled(loaded: LoadedConnector[]): InstalledConnector[];
16
+ export interface CatalogConnector {
17
+ name: string;
18
+ displayName: string;
19
+ description: string;
20
+ tier: string;
21
+ builtin?: boolean;
22
+ signalTypes: string[];
23
+ latest?: string;
24
+ versions: Array<{
25
+ version: string;
26
+ tarballUrl?: string;
27
+ integrity?: string;
28
+ serverCompat?: string;
29
+ changelog?: string;
30
+ releasedAt?: string;
31
+ }>;
32
+ }
33
+ export interface HubCatalogEntry extends CatalogConnector {
34
+ installed: boolean;
35
+ installedVersion: string | null;
36
+ }
37
+ /**
38
+ * Merge the hub catalog with what's installed so the UI can show
39
+ * status + offer not-yet-installed connectors.
40
+ */
41
+ export declare function mergeCatalog(catalog: {
42
+ connectors?: CatalogConnector[];
43
+ } | null, installed: InstalledConnector[]): HubCatalogEntry[];
44
+ /** Fetch + parse the catalog. fetchImpl injected for tests. */
45
+ export declare function fetchHubCatalog(url: string, fetchImpl?: typeof fetch): Promise<{
46
+ connectors: CatalogConnector[];
47
+ catalogVersion?: number;
48
+ }>;
@@ -0,0 +1,51 @@
1
+ // Connector-hub integration helpers (pure, IO-injectable so they unit
2
+ // test without network). Powers the Web UI "Connectors" page:
3
+ // - what's installed/loaded right now
4
+ // - what the hub catalog offers, with an "installed" marker
5
+ export const DEFAULT_HUB_CATALOG_URL = "https://thotischner.github.io/observability-mcp/hub/index.json";
6
+ /** Catalog source: env override wins (airgapped mirror / private hub). */
7
+ export function resolveHubCatalogUrl(env = process.env) {
8
+ return env.HUB_CATALOG_URL || DEFAULT_HUB_CATALOG_URL;
9
+ }
10
+ /** Shape the loader's entries into the UI's installed list. */
11
+ export function describeInstalled(loaded) {
12
+ return loaded
13
+ .map((p) => ({
14
+ name: p.name,
15
+ source: p.source,
16
+ displayName: p.manifest?.displayName ?? p.name,
17
+ description: p.manifest?.description ?? "",
18
+ version: p.manifest?.version ?? null,
19
+ signalTypes: p.manifest?.signalTypes ?? [],
20
+ capabilities: p.manifest?.capabilities ?? {},
21
+ }))
22
+ .sort((a, b) => a.name.localeCompare(b.name));
23
+ }
24
+ /**
25
+ * Merge the hub catalog with what's installed so the UI can show
26
+ * status + offer not-yet-installed connectors.
27
+ */
28
+ export function mergeCatalog(catalog, installed) {
29
+ const byName = new Map(installed.map((i) => [i.name, i]));
30
+ return (catalog?.connectors ?? [])
31
+ .map((c) => {
32
+ const have = byName.get(c.name);
33
+ return {
34
+ ...c,
35
+ installed: !!have,
36
+ installedVersion: have?.version ?? null,
37
+ };
38
+ })
39
+ .sort((a, b) => a.name.localeCompare(b.name));
40
+ }
41
+ /** Fetch + parse the catalog. fetchImpl injected for tests. */
42
+ export async function fetchHubCatalog(url, fetchImpl = fetch) {
43
+ const res = await fetchImpl(url);
44
+ if (!res.ok)
45
+ throw new Error(`hub catalog HTTP ${res.status} from ${url}`);
46
+ const body = (await res.json());
47
+ if (!body || !Array.isArray(body.connectors)) {
48
+ throw new Error("hub catalog malformed (no connectors[])");
49
+ }
50
+ return { connectors: body.connectors, catalogVersion: body.catalogVersion };
51
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { resolveHubCatalogUrl, describeInstalled, mergeCatalog, fetchHubCatalog, DEFAULT_HUB_CATALOG_URL, } from "./hub.js";
4
+ test("resolveHubCatalogUrl: default + env override", () => {
5
+ assert.equal(resolveHubCatalogUrl({}), DEFAULT_HUB_CATALOG_URL);
6
+ assert.equal(resolveHubCatalogUrl({ HUB_CATALOG_URL: "http://mirror/idx.json" }), "http://mirror/idx.json");
7
+ });
8
+ test("describeInstalled maps loader entries, sorts, defaults", () => {
9
+ const loaded = [
10
+ { name: "loki", source: "builtin", factory: () => ({}) },
11
+ {
12
+ name: "datadog",
13
+ source: "filesystem",
14
+ factory: () => ({}),
15
+ manifest: {
16
+ displayName: "Datadog",
17
+ description: "DD",
18
+ version: "1.0.0",
19
+ signalTypes: ["metrics", "logs"],
20
+ capabilities: { queryMetrics: true },
21
+ },
22
+ },
23
+ ];
24
+ const d = describeInstalled(loaded);
25
+ assert.deepEqual(d.map((x) => x.name), ["datadog", "loki"]); // sorted
26
+ assert.equal(d[0].displayName, "Datadog");
27
+ assert.deepEqual(d[0].signalTypes, ["metrics", "logs"]);
28
+ assert.equal(d[1].displayName, "loki"); // falls back to name
29
+ assert.equal(d[1].version, null);
30
+ assert.deepEqual(d[1].capabilities, {});
31
+ });
32
+ test("mergeCatalog marks installed + version, sorts, tolerates null", () => {
33
+ const installed = describeInstalled([
34
+ { name: "datadog", source: "filesystem", factory: () => ({}), manifest: { version: "1.0.0" } },
35
+ ]);
36
+ const merged = mergeCatalog({ connectors: [
37
+ { name: "grafana", displayName: "Grafana", description: "", tier: "official", signalTypes: ["metrics"], versions: [{ version: "1.0.0" }] },
38
+ { name: "datadog", displayName: "Datadog", description: "", tier: "official", signalTypes: ["metrics"], versions: [{ version: "1.0.0" }] },
39
+ ] }, installed);
40
+ assert.deepEqual(merged.map((m) => m.name), ["datadog", "grafana"]);
41
+ assert.equal(merged[0].installed, true);
42
+ assert.equal(merged[0].installedVersion, "1.0.0");
43
+ assert.equal(merged[1].installed, false);
44
+ assert.deepEqual(mergeCatalog(null, installed), []);
45
+ });
46
+ test("fetchHubCatalog: ok, http error, malformed", async () => {
47
+ const ok = async () => ({ ok: true, status: 200, json: async () => ({ connectors: [{ name: "x" }], catalogVersion: 1 }) });
48
+ const r = await fetchHubCatalog("u", ok);
49
+ assert.equal(r.connectors.length, 1);
50
+ await assert.rejects(() => fetchHubCatalog("u", (async () => ({ ok: false, status: 503, json: async () => ({}) }))), /HTTP 503/);
51
+ await assert.rejects(() => fetchHubCatalog("u", (async () => ({ ok: true, status: 200, json: async () => ({}) }))), /malformed/);
52
+ });
@@ -0,0 +1,24 @@
1
+ /** A connector name is safe iff kebab-case ASCII (also blocks traversal). */
2
+ export declare function isValidConnectorName(name: unknown): name is string;
3
+ /**
4
+ * Resolve the install target and guarantee it stays directly inside
5
+ * pluginsDir (defence-in-depth against `..`/absolute names even though
6
+ * isValidConnectorName already rejects them).
7
+ */
8
+ export declare function safeTarget(pluginsDir: string, name: string): string;
9
+ export interface InstallResult {
10
+ name: string;
11
+ version: string | null;
12
+ }
13
+ /**
14
+ * Install a connector tarball into pluginsDir, fail-closed. Always
15
+ * verifies the manifest signature + entry integrity against trustRoot
16
+ * — there is intentionally NO insecure bypass on this path (it's
17
+ * reachable over HTTP). Throws PluginVerificationError on any failure.
18
+ */
19
+ export declare function installTarball(opts: {
20
+ tgzPath: string;
21
+ pluginsDir: string;
22
+ trustRootPath: string;
23
+ expectedName?: string;
24
+ }): InstallResult;
@@ -0,0 +1,100 @@
1
+ // Shared connector-install core: extract a tarball, verify it
2
+ // fail-closed against a trust root (the SAME crypto as the server
3
+ // loader / omcp CLI — verify.ts), then atomically place it under
4
+ // PLUGINS_DIR. Used by the Web UI install API (and reusable by the
5
+ // CLI). Pure guards are split out for unit testing.
6
+ import { spawnSync } from "node:child_process";
7
+ import { existsSync, readFileSync, readdirSync, statSync, mkdtempSync, mkdirSync, rmSync, cpSync, } from "node:fs";
8
+ import { join, resolve } from "node:path";
9
+ import { tmpdir } from "node:os";
10
+ import { loadTrustRoot, verifyIntegrity, verifyManifestSignature, PluginVerificationError, } from "./verify.js";
11
+ const NAME_RE = /^[a-z][a-z0-9-]*$/;
12
+ /** A connector name is safe iff kebab-case ASCII (also blocks traversal). */
13
+ export function isValidConnectorName(name) {
14
+ return typeof name === "string" && NAME_RE.test(name);
15
+ }
16
+ /**
17
+ * Resolve the install target and guarantee it stays directly inside
18
+ * pluginsDir (defence-in-depth against `..`/absolute names even though
19
+ * isValidConnectorName already rejects them).
20
+ */
21
+ export function safeTarget(pluginsDir, name) {
22
+ if (!isValidConnectorName(name))
23
+ throw new Error(`invalid connector name: ${String(name)}`);
24
+ const base = resolve(pluginsDir);
25
+ const target = resolve(base, name);
26
+ if (target !== join(base, name) || !target.startsWith(base + "/")) {
27
+ throw new Error("refusing path outside PLUGINS_DIR");
28
+ }
29
+ return target;
30
+ }
31
+ function tarExtract(tgz, dest) {
32
+ const r = spawnSync("tar", ["-xzf", tgz, "-C", dest], { stdio: "pipe" });
33
+ if (r.status !== 0)
34
+ throw new Error(`tar extraction failed: ${r.stderr?.toString() || r.status}`);
35
+ }
36
+ function findPluginRoot(base) {
37
+ for (const dir of [base, ...readdirSync(base).map((e) => join(base, e))]) {
38
+ try {
39
+ if (!statSync(dir).isDirectory())
40
+ continue;
41
+ const pkgPath = join(dir, "package.json");
42
+ if (!existsSync(pkgPath))
43
+ continue;
44
+ if (JSON.parse(readFileSync(pkgPath, "utf8")).observabilityMcp?.kind === "connector")
45
+ return dir;
46
+ }
47
+ catch {
48
+ /* skip */
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+ /**
54
+ * Install a connector tarball into pluginsDir, fail-closed. Always
55
+ * verifies the manifest signature + entry integrity against trustRoot
56
+ * — there is intentionally NO insecure bypass on this path (it's
57
+ * reachable over HTTP). Throws PluginVerificationError on any failure.
58
+ */
59
+ export function installTarball(opts) {
60
+ const trustRoot = loadTrustRoot(opts.trustRootPath); // throws if unreadable/bad
61
+ const work = mkdtempSync(join(tmpdir(), "obsmcp-install-"));
62
+ try {
63
+ const stage = join(work, "stage");
64
+ mkdirSync(stage);
65
+ tarExtract(opts.tgzPath, stage);
66
+ const root = findPluginRoot(stage);
67
+ if (!root)
68
+ throw new PluginVerificationError("tarball has no connector package.json marker");
69
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
70
+ const marker = pkg.observabilityMcp;
71
+ const name = marker?.name;
72
+ if (!isValidConnectorName(name))
73
+ throw new PluginVerificationError(`invalid connector name in package: ${String(name)}`);
74
+ if (opts.expectedName && opts.expectedName !== name) {
75
+ throw new PluginVerificationError(`tarball is '${name}', expected '${opts.expectedName}'`);
76
+ }
77
+ const manifestRel = marker.manifest || "./manifest.json";
78
+ const manifestPath = resolve(root, manifestRel);
79
+ if (!existsSync(manifestPath))
80
+ throw new PluginVerificationError(`manifest not found: ${manifestRel}`);
81
+ const sigPath = manifestPath + ".sig";
82
+ if (!existsSync(sigPath))
83
+ throw new PluginVerificationError(`missing manifest signature: ${manifestRel}.sig`);
84
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
85
+ const entryPath = resolve(root, pkg.main || "index.js");
86
+ // Fail-closed: signature over the manifest + manifest pins the
87
+ // entry hash. Throws PluginVerificationError on mismatch.
88
+ verifyManifestSignature(readFileSync(manifestPath), readFileSync(sigPath), trustRoot);
89
+ verifyIntegrity(entryPath, manifest.integrity);
90
+ const target = safeTarget(opts.pluginsDir, name);
91
+ mkdirSync(opts.pluginsDir, { recursive: true });
92
+ if (existsSync(target))
93
+ rmSync(target, { recursive: true, force: true });
94
+ cpSync(root, target, { recursive: true });
95
+ return { name, version: manifest.version ?? null };
96
+ }
97
+ finally {
98
+ rmSync(work, { recursive: true, force: true });
99
+ }
100
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { generateKeyPairSync, sign as edSign, createHash } from "node:crypto";
4
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { spawnSync } from "node:child_process";
9
+ import { isValidConnectorName, safeTarget, installTarball } from "./install.js";
10
+ import { PluginVerificationError } from "./verify.js";
11
+ test("isValidConnectorName: kebab only, blocks traversal", () => {
12
+ assert.ok(isValidConnectorName("datadog"));
13
+ assert.ok(isValidConnectorName("my-connector9"));
14
+ for (const bad of ["../evil", "Foo", "a/b", "", "1abc", "a_b", null, 42]) {
15
+ assert.equal(isValidConnectorName(bad), false);
16
+ }
17
+ });
18
+ test("safeTarget keeps the path inside pluginsDir", () => {
19
+ const t = safeTarget("/plugins", "datadog");
20
+ assert.equal(t, "/plugins/datadog");
21
+ assert.throws(() => safeTarget("/plugins", "../etc"), /invalid connector name|outside/);
22
+ });
23
+ function mkSignedTarball(dir, name, priv, opts = {}) {
24
+ const src = join(dir, "src");
25
+ mkdirSync(src, { recursive: true });
26
+ writeFileSync(join(src, "index.js"), "export default () => ({});\n");
27
+ const integ = "sha256-" + createHash("sha256").update(readFileSync(join(src, "index.js"))).digest("base64");
28
+ const manifest = { schemaVersion: 1, name, displayName: name, version: "1.0.0", description: "x", signalTypes: ["metrics"], integrity: integ };
29
+ const mb = Buffer.from(JSON.stringify(manifest));
30
+ writeFileSync(join(src, "manifest.json"), mb);
31
+ if (!opts.noSig)
32
+ writeFileSync(join(src, "manifest.json.sig"), edSign(null, mb, priv));
33
+ writeFileSync(join(src, "package.json"), JSON.stringify({ name, main: "index.js", observabilityMcp: { kind: "connector", name, manifest: "./manifest.json" } }));
34
+ if (opts.tamper)
35
+ writeFileSync(join(src, "index.js"), "export default () => ({}); /* tampered */\n");
36
+ const tgz = join(dir, `${name}-1.0.0.tgz`);
37
+ const r = spawnSync("tar", ["-czf", tgz, "-C", src, "."]);
38
+ assert.equal(r.status, 0, "tar failed in test setup");
39
+ return tgz;
40
+ }
41
+ test("installTarball: verifies + installs; rejects tamper / missing sig / wrong name", () => {
42
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
43
+ const work = mkdtempSync(join(tmpdir(), "it-"));
44
+ const pub = join(work, "pub.pem");
45
+ writeFileSync(pub, publicKey.export({ type: "spki", format: "pem" }));
46
+ const pluginsDir = join(work, "plugins");
47
+ const good = mkSignedTarball(join(work, "good"), "datadog", privateKey);
48
+ const r = installTarball({ tgzPath: good, pluginsDir, trustRootPath: pub });
49
+ assert.equal(r.name, "datadog");
50
+ assert.equal(r.version, "1.0.0");
51
+ assert.ok(existsSync(join(pluginsDir, "datadog", "manifest.json")));
52
+ const tampered = mkSignedTarball(join(work, "bad"), "datadog", privateKey, { tamper: true });
53
+ assert.throws(() => installTarball({ tgzPath: tampered, pluginsDir, trustRootPath: pub }), PluginVerificationError);
54
+ const noSig = mkSignedTarball(join(work, "nosig"), "datadog", privateKey, { noSig: true });
55
+ assert.throws(() => installTarball({ tgzPath: noSig, pluginsDir, trustRootPath: pub }), /missing manifest signature/);
56
+ const other = mkSignedTarball(join(work, "other"), "grafana", privateKey);
57
+ assert.throws(() => installTarball({ tgzPath: other, pluginsDir, trustRootPath: pub, expectedName: "datadog" }), /expected 'datadog'/);
58
+ });
@@ -23,9 +23,14 @@ export declare class PluginLoader {
23
23
  private connectors;
24
24
  private pluginsDir;
25
25
  private disabled;
26
+ private verify;
27
+ private trustRootPath?;
28
+ private trustRoot?;
26
29
  constructor(opts?: {
27
30
  pluginsDir?: string;
28
31
  disabled?: string[];
32
+ verify?: boolean;
33
+ trustRoot?: string;
29
34
  });
30
35
  load(): Promise<void>;
31
36
  list(): LoadedConnector[];
@@ -6,6 +6,7 @@ import { PrometheusConnector } from "./prometheus.js";
6
6
  import { LokiConnector } from "./loki.js";
7
7
  import { sanitizeForLog } from "../util/sanitize.js";
8
8
  import { instrumentConnector } from "../metrics/instrument-connector.js";
9
+ import { loadTrustRoot, verifyIntegrity, verifyManifestSignature, PluginVerificationError, } from "./verify.js";
9
10
  /**
10
11
  * Resolves which connector implementations the server should know about,
11
12
  * applying three sources in order (later overrides earlier):
@@ -20,6 +21,13 @@ export class PluginLoader {
20
21
  connectors = new Map();
21
22
  pluginsDir;
22
23
  disabled;
24
+ // Fail-closed verification for filesystem plugins. Builtins are part
25
+ // of the trusted image and are never gated. Default off so existing
26
+ // deployments are unchanged; recommended on in prod/airgapped (the
27
+ // Helm chart sets it).
28
+ verify;
29
+ trustRootPath;
30
+ trustRoot;
23
31
  constructor(opts = {}) {
24
32
  this.pluginsDir = opts.pluginsDir
25
33
  ?? process.env.PLUGINS_DIR
@@ -30,9 +38,25 @@ export class PluginLoader {
30
38
  .map((s) => s.trim())
31
39
  .filter(Boolean);
32
40
  this.disabled = new Set([...(opts.disabled ?? []), ...envDisabled]);
41
+ this.verify = opts.verify ?? /^(1|true|yes)$/i.test(process.env.VERIFY_PLUGINS ?? "");
42
+ this.trustRootPath = opts.trustRoot ?? process.env.PLUGIN_TRUST_ROOT;
33
43
  }
34
44
  async load() {
35
45
  this.loadBuiltins();
46
+ if (this.verify) {
47
+ if (!this.trustRootPath) {
48
+ console.warn("VERIFY_PLUGINS is on but PLUGIN_TRUST_ROOT is unset — refusing to load any filesystem plugins (fail-closed). Builtins remain available.");
49
+ return;
50
+ }
51
+ try {
52
+ this.trustRoot = loadTrustRoot(this.trustRootPath);
53
+ console.log("Plugin verification enabled; trust root loaded from %s", sanitizeForLog(this.trustRootPath));
54
+ }
55
+ catch (err) {
56
+ console.warn("VERIFY_PLUGINS is on but trust root failed to load (%s) — refusing to load any filesystem plugins (fail-closed). Builtins remain available.", sanitizeForLog(String(err)));
57
+ return;
58
+ }
59
+ }
36
60
  await this.loadFilesystem();
37
61
  }
38
62
  list() {
@@ -105,10 +129,13 @@ export class PluginLoader {
105
129
  if (!marker || marker.kind !== "connector" || !marker.name)
106
130
  return;
107
131
  let manifest;
132
+ let manifestPath;
133
+ let manifestBytes;
108
134
  if (marker.manifest) {
109
- const manifestPath = resolve(pluginRoot, marker.manifest);
135
+ manifestPath = resolve(pluginRoot, marker.manifest);
110
136
  if (existsSync(manifestPath)) {
111
- const raw = JSON.parse(readFileSync(manifestPath, "utf8"));
137
+ manifestBytes = readFileSync(manifestPath);
138
+ const raw = JSON.parse(manifestBytes.toString("utf8"));
112
139
  const parsed = manifestSchema.safeParse(raw);
113
140
  if (!parsed.success) {
114
141
  const issues = parsed.error.issues
@@ -130,6 +157,31 @@ export class PluginLoader {
130
157
  console.warn("Plugin %s missing entry file %s", sanitizeForLog(marker.name), sanitizeForLog(entryFile));
131
158
  return;
132
159
  }
160
+ // Fail-closed verification gate. A plugin only loads under
161
+ // VERIFY_PLUGINS if it ships a manifest whose `integrity` matches
162
+ // the entry file AND a detached `<manifest>.sig` that verifies
163
+ // against the trust root. Everything is local — airgapped-safe.
164
+ if (this.verify) {
165
+ if (!manifest || !manifestPath || !manifestBytes) {
166
+ console.warn("VERIFY_PLUGINS: plugin %s has no manifest.json — skipping (fail-closed)", sanitizeForLog(marker.name));
167
+ return;
168
+ }
169
+ const sigPath = manifestPath + ".sig";
170
+ if (!existsSync(sigPath)) {
171
+ console.warn("VERIFY_PLUGINS: plugin %s missing manifest signature %s — skipping (fail-closed)", sanitizeForLog(marker.name), sanitizeForLog(marker.manifest + ".sig"));
172
+ return;
173
+ }
174
+ try {
175
+ verifyManifestSignature(manifestBytes, readFileSync(sigPath), this.trustRoot);
176
+ verifyIntegrity(entryPath, manifest.integrity);
177
+ }
178
+ catch (err) {
179
+ const detail = err instanceof PluginVerificationError ? err.message : String(err);
180
+ console.warn("VERIFY_PLUGINS: plugin %s failed verification (%s) — skipping (fail-closed)", sanitizeForLog(marker.name), sanitizeForLog(detail));
181
+ return;
182
+ }
183
+ console.log("VERIFY_PLUGINS: plugin %s signature + integrity OK", sanitizeForLog(marker.name));
184
+ }
133
185
  const mod = await import(pathToFileURL(entryPath).href);
134
186
  const factory = mod.default ?? mod.createConnector;
135
187
  if (typeof factory !== "function") {
@@ -54,13 +54,19 @@ export class LokiConnector {
54
54
  }
55
55
  async disconnect() { }
56
56
  async listServices() {
57
- // Probe each candidate label and merge values. Loki streams may identify
58
- // services via service_name, service, job, app, or container depending on
59
- // the shipper configuration. Walking all candidates ensures historical
60
- // streams remain reachable when label conventions change over time.
57
+ // Candidate labels are ordered by preference (service_name, service,
58
+ // job, app, container). The FIRST label that yields any values wins —
59
+ // we do not union across labels. Unioning duplicated every service:
60
+ // one real container is simultaneously `service="api-gateway"` and
61
+ // `container="myproj-api-gateway-1"`, and a co-located shipper can add
62
+ // unrelated `container` values (e.g. other compose/k8s containers on
63
+ // the same Docker host). The ordered fallback still keeps streams
64
+ // reachable on backends that only carry a low-priority label.
61
65
  const seen = new Map();
62
66
  for (const label of this.serviceLabels) {
63
67
  const values = await this.getLabelValues(label);
68
+ if (values.length === 0)
69
+ continue;
64
70
  for (const raw of values) {
65
71
  // Docker's loki.source.docker writes container names with a leading '/'
66
72
  // (Docker API Names[0] convention). Strip it for display so the name
@@ -75,6 +81,7 @@ export class LokiConnector {
75
81
  });
76
82
  }
77
83
  }
84
+ break; // first non-empty label is authoritative
78
85
  }
79
86
  return Array.from(seen.values());
80
87
  }
@@ -108,4 +108,31 @@ describe("LokiConnector", () => {
108
108
  assert.equal(proto.escapeLogQLRegex("error`test`"), "error\\`test\\`");
109
109
  });
110
110
  });
111
+ describe("listServices", () => {
112
+ function withLabelValues(map) {
113
+ const c = new LokiConnector();
114
+ c.serviceLabels = ["service_name", "service", "job", "app", "container"];
115
+ c.getLabelValues = async (label) => map[label] ?? [];
116
+ return c;
117
+ }
118
+ it("first non-empty label wins — does NOT union container aliases", async () => {
119
+ const c = withLabelValues({
120
+ service: ["api-gateway", "payment-service"],
121
+ // co-located shipper noise that must NOT leak in:
122
+ container: ["myproj-api-gateway-1", "k8s_POD_api-gateway_demo_x"],
123
+ });
124
+ const names = (await c.listServices()).map((s) => s.name).sort();
125
+ assert.deepEqual(names, ["api-gateway", "payment-service"]);
126
+ });
127
+ it("falls back to a lower-priority label only when higher ones are empty", async () => {
128
+ const c = withLabelValues({ container: ["/svc-a", "/svc-b"] });
129
+ const svcs = await c.listServices();
130
+ assert.deepEqual(svcs.map((s) => s.name).sort(), ["svc-a", "svc-b"]);
131
+ assert.equal(svcs[0].labels.discoveredVia, "container");
132
+ });
133
+ it("returns empty when no candidate label has values", async () => {
134
+ const c = withLabelValues({});
135
+ assert.deepEqual(await c.listServices(), []);
136
+ });
137
+ });
111
138
  });