Env-var overrides for offline/air-gapped installs (update check, plugin catalog, updater) (#7917)

* feat(settings): env-var overrides for update-check/plugin-catalog/updater (offline installs)

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) <noreply@anthropic.com>

* docs: address Qodo review — point to PRIVACY.md, clarify tier=off scope

- 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-06-13 14:32:46 +01:00 committed by GitHub
parent 662678275a
commit f79cabe74c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 130 additions and 12 deletions

View file

@ -102,7 +102,9 @@ The notice auto-fades after 8 seconds and can be dismissed immediately. The publ
## Disabling everything ## 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](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.
## Privacy ## Privacy

View file

@ -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) | | | `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 [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 |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------- |
| `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 ### Database
| Variable | Description | Default | | Variable | Description | Default |

View file

@ -210,15 +210,17 @@
* tier: "off" | "notify" | "manual" | "auto" | "autonomous" * tier: "off" | "notify" | "manual" | "auto" | "autonomous"
* Default "notify" shows a banner when an update is available. * Default "notify" shows a banner when an update is available.
* Docker installs are read-only — tiers above "notify" are not applied even if requested. * 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": { "updates": {
"tier": "notify", "tier": "${UPDATES_TIER:notify}",
"source": "github", "source": "${UPDATES_SOURCE:github}",
"channel": "stable", "channel": "${UPDATES_CHANNEL:stable}",
"installMethod": "docker", "installMethod": "docker",
"checkIntervalHours": 6, "checkIntervalHours": "${UPDATES_CHECK_INTERVAL_HOURS:6}",
"githubRepo": "ether/etherpad", "githubRepo": "${UPDATES_GITHUB_REPO:ether/etherpad}",
"requireAdminForStatus": false, "requireAdminForStatus": "${UPDATES_REQUIRE_ADMIN_FOR_STATUS:false}",
"preApplyGraceMinutes": 0, "preApplyGraceMinutes": 0,
"drainSeconds": 60, "drainSeconds": 60,
"rollbackHealthCheckSeconds": 60, "rollbackHealthCheckSeconds": 60,
@ -321,7 +323,19 @@
* https://etherpad.org/ep_infos * 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.
* - 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. * The type of the database.

View file

@ -216,7 +216,7 @@
* Default "notify" shows a banner when an update is available. "off" disables the version check. * Default "notify" shows a banner when an update is available. "off" disables the version check.
*/ */
"updates": { "updates": {
"tier": "notify", "tier": "${UPDATES_TIER:notify}",
"source": "github", "source": "github",
"channel": "stable", "channel": "stable",
"installMethod": "auto", "installMethod": "auto",
@ -443,17 +443,19 @@
* https://etherpad.org/ep_infos * 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 PRIVACY.md for what each one sends.
* - updateCheck=false : disables hourly version check (UpdateCheck.ts) * - updateCheck=false : disables hourly version check (UpdateCheck.ts)
* - pluginCatalog=false: disables admin plugin browser * - pluginCatalog=false: disables admin plugin browser
* (manual install-by-name via CLI still works) * (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": { "privacy": {
"updateCheck": true, "updateCheck": "${PRIVACY_UPDATE_CHECK:true}",
"pluginCatalog": true "pluginCatalog": "${PRIVACY_PLUGIN_CATALOG:true}"
}, },
/* /*

View file

@ -197,4 +197,85 @@ describe(__filename, function () {
assert.strictEqual(settings.padOptions.fadeInactiveAuthorColors, true); 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);
});
});
}); });