nextcloud: repair only the module's own account, read its existence from the user list, no admin group (issue 1253, review 1)

occ's user:info says 'user not found' on stdout with an empty stderr, so the
error read as 'occ produced no output' and mesh-admin was never made on novox:
occ now says stderr and stdout both, and existence is read from user:list.
The repair could reset whatever account the user setting names, the
operator's included, and the 'admin' fallback is a hand-made install's human
administrator: only mesh-admin is repaired now, and it is the default. The
shares poll needs no admin rights, so the account gets no group. After a
repair the throttle of the caller's address is reset, and a failing repair
reaches the back-off's one loud line with occ's message.
This commit is contained in:
2026-10-25 16:54:51 +01:00
parent 67b13c42d3
commit 962bc359a7
3 changed files with 153 additions and 37 deletions
+51 -21
View File
@@ -61,6 +61,13 @@ 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
@@ -133,7 +140,7 @@ 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.
*/
@@ -142,7 +149,8 @@ export class NextcloudClient {
// 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";
// 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
@@ -174,7 +182,9 @@ export class NextcloudClient {
...(opts.env ? { env: { ...process.env, ...opts.env } } : {}),
}).trim();
} catch (err: any) {
const detail = String(err?.stderr ?? err?.stdout ?? err?.message ?? "").trim();
// 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();
throw new Error(detail || `occ produced no output — is the ${this.container} container running?`);
}
}
@@ -253,31 +263,51 @@ export class NextcloudClient {
/**
* 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.
* 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:list's
* membership; 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. Then the brute-force record of the
* addresses the module's calls arrive from is reset, so the first good login is not held behind
* the throttle. A failing occ write is thrown as LoginRefusedError carrying occ's own message.
*/
ensureAdminAccount(): "created" | "password-set" {
ensureAdminAccount(): { account: "created" | "password-set"; throttleReset: boolean } {
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 } };
let exists = true;
let account: "created" | "password-set";
try {
this.occ(["user:info", this.adminUser, "--output=json"]);
if (!this.listUsers().some((u) => u.uid === this.adminUser)) {
this.occ(["user:add", "--password-from-env", "--display-name=Mesh administration", this.adminUser], pass);
account = "created";
} else {
this.occ(["user:resetpassword", "--password-from-env", this.adminUser], pass);
account = "password-set";
}
} catch (err) {
if (!/not found|does not exist/i.test(String((err as Error)?.message ?? err))) throw err;
exists = false;
throw new LoginRefusedError(401, this.adminUser, `repair failed: ${String((err as Error)?.message ?? err).split("\n")[0]}`);
}
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);
let throttleReset = true;
try {
this.occ(["group:adduser", "admin", this.adminUser]);
} catch (err) {
if (!/already/i.test(String((err as Error)?.message ?? err))) throw err;
for (const address of this.callerAddresses()) this.occ(["security:bruteforce:reset", address]);
} catch {
throttleReset = false;
}
return "password-set";
return { account, throttleReset };
}
/**
* The addresses the module's calls reach Nextcloud from, as Nextcloud sees them: the gateway of
* each network the container is on (a published port arrives from it; the access log shows
* 172.17.0.1 on the default bridge).
*/
callerAddresses(): string[] {
const out = execFileSync("docker", ["inspect", "-f", "{{range .NetworkSettings.Networks}}{{.Gateway}} {{end}}", this.container], {
encoding: "utf8", timeout: 30_000,
});
return out.split(/\s+/).filter(Boolean);
}
private fetchShares(): Promise<Response> {