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");
}