Files
mesh-catalog/modules/nextcloud/client.ts
T
jschoubben 67b13c42d3
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 67b13c42d32a on f27f4c8cd58c
nextcloud: repair the account on a throttle too, since a throttle follows refusals (issue 1253)
Right after the delivery the first answer may be Nextcloud's 429, not a 401;
without a repair then the account would never be made and the back-off would
wait out a throttle that cannot end.
2026-10-25 16:40:54 +01:00

350 lines
16 KiB
TypeScript

// Nextcloud's client — nextcloud's own code, living in the module (novox/hq ADR 0039). Both this
// module's tools and its events entrypoint import it, and nothing outside nextcloud does.
//
// Nextcloud is administered two ways, and this client speaks both:
// - occ, its admin CLI, is a PHP script inside the container runnable only as the web user. We
// reach it with `docker exec`, the same side channel an operator would use by hand — turned
// into something the mesh can call. Users and apps come from here.
// - the OCS Sharing API answers over HTTP with the admin credentials. Shares come from here,
// because occ has no version-stable "list every share" across the releases we run.
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
export interface NextcloudUser {
uid: string;
displayName: string;
}
export interface NextcloudShare {
/** The OCS share id — the stable identity a new share is diffed on. */
id: string;
path: string;
shareType: number;
shareWith?: string;
owner: string;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
/**
* A password, token or API key is the vault's, never a setting (novox/hq issue 342): a value for one of
* them in the settings file is ignored, and that is said once — by its key, never with its value.
*/
const saidIgnored = new Set<string>();
function ignoreSecretSetting(cfg: Record<string, unknown>, key: string): void {
if (cfg[key] === undefined || saidIgnored.has(key)) return;
saidIgnored.add(key);
console.error(`the setting ${key} is ignored: it comes from the vault`);
}
/** What applyOnlyOffice changed and whether it checked the connection (novox/hq issue 1830). */
export interface OnlyOfficeResult {
/** The onlyoffice occ keys that were set this call — key names only, never a value. */
changed: string[];
/** Whether `onlyoffice:documentserver --check` ran, and passed; absent when nothing changed. */
checkOk?: boolean;
}
// occ's config key, and the mesh-delivered config.json key it comes from (files/config.json).
const ONLYOFFICE_KEYS: ReadonlyArray<readonly [occKey: string, configKey: string]> = [
["DocumentServerUrl", "onlyofficeUrl"],
["DocumentServerInternalUrl", "onlyofficeInternalUrl"],
["StorageUrl", "onlyofficeStorageUrl"],
["jwt_secret", "onlyofficeSecret"],
];
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,
private readonly ocsUrl: string,
private readonly adminUser: string,
private readonly adminPassword: string,
) {}
/**
* 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
* 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);
// 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
?? (passwordFile ? readFileSync(passwordFile, "utf8").trim() : undefined);
if (!adminPassword) {
throw new Error("no Nextcloud admin password — set MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE " +
"(or MESH_NEXTCLOUD_ADMIN_PASSWORD)");
}
return new NextcloudClient(container, ocsUrl.replace(/\/$/, ""), adminUser, adminPassword);
}
/**
* 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). 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; 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?`);
}
}
/**
* Applies OnlyOffice's settings from the mesh-delivered config (novox/hq issue 1830), read fresh
* from the config file on every call. Sets only the occ keys that differ from Nextcloud's current
* ones, then runs the connection check once something changed. When any of the four values is not
* given yet (document-server not bound, or its secret not given), changes nothing and says so once
* — never on every call. Never logs, returns or emits the secret's value: `changed` holds key names
* only, `jwt_secret` among them, never jwt_secret's value.
*/
applyOnlyOffice(configFile: string | undefined = process.env.MESH_NEXTCLOUD_CONFIG_FILE): OnlyOfficeResult {
const cfg = meshConfig(configFile);
const desired = ONLYOFFICE_KEYS.map(([occKey, configKey]) => [occKey, cfg[configKey] ?? ""] as const);
if (desired.some(([, value]) => !value)) {
if (!saidOnlyOfficeNotGiven) {
saidOnlyOfficeNotGiven = true;
console.error("onlyoffice: document-server is not bound yet, or its secret is not given yet — nothing applied");
}
return { changed: [] };
}
const changed: string[] = [];
for (const [occKey, value] of desired) {
if (this.currentAppConfig(occKey) !== value) {
// jwt_secret is a secret: apply it by stdin of config:import, never on an argv (issue 1831).
if (occKey === "jwt_secret") {
this.occ(["config:import"], { input: JSON.stringify({ apps: { onlyoffice: { jwt_secret: value } } }) });
} else {
this.occ(["config:app:set", "onlyoffice", occKey, `--value=${value}`]);
}
changed.push(occKey);
}
}
// Nothing changed, and the last check (if any) passed: there is nothing to check again
// (novox/hq issue 1832). Otherwise — something changed, or the last check failed — run it.
if (changed.length === 0 && !this.lastCheckFailed) return { changed };
let checkOk = true;
try {
this.occ(["onlyoffice:documentserver", "--check"]);
} catch {
checkOk = false;
}
this.lastCheckFailed = !checkOk;
return { changed, checkOk };
}
/**
* config:app:get of a key onlyoffice never set can throw occ's own "produced no output" error
* (occ above) rather than answer an empty string; read either as unset, so a first run sets it
* rather than aborting the loop.
*/
private currentAppConfig(key: string): string {
try {
return this.occ(["config:app:get", "onlyoffice", key]);
} catch {
return "";
}
}
listUsers(): NextcloudUser[] {
// user:list --output=json answers an object of uid → display name.
const raw = this.occ(["user:list", "--output=json"]);
const map = JSON.parse(raw || "{}") as Record<string, string>;
return Object.entries(map).map(([uid, displayName]) => ({ uid, displayName }));
}
listApps(): { enabled: string[]; disabled: string[] } {
const raw = this.occ(["app:list", "--output=json"]);
const parsed = JSON.parse(raw || "{}") as { enabled?: Record<string, unknown>; disabled?: Record<string, unknown> };
return { enabled: Object.keys(parsed.enabled ?? {}), disabled: Object.keys(parsed.disabled ?? {}) };
}
/**
* 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");
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" } },
);
}
/**
* List every share, over the OCS Sharing API as the mesh's own account. A refused login (401) or
* a throttle (429) makes the account right once (ensureAdminAccount) and asks again; a refusal
* or a throttle after that throws LoginRefusedError, so the caller backs off (novox/hq issue 1253).
*/
async listShares(): Promise<NextcloudShare[]> {
let res = await this.fetchShares();
// A throttle (429) follows refusals, so it gets the same one repair: the repair goes by occ,
// never by a login, and the throttle then wears off under the caller's back-off.
if ((res.status === 401 || res.status === 429) && !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),
path: s.path ?? "",
shareType: Number(s.share_type ?? -1),
shareWith: s.share_with || undefined,
owner: s.uid_owner ?? "unknown",
}));
}
}
/**
* 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");
}