Merge pull request 'nextcloud: the module makes its own mesh-admin account right on a 401 and backs off on a refusal, so its shares poll stops being refused every minute (issue 1253)' (#478) from fix/nextcloud-admin-401 into main
Reviewed-on: #478
This commit was merged in pull request #478.
This commit is contained in:
+179
-15
@@ -61,10 +61,74 @@ const ONLYOFFICE_KEYS: ReadonlyArray<readonly [occKey: string, configKey: string
|
||||
|
||||
let saidOnlyOfficeNotGiven = false;
|
||||
|
||||
/**
|
||||
* The one Nextcloud account the module makes and keeps for itself: the same name files/server.env
|
||||
* gives NEXTCLOUD_ADMIN_USER (novox/hq issue 1253). Only this account is ever repaired; a `user`
|
||||
* setting naming any other account is used as given and never written to.
|
||||
*/
|
||||
export const MESH_ACCOUNT = "mesh-admin";
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -76,15 +140,17 @@ export class NextcloudClient {
|
||||
/**
|
||||
* Build from the module's resolved environment. occ needs only the container name (default
|
||||
* "nextcloud"); the OCS surface needs the admin password the module keeps as its own secret
|
||||
* (MESH_NEXTCLOUD_ADMIN_PASSWORD, user MESH_NEXTCLOUD_ADMIN_USER default admin, URL the local
|
||||
* (MESH_NEXTCLOUD_ADMIN_PASSWORD, user MESH_NEXTCLOUD_ADMIN_USER default mesh-admin, URL the local
|
||||
* container). The admin password is treated as the "configured for mesh administration" signal:
|
||||
* throws without it, and the module then contributes nothing rather than failing.
|
||||
*/
|
||||
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"}`;
|
||||
// No "admin" fallback: that is what a hand-made install calls its human administrator.
|
||||
const adminUser = cfg.user || env.MESH_NEXTCLOUD_ADMIN_USER || MESH_ACCOUNT;
|
||||
const passwordFile = env.MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE;
|
||||
ignoreSecretSetting(cfg, "password");
|
||||
const adminPassword = env.MESH_NEXTCLOUD_ADMIN_PASSWORD
|
||||
@@ -100,20 +166,28 @@ 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();
|
||||
throw new Error(detail || `occ produced no output — is the ${this.container} container running?`);
|
||||
// occ writes many refusals ("user not found") to stdout with an empty stderr: say both.
|
||||
const detail = [err?.stderr, err?.stdout].map((x) => String(x ?? "").trim()).filter(Boolean).join("\n")
|
||||
|| String(err?.message ?? "").trim();
|
||||
// The exit status rides along, so a caller can tell occ's own answer from a failure.
|
||||
throw Object.assign(new Error(detail || `occ produced no output — is the ${this.container} container running?`),
|
||||
{ status: typeof err?.status === "number" ? err.status : undefined });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,14 +262,75 @@ 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. Only the module's own
|
||||
* account (MESH_ACCOUNT) is touched: under any other name it writes nothing and throws
|
||||
* LoginRefusedError. Through occ: whether the account exists is read from `user:info`, whose
|
||||
* exit status 1 with "not found" means missing (user:list pages, so it is not asked); a missing
|
||||
* one is added, an existing one gets its password set to the secret. It gets no admin group: the
|
||||
* shares poll needs no admin rights (least privilege). The password goes by OC_PASS
|
||||
* (`--password-from-env`), never on an argv. Nextcloud's brute-force record is left alone: it is
|
||||
* shared by every client behind the same address, and the caller's back-off outlasts it. A
|
||||
* failing occ is thrown as LoginRefusedError carrying occ's own message.
|
||||
*/
|
||||
ensureAdminAccount(): "created" | "password-set" {
|
||||
if (this.adminUser !== MESH_ACCOUNT) {
|
||||
throw new LoginRefusedError(401, this.adminUser, `not repaired: only ${MESH_ACCOUNT}, the module's own account, is`);
|
||||
}
|
||||
const pass = { env: { OC_PASS: this.adminPassword } };
|
||||
try {
|
||||
if (!this.accountExists()) {
|
||||
this.occ(["user:add", "--password-from-env", "--display-name=Mesh administration", this.adminUser], pass);
|
||||
return "created";
|
||||
}
|
||||
this.occ(["user:resetpassword", "--password-from-env", this.adminUser], pass);
|
||||
return "password-set";
|
||||
} catch (err) {
|
||||
throw new LoginRefusedError(401, this.adminUser, `repair failed: ${String((err as Error)?.message ?? err).split("\n")[0]}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the account exists: occ user:info answers it, exit 1 and "not found" when missing. */
|
||||
private accountExists(): boolean {
|
||||
try {
|
||||
this.occ(["user:info", this.adminUser, "--output=json"]);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
if (err?.status === 1 && /not found/i.test(String(err?.message ?? ""))) return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
* any throttle (429), throws LoginRefusedError, so the caller backs off (novox/hq issue 1253).
|
||||
*/
|
||||
async listShares(): Promise<NextcloudShare[]> {
|
||||
let res = await this.fetchShares();
|
||||
// Only a refused login (401) is repaired. A throttle (429) is no evidence the password is
|
||||
// wrong (anyone guessing passwords from the same address causes it): it is backed off only.
|
||||
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 +341,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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
},
|
||||
"user": {
|
||||
"type": "string",
|
||||
"description": "The Nextcloud account the module's tools act as; empty is admin.",
|
||||
"description": "The Nextcloud account the module's tools act as; empty is mesh-admin, the account the module makes and keeps for itself. Any other account is used as given and never repaired.",
|
||||
"trusted": true,
|
||||
"default": ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
// 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(user = "mesh-admin"): any {
|
||||
return new (NextcloudClient as any)("nextcloud", "http://127.0.0.1:20003", user, 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, opts: { failWrite?: string } = {}) {
|
||||
const calls: string[][] = [];
|
||||
const envs: Array<Record<string, string> | undefined> = [];
|
||||
const impl = (args: string[], occOpts: { env?: Record<string, string> } = {}): string => {
|
||||
calls.push(args);
|
||||
envs.push(occOpts.env);
|
||||
if (args[0] === "user:info") {
|
||||
// As the real occ() throws it: occ's stdout as the message, its exit status alongside.
|
||||
if (!accountExists) throw Object.assign(new Error("user not found"), { status: 1 });
|
||||
return "{}";
|
||||
}
|
||||
if (["user:add", "user:resetpassword"].includes(args[0])) {
|
||||
if (opts.failWrite) throw new Error(opts.failWrite);
|
||||
return "";
|
||||
}
|
||||
throw new Error("unexpected occ call: " + args.join(" "));
|
||||
};
|
||||
return { calls, envs, impl };
|
||||
}
|
||||
|
||||
/** The occ calls that write: anything but a read of the account. */
|
||||
const writes = (calls: string[][]) => calls.filter((a) => a[0] !== "user:info");
|
||||
|
||||
// 1253/AC1: given an adopted instance without the mesh's account, when Nextcloud refuses the
|
||||
// login, then the account is added (no admin group) with the module's own secret, by OC_PASS and
|
||||
// never on an argv, and the shares are listed; Nextcloud's brute-force record is left alone.
|
||||
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("mesh-admin"));
|
||||
assert.ok(!add.some((a) => a.includes("group")), "no admin group: least privilege");
|
||||
assert.deepEqual(occ.calls.map((a) => a[0]), ["user:info", "user:add"]);
|
||||
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, in no group.
|
||||
test("a refused login resets an existing account's password, in no 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"]);
|
||||
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 no evidence the password is wrong: two cycles of 429 then 200
|
||||
// run no occ write, and the 429s are LoginRefusedError for the back-off.
|
||||
test("a throttle (429) is backed off, never repaired", async () => {
|
||||
const c = client();
|
||||
const occ = standInOcc(true);
|
||||
mock.method(c, "occ", occ.impl);
|
||||
const f = mock.method(globalThis, "fetch", standInFetch([429, 200, 429, 200]).impl);
|
||||
try {
|
||||
for (let cycle = 0; cycle < 2; cycle++) {
|
||||
await assert.rejects(c.listShares(), (err: any) => err instanceof LoginRefusedError && err.status === 429);
|
||||
assert.equal((await c.listShares()).length, 1);
|
||||
}
|
||||
assert.deepEqual(occ.calls, [], "no occ call at all, so no write");
|
||||
} finally {
|
||||
f.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// 1253/AC3c: a 401 runs exactly one repair.
|
||||
test("a 401 runs exactly one repair", 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.equal(writes(occ.calls).length, 1);
|
||||
} 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");
|
||||
});
|
||||
|
||||
// 1253/AC7: the repair touches only the module's own account: a refusal under a user setting
|
||||
// naming another account (the operator's) runs no occ write and throws LoginRefusedError.
|
||||
test("a refusal under another account's name writes nothing", async () => {
|
||||
const c = client("jochens");
|
||||
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.match(err.message, /refused the login of jochens .*not repaired: only mesh-admin/);
|
||||
return true;
|
||||
});
|
||||
assert.deepEqual(writes(occ.calls), []);
|
||||
} finally {
|
||||
f.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// 1253/AC8: a repair whose occ write fails is said as a LoginRefusedError carrying occ's own
|
||||
// message, so the back-off's one loud line says why.
|
||||
test("a failing repair carries occ's message into LoginRefusedError", async () => {
|
||||
const c = client();
|
||||
mock.method(c, "occ", standInOcc(false, { failWrite: "The password policy refused the password" }).impl);
|
||||
const f = mock.method(globalThis, "fetch", standInFetch([401]).impl);
|
||||
try {
|
||||
await assert.rejects(c.listShares(), (err: any) =>
|
||||
err instanceof LoginRefusedError && /repair failed: The password policy refused the password/.test(err.message));
|
||||
} finally {
|
||||
f.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
/** A fake docker on PATH: answers occ like Nextcloud (user:info's refusal on stdout, exit 1). */
|
||||
function fakeDocker(): { dir: string; record: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nextcloud-1253-docker-"));
|
||||
const record = join(dir, "record");
|
||||
writeFileSync(join(dir, "docker"), [
|
||||
"#!/bin/sh",
|
||||
`printf '%s\\n' "$*" >> ${record}`,
|
||||
'case "$*" in',
|
||||
" *' occ user:info '*) echo 'user not found'; exit 1 ;;",
|
||||
"esac",
|
||||
"exit 0",
|
||||
"",
|
||||
].join("\n"));
|
||||
chmodSync(join(dir, "docker"), 0o755);
|
||||
return { dir, record };
|
||||
}
|
||||
|
||||
function withPath<T>(dir: string, fn: () => T): T {
|
||||
const path = process.env.PATH;
|
||||
process.env.PATH = `${dir}:${path}`;
|
||||
try { return fn(); } finally { process.env.PATH = path; }
|
||||
}
|
||||
|
||||
// 1253/AC9: occ's own refusal on stdout with an empty stderr reaches the error, and the real
|
||||
// occ() through a docker answering like Nextcloud makes the missing account.
|
||||
test("occ says a refusal printed on stdout, and the real occ makes the missing account", () => {
|
||||
const docker = fakeDocker();
|
||||
const c = new (NextcloudClient as any)("nextcloud", "http://127.0.0.1:20003", "mesh-admin", PASSWORD);
|
||||
withPath(docker.dir, () => {
|
||||
assert.throws(() => c.occ(["user:info", "mesh-admin", "--output=json"]), /user not found/);
|
||||
const did = c.ensureAdminAccount();
|
||||
assert.equal(did, "created");
|
||||
});
|
||||
const lines = readFileSync(docker.record, "utf8").trim().split("\n");
|
||||
assert.ok(lines.some((l) => l.endsWith("php occ user:add --password-from-env --display-name=Mesh administration mesh-admin")), lines.join("\n"));
|
||||
assert.ok(!lines.some((l) => l.includes("bruteforce")), "the brute-force record is left alone");
|
||||
assert.ok(!lines.join("\n").includes(PASSWORD));
|
||||
});
|
||||
Reference in New Issue
Block a user