Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a906b757d | ||
|
|
d8ee88e487 | ||
|
|
54557b77bf | ||
|
|
a5e21cb438 | ||
|
|
8064e5da8f | ||
|
|
63a255c5cb | ||
|
|
7ad1fbd5c6 | ||
|
|
67f5f4cffd | ||
|
|
8797335fbc | ||
|
|
53dc108603 | ||
|
|
ebf5ba2d4c | ||
|
|
bbac08a7d2 | ||
|
|
784a5a6514 | ||
|
|
0c31499fb0 | ||
|
|
f118344246 | ||
|
|
822df220ab | ||
|
|
9eb1265bc8 | ||
|
|
41cfc70b53 | ||
|
|
acedc5d9d9 | ||
|
|
521a8dd1e2 | ||
|
|
e145e2236c | ||
|
|
4d7e37e319 | ||
|
|
026421fd6e | ||
|
|
af89bb11ff | ||
|
|
7c18cdbd39 | ||
|
|
4fb16b2e6b | ||
|
|
c5af8635c8 | ||
|
|
87366c5f36 | ||
|
|
f8ca36aacf | ||
|
|
812355bf31 | ||
|
|
016ddb2b3a | ||
|
|
ea17bf46d2 | ||
|
|
4258f01614 | ||
|
|
4d9b4fdfa6 | ||
|
|
eff11b1d4d | ||
|
|
4ead13d4d4 | ||
|
|
3b77dde666 | ||
|
|
5ea4961980 | ||
|
|
2b8a668d06 | ||
|
|
719fb1e025 | ||
|
|
ac5630bee2 | ||
|
|
1c995fa9fc | ||
|
|
d70cb18ea0 | ||
|
|
1d71787896 | ||
|
|
eb62289f89 | ||
|
|
f5969a2f9f | ||
|
|
7f3d259cf5 | ||
|
|
721149eda1 | ||
|
|
8e27bc1e36 | ||
|
|
f1212620e4 | ||
|
|
b1b18ae390 |
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"route": {
|
||||
"label": "baserow",
|
||||
"port": 80
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -30,6 +30,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 80,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 6767,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -110,7 +111,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "subs",
|
||||
"port": 6767
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8787,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -87,7 +88,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "books",
|
||||
"port": 8787
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"module": "ca-trust",
|
||||
"version": "1",
|
||||
"slug": "catrust",
|
||||
"capabilities": [
|
||||
"service-manager"
|
||||
],
|
||||
"requires": [
|
||||
"internal-acme-ca"
|
||||
],
|
||||
"seats": [
|
||||
{
|
||||
"name": "the-mesh-trust-anchor",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "the-mesh-trust-anchor",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "anchor",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/anchor",
|
||||
"mode": "0755",
|
||||
"content": "#!/bin/sh\n# The mesh's internal certificate authority, trusted by this machine.\n#\n# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147).\n# Editing it here lasts until the next apply.\n#\n# There is no prior trust to verify the fetch against \u2014 this is the thing that\n# establishes it \u2014 so it is made over the mesh's own private network, which is\n# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the\n# route proxy fetch this root for itself). What comes back is checked here: a\n# body that is not a certificate is refused now, rather than believed and then\n# failed by whatever reads the trust store next.\nset -eu\n\nROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}'\nANCHORS=/etc/ca-certificates/trust-source/anchors\nANCHOR=\"$ANCHORS/mesh-internal-ca.crt\"\n\n# Arch's layout, said out loud rather than assumed: a machine that keeps its\n# anchors elsewhere fails here, visibly, instead of writing a file nothing\n# reads. That failure is the signal that this belongs in the host, where one\n# operating system's difference lives (novox/hq ADR 0147, option 2).\n[ -d \"$ANCHORS\" ] || {\n\techo \"this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout\" >&2\n\texit 1\n}\n\ncase \"${1:-}\" in\ninstall)\n\ttmp=$(mktemp)\n\ttrap 'rm -f \"$tmp\"' EXIT\n\t# The authority may still be starting, or this machine may have come up\n\t# before it: two minutes of asking, then an honest failure.\n\tn=0\n\twhile [ \"$n\" -lt 60 ]; do\n\t\tif curl --fail --silent --show-error --insecure --max-time 10 \\\n\t\t\t--output \"$tmp\" \"$ROOTS\" &&\n\t\t\tgrep -q 'BEGIN CERTIFICATE' \"$tmp\"; then\n\t\t\tinstall -m 0644 \"$tmp\" \"$ANCHOR\"\n\t\t\tupdate-ca-trust\n\t\t\texit 0\n\t\tfi\n\t\tn=$((n + 1))\n\t\tsleep 2\n\tdone\n\techo \"the authority at $ROOTS did not serve a certificate within two minutes\" >&2\n\texit 1\n\t;;\nremove)\n\t# What stopping the unit does, and therefore what being unassigned does.\n\trm -f \"$ANCHOR\"\n\tupdate-ca-trust\n\t;;\n*)\n\techo \"usage: $(basename \"$0\") install|remove\" >&2\n\texit 2\n\t;;\nesac\n"
|
||||
},
|
||||
{
|
||||
"id": "unit",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/mesh-ca-trust.service",
|
||||
"mode": "0644",
|
||||
"content": "[Unit]\nDescription=The mesh's internal certificate authority, trusted by this machine\n# novox/hq ADR 0147. Starting this unit places the mesh's root among this\n# machine's trust anchors; stopping it takes the root away again, which is what\n# the host does when the module is no longer assigned here.\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=${dir:state}/anchor install\nExecStop=${dir:state}/anchor remove\n\n[Install]\nWantedBy=multi-user.target\n"
|
||||
},
|
||||
{
|
||||
"id": "trust",
|
||||
"type": "service",
|
||||
"unit": "mesh-ca-trust.service",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"restart-on": [
|
||||
"anchor",
|
||||
"unit"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "de-spiegel",
|
||||
"port": 35621
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 35621,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "registry",
|
||||
"port": 5000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -22,11 +22,20 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "dns-udp",
|
||||
"port": 53,
|
||||
"protocol": "udp",
|
||||
"from": "mesh",
|
||||
"why": "every name for this machine and what it runs \u2014 the mesh's own answered here, the rest forwarded",
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"name": "dns-tcp",
|
||||
"port": 53,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the same names over tcp, which a resolver answers on as well and is asked for whenever an answer will not fit in a datagram. Declared because the daemon serves it: a declaration that covers one of the two protocols its own service listens on leaves the other closed while everything reports success",
|
||||
"fixed": true
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"type": "file",
|
||||
"path": "/etc/fail2ban/jail.local",
|
||||
"mode": "0644",
|
||||
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\nbanaction = ufw\nbanaction_allports = iptables-allports\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
|
||||
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
|
||||
},
|
||||
{
|
||||
"id": "jail-sshd",
|
||||
@@ -42,6 +42,14 @@
|
||||
"mode": "0644",
|
||||
"content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 5\n"
|
||||
},
|
||||
{
|
||||
"id": "log",
|
||||
"type": "file",
|
||||
"path": "/var/log/fail2ban.log",
|
||||
"mode": "0640",
|
||||
"create-once": true,
|
||||
"content": ""
|
||||
},
|
||||
{
|
||||
"id": "jail-recidive",
|
||||
"type": "file",
|
||||
|
||||
+35
-2
@@ -9,6 +9,8 @@ import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
|
||||
/** A repository, trimmed to what the mesh cares about. */
|
||||
export interface GiteaRepo {
|
||||
full_name: string;
|
||||
/** The URL a build clones — what a module records as its source. */
|
||||
clone_url?: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
private: boolean;
|
||||
@@ -34,6 +36,9 @@ export interface GiteaPull {
|
||||
title: string;
|
||||
state: string;
|
||||
merged: boolean;
|
||||
/** The commit the merge produced — what a build of the base branch is made from. */
|
||||
merge_commit_sha?: string;
|
||||
merged_at?: string;
|
||||
user?: string;
|
||||
head?: string;
|
||||
base?: string;
|
||||
@@ -116,9 +121,23 @@ export class GiteaClient {
|
||||
|
||||
// ---- Repositories ----
|
||||
|
||||
/** Every repository this token can see, one page. `/user/repos` is only what the token's own
|
||||
* user owns — for the mesh's administrator that is nothing, which is how the forge watched an
|
||||
* empty list and announced no merge (2026-09-28). The search endpoint is the forge's whole view. */
|
||||
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);
|
||||
const found = await this.request<{ data?: any[] }>(`/repos/search?page=${page}&limit=${limit}`);
|
||||
return (found?.data ?? []).map(GiteaClient.mapRepo);
|
||||
}
|
||||
|
||||
/** Every repository, all pages. */
|
||||
async listAllRepos(): Promise<GiteaRepo[]> {
|
||||
const all: GiteaRepo[] = [];
|
||||
for (let page = 1; page < 100; page++) {
|
||||
const batch = await this.listRepos(page, 50);
|
||||
all.push(...batch);
|
||||
if (batch.length < 50) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async createRepo(data: {
|
||||
@@ -210,6 +229,17 @@ export class GiteaClient {
|
||||
return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`));
|
||||
}
|
||||
|
||||
/** The files a merged pull request changed, as paths from the repository's root.
|
||||
*
|
||||
* `limit` is what is asked for, and a merge that changed more says so rather than being read
|
||||
* page by page: what the mesh does with a partial list is treat the whole repository as changed,
|
||||
* so more pages would buy nothing. */
|
||||
async listPullFiles(owner: string, repo: string, index: number, limit = 100): Promise<{ paths: string[]; truncated: boolean }> {
|
||||
const files = await this.request<any[]>(`/repos/${owner}/${repo}/pulls/${index}/files?limit=${limit}`);
|
||||
const paths = (files ?? []).map((f) => String(f?.filename ?? "")).filter((p) => p !== "");
|
||||
return { paths, truncated: paths.length >= limit };
|
||||
}
|
||||
|
||||
async createPullRequest(
|
||||
owner: string,
|
||||
repo: string,
|
||||
@@ -232,6 +262,7 @@ export class GiteaClient {
|
||||
private static mapRepo(r: any): GiteaRepo {
|
||||
return {
|
||||
full_name: r.full_name,
|
||||
clone_url: r.clone_url ?? undefined,
|
||||
name: r.name,
|
||||
owner: r.owner?.login ?? r.full_name?.split("/")[0] ?? "unknown",
|
||||
private: Boolean(r.private),
|
||||
@@ -259,6 +290,8 @@ export class GiteaClient {
|
||||
title: p.title,
|
||||
state: p.state,
|
||||
merged: Boolean(p.merged),
|
||||
merge_commit_sha: p.merge_commit_sha ?? undefined,
|
||||
merged_at: p.merged_at ?? undefined,
|
||||
user: p.user?.login,
|
||||
head: p.head?.ref,
|
||||
base: p.base?.ref,
|
||||
|
||||
+80
-2
@@ -31,7 +31,7 @@ try {
|
||||
const seen = new Set<string>();
|
||||
let primed = false;
|
||||
async function pollRepos(client: GiteaClient): Promise<void> {
|
||||
const repos = await client.listRepos(1, 50);
|
||||
const repos = await client.listAllRepos();
|
||||
for (const repo of repos) {
|
||||
if (!seen.has(repo.full_name)) {
|
||||
if (primed) {
|
||||
@@ -49,6 +49,83 @@ async function pollRepos(client: GiteaClient): Promise<void> {
|
||||
primed = true;
|
||||
}
|
||||
|
||||
// **A merge is announced whoever made it.** The merge tool below emits at the instant it acts; a
|
||||
// merge made in the forge's own pages or over its API would emit nothing, and the mesh would go on
|
||||
// believing every module current with its source (novox/hq 04-ISSUES/131). So merged pull requests
|
||||
// are watched the way repositories are: what the forge holds, asked for on a tick, announced once.
|
||||
// What has been announced is kept beside the module's state, so a restart does not announce the
|
||||
// whole history again — and the first tick on a machine with no record announces nothing, because
|
||||
// everything it sees then predates the watching.
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
const mergedRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "merged-announced.json") : null;
|
||||
const announced = new Set<string>();
|
||||
let primedMerges = false;
|
||||
// since is the moment the watching began: a merge made before it is history, whatever page of the
|
||||
// forge's listing it surfaces on. Without it, an old merge past the first page — pushed into view
|
||||
// as newer pull requests were updated — was announced as if it had just happened, and the mesh
|
||||
// rebuilt everything built from that repository, once per old merge (2026-09-28).
|
||||
let since = "";
|
||||
if (mergedRecord && existsSync(mergedRecord)) {
|
||||
try {
|
||||
const kept = JSON.parse(readFileSync(mergedRecord, "utf8")) as string[] | { announced: string[]; since: string };
|
||||
const list = Array.isArray(kept) ? kept : kept.announced;
|
||||
for (const sha of list) announced.add(sha);
|
||||
since = Array.isArray(kept) ? new Date().toISOString() : kept.since;
|
||||
primedMerges = true;
|
||||
} catch {
|
||||
// An unreadable record is treated as no record: prime again rather than re-announce history.
|
||||
}
|
||||
}
|
||||
function keepAnnounced(): void {
|
||||
if (!mergedRecord) return;
|
||||
mkdirSync(join(mergedRecord, ".."), { recursive: true });
|
||||
const tmp = mergedRecord + ".tmp";
|
||||
writeFileSync(tmp, JSON.stringify({ announced: [...announced].slice(-2000), since }));
|
||||
renameSync(tmp, mergedRecord);
|
||||
}
|
||||
async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
const repos = await client.listAllRepos();
|
||||
let changed = false;
|
||||
for (const repo of repos) {
|
||||
const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" });
|
||||
for (const pull of pulls) {
|
||||
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) continue;
|
||||
// Announced only if merged since the watching began; recorded either way, so it is looked
|
||||
// at once.
|
||||
const fresh = !!pull.merged_at && !!since && pull.merged_at > since;
|
||||
if (primedMerges && fresh) {
|
||||
// What it changed, asked for only now: a module is rebuilt because a file inside its own
|
||||
// directory moved, and without this every module built from a repository is rebuilt for a
|
||||
// change to any of them (novox/hq 04-ISSUES/131).
|
||||
const changed = await client.listPullFiles(repo.owner, repo.name, pull.number);
|
||||
await emit("pull.merged", {
|
||||
owner: repo.owner,
|
||||
repo: repo.name,
|
||||
number: pull.number,
|
||||
title: pull.title,
|
||||
head: pull.head,
|
||||
base: pull.base,
|
||||
merge_commit_sha: pull.merge_commit_sha,
|
||||
merged_at: pull.merged_at,
|
||||
clone_url: repo.clone_url,
|
||||
html_url: pull.html_url,
|
||||
paths: changed.paths,
|
||||
paths_truncated: changed.truncated,
|
||||
});
|
||||
// Said, because a trigger that fires silently is indistinguishable from one that did not
|
||||
// fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told.
|
||||
console.log(`[gitea] announced merge ${repo.full_name}#${pull.number} (${pull.merge_commit_sha.slice(0, 8)}) into ${pull.base}`);
|
||||
}
|
||||
announced.add(pull.merge_commit_sha);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!primedMerges) since = new Date().toISOString();
|
||||
if (!primedMerges || changed) keepAnnounced();
|
||||
primedMerges = true;
|
||||
}
|
||||
|
||||
if (gitea) {
|
||||
const client = gitea;
|
||||
// A poll that fails says so once, not once a minute: the same reason repeating (the forge not up
|
||||
@@ -70,5 +147,6 @@ if (gitea) {
|
||||
run();
|
||||
};
|
||||
tick(() => pollRepos(client), 60_000);
|
||||
console.log("[gitea] watching for new repositories");
|
||||
tick(() => pollMerged(client), 30_000);
|
||||
console.log("[gitea] watching for new repositories and merged pull requests");
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"route": {
|
||||
"web": {
|
||||
"label": "git",
|
||||
"port": 3000
|
||||
"endpoint": "web"
|
||||
},
|
||||
"internal-api-refused": {
|
||||
"label": "git",
|
||||
@@ -44,12 +44,14 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 3000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the forge, over http"
|
||||
},
|
||||
{
|
||||
"name": "ssh",
|
||||
"port": 22,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -231,6 +231,11 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
// 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);
|
||||
// Read it again: the merge commit only exists now, and it is what a build is made from.
|
||||
const merged = await gitea.getPullRequest(owner, repo, number);
|
||||
// And what it changed, so the mesh rebuilds the modules whose own files moved rather than
|
||||
// every module built from the repository (novox/hq 04-ISSUES/131).
|
||||
const changed = await gitea.listPullFiles(owner, repo, number);
|
||||
await emit("pull.merged", {
|
||||
owner,
|
||||
repo,
|
||||
@@ -238,8 +243,12 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
title: pull.title,
|
||||
head: pull.head,
|
||||
base: pull.base,
|
||||
merge_commit_sha: merged.merge_commit_sha,
|
||||
merged_at: merged.merged_at,
|
||||
method,
|
||||
html_url: pull.html_url,
|
||||
paths: changed.paths,
|
||||
paths_truncated: changed.truncated,
|
||||
});
|
||||
return { merged: true, number, method, deleted_branch: deleteBranch };
|
||||
},
|
||||
|
||||
+51
-17
@@ -5,7 +5,7 @@
|
||||
"alert.firing"
|
||||
],
|
||||
"own-secrets": {
|
||||
"admin": "/var/lib/grafana-module/admin.secret",
|
||||
"admin": "/var/lib/mesh/grafana/admin",
|
||||
"broker": "/var/lib/mesh/grafana/broker"
|
||||
},
|
||||
"capabilities": [
|
||||
@@ -13,6 +13,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 3000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -29,45 +30,68 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/grafana-module",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/grafana/data",
|
||||
"mode": "0700",
|
||||
"owner": "472:472"
|
||||
},
|
||||
{
|
||||
"id": "server-env",
|
||||
"id": "admin-secret",
|
||||
"type": "file",
|
||||
"path": "/var/lib/grafana-module/server.env",
|
||||
"mode": "0600",
|
||||
"content": "GF_SECURITY_ADMIN_PASSWORD=${secret:admin}\n"
|
||||
"path": "${dir:state}/admin.secret",
|
||||
"mode": "0400",
|
||||
"owner": "472:472",
|
||||
"content": "${secret:admin}"
|
||||
},
|
||||
{
|
||||
"id": "oidc-secret",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/oidc-client.secret",
|
||||
"mode": "0400",
|
||||
"owner": "472:472",
|
||||
"content": "${secret:oidc-client}"
|
||||
},
|
||||
{
|
||||
"id": "oidc-env",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/oidc.env",
|
||||
"mode": "0644",
|
||||
"content": "GF_SERVER_ROOT_URL=https://${bound:route:name}\nGF_AUTH_GENERIC_OAUTH_ENABLED=true\nGF_AUTH_GENERIC_OAUTH_NAME=Keycloak\nGF_AUTH_GENERIC_OAUTH_CLIENT_ID=${bound:oidc-client:as}\nGF_AUTH_GENERIC_OAUTH_CLIENT_SECRET__FILE=/run/secrets/oidc-client\nGF_AUTH_GENERIC_OAUTH_SCOPES=openid email profile roles\nGF_AUTH_GENERIC_OAUTH_AUTH_URL=${bound:oidc-client:issuer}${bound:oidc-client:authorization-path}\nGF_AUTH_GENERIC_OAUTH_TOKEN_URL=${bound:oidc-client:issuer}${bound:oidc-client:token-path}\nGF_AUTH_GENERIC_OAUTH_API_URL=${bound:oidc-client:issuer}${bound:oidc-client:userinfo-path}\nGF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH=contains(roles[*], 'admin') && 'Admin' || contains(realm_access.roles[*], 'admin') && 'Admin' || 'Viewer'\nGF_AUTH_GENERIC_OAUTH_USE_PKCE=true\nGF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true\nGF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_GRAFANA_ADMIN=true\n"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "grafana",
|
||||
"image": "grafana/grafana@sha256:f772d434e8fab0049deb2b1b30abd43342bcfca1537614aa8d36080232cf4283",
|
||||
"image": "grafana/grafana@sha256:ac461fb352abc50da10a51c7d02462e9c05488f11f53f14b3ad79a8145f638a0",
|
||||
"ports": [
|
||||
"3000"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/grafana/data:/var/lib/grafana"
|
||||
"${dir:data}:/var/lib/grafana",
|
||||
"${dir:state}/admin.secret:/run/secrets/admin:ro",
|
||||
"${dir:state}/oidc-client.secret:/run/secrets/oidc-client:ro"
|
||||
],
|
||||
"env": {
|
||||
"GF_SECURITY_ADMIN_PASSWORD__FILE": "/run/secrets/admin"
|
||||
},
|
||||
"env-file": [
|
||||
"/var/lib/grafana-module/server.env"
|
||||
"${dir:state}/oidc.env"
|
||||
],
|
||||
"secrets-in-environment": "grafana honours GF_SECURITY_ADMIN_PASSWORD__FILE; convertible, awaiting a bed that exercises the admin password (assigned-grafana serves tools only)"
|
||||
"restart-on": [
|
||||
"oidc-env",
|
||||
"oidc-secret"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/grafana/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"content": "{\n \"user\": \"admin\",\n \"password\": \"${secret:admin}\"\n}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
@@ -81,7 +105,7 @@
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_GRAFANA_URL": "http://127.0.0.1:3000",
|
||||
"MESH_GRAFANA_URL": "http://127.0.0.1:${port:3000}",
|
||||
"MESH_GRAFANA_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
@@ -91,16 +115,26 @@
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
"route"
|
||||
"route",
|
||||
"oidc-client"
|
||||
],
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "grafana",
|
||||
"port": 3000
|
||||
"endpoint": "web"
|
||||
},
|
||||
"oidc-client": {
|
||||
"label": "grafana",
|
||||
"endpoint": "web",
|
||||
"callback": "/login/generic_oauth"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "/var/lib/mesh/grafana/route.json"
|
||||
"route": "${dir:state}/route.json",
|
||||
"oidc-client": "${dir:state}/oidc.json"
|
||||
},
|
||||
"secrets": {
|
||||
"oidc-client": "/var/lib/mesh/grafana/oidc-client"
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "hello",
|
||||
"port": 8080
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -19,6 +19,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8123,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -85,7 +86,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "home-assistant",
|
||||
"port": 8123
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "stream",
|
||||
"port": 8000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "api",
|
||||
"port": 8086,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
"route": {
|
||||
"site": {
|
||||
"label": "invoicing",
|
||||
"port": 80
|
||||
"endpoint": "web"
|
||||
},
|
||||
"api": {
|
||||
"label": "invoicing-api",
|
||||
"port": 9000
|
||||
"endpoint": "api"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -36,12 +36,14 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 80,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the invoicing web frontend; a public name is a route grant later"
|
||||
},
|
||||
{
|
||||
"name": "api",
|
||||
"port": 9000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 9117,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -82,7 +83,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "indexers",
|
||||
"port": 9117
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -17,7 +17,7 @@ FROM ${BUILD_BASE} AS build
|
||||
# resolved away.
|
||||
WORKDIR /app/modules/keycloak
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts oidc.ts index.ts provisioner/index.ts tools/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
@@ -27,4 +27,4 @@ COPY --from=build /app/modules/keycloak/dist /app/modules/keycloak/dist
|
||||
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
|
||||
# provisioner (`run`) served no tools and emitted no events; a container that named no command
|
||||
# ran no provisioner at all.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/keycloak/dist/index.js,/app/modules/keycloak/dist/tools/index.js
|
||||
ENV MESH_TOOL_MODULES=/app/modules/keycloak/dist/index.js,/app/modules/keycloak/dist/tools/index.js,/app/modules/keycloak/dist/provisioner/index.js
|
||||
|
||||
@@ -12,6 +12,45 @@ function meshConfig(file?: string): Record<string, string> {
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
/** A secret file's value, trailing newline trimmed; undefined when unset or unreadable. */
|
||||
function secretFile(file?: string): string | undefined {
|
||||
if (!file) return undefined;
|
||||
try { return readFileSync(file, "utf8").replace(/\n$/, "") || undefined; }
|
||||
catch { return undefined; }
|
||||
}
|
||||
|
||||
/** A client as the admin API represents it — only the fields this module reads or writes are typed;
|
||||
* the rest travel through untouched, so an update never drops what somebody else set. */
|
||||
export interface ClientRepresentation {
|
||||
id?: string;
|
||||
clientId: string;
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
protocol?: string;
|
||||
publicClient?: boolean;
|
||||
clientAuthenticatorType?: string;
|
||||
secret?: string;
|
||||
rootUrl?: string;
|
||||
baseUrl?: string;
|
||||
redirectUris?: string[];
|
||||
webOrigins?: string[];
|
||||
standardFlowEnabled?: boolean;
|
||||
implicitFlowEnabled?: boolean;
|
||||
directAccessGrantsEnabled?: boolean;
|
||||
serviceAccountsEnabled?: boolean;
|
||||
attributes?: Record<string, string>;
|
||||
protocolMappers?: ProtocolMapperRepresentation[];
|
||||
[other: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ProtocolMapperRepresentation {
|
||||
id?: string;
|
||||
name: string;
|
||||
protocol: string;
|
||||
protocolMapper: string;
|
||||
config: Record<string, string>;
|
||||
}
|
||||
|
||||
export class KeycloakClient {
|
||||
readonly baseUrl: string;
|
||||
readonly defaultRealm: string;
|
||||
@@ -40,8 +79,13 @@ export class 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");
|
||||
// The admin password reaches the runtime as a file (novox/hq ADR 0086): the module's own `admin`
|
||||
// secret, mounted read-only. The environment forms stay for a co-located server that has them.
|
||||
const adminPass = cfg.password ?? secretFile(env.MESH_KEYCLOAK_PASSWORD_FILE)
|
||||
?? env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD;
|
||||
if (!adminPass) {
|
||||
throw new Error("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD_FILE (or MESH_KEYCLOAK_PASSWORD)");
|
||||
}
|
||||
const realm = cfg.realm ?? env.MESH_KEYCLOAK_REALM ?? "master";
|
||||
return new KeycloakClient(url, adminUser, adminPass, realm);
|
||||
}
|
||||
@@ -159,6 +203,50 @@ export class KeycloakClient {
|
||||
return client.id as string;
|
||||
}
|
||||
|
||||
/** The one client with exactly this clientId, or undefined. The admin API's `clientId` filter is an
|
||||
* exact match unless `search=true` is asked for. */
|
||||
async findClient(realm: string, clientId: string): Promise<ClientRepresentation | undefined> {
|
||||
const found = await this.request<ClientRepresentation[]>(
|
||||
`/${realm}/clients?clientId=${encodeURIComponent(clientId)}`);
|
||||
return found.find((c) => c.clientId === clientId);
|
||||
}
|
||||
|
||||
async createClientFrom(realm: string, rep: ClientRepresentation): Promise<void> {
|
||||
await this.request(`/${realm}/clients`, { method: "POST", body: JSON.stringify(rep) });
|
||||
}
|
||||
|
||||
/** Replace a client's representation, addressed by its internal id. */
|
||||
async updateClient(realm: string, id: string, rep: ClientRepresentation): Promise<void> {
|
||||
await this.request(`/${realm}/clients/${id}`, { method: "PUT", body: JSON.stringify(rep) });
|
||||
}
|
||||
|
||||
async deleteClientById(realm: string, id: string): Promise<void> {
|
||||
await this.request(`/${realm}/clients/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async clientSecretById(realm: string, id: string): Promise<string | undefined> {
|
||||
const result = await this.request<{ value?: string }>(`/${realm}/clients/${id}/client-secret`);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
async listClientMappers(realm: string, id: string): Promise<ProtocolMapperRepresentation[]> {
|
||||
return this.request(`/${realm}/clients/${id}/protocol-mappers/models`);
|
||||
}
|
||||
|
||||
async addClientMapper(realm: string, id: string, mapper: ProtocolMapperRepresentation): Promise<void> {
|
||||
await this.request(`/${realm}/clients/${id}/protocol-mappers/models`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(mapper),
|
||||
});
|
||||
}
|
||||
|
||||
async updateClientMapper(realm: string, id: string, mapper: ProtocolMapperRepresentation): Promise<void> {
|
||||
await this.request(`/${realm}/clients/${id}/protocol-mappers/models/${mapper.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(mapper),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteClient(realm: string, clientId: string): Promise<void> {
|
||||
await this.request(`/${realm}/clients/${await this.resolveClientId(realm, clientId)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"module": "keycloak",
|
||||
"version": "1",
|
||||
"provides": [
|
||||
{
|
||||
"name": "oidc-client",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
"postgres-database",
|
||||
"route"
|
||||
@@ -11,7 +17,7 @@
|
||||
},
|
||||
"route": {
|
||||
"label": "keycloak",
|
||||
"port": 8080
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -34,12 +40,26 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "anything the mesh runs that authenticates a person"
|
||||
}
|
||||
],
|
||||
"serves": {
|
||||
"oidc-client": {
|
||||
"authorization-path": "/protocol/openid-connect/auth",
|
||||
"token-path": "/protocol/openid-connect/token",
|
||||
"userinfo-path": "/protocol/openid-connect/userinfo"
|
||||
}
|
||||
},
|
||||
"receives": {
|
||||
"oidc-client": "/var/lib/keycloak/grants/mesh.json"
|
||||
},
|
||||
"grants": {
|
||||
"oidc-client": "/var/lib/keycloak/grants"
|
||||
},
|
||||
"own-secrets": {
|
||||
"admin": "/var/lib/keycloak/admin.secret",
|
||||
"broker": "/var/lib/mesh/keycloak/broker"
|
||||
@@ -57,6 +77,12 @@
|
||||
"path": "/var/lib/keycloak",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "grants",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/keycloak/grants",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "admin-env",
|
||||
"type": "file",
|
||||
@@ -76,6 +102,13 @@
|
||||
"type": "network",
|
||||
"name": "keycloak"
|
||||
},
|
||||
{
|
||||
"id": "hostname",
|
||||
"type": "file",
|
||||
"path": "/var/lib/keycloak/hostname.env",
|
||||
"mode": "0644",
|
||||
"content": "KC_HOSTNAME=https://${bound:route:name}\n"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
@@ -89,17 +122,20 @@
|
||||
"KC_DB": "postgres",
|
||||
"KC_HTTP_ENABLED": "true",
|
||||
"KC_HEALTH_ENABLED": "true",
|
||||
"KC_HOSTNAME": "https://keycloak.novox.be",
|
||||
"KC_PROXY_HEADERS": "xforwarded"
|
||||
},
|
||||
"env-file": [
|
||||
"/var/lib/keycloak/admin.env",
|
||||
"/var/lib/keycloak/database.env"
|
||||
"/var/lib/keycloak/database.env",
|
||||
"/var/lib/keycloak/hostname.env"
|
||||
],
|
||||
"ports": [
|
||||
"8080"
|
||||
],
|
||||
"secrets-in-environment": "KC_DB_PASSWORD is convertible through a generated keycloak.conf (db-password=); KEYCLOAK_ADMIN_PASSWORD is env-only before Keycloak 26; not yet converted"
|
||||
"secrets-in-environment": "KC_DB_PASSWORD is convertible through a generated keycloak.conf (db-password=); KEYCLOAK_ADMIN_PASSWORD is env-only before Keycloak 26; not yet converted",
|
||||
"restart-on": [
|
||||
"hostname"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
@@ -116,12 +152,16 @@
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/keycloak/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/keycloak/config.json:/run/config/config.json:ro"
|
||||
"/var/lib/mesh/keycloak/config.json:/run/config/config.json:ro",
|
||||
"/var/lib/keycloak/admin.secret:/run/secrets/admin:ro",
|
||||
"/var/lib/keycloak/grants:/var/lib/keycloak/grants:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
|
||||
"MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json"
|
||||
"MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_KEYCLOAK_PASSWORD_FILE": "/run/secrets/admin",
|
||||
"MESH_RECEIVES": "/var/lib/keycloak/grants/mesh.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// What the `oidc-client` provision means in Keycloak: one confidential OpenID Connect client per
|
||||
// consumer, in the realm this module serves, under the name and secret the mesh gave both ends.
|
||||
// The provisioner (provisioner/index.ts) is the sdk harness calling these; they are here, apart from
|
||||
// it, so they can be exercised against a fake admin API without a broker or a contributions file.
|
||||
//
|
||||
// **The client id and the secret are the mesh's, not Keycloak's (novox/hq ADR 0048).** The mesh
|
||||
// derives the consumer's identity (`as`, e.g. `mesh_ace_grafana`) and hands it to both ends — the
|
||||
// consumer names it as its client id through `${bound:oidc-client:as}` — and mints the secret, which
|
||||
// this sets as the client's secret. Keycloak generates neither.
|
||||
//
|
||||
// **Where the consumer's browser comes back to is the consumer's to say.** Its contribution carries
|
||||
// `callback` (a path, e.g. `/login/generic_oauth`) and the `label`/`endpoint` of the endpoint it is
|
||||
// reached on; the mesh composes that endpoint's names into `name` (public) and `internal-name`
|
||||
// (private network) exactly as it does for a route (novox/hq ADR 0056, 0138), so the redirect URI
|
||||
// registered here is built from the same names the proxy serves the consumer under.
|
||||
//
|
||||
// **Only what the mesh made is touched.** A client this module creates carries the attribute
|
||||
// `mesh.provisioned=true`, and its id starts with the mesh's own prefix. A client with the same id
|
||||
// that lacks the mark is somebody else's: it is refused, never adopted, never updated, never deleted.
|
||||
|
||||
import type { ClientRepresentation, KeycloakClient, ProtocolMapperRepresentation } from "./client.js";
|
||||
|
||||
/** The attribute marking a client as the mesh's own work. */
|
||||
export const MARK = "mesh.provisioned";
|
||||
|
||||
/** The mapper every mesh client carries: realm roles as a flat `roles` claim in the id token, the
|
||||
* access token and userinfo — what a consumer maps its own roles from (grafana's role path reads
|
||||
* `roles[*]`), and what the predecessor added to its hand-made clients by hand. */
|
||||
export const ROLES_MAPPER: ProtocolMapperRepresentation = {
|
||||
name: "realm roles",
|
||||
protocol: "openid-connect",
|
||||
protocolMapper: "oidc-usermodel-realm-role-mapper",
|
||||
config: {
|
||||
"claim.name": "roles",
|
||||
"jsonType.label": "String",
|
||||
multivalued: "true",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "true",
|
||||
},
|
||||
};
|
||||
|
||||
/** One consumer, as the harness hands it over. */
|
||||
export interface OidcGrant {
|
||||
readonly as: string;
|
||||
readonly password: string;
|
||||
readonly values: Readonly<Record<string, unknown>>;
|
||||
readonly consumer?: string;
|
||||
}
|
||||
|
||||
/** The realm named by an issuer URL — `https://id.example/realms/Novox` is realm `Novox`. The issuer is
|
||||
* the one value an assignment sets (it is also what consumers are served), so the realm is read
|
||||
* out of it rather than set a second time where the two could disagree. */
|
||||
export function realmOf(issuer: string): string {
|
||||
let path: string;
|
||||
try {
|
||||
path = new URL(issuer).pathname;
|
||||
} catch {
|
||||
throw new Error(`the issuer ${JSON.stringify(issuer)} is not a URL`);
|
||||
}
|
||||
const m = /\/realms\/([^/]+)\/?$/.exec(path);
|
||||
if (!m) throw new Error(`the issuer ${JSON.stringify(issuer)} does not end in /realms/<realm>`);
|
||||
return decodeURIComponent(m[1]);
|
||||
}
|
||||
|
||||
/** The redirect URIs a consumer's contribution asks for: its callback under each name the mesh
|
||||
* composed for its endpoint. Refused when there is nothing to register — a client that accepts no
|
||||
* redirect is a client nobody can log in through, and one that accepts any is worse. */
|
||||
export function redirectsOf(values: Readonly<Record<string, unknown>>): { root: string; redirects: string[] } {
|
||||
const callback = values.callback;
|
||||
if (typeof callback !== "string" || !callback.startsWith("/")) {
|
||||
throw new Error(`contributes no callback path (\`callback\`, starting with "/"): ${JSON.stringify(callback)}`);
|
||||
}
|
||||
const names: string[] = [];
|
||||
for (const key of ["name", "internal-name"]) {
|
||||
const n = values[key];
|
||||
if (typeof n === "string" && n.trim() !== "" && !names.includes(n.trim())) names.push(n.trim());
|
||||
}
|
||||
if (names.length === 0) {
|
||||
throw new Error("has no name the mesh composed (`name` / `internal-name`) — contribute a `label` and the `endpoint` it is reached on");
|
||||
}
|
||||
return { root: `https://${names[0]}`, redirects: names.map((n) => `https://${n}${callback}`) };
|
||||
}
|
||||
|
||||
/** The fields the mesh owns on a client it made. Everything else on the client is left as found. */
|
||||
function wanted(g: OidcGrant): ClientRepresentation {
|
||||
const { root, redirects } = redirectsOf(g.values);
|
||||
return {
|
||||
clientId: g.as,
|
||||
name: g.as,
|
||||
description: `made by the mesh for ${g.consumer ? `a module on ${g.consumer}` : "a consumer"} — do not edit; it is reset`,
|
||||
enabled: true,
|
||||
protocol: "openid-connect",
|
||||
publicClient: false,
|
||||
clientAuthenticatorType: "client-secret",
|
||||
secret: g.password,
|
||||
rootUrl: root,
|
||||
baseUrl: root,
|
||||
redirectUris: redirects,
|
||||
standardFlowEnabled: true,
|
||||
implicitFlowEnabled: false,
|
||||
directAccessGrantsEnabled: false,
|
||||
serviceAccountsEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
function sameSet(a: readonly string[] | undefined, b: readonly string[]): boolean {
|
||||
const x = [...(a ?? [])].sort();
|
||||
const y = [...b].sort();
|
||||
return x.length === y.length && x.every((v, i) => v === y[i]);
|
||||
}
|
||||
|
||||
function marked(c: ClientRepresentation): boolean {
|
||||
return c.attributes?.[MARK] === "true";
|
||||
}
|
||||
|
||||
export class OidcClients {
|
||||
constructor(private readonly kc: KeycloakClient, readonly realm: string) {}
|
||||
|
||||
/** Create the consumer's client, or bring the mesh's existing one back to what the grant says.
|
||||
* Returns whether it was newly created. Idempotent: applying the same grant twice changes nothing
|
||||
* the second time beyond re-asserting it. */
|
||||
async ensure(g: OidcGrant): Promise<"created" | "updated"> {
|
||||
const want = wanted(g);
|
||||
const found = await this.kc.findClient(this.realm, g.as);
|
||||
if (found && !marked(found)) {
|
||||
throw new Error(
|
||||
`realm ${this.realm} already has a client ${g.as} the mesh did not make — left alone; ` +
|
||||
`delete or rename it if the mesh should own that id`);
|
||||
}
|
||||
if (!found) {
|
||||
await this.kc.createClientFrom(this.realm, {
|
||||
...want,
|
||||
attributes: { [MARK]: "true" },
|
||||
protocolMappers: [ROLES_MAPPER],
|
||||
});
|
||||
return "created";
|
||||
}
|
||||
// Overlay what the mesh owns on what is there, so a field Keycloak added or an operator set on a
|
||||
// field the mesh does not own survives the update.
|
||||
await this.kc.updateClient(this.realm, found.id!, {
|
||||
...found,
|
||||
...want,
|
||||
attributes: { ...(found.attributes ?? {}), [MARK]: "true" },
|
||||
});
|
||||
await this.ensureMapper(found.id!);
|
||||
return "updated";
|
||||
}
|
||||
|
||||
private async ensureMapper(id: string): Promise<void> {
|
||||
const mappers = await this.kc.listClientMappers(this.realm, id);
|
||||
const have = mappers.find((m) => m.name === ROLES_MAPPER.name);
|
||||
if (!have) {
|
||||
await this.kc.addClientMapper(this.realm, id, ROLES_MAPPER);
|
||||
return;
|
||||
}
|
||||
const drifted =
|
||||
have.protocolMapper !== ROLES_MAPPER.protocolMapper ||
|
||||
Object.entries(ROLES_MAPPER.config).some(([k, v]) => have.config?.[k] !== v);
|
||||
if (drifted) {
|
||||
await this.kc.updateClientMapper(this.realm, id, { ...ROLES_MAPPER, id: have.id });
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether Keycloak still holds this consumer's client exactly as the grant says: present, the
|
||||
* mesh's, enabled, confidential, with the mesh's secret and the redirects asked for. Reads only. */
|
||||
async holds(g: OidcGrant): Promise<boolean> {
|
||||
const want = wanted(g);
|
||||
const found = await this.kc.findClient(this.realm, g.as);
|
||||
if (!found || !marked(found) || found.enabled === false || found.publicClient) return false;
|
||||
if (!sameSet(found.redirectUris, want.redirectUris!)) return false;
|
||||
const mappers = await this.kc.listClientMappers(this.realm, found.id!);
|
||||
if (!mappers.some((m) => m.name === ROLES_MAPPER.name)) return false;
|
||||
return (await this.kc.clientSecretById(this.realm, found.id!)) === g.password;
|
||||
}
|
||||
|
||||
/** Withdraw a consumer's client — only one the mesh made. Returns what happened, for the log. */
|
||||
async remove(as: string): Promise<"removed" | "absent" | "not ours"> {
|
||||
const found = await this.kc.findClient(this.realm, as);
|
||||
if (!found) return "absent";
|
||||
if (!marked(found)) return "not ours";
|
||||
await this.kc.deleteClientById(this.realm, found.id!);
|
||||
return "removed";
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"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 0039).",
|
||||
"description": "keycloak — identity and access; provides the mesh oidc-client interface. Its admin API client, provisioner, tools and events live here (novox/hq ADR 0039).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc client.ts oidc.ts index.ts provisioner/index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
||||
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// keycloak's provisioner — the adapter that makes keycloak a provider of the mesh `oidc-client`
|
||||
// interface. 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 Keycloak creates, checks and removes a
|
||||
// consumer's client (novox/hq ADR 0039/0040/0048). What a client is, and which ones are the mesh's,
|
||||
// is in ../oidc.ts.
|
||||
//
|
||||
// The `oidc-client` interface: a consumer logs people in through the realm this module serves, as
|
||||
// the confidential client `as` with the secret the mesh minted, and is redirected back to the
|
||||
// callback it contributed under the names the mesh composed for its endpoint. What it is served —
|
||||
// the issuer and the endpoint paths under it — is in the manifest's `serves`, settled with the
|
||||
// assignment's settings.
|
||||
//
|
||||
// **The realm is read out of the issuer**, the one value an assignment sets (settings reach both the
|
||||
// served facts and this module's config.json): a realm set in one place and an issuer in another
|
||||
// would let the consumer be told one realm while its client is made in another.
|
||||
|
||||
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { KeycloakClient } from "../client.js";
|
||||
import { OidcClients, realmOf } from "../oidc.js";
|
||||
|
||||
/** The issuer this assignment serves, from the settings-merged config the mesh delivers. */
|
||||
function issuer(): string {
|
||||
const file = process.env.MESH_KEYCLOAK_CONFIG_FILE;
|
||||
let cfg: Record<string, unknown> = {};
|
||||
if (file) {
|
||||
try {
|
||||
cfg = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Absent or unreadable: fall through to the environment, and refuse below if that is empty too.
|
||||
}
|
||||
}
|
||||
const said = typeof cfg.issuer === "string" ? cfg.issuer : process.env.MESH_KEYCLOAK_ISSUER;
|
||||
if (!said) throw new Error("no issuer — the module's config.json carries none and MESH_KEYCLOAK_ISSUER is unset");
|
||||
return said;
|
||||
}
|
||||
|
||||
const clients = new OidcClients(KeycloakClient.fromEnv(), realmOf(issuer()));
|
||||
|
||||
/** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */
|
||||
async function announce(type: string, body: Record<string, string>): Promise<void> {
|
||||
try {
|
||||
await emit(type, body);
|
||||
} catch (err) {
|
||||
console.error(`[provisioner:oidc-client] emit ${type} failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
runProvisioner("oidc-client", {
|
||||
async create(p: Provision): Promise<void> {
|
||||
const done = await clients.ensure(p);
|
||||
if (done === "created") {
|
||||
console.log(`[provisioner:oidc-client] created client ${p.as} in realm ${clients.realm}`);
|
||||
await announce("client.created", { realm: clients.realm, clientId: p.as, consumer: p.consumer ?? "" });
|
||||
}
|
||||
},
|
||||
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
const done = await clients.remove(p.as);
|
||||
if (done === "not ours") {
|
||||
console.error(`[provisioner:oidc-client] ${p.as}: a client of that id exists that the mesh did not make — left alone`);
|
||||
} else if (done === "removed") {
|
||||
console.log(`[provisioner:oidc-client] removed client ${p.as} from realm ${clients.realm}`);
|
||||
}
|
||||
},
|
||||
|
||||
// Asked every minute by the harness: whether Keycloak still holds this consumer's client exactly as
|
||||
// the mesh gave it, so a client deleted or edited behind the mesh's back is made again (hq issue 120).
|
||||
async holds(p: Provision): Promise<boolean> {
|
||||
return clients.holds(p);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
// What holds keycloak to the `oidc-client` provision (oidc.ts): one confidential client per consumer,
|
||||
// under the id and secret the mesh gave, redirecting only to the consumer's own callback under the
|
||||
// names the mesh composed; made once and brought back on every apply; and a client the mesh did not
|
||||
// make — same id or not — never adopted, changed or deleted.
|
||||
//
|
||||
// Keycloak is a fake: the admin routes the module touches, answering with the status codes and the
|
||||
// shapes Keycloak gives. Run against the compiled module (npm test builds first), the way the runtime
|
||||
// loads it.
|
||||
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { KeycloakClient } from "../dist/client.js";
|
||||
import { MARK, OidcClients, ROLES_MAPPER, realmOf, redirectsOf } from "../dist/oidc.js";
|
||||
|
||||
type Client = Record<string, any>;
|
||||
|
||||
/** The realm's clients, by internal id, and what the fake was asked. */
|
||||
const realm = "Novox";
|
||||
const clients = new Map<string, Client>();
|
||||
const calls: string[] = [];
|
||||
|
||||
function body(req: IncomingMessage): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
req.on("data", (c) => (raw += c));
|
||||
req.on("end", () => resolve(raw ? JSON.parse(raw) : undefined));
|
||||
});
|
||||
}
|
||||
|
||||
function send(res: ServerResponse, status: number, value?: unknown): void {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(value === undefined ? "" : JSON.stringify(value));
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, "http://fake");
|
||||
calls.push(`${req.method} ${url.pathname}`);
|
||||
if (url.pathname === "/realms/master/protocol/openid-connect/token") {
|
||||
return send(res, 200, { access_token: "t", expires_in: 300 });
|
||||
}
|
||||
const base = `/admin/realms/${realm}/clients`;
|
||||
if (!url.pathname.startsWith(base)) return send(res, 404, { error: "Realm not found." });
|
||||
const rest = url.pathname.slice(base.length).split("/").filter(Boolean);
|
||||
if (rest.length === 0 && req.method === "GET") {
|
||||
const want = url.searchParams.get("clientId");
|
||||
return send(res, 200, [...clients.values()].filter((c) => !want || c.clientId === want));
|
||||
}
|
||||
if (rest.length === 0 && req.method === "POST") {
|
||||
const rep = await body(req);
|
||||
if ([...clients.values()].some((c) => c.clientId === rep.clientId)) {
|
||||
return send(res, 409, { errorMessage: `Client ${rep.clientId} already exists` });
|
||||
}
|
||||
const id = randomUUID();
|
||||
const mappers = (rep.protocolMappers ?? []).map((m: Client) => ({ ...m, id: randomUUID() }));
|
||||
clients.set(id, { ...rep, id, protocolMappers: mappers });
|
||||
return send(res, 201);
|
||||
}
|
||||
const c = clients.get(rest[0]);
|
||||
if (!c) return send(res, 404, { error: "Could not find client" });
|
||||
if (rest.length === 1 && req.method === "PUT") {
|
||||
// Keycloak ignores protocolMappers on a client update: they have their own endpoints.
|
||||
const rep = await body(req);
|
||||
clients.set(c.id, { ...rep, id: c.id, protocolMappers: c.protocolMappers });
|
||||
return send(res, 204);
|
||||
}
|
||||
if (rest.length === 1 && req.method === "DELETE") {
|
||||
clients.delete(c.id);
|
||||
return send(res, 204);
|
||||
}
|
||||
if (rest[1] === "client-secret" && req.method === "GET") {
|
||||
return send(res, 200, { type: "secret", value: c.secret });
|
||||
}
|
||||
if (rest[1] === "protocol-mappers") {
|
||||
if (req.method === "GET") return send(res, 200, c.protocolMappers ?? []);
|
||||
if (req.method === "POST") {
|
||||
c.protocolMappers = [...(c.protocolMappers ?? []), { ...(await body(req)), id: randomUUID() }];
|
||||
return send(res, 201);
|
||||
}
|
||||
if (req.method === "PUT") {
|
||||
const m = await body(req);
|
||||
c.protocolMappers = c.protocolMappers.map((x: Client) => (x.id === rest[4] ? m : x));
|
||||
return send(res, 204);
|
||||
}
|
||||
}
|
||||
send(res, 405);
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
||||
after(() => server.close());
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
const oidc = new OidcClients(new KeycloakClient(`http://127.0.0.1:${port}`, "admin", "pw"), realm);
|
||||
|
||||
/** Grafana on ace, as the mesh hands it to the provisioner. */
|
||||
function grafana(secret = "s3cret", values: Record<string, unknown> = {}) {
|
||||
return {
|
||||
as: "mesh_ace_grafana",
|
||||
password: secret,
|
||||
consumer: "ace",
|
||||
values: {
|
||||
label: "grafana", endpoint: "web", port: 20010, callback: "/login/generic_oauth",
|
||||
name: "grafana.zurag.be", "internal-name": "grafana.ace.internal", ...values,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function only(clientId: string): Client {
|
||||
const found = [...clients.values()].filter((c) => c.clientId === clientId);
|
||||
assert.equal(found.length, 1, `exactly one client ${clientId}, found ${found.length}`);
|
||||
return found[0];
|
||||
}
|
||||
|
||||
test("the realm is read out of the issuer, and an issuer that names none is refused", () => {
|
||||
assert.equal(realmOf("https://keycloak.novox.be/realms/Novox"), "Novox");
|
||||
assert.equal(realmOf("https://keycloak.novox.be/realms/Novox/"), "Novox");
|
||||
assert.equal(realmOf("http://127.0.0.1:18500/realms/master"), "master");
|
||||
assert.throws(() => realmOf("https://keycloak.novox.be"), /realms/);
|
||||
assert.throws(() => realmOf("keycloak"), /not a URL/);
|
||||
});
|
||||
|
||||
test("the redirect is the consumer's callback under every name the mesh composed for it", () => {
|
||||
assert.deepEqual(redirectsOf(grafana().values), {
|
||||
root: "https://grafana.zurag.be",
|
||||
redirects: ["https://grafana.zurag.be/login/generic_oauth", "https://grafana.ace.internal/login/generic_oauth"],
|
||||
});
|
||||
// A route reaching only the private network has only the internal name, and that is enough.
|
||||
assert.deepEqual(redirectsOf({ callback: "/cb", "internal-name": "x.ace.internal" }).redirects,
|
||||
["https://x.ace.internal/cb"]);
|
||||
assert.throws(() => redirectsOf({ name: "grafana.zurag.be" }), /callback/);
|
||||
assert.throws(() => redirectsOf({ name: "grafana.zurag.be", callback: "login" }), /callback/);
|
||||
assert.throws(() => redirectsOf({ callback: "/cb" }), /label/);
|
||||
});
|
||||
|
||||
test("a consumer is given one confidential client, under its id and the mesh's secret", async () => {
|
||||
clients.clear();
|
||||
assert.equal(await oidc.ensure(grafana()), "created");
|
||||
const c = only("mesh_ace_grafana");
|
||||
assert.equal(c.publicClient, false);
|
||||
assert.equal(c.clientAuthenticatorType, "client-secret");
|
||||
assert.equal(c.secret, "s3cret");
|
||||
assert.equal(c.enabled, true);
|
||||
assert.equal(c.standardFlowEnabled, true);
|
||||
assert.equal(c.directAccessGrantsEnabled, false);
|
||||
assert.equal(c.implicitFlowEnabled, false);
|
||||
assert.deepEqual(c.redirectUris, [
|
||||
"https://grafana.zurag.be/login/generic_oauth", "https://grafana.ace.internal/login/generic_oauth"]);
|
||||
assert.equal(c.attributes[MARK], "true");
|
||||
assert.deepEqual(c.protocolMappers.map((m: Client) => m.name), [ROLES_MAPPER.name]);
|
||||
assert.equal(await oidc.holds(grafana()), true);
|
||||
});
|
||||
|
||||
test("applying the same grant again makes no second client", async () => {
|
||||
clients.clear();
|
||||
await oidc.ensure(grafana());
|
||||
assert.equal(await oidc.ensure(grafana()), "updated");
|
||||
assert.equal(await oidc.ensure(grafana()), "updated");
|
||||
only("mesh_ace_grafana");
|
||||
assert.equal(only("mesh_ace_grafana").protocolMappers.length, 1, "the roles mapper is not added twice");
|
||||
});
|
||||
|
||||
test("a new secret or a moved name is applied in place, and what the mesh does not own survives", async () => {
|
||||
clients.clear();
|
||||
await oidc.ensure(grafana());
|
||||
const id = only("mesh_ace_grafana").id;
|
||||
// Something the mesh does not own, set on the client after it was made.
|
||||
clients.get(id)!.consentRequired = true;
|
||||
clients.get(id)!.attributes["post.logout.redirect.uris"] = "+";
|
||||
|
||||
assert.equal(await oidc.holds(grafana("rotated")), false, "a rotated secret is not held until applied");
|
||||
await oidc.ensure(grafana("rotated", { name: "dash.zurag.be" }));
|
||||
const c = only("mesh_ace_grafana");
|
||||
assert.equal(c.id, id, "updated, not replaced");
|
||||
assert.equal(c.secret, "rotated");
|
||||
assert.deepEqual(c.redirectUris, [
|
||||
"https://dash.zurag.be/login/generic_oauth", "https://grafana.ace.internal/login/generic_oauth"]);
|
||||
assert.equal(c.rootUrl, "https://dash.zurag.be");
|
||||
assert.equal(c.consentRequired, true);
|
||||
assert.equal(c.attributes["post.logout.redirect.uris"], "+");
|
||||
assert.equal(c.attributes[MARK], "true");
|
||||
assert.equal(await oidc.holds(grafana("rotated", { name: "dash.zurag.be" })), true);
|
||||
});
|
||||
|
||||
test("a client lost or edited behind the mesh's back is not held, and is made whole again", async () => {
|
||||
clients.clear();
|
||||
await oidc.ensure(grafana());
|
||||
const c = only("mesh_ace_grafana");
|
||||
c.redirectUris = ["*"];
|
||||
assert.equal(await oidc.holds(grafana()), false, "a widened redirect is not what the mesh gave");
|
||||
await oidc.ensure(grafana());
|
||||
assert.equal(await oidc.holds(grafana()), true);
|
||||
|
||||
only("mesh_ace_grafana").protocolMappers = [];
|
||||
assert.equal(await oidc.holds(grafana()), false, "a client without its roles mapper is not held");
|
||||
await oidc.ensure(grafana());
|
||||
assert.equal(await oidc.holds(grafana()), true);
|
||||
|
||||
clients.clear();
|
||||
assert.equal(await oidc.holds(grafana()), false);
|
||||
});
|
||||
|
||||
test("a client of the same id the mesh did not make is refused, and left exactly as it was", async () => {
|
||||
clients.clear();
|
||||
clients.set("theirs", { id: "theirs", clientId: "mesh_ace_grafana", secret: "their-secret", redirectUris: ["*"] });
|
||||
const before = JSON.stringify(clients.get("theirs"));
|
||||
const writes = calls.length;
|
||||
await assert.rejects(oidc.ensure(grafana()), /did not make/);
|
||||
assert.equal(JSON.stringify(clients.get("theirs")), before);
|
||||
assert.ok(calls.slice(writes).every((c) => c.startsWith("GET") || c.startsWith("POST /realms/master")),
|
||||
`only reads were made: ${calls.slice(writes).join(", ")}`);
|
||||
assert.equal(await oidc.holds(grafana()), false);
|
||||
assert.equal(await oidc.remove("mesh_ace_grafana"), "not ours");
|
||||
assert.ok(clients.has("theirs"), "a client the mesh did not make is never deleted");
|
||||
});
|
||||
|
||||
test("the predecessor's hand-made client is never touched: the mesh's has its own id", async () => {
|
||||
clients.clear();
|
||||
clients.set("hal", { id: "hal", clientId: "grafana", secret: "old", redirectUris: ["https://grafana.zurag.be/*"] });
|
||||
await oidc.ensure(grafana());
|
||||
assert.equal(clients.get("hal")!.secret, "old");
|
||||
only("mesh_ace_grafana");
|
||||
assert.equal(await oidc.remove("grafana"), "not ours");
|
||||
assert.ok(clients.has("hal"));
|
||||
});
|
||||
|
||||
test("a withdrawn consumer's client is removed, and an absent one is not an error", async () => {
|
||||
clients.clear();
|
||||
await oidc.ensure(grafana());
|
||||
assert.equal(await oidc.remove("mesh_ace_grafana"), "removed");
|
||||
assert.equal([...clients.values()].length, 0);
|
||||
assert.equal(await oidc.remove("mesh_ace_grafana"), "absent");
|
||||
});
|
||||
|
||||
test("a contribution with no callback makes no client at all", async () => {
|
||||
clients.clear();
|
||||
await assert.rejects(oidc.ensure({ ...grafana(), values: { name: "grafana.zurag.be" } }), /callback/);
|
||||
assert.equal(clients.size, 0);
|
||||
});
|
||||
@@ -8,5 +8,5 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||
"include": ["client.ts", "oidc.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8283,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8686,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -86,7 +87,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "lidarr",
|
||||
"port": 8686
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -16,27 +16,27 @@
|
||||
"route": {
|
||||
"web": {
|
||||
"label": "mail",
|
||||
"port": 7443,
|
||||
"endpoint": "web-tls",
|
||||
"scheme": "https",
|
||||
"insecure": true
|
||||
},
|
||||
"acme": {
|
||||
"label": "mail",
|
||||
"path": "/.well-known/acme-challenge",
|
||||
"port": 7080,
|
||||
"endpoint": "web",
|
||||
"priority": 100
|
||||
},
|
||||
"autoconfig": {
|
||||
"label": "autoconfig",
|
||||
"port": 4243
|
||||
"endpoint": "autoconfig"
|
||||
},
|
||||
"autodiscover": {
|
||||
"label": "autodiscover",
|
||||
"port": 4243
|
||||
"endpoint": "autoconfig"
|
||||
},
|
||||
"automx": {
|
||||
"label": "automx",
|
||||
"port": 4243
|
||||
"endpoint": "autoconfig"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -60,6 +60,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "smtp",
|
||||
"port": 25,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
@@ -67,6 +68,7 @@
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"name": "pop3",
|
||||
"port": 110,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
@@ -74,6 +76,7 @@
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"name": "imap",
|
||||
"port": 143,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
@@ -81,6 +84,7 @@
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"name": "smtps",
|
||||
"port": 465,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
@@ -88,6 +92,7 @@
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"name": "submission",
|
||||
"port": 587,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
@@ -95,6 +100,7 @@
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"name": "imaps",
|
||||
"port": 993,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
@@ -102,6 +108,7 @@
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"name": "pop3s",
|
||||
"port": 995,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
@@ -109,18 +116,21 @@
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"name": "web",
|
||||
"port": 7080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the web front over http; only the ACME HTTP-01 passthrough is routed here \u2014 everything else 301s to https and would loop a proxy"
|
||||
},
|
||||
{
|
||||
"name": "web-tls",
|
||||
"port": 7443,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the web front over its own TLS (admin, webmail, API); the public name mail.novox.be is a route grant reaching it here"
|
||||
},
|
||||
{
|
||||
"name": "autoconfig",
|
||||
"port": 4243,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "api",
|
||||
"port": 59125,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -22,7 +22,7 @@ COPY . .
|
||||
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
|
||||
# symlinks to a launcher that requires its library relatively — resolved away when the base image
|
||||
# was assembled.
|
||||
RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts \
|
||||
RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts prepare/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
# **A module may need something the base image does not carry.** The base holds what every module
|
||||
@@ -48,3 +48,8 @@ COPY --from=build /deps/node_modules /app/modules/mesh-catalog/node_modules
|
||||
# to listen for what the builder announces. Serve binds the broker first, then imports these, so
|
||||
# `on()` has something to subscribe to.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/index.js,/app/modules/mesh-catalog/dist/tools/index.js
|
||||
|
||||
# And what prepares this module's state, for the runtime's `prepare` mode (novox/hq ADR 0135). Named
|
||||
# here, beside the entrypoints above, because the module knows which of its files prepares its state
|
||||
# and nothing else could: the mesh asks one word and this says what answers it.
|
||||
ENV MESH_PREPARE=/app/modules/mesh-catalog/dist/prepare/index.js
|
||||
|
||||
@@ -14,10 +14,12 @@ import { Graph, type Made } from "./store.js";
|
||||
|
||||
const graph = Graph.fromEnv();
|
||||
|
||||
// Before subscribing, and idempotent. The runtime is restarted until its store is reachable, which
|
||||
// is the same arrangement model-usage uses: a schema step that had to reach the provider over the
|
||||
// overlay would block the very apply that brings the overlay up.
|
||||
await graph.migrate();
|
||||
// The schema is not brought up here. The mesh prepares this module's state before it starts this
|
||||
// version, and does not start it if that failed (novox/hq ADR 0135) — see prepare/index.ts. Doing it
|
||||
// at start made a schema that could not be reached a crash loop instead of a stop, with the graph
|
||||
// keeping a gap and nothing saying so. The reason it used to be here — that a step blocking the apply
|
||||
// would block the very apply that brings the overlay up — stopped being true when a step's failure
|
||||
// became this module's business and not the machine's (ADR 0136).
|
||||
|
||||
/** What the builder says when it has built something. */
|
||||
interface Built {
|
||||
@@ -47,7 +49,15 @@ interface Built {
|
||||
replay?: boolean;
|
||||
}
|
||||
|
||||
await on("mesh-build-machine.built", async (event) => {
|
||||
/**
|
||||
* What a build means for the graph, wherever it came from.
|
||||
*
|
||||
* Two emitters say the same thing and neither is a mistake: the build machine says it as it happens,
|
||||
* and the control plane says what it already held when this module asks what it missed
|
||||
* (novox/hq ADR 0134). A replay is marked as one in its body, so nothing acts on a module that moved
|
||||
* months ago — see `replay` above.
|
||||
*/
|
||||
const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
|
||||
const body = event.body as Built;
|
||||
if (!body.module || !body.commit) {
|
||||
// Said rather than dropped: a build that announced itself without saying what it built is a
|
||||
@@ -89,7 +99,11 @@ await on("mesh-build-machine.built", async (event) => {
|
||||
because: next.because,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// As it happens, and what the mesh already held when this module asked what it missed.
|
||||
await on("mesh-build-machine.built", placeTheBuild);
|
||||
await on("mesh-controller.built-before", placeTheBuild);
|
||||
|
||||
// **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050).
|
||||
//
|
||||
|
||||
@@ -29,13 +29,16 @@
|
||||
"broker": "/var/lib/mesh/mesh-catalog/broker"
|
||||
},
|
||||
"consumes": [
|
||||
"mesh-build-machine.built"
|
||||
"mesh-build-machine.built",
|
||||
"mesh-controller.built-before"
|
||||
],
|
||||
"emits": [
|
||||
"registered",
|
||||
"upgraded",
|
||||
"rebuild-needed"
|
||||
"rebuild-needed",
|
||||
"catching-up"
|
||||
],
|
||||
"prepares": true,
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// The catalogue's state, brought to the shape this version needs (novox/hq ADR 0135).
|
||||
//
|
||||
// **The mesh runs this before the version that needs it, and does not start that version if it
|
||||
// fails** — and the refusal reaches this module and nothing else on the machine
|
||||
// (novox/hq ADR 0136). That is the whole difference from where this used to happen: at start, inside
|
||||
// the runtime, a schema that could not be brought up was a crash loop, the graph kept a gap, and
|
||||
// nothing anywhere said so.
|
||||
//
|
||||
// Nothing here connects to the broker. Preparation runs before the version that would use it, so
|
||||
// there is nothing yet to talk to; the runtime's `prepare` mode imports this and awaits it, and this
|
||||
// process exiting non-zero is how the host knows not to start the runtime.
|
||||
import { Graph } from "../store.js";
|
||||
|
||||
const graph = Graph.fromEnv();
|
||||
await graph.migrate();
|
||||
console.log("[mesh-catalog] the module graph's schema is what this version needs");
|
||||
await graph.close();
|
||||
@@ -12,6 +12,7 @@
|
||||
"pg.d.ts",
|
||||
"store.ts",
|
||||
"index.ts",
|
||||
"tools/index.ts"
|
||||
"tools/index.ts",
|
||||
"prepare/index.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
"route": {
|
||||
"api": {
|
||||
"label": "files-api",
|
||||
"port": 9000
|
||||
"endpoint": "s3"
|
||||
},
|
||||
"console": {
|
||||
"label": "files",
|
||||
"port": 9001
|
||||
"endpoint": "console"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -31,12 +31,14 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "s3",
|
||||
"port": 9000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the S3 endpoint"
|
||||
},
|
||||
{
|
||||
"name": "console",
|
||||
"port": 9001,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "database",
|
||||
"port": 27017,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -34,12 +34,14 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "mqtt",
|
||||
"port": 1883,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "modules on any machine that were granted a topic namespace"
|
||||
},
|
||||
{
|
||||
"name": "mqtt-websockets",
|
||||
"port": 8081,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "database",
|
||||
"port": 4848,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"route": {
|
||||
"label": "n8n",
|
||||
"port": 5682
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -29,6 +29,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 5682,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"consumes": [],
|
||||
"listens": [
|
||||
{
|
||||
"name": "bus",
|
||||
"port": 4222,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -47,7 +48,7 @@
|
||||
"id": "server-conf",
|
||||
"type": "file",
|
||||
"path": "/var/lib/nats-module/conf/nats.conf",
|
||||
"content": "# The nats module's own server settings. Declared by the module, because a port, a TLS path\n# and a store directory are properties of the container this module raises: they live in its\n# image and its mounts and change when it does.\n#\n# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the\n# whole file would have to be kept in step with a Dockerfile it never sees.\n\nport: 4222\nhttp: 127.0.0.1:8222\n\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n ca_file: \"/tls/ca.crt\"\n}\n\n# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That\n# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a\n# host pins this server's exact certificate and authenticates with the password the mesh minted\n# (novox/hq ADR 0004, design 25 \u00a74), and so does a module's runtime. With it on, every connection\n# in the mesh is refused at the TLS handshake, before any password is looked at \u2014 and the error is\n# \"client didn't provide a certificate\", which reads as a client fault.\n#\n# TLS is still required: a tls block is what makes it required, and verify only decides whether\n# client certificates are checked. What is given up is a second factor the mesh has no machinery\n# to issue or rotate \u2014 a certificate per module per node \u2014 and what is kept is stronger than a\n# name check in both directions: an exact pin outward, a per-user password inward.\n\njetstream {\n store_dir: \"/data\"\n}\n\n# Every user of the mesh, composed by the controller and rewritten whenever a module is\n# assigned, a node enrols or a person's access changes.\n#\n# **Relative, and in this same directory, because it has to be.** An absolute include path is\n# resolved relative to the including file's directory, not from the root: nats-server given\n# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for\n# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server.\ninclude accounts.conf\n",
|
||||
"content": "# The nats module's own server settings. Declared by the module, because a port, a TLS path\n# and a store directory are properties of the container this module raises: they live in its\n# image and its mounts and change when it does.\n#\n# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the\n# whole file would have to be kept in step with a Dockerfile it never sees.\n\nport: 4222\nhttp: 127.0.0.1:8222\n\n# The mesh's own broker certificate \u2014 the one every machine already pins by fingerprint and the\n# controller already trusts (MESH_BROKER_CERTIFICATE). Serving the new bus with it means no\n# machine's pin changes when it moves, and no second certificate exists to be wrong about.\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n}\n\n# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That\n# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a\n# host pins this server's exact certificate and authenticates with the password the mesh minted\n# (novox/hq ADR 0004, design 25 \u00a74), and so does a module's runtime. With it on, every connection\n# in the mesh is refused at the TLS handshake, before any password is looked at \u2014 and the error is\n# \"client didn't provide a certificate\", which reads as a client fault.\n#\n# TLS is still required: a tls block is what makes it required, and verify only decides whether\n# client certificates are checked. What is given up is a second factor the mesh has no machinery\n# to issue or rotate \u2014 a certificate per module per node \u2014 and what is kept is stronger than a\n# name check in both directions: an exact pin outward, a per-user password inward.\n\njetstream {\n store_dir: \"/data\"\n}\n\n# Every user of the mesh, composed by the controller and rewritten whenever a module is\n# assigned, a node enrols or a person's access changes.\n#\n# **Relative, and in this same directory, because it has to be.** An absolute include path is\n# resolved relative to the including file's directory, not from the root: nats-server given\n# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for\n# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server.\ninclude accounts.conf\n",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
@@ -61,14 +62,14 @@
|
||||
"volumes": [
|
||||
"/var/lib/mesh-broker-nats:/data",
|
||||
"/var/lib/nats-module/conf:/etc/nats:ro",
|
||||
"/var/lib/mesh-broker-nats-tls:/tls:ro"
|
||||
"/var/lib/mesh-broker-tls:/tls:ro"
|
||||
],
|
||||
"artifact": "server"
|
||||
}
|
||||
],
|
||||
"accesses": [
|
||||
{
|
||||
"path": "/var/lib/mesh-broker-nats-tls",
|
||||
"path": "/var/lib/mesh-broker-tls",
|
||||
"mode": "read"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"route": {
|
||||
"label": "drive",
|
||||
"port": 80
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -38,6 +38,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 80,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 1880,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -81,7 +82,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "nodered",
|
||||
"port": 1880
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "@",
|
||||
"port": 4000
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 4000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 6789,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "api",
|
||||
"port": 11434,
|
||||
"protocol": "tcp",
|
||||
"from": "machine",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 3579,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -89,7 +90,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "ombi",
|
||||
"port": 3579
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "office",
|
||||
"port": 9070
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 9070,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "eef",
|
||||
"port": 4012
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -19,6 +19,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 4012,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "filip",
|
||||
"port": 4013
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -19,6 +19,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 4013,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"route": {
|
||||
"label": "photos",
|
||||
"port": 4001
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -32,12 +32,14 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "api",
|
||||
"port": 9000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the photos backend API; the client sites on the module network call it"
|
||||
},
|
||||
{
|
||||
"name": "web",
|
||||
"port": 4001,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "stream",
|
||||
"port": 32400,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 9090,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the dashboard over http; portainer.novox.be is a route grant and the proxy reaches it here \u2014 the machine side of 9090:9000, the predecessor's number"
|
||||
},
|
||||
{
|
||||
"name": "web-tls",
|
||||
"port": 9443,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -103,7 +105,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "portainer",
|
||||
"port": 9090
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "database",
|
||||
"port": 5432,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 7878,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -86,7 +87,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "movies",
|
||||
"port": 7878
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "cache",
|
||||
"port": 6379,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -27,12 +27,14 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "http",
|
||||
"port": 80,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
"why": "public HTTP, and the ACME HTTP-01 challenge answered at the name being certified"
|
||||
},
|
||||
{
|
||||
"name": "https",
|
||||
"port": 443,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
|
||||
+23
-21
@@ -5,11 +5,12 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"own-secrets": {
|
||||
"secret": "/var/lib/searxng-module/secret.secret",
|
||||
"secret": "/var/lib/mesh/searxng/secret",
|
||||
"broker": "/var/lib/mesh/searxng/broker"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -26,22 +27,14 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/searxng-module",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "valkey-data",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/searxng-module/valkey-data",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "server-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/searxng-module/server.env",
|
||||
"mode": "0600",
|
||||
"content": "SEARXNG_SECRET=${secret:secret}\nSEARXNG_VALKEY_URL=valkey://valkey:6379/0\n"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
"type": "network",
|
||||
@@ -62,30 +55,39 @@
|
||||
"warning"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/searxng-module/valkey-data:/data"
|
||||
"${dir:valkey-data}:/data"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.yml",
|
||||
"mode": "0600",
|
||||
"merge": "json",
|
||||
"content": "{\n \"use_default_settings\": true,\n \"server\": {\n \"secret_key\": \"${secret:secret}\",\n \"base_url\": false,\n \"limiter\": false,\n \"image_proxy\": false,\n \"public_instance\": false\n },\n \"search\": {\n \"formats\": [\"html\", \"json\"]\n },\n \"valkey\": {\n \"url\": \"valkey://valkey:6379/0\"\n }\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "searxng",
|
||||
"image": "searxng/searxng@sha256:c7cc75852051bf6254afda6ed1b920dd1677d8efe4ab141bf558f02e582f4371",
|
||||
"image": "searxng/searxng@sha256:cd8812607ab73730a0b1a0dc4990223fe1b9e383f6f35947114d0bef7f8bb441",
|
||||
"network": "searxng",
|
||||
"env-file": [
|
||||
"/var/lib/searxng-module/server.env"
|
||||
],
|
||||
"ports": [
|
||||
"8080"
|
||||
],
|
||||
"secrets-in-environment": "SEARXNG_SECRET is env-only, but settings.yml carries server.secret_key; convertible by mounting a generated settings.yml, not yet done"
|
||||
"volumes": [
|
||||
"${dir:state}/settings.yml:/etc/searxng/settings.yml:ro"
|
||||
],
|
||||
"restart-on": [
|
||||
"settings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/searxng/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
"content": "{}\n"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
@@ -113,11 +115,11 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "searxng",
|
||||
"port": 8080
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "/var/lib/searxng-module/route.json"
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8989,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -91,7 +92,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "series",
|
||||
"port": 8989
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "ssh",
|
||||
"port": 22,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
@@ -31,6 +32,7 @@
|
||||
"type": "service",
|
||||
"unit": "sshd.service",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"restart-on": [
|
||||
"config"
|
||||
]
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "acme",
|
||||
"port": 9000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8181,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
@@ -85,7 +86,7 @@
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "tautulli",
|
||||
"port": 8181
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"route": {
|
||||
"label": "umami",
|
||||
"port": 3000
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -49,10 +49,11 @@
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 3000,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
"why": "one port serves two surfaces: the dashboard (the proxy gates it to the mesh) and the public collection endpoint that the browsers of every tracked site POST to \u2014 so the port itself must be reachable from anywhere"
|
||||
"from": "mesh",
|
||||
"why": "one port serves two surfaces \u2014 the dashboard and the collection endpoint that the browsers of every tracked site POST to. Both are reached through the proxy, by name, so the port is how the proxy reaches this module and nothing else (novox/hq ADR 0045). It said \"anywhere\" and gave the reason that the collection endpoint must be public, which is true of the name and not of the port: opened, the machine-side port served the dashboard over plain HTTP to the internet, bypassing every rule the proxy applies by path"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
|
||||
@@ -6,54 +6,63 @@
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8443,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the controller web UI, over its own self-signed tls; reaching it from outside is a route grant later"
|
||||
},
|
||||
{
|
||||
"name": "inform",
|
||||
"port": 8080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "device inform \u2014 how APs and switches check in and are adopted"
|
||||
},
|
||||
{
|
||||
"name": "stun",
|
||||
"port": 3478,
|
||||
"protocol": "udp",
|
||||
"from": "mesh",
|
||||
"why": "STUN, so managed devices can find the controller through NAT"
|
||||
},
|
||||
{
|
||||
"name": "discovery",
|
||||
"port": 10001,
|
||||
"protocol": "udp",
|
||||
"from": "mesh",
|
||||
"why": "device discovery \u2014 the controller finds unadopted devices on the network"
|
||||
},
|
||||
{
|
||||
"name": "discovery-l2",
|
||||
"port": 1902,
|
||||
"protocol": "udp",
|
||||
"from": "mesh",
|
||||
"why": "layer-2 (UBNT) discovery broadcasts; published on 1902, the container listens on 1900"
|
||||
},
|
||||
{
|
||||
"name": "portal-tls",
|
||||
"port": 8843,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the guest captive portal over https"
|
||||
},
|
||||
{
|
||||
"name": "portal",
|
||||
"port": 8880,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the guest captive portal over http"
|
||||
},
|
||||
{
|
||||
"name": "speedtest",
|
||||
"port": 6789,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "mobile-app speed-test throughput measurement"
|
||||
},
|
||||
{
|
||||
"name": "syslog",
|
||||
"port": 5514,
|
||||
"protocol": "udp",
|
||||
"from": "mesh",
|
||||
|
||||
Reference in New Issue
Block a user