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.
348 lines
15 KiB
TypeScript
348 lines
15 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)
|
|
* 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),
|
|
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");
|
|
}
|