Compare commits

..
Author SHA1 Message Date
jschoubben 76443ec06d postgres: its server container is not named after the seat
The module's server container was called mesh-store and its data directory
/var/lib/mesh-store — the seat's name reused for the module's own resources,
a leftover from the first migration. On a machine whose postgres holds no
seat (ace, as a database provider only) that produced a container called
mesh-store holding nothing of the kind. The container is now named
postgres. The data directory keeps its path: a path change recreates a
running container on an empty directory (hq 126), and novox's store lives
there.

Rolling this out recreates novox's store container once (a restart on its
bind mount, no data moves). The two catalogue-test failures on this branch
(resolver_manifests_test) fail identically on main today.
2026-09-30 14:56:31 +02:00
jschoubben 7ab522ba31 Merge pull request 'dnsmasq: answer a container's query, which arrives on the runtime's bridge' (#176) from fix/110-the-resolver-answers-a-container into main 2026-09-30 12:31:30 +00:00
jschoubben 204bfbbaf9 dnsmasq: answer a container's query, which arrives on the runtime's bridge
dnsmasq admits a query by the interface it arrives on when told interface=,
and by the address it is sent to when told listen-address=. A container's
query is sent to the machine's private address but arrives on docker0, so
interface=mesh0 dropped it silently on every machine (novox/hq issue 110).
Name the address, not the interface.
2026-09-30 14:31:26 +02:00
jschoubben e0c09f46b6 Merge pull request 'dnsmasq: the runtime is reloaded when its dns file changes, and may then be restarted safely' (#175) from fix/110-the-runtime-reads-its-dns into main 2026-09-30 12:22:10 +00:00
jschoubben e0faf012be dnsmasq: the runtime is reloaded when its dns file changes, and may then be restarted safely
novox/hq 04-ISSUES/110. The module writes the runtime's `dns` key and
deliberately ordered no restart, because a restart stops every container.
It also ordered no reload, and the key holds only for containers created
after the runtime next starts. On two of four machines the runtime
predated the file — one since August — so every container there was
handed a public resolver and no mesh name resolved, while everything read
as fine. The third machine works only because its runtime happened to
restart later.

The file now also sets live-restore, which the runtime reads on a reload,
and the module declares the runtime reloaded when the file changes (ADR
0102: reload, don't restart). A reload still does not make `dns` take
effect; what it does is make the one restart that key needs keep every
container running. That restart stays the operator's, once per machine,
and is harmless from the second time on.

The mesh restarts nothing here. Undeclared, the runtime's unit goes back
to the state it was found in (ADR 0118).
2026-09-30 14:22:03 +02:00
mesh-admin 1b19c79d63 Merge pull request 'postgres: the provisioner dials the port the mesh gave the store' (#174) from fix/postgres-provisioner-dials-the-port-it-was-given into main 2026-09-30 12:06:25 +00:00
jschoubben 4ec2ae1f7f postgres: the provisioner dials the port the mesh gave the store
MESH_PROVISION_POSTGRES named 127.0.0.1:5432 literally; the seat twin
(${seat:mesh-store:5432}) corrected it only on the machine holding the
mesh-store seat. On any other machine — ace, where the module provides
postgres-database without the seat — the twin is empty and the sidecar
would have dialled whatever else holds 5432 (HAL's postgres). ${port:5432}
is the machine port on every node; on novox the twin still says the same
6852, so nothing moves there.
2026-09-30 14:01:53 +02:00
11 changed files with 51 additions and 110 deletions
+1 -1
View File
@@ -94,7 +94,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BAZARR_URL": "http://127.0.0.1:${port:6767}",
"MESH_BAZARR_URL": "http://127.0.0.1:6767",
"MESH_BAZARR_API_KEY_FILE": "/run/secrets/api-key",
"MESH_BAZARR_CONFIG_FILE": "/run/config/config.json",
"MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config"
+1 -1
View File
@@ -76,7 +76,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BOOKSHELF_URL": "http://127.0.0.1:${port:8787}",
"MESH_BOOKSHELF_URL": "http://127.0.0.1:8787",
"MESH_BOOKSHELF_CONFIG_DIR": "/var/lib/bookshelf/config"
},
"artifact": "runtime"
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -75,7 +75,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_LIDARR_URL": "http://127.0.0.1:${port:8686}",
"MESH_LIDARR_URL": "http://127.0.0.1:8686",
"MESH_LIDARR_CONFIG_DIR": "/var/lib/lidarr/config"
},
"artifact": "runtime"
+1 -1
View File
@@ -81,7 +81,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_NZBGET_URL": "http://127.0.0.1:${port:6789}",
"MESH_NZBGET_URL": "http://127.0.0.1:6789",
"MESH_NZBGET_PASSWORD_FILE": "/run/secrets/password",
"MESH_NZBGET_CONFIG_FILE": "/run/config/config.json",
"MESH_NZBGET_CONFIG_DIR": "/var/lib/nzbget/config"
+2 -2
View File
@@ -80,7 +80,7 @@
{
"id": "server",
"type": "container",
"name": "mesh-store",
"name": "postgres",
"image": "pgvector/pgvector@sha256:cf134a767f474095eeba57e0117be8e568e011a63f33fbf252f14c9b760f8e6f",
"env": {
"POSTGRES_PASSWORD_FILE": "/run/secrets/superuser",
@@ -105,7 +105,7 @@
"/var/lib/postgres/superuser.secret:/run/secrets/superuser:ro"
],
"env": {
"MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable",
"MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:${port:5432}/postgres?sslmode=disable",
"MESH_PROVISION_POSTGRES_PORT": "${seat:mesh-store:5432}",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/superuser",
"MESH_BROKER_FILE": "/run/secrets/broker",
+13 -38
View File
@@ -3,10 +3,8 @@
// qbittorrent. Both this module's tools and its events entrypoint import it, and nothing outside
// qbittorrent does.
//
// The WebUI authenticates with a session cookie obtained by POSTing credentials, and guards
// against CSRF by checking the Referer header. The cookie was `SID` before qBittorrent 5.2 and is
// `QBT_SID_<port>` since, and a successful login answers 200 "Ok." before and 204 with no body
// since — both are accepted. Node's fetch keeps no cookie jar, so the cookie is
// The WebUI authenticates with a session cookie (SID) obtained by POSTing credentials, and guards
// against CSRF by checking the Referer header. Node's fetch keeps no cookie jar, so the SID is
// captured on login and carried by hand on every later call, with a single re-login on expiry.
import { readFileSync } from "node:fs";
@@ -41,21 +39,6 @@ function meshConfig(file?: string): Record<string, string> {
catch { return {}; }
}
/** The WebUI username from qBittorrent's own qBittorrent.conf, in the config directory the mesh
* mounts read-only (MESH_QBITTORRENT_CONFIG_DIR, which is the container's /config). The software's
* file is the truth about who may log in, so the tools ask it rather than a setting that could
* disagree. Absent, unreadable or unset yields undefined. */
function confUsername(dir: string | undefined): string | undefined {
if (!dir) return undefined;
try {
const line = readFileSync(`${dir.replace(/\/$/, "")}/qBittorrent/qBittorrent.conf`, "utf8")
.split(/\r?\n/)
.find((l) => l.startsWith("WebUI\\Username="));
const value = line?.slice("WebUI\\Username=".length).trim();
return value ? value : undefined;
} catch { return undefined; }
}
/** Read a secret the mesh mounted at a file path (an own-secret delivered by `secret accept`);
* absent or unreadable yields undefined so callers fall back rather than crash. */
function readSecret(file?: string): string | undefined {
@@ -66,8 +49,7 @@ function readSecret(file?: string): string | undefined {
export class QbittorrentClient {
readonly baseUrl: string;
/** The session cookie as `name=value`, sent back exactly as it was set. */
private session: string | null = null;
private sid: string | null = null;
constructor(
baseUrl: string,
@@ -81,9 +63,7 @@ export class QbittorrentClient {
* Build from the module's resolved environment. URL and password are read from
* MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD; both must be present — an unconfigured
* qBittorrent throws rather than pretend to be reachable, so the tools/events simply do not load
* (the harness treats the throw as "exposes nothing"). The user is read from qBittorrent.conf,
* falling back to "admin", the image's default. The password cannot be read there — qBittorrent
* keeps only a PBKDF2 hash — so it is the own-secret the operator accepts.
* (the harness treats the throw as "exposes nothing"). The user defaults to "admin".
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): QbittorrentClient {
const cfg = meshConfig(env.MESH_QBITTORRENT_CONFIG_FILE);
@@ -92,9 +72,7 @@ export class QbittorrentClient {
if (!url || !password) {
throw new Error("qBittorrent not configured — set MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD");
}
// The WebUI username: a setting or the environment if one says so, else whatever
// qBittorrent.conf holds (an adopted machine keeps its own), else the image's "admin".
const user = cfg.user ?? env.MESH_QBITTORRENT_USER ?? confUsername(env.MESH_QBITTORRENT_CONFIG_DIR) ?? "admin";
const user = cfg.user ?? env.MESH_QBITTORRENT_USER ?? "admin";
return new QbittorrentClient(url, user, password);
}
@@ -104,22 +82,19 @@ export class QbittorrentClient {
headers: { "Content-Type": "application/x-www-form-urlencoded", Referer: this.baseUrl },
body: new URLSearchParams({ username: this.user, password: this.password }),
});
if (res.status === 401) throw new Error("qBittorrent login rejected — check credentials");
if (!res.ok) throw new Error(`qBittorrent login: ${res.status} ${await res.text()}`);
// 4.x/5.0/5.1 answer 200 "Ok." or 200 "Fails."; 5.2 answers 204 with no body, or 401.
const body = (await res.text()).trim();
if (res.status !== 204 && body !== "Ok.") {
if ((await res.text()).trim() !== "Ok.") {
throw new Error("qBittorrent login rejected — check credentials");
}
const match = res.headers.get("set-cookie")?.match(/((?:QBT_)?SID(?:_\d+)?)=([^;]+)/);
if (!match) throw new Error("qBittorrent login returned no session cookie");
this.session = `${match[1]}=${match[2]}`;
const match = res.headers.get("set-cookie")?.match(/SID=([^;]+)/);
if (!match) throw new Error("qBittorrent login returned no SID cookie");
this.sid = match[1];
}
private async call(method: "GET" | "POST", path: string, form?: Record<string, string>): Promise<Response> {
if (!this.session) await this.login();
if (!this.sid) await this.login();
const doFetch = (): Promise<Response> => {
const headers: Record<string, string> = { Referer: this.baseUrl, Cookie: this.session ?? "" };
const headers: Record<string, string> = { Referer: this.baseUrl, Cookie: `SID=${this.sid}` };
const init: RequestInit = { method, headers };
if (form) {
headers["Content-Type"] = "application/x-www-form-urlencoded";
@@ -128,8 +103,8 @@ export class QbittorrentClient {
return fetch(`${this.baseUrl}/api/v2/${path}`, init);
};
let res = await doFetch();
if (res.status === 403 || res.status === 401) {
// The session expired — re-authenticate once and retry, rather than fail a routine call.
if (res.status === 403) {
// The SID expired — re-authenticate once and retry, rather than fail a routine call.
await this.login();
res = await doFetch();
}
+16 -59
View File
@@ -17,24 +17,10 @@
"listens": [
{
"name": "web",
"port": 8112,
"port": 8080,
"protocol": "tcp",
"from": "mesh",
"why": "the download client's pages, and the WebUI API its consumers and its own tools call. qBittorrent refuses a request whose Host names a port other than the one it listens on, so it listens on the machine port itself (host network, WEBUI_PORT=${port:8112}) and the two cannot differ on any machine"
},
{
"name": "peers",
"port": 6881,
"protocol": "tcp",
"from": "mesh",
"why": "incoming BitTorrent peer connections; announced to trackers and peers, so qBittorrent listens on the machine port itself (TORRENTING_PORT=${port:6881})"
},
{
"name": "peers-udp",
"port": 6881,
"protocol": "udp",
"from": "mesh",
"why": "DHT and uTP on the same number as the peer port"
"why": "the download client's pages"
}
],
"accesses": [
@@ -50,15 +36,10 @@
"path": "/var/lib/mesh/qbittorrent",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "config",
"type": "directory",
"path": "/services/qbittorrent/config",
"mode": "0700",
"owner": "1000:1000"
},
@@ -66,24 +47,24 @@
"id": "server",
"type": "container",
"name": "qbittorrent",
"image": "lscr.io/linuxserver/qbittorrent@sha256:457e4eec2ee3f5e4ef59f237ad51f6143deba9f7445ab48bb5204a98888ef9aa",
"image": "lscr.io/linuxserver/qbittorrent@sha256:a00b6a597a3832a1814cde0ef60abc55c94644f3f80902c3432f6af6de8d4a96",
"env": {
"PUID": "1000",
"PGID": "1000",
"TZ": "Etc/UTC",
"WEBUI_PORT": "${port:8112}",
"TORRENTING_PORT": "${port:6881}"
"TZ": "Etc/UTC"
},
"volumes": [
"${dir:config}:/config",
"/services/media/downloads:/downloads"
"ports": [
"8080"
],
"network": "host"
"volumes": [
"/services/qbittorrent/config:/config",
"/services/media/downloads:/downloads"
]
},
{
"id": "runtime-config",
"type": "file",
"path": "${dir:state}/config.json",
"path": "/var/lib/mesh/qbittorrent/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
@@ -96,46 +77,22 @@
"volumes": [
"/var/lib/mesh/qbittorrent/broker:/run/secrets/broker:ro",
"/var/lib/mesh/qbittorrent/password:/run/secrets/password:ro",
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:config}:/var/lib/qbittorrent/config:ro"
"/var/lib/mesh/qbittorrent/config.json:/run/config/config.json:ro",
"/services/qbittorrent/config:/var/lib/qbittorrent/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_QBITTORRENT_URL": "http://127.0.0.1:${port:8112}",
"MESH_QBITTORRENT_URL": "http://127.0.0.1:8080",
"MESH_QBITTORRENT_PASSWORD_FILE": "/run/secrets/password",
"MESH_QBITTORRENT_CONFIG_FILE": "/run/config/config.json",
"MESH_QBITTORRENT_CONFIG_DIR": "/var/lib/qbittorrent/config"
},
"restart-on": [
"runtime-config",
"needs-password"
"runtime-config"
],
"artifact": "runtime"
}
],
"provides": [
"qbittorrent-api"
],
"serves": {
"qbittorrent-api": {
"scheme": "http",
"port": 8112,
"url-base": "",
"username": "admin"
}
},
"requires": [
"route"
],
"contributes": {
"route": {
"label": "qbittorrent",
"endpoint": "web"
}
},
"binds": {
"route": "${dir:state}/route.json"
},
"build": {
"on": [
{
+1 -1
View File
@@ -75,7 +75,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RADARR_URL": "http://127.0.0.1:${port:7878}",
"MESH_RADARR_URL": "http://127.0.0.1:7878",
"MESH_RADARR_CONFIG_DIR": "/var/lib/radarr/config"
},
"artifact": "runtime"
+1 -1
View File
@@ -100,7 +100,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_SEARXNG_URL": "http://127.0.0.1:${port:8080}",
"MESH_SEARXNG_URL": "http://127.0.0.1:8080",
"MESH_SEARXNG_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
+1 -1
View File
@@ -80,7 +80,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_SONARR_URL": "http://127.0.0.1:${port:8989}",
"MESH_SONARR_URL": "http://127.0.0.1:8989",
"MESH_SONARR_CONFIG_DIR": "/var/lib/sonarr/config"
},
"artifact": "runtime"