From 5fbaa17c66594b1a590a1dcc6782245936d28a5c Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 8 Jun 2026 21:09:35 +0100 Subject: [PATCH 1/3] feat(settings): env-var overrides for update-check/plugin-catalog/updater (offline installs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Air-gapped and firewalled deployments could not disable Etherpad's outbound calls (the hourly version check, the admin plugin catalogue, and the self-updater) without editing settings.json inside the container image — the shipped settings.json.docker hardcoded updates.tier and omitted the privacy block entirely, so there was no env-var to flip. Wire the relevant keys through the existing ${ENV:default} substitution in both settings.json.docker and settings.json.template: - PRIVACY_UPDATE_CHECK (privacy.updateCheck, default true) - PRIVACY_PLUGIN_CATALOG (privacy.pluginCatalog, default true) - UPDATES_TIER (updates.tier, default notify; "off" = no calls) - UPDATES_SOURCE / UPDATES_CHANNEL / UPDATES_CHECK_INTERVAL_HOURS / UPDATES_GITHUB_REPO / UPDATES_REQUIRE_ADMIN_FOR_STATUS (docker) - UPDATE_SERVER (updateServer endpoint) Document the full set in doc/docker.md (new "Updates & privacy" section) and cross-link from doc/admin/updates.md. Add backend regression tests that parse the shipped settings.json.docker and settings.json.template and assert the overrides apply with correct boolean/numeric coercion, so a future edit that drops the ${ENV} placeholders fails loudly. Addresses #7911 (item 2). No code changes — config + docs + tests only. Co-Authored-By: Claude Opus 4.8 (1M context) --- doc/admin/updates.md | 2 + doc/docker.md | 19 +++++++ settings.json.docker | 28 +++++++--- settings.json.template | 12 +++-- src/tests/backend/specs/settings.ts | 81 +++++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 12 deletions(-) diff --git a/doc/admin/updates.md b/doc/admin/updates.md index b9932e664f4..4a2e5ed7bc7 100644 --- a/doc/admin/updates.md +++ b/doc/admin/updates.md @@ -104,6 +104,8 @@ The notice auto-fades after 8 seconds and can be dismissed immediately. The publ Set `updates.tier` to `"off"`. No HTTP request will leave the instance and no banner or badge will render. +On Docker / air-gapped installs you can do this without editing `settings.json` inside the image by setting `UPDATES_TIER=off` (and, to also drop the separate `updateServer` version check, `PRIVACY_UPDATE_CHECK=false`). See the [Updates & privacy](../docker.md#updates--privacy-offline--air-gapped) table in the Docker docs for the full set of environment variables. + ## Privacy The version check sends no telemetry. Etherpad fetches the public GitHub Releases API (`api.github.com/repos//releases/latest`) with `If-None-Match` to be cache-friendly. The only metadata GitHub sees is the same as any other GitHub API client — your IP and a `User-Agent: etherpad-self-update` header. No instance ID, no version, no identifiers travel upstream. diff --git a/doc/docker.md b/doc/docker.md index 5a5a4281691..71719e58572 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -118,6 +118,25 @@ The `settings.json.docker` available by default allows to control almost every s | `USER_PASSWORD` | the password for the first user `user` (leave unspecified if you do not want to create it) | | +### Updates & privacy (offline / air-gapped) + +Etherpad makes a small number of outbound calls (a periodic version check and the admin plugin catalogue). In an air-gapped or firewalled deployment these can be disabled entirely without editing `settings.json` inside the image — set the variables below. See [doc/privacy.md](privacy.md) and [doc/admin/updates.md](admin/updates.md) for what each call sends. + +| Variable | Description | Default | +| --------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------- | +| `PRIVACY_UPDATE_CHECK` | Set to `false` to disable the hourly version check (`UpdateCheck.ts`). | `true` | +| `PRIVACY_PLUGIN_CATALOG` | Set to `false` to disable the admin plugin browser (manual install-by-name via CLI still works). | `true` | +| `UPDATES_TIER` | Self-updater tier: `off` \| `notify` \| `manual` \| `auto` \| `autonomous`. Set to `off` to suppress the GitHub Releases check entirely. | `notify` | +| `UPDATES_SOURCE` | Where update metadata is fetched from. | `github` | +| `UPDATES_CHANNEL` | Release channel to track. | `stable` | +| `UPDATES_CHECK_INTERVAL_HOURS` | How often (hours) the updater polls when not `off`. | `6` | +| `UPDATES_GITHUB_REPO` | Repository the updater checks for releases. | `ether/etherpad` | +| `UPDATES_REQUIRE_ADMIN_FOR_STATUS`| Lock `/admin/update/status` to authenticated admins. | `false` | +| `UPDATE_SERVER` | Endpoint backing the version check. Point elsewhere (or disable the check above) for offline installs. | `https://etherpad.org/ep_infos` | + +> **Fully offline:** set `UPDATES_TIER=off`, `PRIVACY_UPDATE_CHECK=false`, and `PRIVACY_PLUGIN_CATALOG=false`. The version check is fire-and-forget and already fails closed (a blocked endpoint is caught and logged, it does not prevent startup), but disabling it removes the outbound attempt and the log noise. + + ### Database | Variable | Description | Default | diff --git a/settings.json.docker b/settings.json.docker index 5da12e7e82b..7470b0cc4bf 100644 --- a/settings.json.docker +++ b/settings.json.docker @@ -210,15 +210,17 @@ * tier: "off" | "notify" | "manual" | "auto" | "autonomous" * Default "notify" shows a banner when an update is available. * Docker installs are read-only — tiers above "notify" are not applied even if requested. + * Air-gapped / offline deployments should set UPDATES_TIER=off to suppress the + * periodic check against the GitHub Releases API entirely. */ "updates": { - "tier": "notify", - "source": "github", - "channel": "stable", + "tier": "${UPDATES_TIER:notify}", + "source": "${UPDATES_SOURCE:github}", + "channel": "${UPDATES_CHANNEL:stable}", "installMethod": "docker", - "checkIntervalHours": 6, - "githubRepo": "ether/etherpad", - "requireAdminForStatus": false, + "checkIntervalHours": "${UPDATES_CHECK_INTERVAL_HOURS:6}", + "githubRepo": "${UPDATES_GITHUB_REPO:ether/etherpad}", + "requireAdminForStatus": "${UPDATES_REQUIRE_ADMIN_FOR_STATUS:false}", "preApplyGraceMinutes": 0, "drainSeconds": 60, "rollbackHealthCheckSeconds": 60, @@ -321,7 +323,19 @@ * https://etherpad.org/ep_infos */ - "updateServer": "https://etherpad.org/ep_infos", + "updateServer": "${UPDATE_SERVER:https://etherpad.org/ep_infos}", + + /* + * Outbound network calls. See doc/privacy.md for what each one sends. + * - PRIVACY_UPDATE_CHECK=false : disables the hourly version check (UpdateCheck.ts) + * - PRIVACY_PLUGIN_CATALOG=false : disables the admin plugin browser + * (manual install-by-name via CLI still works) + * Air-gapped / firewalled deployments should set both to false. + */ + "privacy": { + "updateCheck": "${PRIVACY_UPDATE_CHECK:true}", + "pluginCatalog": "${PRIVACY_PLUGIN_CATALOG:true}" + }, /* * The type of the database. diff --git a/settings.json.template b/settings.json.template index 937456a3378..fd88cd3764e 100644 --- a/settings.json.template +++ b/settings.json.template @@ -216,7 +216,7 @@ * Default "notify" shows a banner when an update is available. "off" disables the version check. */ "updates": { - "tier": "notify", + "tier": "${UPDATES_TIER:notify}", "source": "github", "channel": "stable", "installMethod": "auto", @@ -443,17 +443,19 @@ * https://etherpad.org/ep_infos */ - "updateServer": "https://etherpad.org/ep_infos", + "updateServer": "${UPDATE_SERVER:https://etherpad.org/ep_infos}", /* - * Outbound network calls. See PRIVACY.md for what each one sends. + * Outbound network calls. See doc/privacy.md for what each one sends. * - updateCheck=false : disables hourly version check (UpdateCheck.ts) * - pluginCatalog=false: disables admin plugin browser * (manual install-by-name via CLI still works) + * Air-gapped / firewalled deployments should set both PRIVACY_UPDATE_CHECK and + * PRIVACY_PLUGIN_CATALOG to false (or UPDATES_TIER=off, which covers the version check). */ "privacy": { - "updateCheck": true, - "pluginCatalog": true + "updateCheck": "${PRIVACY_UPDATE_CHECK:true}", + "pluginCatalog": "${PRIVACY_PLUGIN_CATALOG:true}" }, /* diff --git a/src/tests/backend/specs/settings.ts b/src/tests/backend/specs/settings.ts index 23290156f9a..52d5a2dc139 100644 --- a/src/tests/backend/specs/settings.ts +++ b/src/tests/backend/specs/settings.ts @@ -197,4 +197,85 @@ describe(__filename, function () { assert.strictEqual(settings.padOptions.fadeInactiveAuthorColors, true); }); }); + + // Regression test for ether/etherpad#7911. + // Air-gapped / firewalled deployments must be able to disable Etherpad's + // outbound calls (version check + plugin catalogue + self-updater) purely + // via environment variables, without editing settings.json inside the image. + // These assertions parse the *shipped* settings.json.docker so a future edit + // that drops the ${ENV} placeholders fails loudly here. + describe('offline / air-gapped env overrides (issue #7911)', function () { + const dockerSettings = path.join(__dirname, '../../../../settings.json.docker'); + const templateSettings = path.join(__dirname, '../../../../settings.json.template'); + const envVars = [ + 'PRIVACY_UPDATE_CHECK', 'PRIVACY_PLUGIN_CATALOG', 'UPDATES_TIER', + 'UPDATES_SOURCE', 'UPDATES_CHANNEL', 'UPDATES_CHECK_INTERVAL_HOURS', + 'UPDATES_GITHUB_REPO', 'UPDATES_REQUIRE_ADMIN_FOR_STATUS', 'UPDATE_SERVER', + ]; + const saved: {[k: string]: string | undefined} = {}; + + before(function () { for (const v of envVars) saved[v] = process.env[v]; }); + afterEach(function () { + for (const v of envVars) { + if (saved[v] == null) delete process.env[v]; + else process.env[v] = saved[v]; + } + }); + + it('keeps shipped defaults when no env vars are set', function () { + for (const v of envVars) delete process.env[v]; + const s = exportedForTestingOnly.parseSettings(dockerSettings, true); + assert.strictEqual(s!.privacy.updateCheck, true); + assert.strictEqual(s!.privacy.pluginCatalog, true); + assert.strictEqual(s!.updates.tier, 'notify'); + assert.strictEqual(s!.updates.checkIntervalHours, 6); + assert.strictEqual(s!.updateServer, 'https://etherpad.org/ep_infos'); + }); + + it('disables all outbound calls when the offline env vars are set', function () { + process.env.PRIVACY_UPDATE_CHECK = 'false'; + process.env.PRIVACY_PLUGIN_CATALOG = 'false'; + process.env.UPDATES_TIER = 'off'; + const s = exportedForTestingOnly.parseSettings(dockerSettings, true); + // Coerced to real booleans, not the strings "false". + assert.strictEqual(s!.privacy.updateCheck, false); + assert.strictEqual(s!.privacy.pluginCatalog, false); + assert.strictEqual(s!.updates.tier, 'off'); + }); + + it('honours the remaining updates.* and updateServer overrides', function () { + process.env.UPDATES_SOURCE = 'gitlab'; + process.env.UPDATES_CHANNEL = 'beta'; + process.env.UPDATES_CHECK_INTERVAL_HOURS = '24'; + process.env.UPDATES_GITHUB_REPO = 'acme/etherpad-fork'; + process.env.UPDATES_REQUIRE_ADMIN_FOR_STATUS = 'true'; + process.env.UPDATE_SERVER = 'https://mirror.internal/ep_infos'; + const s = exportedForTestingOnly.parseSettings(dockerSettings, true); + assert.strictEqual(s!.updates.source, 'gitlab'); + assert.strictEqual(s!.updates.channel, 'beta'); + assert.strictEqual(s!.updates.checkIntervalHours, 24); // numeric coercion + assert.strictEqual(s!.updates.githubRepo, 'acme/etherpad-fork'); + assert.strictEqual(s!.updates.requireAdminForStatus, true); // boolean coercion + assert.strictEqual(s!.updateServer, 'https://mirror.internal/ep_infos'); + }); + + // The source-install template carries the same placeholders so non-Docker + // deployments get the offline knobs too. + it('settings.json.template exposes the same offline overrides', function () { + for (const v of envVars) delete process.env[v]; + const dflt = exportedForTestingOnly.parseSettings(templateSettings, true); + assert.strictEqual(dflt!.privacy.updateCheck, true); + assert.strictEqual(dflt!.privacy.pluginCatalog, true); + assert.strictEqual(dflt!.updates.tier, 'notify'); + assert.strictEqual(dflt!.updateServer, 'https://etherpad.org/ep_infos'); + + process.env.UPDATES_TIER = 'off'; + process.env.PRIVACY_UPDATE_CHECK = 'false'; + process.env.PRIVACY_PLUGIN_CATALOG = 'false'; + const over = exportedForTestingOnly.parseSettings(templateSettings, true); + assert.strictEqual(over!.updates.tier, 'off'); + assert.strictEqual(over!.privacy.updateCheck, false); + assert.strictEqual(over!.privacy.pluginCatalog, false); + }); + }); }); From 3a955194f849291f0561800e2eb42c317afd44cc Mon Sep 17 00:00:00 2001 From: John McLear Date: Tue, 9 Jun 2026 09:10:29 +0100 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20address=20Qodo=20review=20=E2=80=94?= =?UTF-8?q?=20point=20to=20PRIVACY.md,=20clarify=20tier=3Doff=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Outbound-call docs/comments referenced doc/privacy.md (the storage/logging doc); the canonical outbound-call inventory is repo-root PRIVACY.md, which the runtime messages in UpdateCheck.ts / Settings.ts also reference. Re-point settings.json.{template,docker} and doc/docker.md there. - doc/admin/updates.md said updates.tier="off" means "no HTTP request will leave the instance", but the legacy UpdateCheck.ts call to ${updateServer}/info.json is gated by privacy.updateCheck, not updates.tier. Clarify that air-gapped installs must set PRIVACY_UPDATE_CHECK=false too. Co-Authored-By: Claude Opus 4.8 (1M context) --- doc/admin/updates.md | 4 ++-- doc/docker.md | 2 +- settings.json.docker | 2 +- settings.json.template | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/doc/admin/updates.md b/doc/admin/updates.md index 4a2e5ed7bc7..659c94fa37e 100644 --- a/doc/admin/updates.md +++ b/doc/admin/updates.md @@ -102,9 +102,9 @@ The notice auto-fades after 8 seconds and can be dismissed immediately. The publ ## Disabling everything -Set `updates.tier` to `"off"`. No HTTP request will leave the instance and no banner or badge will render. +Set `updates.tier` to `"off"`. The self-updater goes silent — no request to the GitHub Releases API leaves the instance and no banner or badge renders. Note this does **not** cover the separate legacy version check in `UpdateCheck.ts`, which still fetches `${updateServer}/info.json` until you also set `privacy.updateCheck` to `false` (see [PRIVACY.md](../../PRIVACY.md)). -On Docker / air-gapped installs you can do this without editing `settings.json` inside the image by setting `UPDATES_TIER=off` (and, to also drop the separate `updateServer` version check, `PRIVACY_UPDATE_CHECK=false`). See the [Updates & privacy](../docker.md#updates--privacy-offline--air-gapped) table in the Docker docs for the full set of environment variables. +On Docker / air-gapped installs you can do both without editing `settings.json` inside the image by setting `UPDATES_TIER=off` **and** `PRIVACY_UPDATE_CHECK=false` (add `PRIVACY_PLUGIN_CATALOG=false` to also disable the admin plugin browser's catalogue fetch). See the [Updates & privacy](../docker.md#updates--privacy-offline--air-gapped) table in the Docker docs for the full set of environment variables. ## Privacy diff --git a/doc/docker.md b/doc/docker.md index 71719e58572..11dbbec58fc 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -120,7 +120,7 @@ The `settings.json.docker` available by default allows to control almost every s ### Updates & privacy (offline / air-gapped) -Etherpad makes a small number of outbound calls (a periodic version check and the admin plugin catalogue). In an air-gapped or firewalled deployment these can be disabled entirely without editing `settings.json` inside the image — set the variables below. See [doc/privacy.md](privacy.md) and [doc/admin/updates.md](admin/updates.md) for what each call sends. +Etherpad makes a small number of outbound calls (a periodic version check and the admin plugin catalogue). In an air-gapped or firewalled deployment these can be disabled entirely without editing `settings.json` inside the image — set the variables below. See [PRIVACY.md](../PRIVACY.md) and [doc/admin/updates.md](admin/updates.md) for what each call sends. | Variable | Description | Default | | --------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------- | diff --git a/settings.json.docker b/settings.json.docker index 7470b0cc4bf..27dedfe8e69 100644 --- a/settings.json.docker +++ b/settings.json.docker @@ -326,7 +326,7 @@ "updateServer": "${UPDATE_SERVER:https://etherpad.org/ep_infos}", /* - * Outbound network calls. See doc/privacy.md for what each one sends. + * Outbound network calls. See PRIVACY.md for what each one sends. * - PRIVACY_UPDATE_CHECK=false : disables the hourly version check (UpdateCheck.ts) * - PRIVACY_PLUGIN_CATALOG=false : disables the admin plugin browser * (manual install-by-name via CLI still works) diff --git a/settings.json.template b/settings.json.template index fd88cd3764e..5f5ae29f29c 100644 --- a/settings.json.template +++ b/settings.json.template @@ -446,7 +446,7 @@ "updateServer": "${UPDATE_SERVER:https://etherpad.org/ep_infos}", /* - * Outbound network calls. See doc/privacy.md for what each one sends. + * Outbound network calls. See PRIVACY.md for what each one sends. * - updateCheck=false : disables hourly version check (UpdateCheck.ts) * - pluginCatalog=false: disables admin plugin browser * (manual install-by-name via CLI still works) From c5ce34e83e5859c498ebf193ac892b0569f2c3b4 Mon Sep 17 00:00:00 2001 From: John McLear Date: Tue, 9 Jun 2026 09:34:47 +0100 Subject: [PATCH 3/3] docs: link PRIVACY.md via absolute URL to satisfy VitePress dead-link check PRIVACY.md lives at the repo root, outside the doc/ tree VitePress builds, so a relative link to it (../PRIVACY.md / ../../PRIVACY.md) is flagged as a dead link and fails `docs:build`. Use the absolute GitHub URL instead, matching how doc/configuration.md already links settings.json.template. Co-Authored-By: Claude Opus 4.8 (1M context) --- doc/admin/updates.md | 2 +- doc/docker.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/admin/updates.md b/doc/admin/updates.md index 659c94fa37e..7755f3f32a6 100644 --- a/doc/admin/updates.md +++ b/doc/admin/updates.md @@ -102,7 +102,7 @@ The notice auto-fades after 8 seconds and can be dismissed immediately. The publ ## Disabling everything -Set `updates.tier` to `"off"`. The self-updater goes silent — no request to the GitHub Releases API leaves the instance and no banner or badge renders. Note this does **not** cover the separate legacy version check in `UpdateCheck.ts`, which still fetches `${updateServer}/info.json` until you also set `privacy.updateCheck` to `false` (see [PRIVACY.md](../../PRIVACY.md)). +Set `updates.tier` to `"off"`. The self-updater goes silent — no request to the GitHub Releases API leaves the instance and no banner or badge renders. Note this does **not** cover the separate legacy version check in `UpdateCheck.ts`, which still fetches `${updateServer}/info.json` until you also set `privacy.updateCheck` to `false` (see [PRIVACY.md](https://github.com/ether/etherpad/blob/develop/PRIVACY.md)). On Docker / air-gapped installs you can do both without editing `settings.json` inside the image by setting `UPDATES_TIER=off` **and** `PRIVACY_UPDATE_CHECK=false` (add `PRIVACY_PLUGIN_CATALOG=false` to also disable the admin plugin browser's catalogue fetch). See the [Updates & privacy](../docker.md#updates--privacy-offline--air-gapped) table in the Docker docs for the full set of environment variables. diff --git a/doc/docker.md b/doc/docker.md index 11dbbec58fc..ad7d21f1723 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -120,7 +120,7 @@ The `settings.json.docker` available by default allows to control almost every s ### Updates & privacy (offline / air-gapped) -Etherpad makes a small number of outbound calls (a periodic version check and the admin plugin catalogue). In an air-gapped or firewalled deployment these can be disabled entirely without editing `settings.json` inside the image — set the variables below. See [PRIVACY.md](../PRIVACY.md) and [doc/admin/updates.md](admin/updates.md) for what each call sends. +Etherpad makes a small number of outbound calls (a periodic version check and the admin plugin catalogue). In an air-gapped or firewalled deployment these can be disabled entirely without editing `settings.json` inside the image — set the variables below. See [PRIVACY.md](https://github.com/ether/etherpad/blob/develop/PRIVACY.md) and [doc/admin/updates.md](admin/updates.md) for what each call sends. | Variable | Description | Default | | --------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------- |