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.
This commit is contained in:
2026-10-25 16:40:01 +01:00
parent f27f4c8cd5
commit 4021724ca4
3 changed files with 362 additions and 14 deletions
+151 -12
View File
@@ -61,10 +61,67 @@ const ONLYOFFICE_KEYS: ReadonlyArray<readonly [occKey: string, configKey: string
let saidOnlyOfficeNotGiven = false;
/**
* Nextcloud refused the mesh's own login on its OCS surface (HTTP 401), or throttled it after
* refusals (HTTP 429, its brute-force protection). Said by its status and the account, never a
* password; the events loop backs off on it instead of asking again every tick.
*/
export class LoginRefusedError extends Error {
constructor(readonly status: number, readonly user: string, detail: string) {
super(`Nextcloud refused the login of ${user} (${status}${detail ? ": " + detail : ""})`);
this.name = "LoginRefusedError";
}
}
/** The OCS envelope's own message, the one line worth saying of a refusal; empty when none. */
function ocsMessage(body: string): string {
const xml = /<message>([^<]*)<\/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, string> } = {}): 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<NextcloudShare[]> {
/**
* 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<Response> {
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<NextcloudShare[]> {
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<void>,
now: () => number = Date.now,
): Promise<void> {
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");
}
+6 -2
View File
@@ -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");
}
+205
View File
@@ -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 = `<?xml version="1.0"?>\n<ocs>\n <meta>\n <status>failure</status>\n <statuscode>997</statuscode>\n <message>Unauthorised</message>\n </meta>\n <data/>\n</ocs>\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<Response> => {
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<Record<string, string> | undefined> = [];
const impl = (args: string[], opts: { env?: Record<string, string> } = {}): 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<void> => {
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");
});