From 4021724ca47d627e04b28366fa087bdd7e81e451 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 25 Oct 2026 16:40:01 +0100 Subject: [PATCH] nextcloud: make the mesh's own account match its secret when Nextcloud refuses it, and back off a refusal that stays (issue 1253) The image creates NEXTCLOUD_ADMIN_USER only on a fresh install. novox's instance was installed before the mesh adopted it, so mesh-admin was never created there, and the shares poll asked Nextcloud every minute with a login that cannot pass: 401, then Nextcloud's brute-force throttle (429), which also held the backup before a delivery. On a refused login the client now adds the account through occ (admin group), or sets its password to the module's own secret, passing it by OC_PASS so it is on no argv, and asks once more. A refusal that stays is said once and the poll backs off from 5 minutes to an hour instead of nine lines of XML every minute. An empty setting no longer becomes an empty name. --- modules/nextcloud/client.ts | 163 ++++++++++++++-- modules/nextcloud/index.ts | 8 +- modules/nextcloud/test/admin-login.test.ts | 205 +++++++++++++++++++++ 3 files changed, 362 insertions(+), 14 deletions(-) create mode 100644 modules/nextcloud/test/admin-login.test.ts diff --git a/modules/nextcloud/client.ts b/modules/nextcloud/client.ts index f0461db8..91a79c5a 100644 --- a/modules/nextcloud/client.ts +++ b/modules/nextcloud/client.ts @@ -61,10 +61,67 @@ const ONLYOFFICE_KEYS: ReadonlyArray([^<]*)<\/message>/.exec(body); + if (xml) return xml[1].trim(); + try { return String(JSON.parse(body)?.ocs?.meta?.message ?? "").trim(); } catch { return ""; } +} + +/** + * How long to leave a failing poll alone: the base interval after the first failure, doubling on + * each further one up to `maxMs`, and back to every tick once a poll passes. `due` says whether a + * tick should poll; `failed` records a failure and answers whether it is the first of a run (said + * loud) or the one that reached the ceiling (said once more), so a failure is never said every tick. + */ +export class Backoff { + private delayMs = 0; + private nextAt = 0; + private failures = 0; + + constructor(private readonly baseMs: number, private readonly maxMs: number) {} + + due(now: number): boolean { + return now >= this.nextAt; + } + + failed(now: number): { first: boolean; atCeiling: boolean; delayMs: number } { + this.failures += 1; + const before = this.delayMs; + this.delayMs = before === 0 ? this.baseMs : Math.min(before * 2, this.maxMs); + this.nextAt = now + this.delayMs; + return { first: this.failures === 1, atCeiling: this.delayMs === this.maxMs && before !== this.maxMs, delayMs: this.delayMs }; + } + + /** A poll passed; answers whether it ended a run of failures. */ + passed(): boolean { + const wasFailing = this.failures > 0; + this.failures = 0; + this.delayMs = 0; + this.nextAt = 0; + return wasFailing; + } +} + export class NextcloudClient { // Whether the last connection check failed (novox/hq issue 1832): while it did, every tick runs // the check again, even when nothing changed, until it passes. private lastCheckFailed = false; + // Whether the account was already made right since the last accepted login (novox/hq issue + // 1253): a refusal makes it right once, and a refusal after that is said, not repaired again. + private accountMadeRight = false; constructor( private readonly container: string, @@ -82,9 +139,10 @@ export class NextcloudClient { */ static fromEnv(env: NodeJS.ProcessEnv = process.env): NextcloudClient { const cfg = meshConfig(env.MESH_NEXTCLOUD_CONFIG_FILE); - const container = cfg.container ?? env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud"; - const ocsUrl = cfg.url ?? env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`; - const adminUser = cfg.user ?? env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin"; + // An empty setting is the manifest's "empty is the default", never an empty name. + const container = cfg.container || env.MESH_NEXTCLOUD_CONTAINER || "nextcloud"; + const ocsUrl = cfg.url || env.MESH_NEXTCLOUD_URL || `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`; + const adminUser = cfg.user || env.MESH_NEXTCLOUD_ADMIN_USER || "admin"; const passwordFile = env.MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE; ignoreSecretSetting(cfg, "password"); const adminPassword = env.MESH_NEXTCLOUD_ADMIN_PASSWORD @@ -100,16 +158,20 @@ export class NextcloudClient { * Run occ inside the container as the web user, returning its stdout, throwing its own message. * With `opts.input` given, occ reads it from stdin (`docker exec -i`) instead of taking it on the * command line — the only way a secret value reaches occ without sitting in the docker CLI's argv, - * the container's php argv and docker's exec record (novox/hq issue 1831). + * the container's php argv and docker's exec record (novox/hq issue 1831). With `opts.env` given, + * each variable is passed by name alone (`docker exec -e NAME`), its value taken from the docker + * CLI's own environment, so it is on no argv either (occ's `--password-from-env` reads OC_PASS). */ - occ(args: string[], opts: { input?: string } = {}): string { - const dockerArgs = opts.input !== undefined - ? ["exec", "-i", "-u", "www-data", this.container, "php", "occ", ...args] - : ["exec", "-u", "www-data", this.container, "php", "occ", ...args]; + occ(args: string[], opts: { input?: string; env?: Record } = {}): string { + const dockerArgs = ["exec"]; + if (opts.input !== undefined) dockerArgs.push("-i"); + for (const name of Object.keys(opts.env ?? {})) dockerArgs.push("-e", name); + dockerArgs.push("-u", "www-data", this.container, "php", "occ", ...args); try { return execFileSync("docker", dockerArgs, { encoding: "utf8", timeout: 60_000, ...(opts.input !== undefined ? { input: opts.input } : {}), + ...(opts.env ? { env: { ...process.env, ...opts.env } } : {}), }).trim(); } catch (err: any) { const detail = String(err?.stderr ?? err?.stdout ?? err?.message ?? "").trim(); @@ -188,14 +250,62 @@ export class NextcloudClient { return { enabled: Object.keys(parsed.enabled ?? {}), disabled: Object.keys(parsed.disabled ?? {}) }; } - /** List every share, over the OCS Sharing API with the admin credentials. */ - async listShares(): Promise { + /** + * Makes the mesh's own account in Nextcloud match the module's own secret (novox/hq issue 1253). + * The image creates NEXTCLOUD_ADMIN_USER only when it installs a fresh instance, so an instance + * the mesh adopted never has it, and a secret made anew never reaches it. Through occ: the + * account is added in the admin group when missing; otherwise its password is set to the secret + * and it is put in the admin group. The password goes by OC_PASS (`--password-from-env`), never + * on an argv. Answers what it did; throws occ's own message when occ fails. + */ + ensureAdminAccount(): "created" | "password-set" { + const pass = { env: { OC_PASS: this.adminPassword } }; + let exists = true; + try { + this.occ(["user:info", this.adminUser, "--output=json"]); + } catch (err) { + if (!/not found|does not exist/i.test(String((err as Error)?.message ?? err))) throw err; + exists = false; + } + if (!exists) { + this.occ(["user:add", "--password-from-env", "--display-name=Mesh administration", "--group=admin", this.adminUser], pass); + return "created"; + } + this.occ(["user:resetpassword", "--password-from-env", this.adminUser], pass); + try { + this.occ(["group:adduser", "admin", this.adminUser]); + } catch (err) { + if (!/already/i.test(String((err as Error)?.message ?? err))) throw err; + } + return "password-set"; + } + + private fetchShares(): Promise { const auth = Buffer.from(`${this.adminUser}:${this.adminPassword}`).toString("base64"); - const res = await fetch( + return fetch( `${this.ocsUrl}/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json`, { headers: { Authorization: `Basic ${auth}`, "OCS-APIRequest": "true", Accept: "application/json" } }, ); - if (!res.ok) throw new Error(`Nextcloud OCS shares: ${res.status} ${await res.text()}`); + } + + /** + * List every share, over the OCS Sharing API as the mesh's own account. A refused login (401) + * makes the account right once (ensureAdminAccount) and asks again; a refusal after that, or a + * throttle (429), throws LoginRefusedError, so the caller backs off (novox/hq issue 1253). + */ + async listShares(): Promise { + let res = await this.fetchShares(); + if (res.status === 401 && !this.accountMadeRight) { + await res.text(); + this.accountMadeRight = true; + this.ensureAdminAccount(); + res = await this.fetchShares(); + } + if (res.status === 401 || res.status === 429) { + throw new LoginRefusedError(res.status, this.adminUser, ocsMessage(await res.text())); + } + if (!res.ok) throw new Error(`Nextcloud OCS shares: ${res.status} ${ocsMessage(await res.text())}`.trim()); + this.accountMadeRight = false; const rows = ((await res.json())?.ocs?.data ?? []) as any[]; return rows.map((s) => ({ id: String(s.id), @@ -206,3 +316,32 @@ export class NextcloudClient { })); } } + +/** + * Runs one poll of the shares under a back-off (novox/hq issue 1253): skipped while backing off; + * a LoginRefusedError is said loud at the first failure and once more when the wait reaches its + * ceiling, never on every tick; one line says when it passes again. Other errors are thrown on, + * so the loop logs them as before. + */ +export async function pollSharesGuarded( + client: NextcloudClient, + backoff: Backoff, + poll: (c: NextcloudClient) => Promise, + now: () => number = Date.now, +): Promise { + if (!backoff.due(now())) return; + try { + await poll(client); + } catch (err) { + if (!(err instanceof LoginRefusedError)) throw err; + const step = backoff.failed(now()); + const minutes = Math.round(step.delayMs / 60_000); + if (step.first) { + console.error(`[nextcloud] shares: ${err.message}; not asking again for ${minutes} min, then less often, up to every hour`); + } else if (step.atCeiling) { + console.error(`[nextcloud] shares: ${err.message}, still; asking once an hour from now on`); + } + return; + } + if (backoff.passed()) console.log("[nextcloud] shares: the login is accepted again"); +} diff --git a/modules/nextcloud/index.ts b/modules/nextcloud/index.ts index 86e70d80..b02f2318 100644 --- a/modules/nextcloud/index.ts +++ b/modules/nextcloud/index.ts @@ -9,7 +9,7 @@ // existing user and share as freshly created. import { emit } from "@novox/mesh-sdk/events"; -import { NextcloudClient } from "./client.js"; +import { Backoff, NextcloudClient, pollSharesGuarded } from "./client.js"; // Constructed lazily so an unconfigured node (no admin password) loads this entrypoint without // crashing the events host — it simply watches nothing. @@ -69,7 +69,11 @@ if (nextcloud) { run(); }; tick(pollUsers); - tick(pollShares); + // A refused login is said once and the shares are left alone for 5 minutes, doubling to an hour, + // instead of a refusal every minute that Nextcloud's brute-force protection then throttles + // (novox/hq issue 1253). + const sharesBackoff = new Backoff(5 * 60_000, 60 * 60_000); + tick((c) => pollSharesGuarded(c, sharesBackoff, pollShares)); tick(applyOnlyOffice); console.log("[nextcloud] watching users, shares and onlyoffice settings"); } diff --git a/modules/nextcloud/test/admin-login.test.ts b/modules/nextcloud/test/admin-login.test.ts new file mode 100644 index 00000000..08e2d1e7 --- /dev/null +++ b/modules/nextcloud/test/admin-login.test.ts @@ -0,0 +1,205 @@ +// novox/hq issue 1253: the module's own Nextcloud account is made to match its own secret when +// Nextcloud refuses the login, and a refusal that stays is said once and backed off, never asked +// again every minute into Nextcloud's brute-force throttle. Run against the compiled module, the +// way the runtime loads it. + +import { test, mock } from "node:test"; +import assert from "node:assert/strict"; +import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Backoff, LoginRefusedError, NextcloudClient, pollSharesGuarded } from "../dist/client.js"; + +const PASSWORD = "the-modules-own-secret"; +const REFUSED_XML = `\n\n \n failure\n 997\n Unauthorised\n \n \n\n`; +const SHARES = { ocs: { data: [{ id: 7, path: "/Photos", share_type: 3, share_with: "", uid_owner: "jochens" }] } }; + +function client(): any { + return new (NextcloudClient as any)("nextcloud", "http://127.0.0.1:20003", "mesh-admin", PASSWORD); +} + +/** A stand-in Nextcloud: answers `statuses` in turn (the last one repeating) and counts the asks. */ +function standInFetch(statuses: number[]) { + let asked = 0; + const impl = async (): Promise => { + const status = statuses[Math.min(asked, statuses.length - 1)]; + asked += 1; + if (status === 200) return new Response(JSON.stringify(SHARES), { status }); + return new Response(REFUSED_XML, { status }); + }; + return { impl, asked: () => asked }; +} + +/** A stand-in occ: knows whether the account exists, records every call and every env passed. */ +function standInOcc(accountExists: boolean) { + const calls: string[][] = []; + const envs: Array | undefined> = []; + const impl = (args: string[], opts: { env?: Record } = {}): string => { + calls.push(args); + envs.push(opts.env); + if (args[0] === "user:info") { + if (!accountExists) throw new Error("user not found"); + return "{}"; + } + if (["user:add", "user:resetpassword", "group:adduser"].includes(args[0])) return ""; + throw new Error("unexpected occ call: " + args.join(" ")); + }; + return { calls, envs, impl }; +} + +// 1253/AC1: given an adopted instance without the mesh's account, when Nextcloud refuses the +// login, then the account is added in the admin group with the module's own secret, by +// OC_PASS and never on an argv, and the shares are asked again and listed. +test("a refused login adds the missing account, password by environment, and lists the shares", async () => { + const c = client(); + const occ = standInOcc(false); + mock.method(c, "occ", occ.impl); + const web = standInFetch([401, 200]); + const f = mock.method(globalThis, "fetch", web.impl); + try { + const shares = await c.listShares(); + assert.equal(web.asked(), 2); + assert.deepEqual(shares.map((s: any) => s.id), ["7"]); + const add = occ.calls.find((a) => a[0] === "user:add"); + assert.ok(add, "user:add ran"); + assert.ok(add.includes("--password-from-env") && add.includes("--group=admin") && add.includes("mesh-admin")); + assert.equal(occ.envs[occ.calls.indexOf(add)]?.OC_PASS, PASSWORD); + assert.ok(!occ.calls.flat().some((a) => a.includes(PASSWORD)), "the password is on no argv"); + } finally { + f.mock.restore(); + } +}); + +// 1253/AC2: given the account exists with another password, when Nextcloud refuses the login, +// then its password is set to the module's own secret and it is put in the admin group. +test("a refused login resets an existing account's password and puts it in the admin group", async () => { + const c = client(); + const occ = standInOcc(true); + mock.method(c, "occ", occ.impl); + const f = mock.method(globalThis, "fetch", standInFetch([401, 200]).impl); + try { + await c.listShares(); + assert.deepEqual(occ.calls.map((a) => a[0]), ["user:info", "user:resetpassword", "group:adduser"]); + assert.equal(occ.envs[1]?.OC_PASS, PASSWORD); + assert.ok(!occ.calls.flat().some((a) => a.includes(PASSWORD))); + } finally { + f.mock.restore(); + } +}); + +// 1253/AC3: given the login is still refused after the account was made right, then +// listShares throws LoginRefusedError naming the status and the account, never the password or +// the whole XML, and a later call does not repair the account again. +test("a refusal after the repair throws LoginRefusedError once-repaired, short and without the password", async () => { + const c = client(); + const occ = standInOcc(true); + mock.method(c, "occ", occ.impl); + const web = standInFetch([401]); + const f = mock.method(globalThis, "fetch", web.impl); + try { + await assert.rejects(c.listShares(), (err: any) => { + assert.ok(err instanceof LoginRefusedError); + assert.equal(err.status, 401); + assert.equal(err.message, "Nextcloud refused the login of mesh-admin (401: Unauthorised)"); + assert.ok(!err.message.includes(PASSWORD)); + return true; + }); + await assert.rejects(c.listShares(), LoginRefusedError); + assert.equal(occ.calls.filter((a) => a[0] === "user:resetpassword").length, 1, "repaired once"); + assert.equal(web.asked(), 3); + } finally { + f.mock.restore(); + } +}); + +// 1253/AC3b: a throttle (429) is a refusal too, and is not met by a repair. +test("a throttled login (429) throws LoginRefusedError without touching the account", async () => { + const c = client(); + const occ = standInOcc(true); + mock.method(c, "occ", occ.impl); + const f = mock.method(globalThis, "fetch", standInFetch([429]).impl); + try { + await assert.rejects(c.listShares(), (err: any) => err instanceof LoginRefusedError && err.status === 429); + assert.equal(occ.calls.length, 0); + } finally { + f.mock.restore(); + } +}); + +// 1253/AC4: given the login stays refused, when the loop ticks every minute, then the refusal +// is said once, the shares are asked only after 5, 10, 20, 40 and then every 60 minutes, the +// ceiling is said once more, and one line says when the login is accepted again. +test("a refusal that stays is said once, backed off to an hour, and its end is said", async () => { + const said: string[] = []; + const err = mock.method(console, "error", (...a: unknown[]) => { said.push(a.join(" ")); }); + const log = mock.method(console, "log", (...a: unknown[]) => { said.push(a.join(" ")); }); + try { + let refused = true; + let polls = 0; + const askedAt: number[] = []; + let clock = 0; + const poll = async (): Promise => { + polls += 1; + askedAt.push(clock / 60_000); + if (refused) throw new LoginRefusedError(401, "mesh-admin", "Unauthorised"); + }; + const backoff = new Backoff(5 * 60_000, 60 * 60_000); + for (let minute = 0; minute <= 200; minute++) { + clock = minute * 60_000; + await pollSharesGuarded({} as any, backoff, poll, () => clock); + } + assert.deepEqual(askedAt, [0, 5, 15, 35, 75, 135, 195]); + assert.equal(said.length, 2, said.join("\n")); + assert.match(said[0], /refused the login of mesh-admin \(401: Unauthorised\); not asking again for 5 min/); + assert.match(said[1], /still; asking once an hour/); + + refused = false; + clock = 260 * 60_000; + await pollSharesGuarded({} as any, backoff, poll, () => clock); + assert.equal(said[2], "[nextcloud] shares: the login is accepted again"); + const before = polls; + clock += 60_000; + await pollSharesGuarded({} as any, backoff, poll, () => clock); + assert.equal(polls, before + 1, "every tick again once it passes"); + assert.equal(said.length, 3); + } finally { + err.mock.restore(); + log.mock.restore(); + } +}); + +// 1253/AC5: occ passes an environment variable by name alone, its value through the docker +// CLI's environment, so the password is on no argv. +test("occ passes OC_PASS by name, its value only in docker's environment", () => { + const dir = mkdtempSync(join(tmpdir(), "nextcloud-1253-")); + const record = join(dir, "record"); + writeFileSync(join(dir, "docker"), `#!/bin/sh\nprintf '%s\\n' "$*" > ${record}\nprintf '%s' "$OC_PASS" >> ${record}\n`); + chmodSync(join(dir, "docker"), 0o755); + const path = process.env.PATH; + process.env.PATH = `${dir}:${path}`; + try { + client().occ(["user:resetpassword", "--password-from-env", "mesh-admin"], { env: { OC_PASS: PASSWORD } }); + } finally { + process.env.PATH = path; + } + const [argv, env] = readFileSync(record, "utf8").split("\n"); + assert.equal(argv, "exec -e OC_PASS -u www-data nextcloud php occ user:resetpassword --password-from-env mesh-admin"); + assert.equal(env, PASSWORD); +}); + +// 1253/AC6: an empty setting is the manifest's default, never an empty account name. +test("an empty user setting falls back to the manifest's account", () => { + const dir = mkdtempSync(join(tmpdir(), "nextcloud-1253-")); + writeFileSync(join(dir, "config.json"), JSON.stringify({ user: "", container: "", url: "" })); + writeFileSync(join(dir, "admin.secret"), PASSWORD); + const c: any = NextcloudClient.fromEnv({ + MESH_NEXTCLOUD_CONFIG_FILE: join(dir, "config.json"), + MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE: join(dir, "admin.secret"), + MESH_NEXTCLOUD_ADMIN_USER: "mesh-admin", + MESH_NEXTCLOUD_URL: "http://127.0.0.1:20003", + }); + assert.equal(c.adminUser, "mesh-admin"); + assert.equal(c.container, "nextcloud"); + assert.equal(c.ocsUrl, "http://127.0.0.1:20003"); +});