Skip to content

Commit f79cabe

Browse files
JohnMcLearclaude
andauthored
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>
1 parent 6626782 commit f79cabe

5 files changed

Lines changed: 130 additions & 12 deletions

File tree

‎doc/admin/updates.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,9 @@ The notice auto-fades after 8 seconds and can be dismissed immediately. The publ
102102

103103
## Disabling everything
104104

105-
Set `updates.tier` to `"off"`. No HTTP request will leave the instance and no banner or badge will render.
105+
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)).
106+
107+
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.
106108

107109
## Privacy
108110

‎doc/docker.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,25 @@ The `settings.json.docker` available by default allows to control almost every s
118118
| `USER_PASSWORD` | the password for the first user `user` (leave unspecified if you do not want to create it) | |
119119

120120

121+
### Updates & privacy (offline / air-gapped)
122+
123+
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.
124+
125+
| Variable | Description | Default |
126+
| --------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------- |
127+
| `PRIVACY_UPDATE_CHECK` | Set to `false` to disable the hourly version check (`UpdateCheck.ts`). | `true` |
128+
| `PRIVACY_PLUGIN_CATALOG` | Set to `false` to disable the admin plugin browser (manual install-by-name via CLI still works). | `true` |
129+
| `UPDATES_TIER` | Self-updater tier: `off` \| `notify` \| `manual` \| `auto` \| `autonomous`. Set to `off` to suppress the GitHub Releases check entirely. | `notify` |
130+
| `UPDATES_SOURCE` | Where update metadata is fetched from. | `github` |
131+
| `UPDATES_CHANNEL` | Release channel to track. | `stable` |
132+
| `UPDATES_CHECK_INTERVAL_HOURS` | How often (hours) the updater polls when not `off`. | `6` |
133+
| `UPDATES_GITHUB_REPO` | Repository the updater checks for releases. | `ether/etherpad` |
134+
| `UPDATES_REQUIRE_ADMIN_FOR_STATUS`| Lock `/admin/update/status` to authenticated admins. | `false` |
135+
| `UPDATE_SERVER` | Endpoint backing the version check. Point elsewhere (or disable the check above) for offline installs. | `https://etherpad.org/ep_infos` |
136+
137+
> **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.
138+
139+
121140
### Database
122141

123142
| Variable | Description | Default |

‎settings.json.docker‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -210,15 +210,17 @@
210210
* tier: "off" | "notify" | "manual" | "auto" | "autonomous"
211211
* Default "notify" shows a banner when an update is available.
212212
* Docker installs are read-only — tiers above "notify" are not applied even if requested.
213+
* Air-gapped / offline deployments should set UPDATES_TIER=off to suppress the
214+
* periodic check against the GitHub Releases API entirely.
213215
*/
214216
"updates": {
215-
"tier": "notify",
216-
"source": "github",
217-
"channel": "stable",
217+
"tier": "${UPDATES_TIER:notify}",
218+
"source": "${UPDATES_SOURCE:github}",
219+
"channel": "${UPDATES_CHANNEL:stable}",
218220
"installMethod": "docker",
219-
"checkIntervalHours": 6,
220-
"githubRepo": "ether/etherpad",
221-
"requireAdminForStatus": false,
221+
"checkIntervalHours": "${UPDATES_CHECK_INTERVAL_HOURS:6}",
222+
"githubRepo": "${UPDATES_GITHUB_REPO:ether/etherpad}",
223+
"requireAdminForStatus": "${UPDATES_REQUIRE_ADMIN_FOR_STATUS:false}",
222224
"preApplyGraceMinutes": 0,
223225
"drainSeconds": 60,
224226
"rollbackHealthCheckSeconds": 60,
@@ -321,7 +323,19 @@
321323
* https://etherpad.org/ep_infos
322324
*/
323325

324-
"updateServer": "https://etherpad.org/ep_infos",
326+
"updateServer": "${UPDATE_SERVER:https://etherpad.org/ep_infos}",
327+
328+
/*
329+
* Outbound network calls. See PRIVACY.md for what each one sends.
330+
* - PRIVACY_UPDATE_CHECK=false : disables the hourly version check (UpdateCheck.ts)
331+
* - PRIVACY_PLUGIN_CATALOG=false : disables the admin plugin browser
332+
* (manual install-by-name via CLI still works)
333+
* Air-gapped / firewalled deployments should set both to false.
334+
*/
335+
"privacy": {
336+
"updateCheck": "${PRIVACY_UPDATE_CHECK:true}",
337+
"pluginCatalog": "${PRIVACY_PLUGIN_CATALOG:true}"
338+
},
325339

326340
/*
327341
* The type of the database.

‎settings.json.template‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@
216216
* Default "notify" shows a banner when an update is available. "off" disables the version check.
217217
*/
218218
"updates": {
219-
"tier": "notify",
219+
"tier": "${UPDATES_TIER:notify}",
220220
"source": "github",
221221
"channel": "stable",
222222
"installMethod": "auto",
@@ -443,17 +443,19 @@
443443
* https://etherpad.org/ep_infos
444444
*/
445445

446-
"updateServer": "https://etherpad.org/ep_infos",
446+
"updateServer": "${UPDATE_SERVER:https://etherpad.org/ep_infos}",
447447

448448
/*
449449
* Outbound network calls. See PRIVACY.md for what each one sends.
450450
* - updateCheck=false : disables hourly version check (UpdateCheck.ts)
451451
* - pluginCatalog=false: disables admin plugin browser
452452
* (manual install-by-name via CLI still works)
453+
* Air-gapped / firewalled deployments should set both PRIVACY_UPDATE_CHECK and
454+
* PRIVACY_PLUGIN_CATALOG to false (or UPDATES_TIER=off, which covers the version check).
453455
*/
454456
"privacy": {
455-
"updateCheck": true,
456-
"pluginCatalog": true
457+
"updateCheck": "${PRIVACY_UPDATE_CHECK:true}",
458+
"pluginCatalog": "${PRIVACY_PLUGIN_CATALOG:true}"
457459
},
458460

459461
/*

‎src/tests/backend/specs/settings.ts‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,4 +197,85 @@ describe(__filename, function () {
197197
assert.strictEqual(settings.padOptions.fadeInactiveAuthorColors, true);
198198
});
199199
});
200+
201+
// Regression test for ether/etherpad#7911.
202+
// Air-gapped / firewalled deployments must be able to disable Etherpad's
203+
// outbound calls (version check + plugin catalogue + self-updater) purely
204+
// via environment variables, without editing settings.json inside the image.
205+
// These assertions parse the *shipped* settings.json.docker so a future edit
206+
// that drops the ${ENV} placeholders fails loudly here.
207+
describe('offline / air-gapped env overrides (issue #7911)', function () {
208+
const dockerSettings = path.join(__dirname, '../../../../settings.json.docker');
209+
const templateSettings = path.join(__dirname, '../../../../settings.json.template');
210+
const envVars = [
211+
'PRIVACY_UPDATE_CHECK', 'PRIVACY_PLUGIN_CATALOG', 'UPDATES_TIER',
212+
'UPDATES_SOURCE', 'UPDATES_CHANNEL', 'UPDATES_CHECK_INTERVAL_HOURS',
213+
'UPDATES_GITHUB_REPO', 'UPDATES_REQUIRE_ADMIN_FOR_STATUS', 'UPDATE_SERVER',
214+
];
215+
const saved: {[k: string]: string | undefined} = {};
216+
217+
before(function () { for (const v of envVars) saved[v] = process.env[v]; });
218+
afterEach(function () {
219+
for (const v of envVars) {
220+
if (saved[v] == null) delete process.env[v];
221+
else process.env[v] = saved[v];
222+
}
223+
});
224+
225+
it('keeps shipped defaults when no env vars are set', function () {
226+
for (const v of envVars) delete process.env[v];
227+
const s = exportedForTestingOnly.parseSettings(dockerSettings, true);
228+
assert.strictEqual(s!.privacy.updateCheck, true);
229+
assert.strictEqual(s!.privacy.pluginCatalog, true);
230+
assert.strictEqual(s!.updates.tier, 'notify');
231+
assert.strictEqual(s!.updates.checkIntervalHours, 6);
232+
assert.strictEqual(s!.updateServer, 'https://etherpad.org/ep_infos');
233+
});
234+
235+
it('disables all outbound calls when the offline env vars are set', function () {
236+
process.env.PRIVACY_UPDATE_CHECK = 'false';
237+
process.env.PRIVACY_PLUGIN_CATALOG = 'false';
238+
process.env.UPDATES_TIER = 'off';
239+
const s = exportedForTestingOnly.parseSettings(dockerSettings, true);
240+
// Coerced to real booleans, not the strings "false".
241+
assert.strictEqual(s!.privacy.updateCheck, false);
242+
assert.strictEqual(s!.privacy.pluginCatalog, false);
243+
assert.strictEqual(s!.updates.tier, 'off');
244+
});
245+
246+
it('honours the remaining updates.* and updateServer overrides', function () {
247+
process.env.UPDATES_SOURCE = 'gitlab';
248+
process.env.UPDATES_CHANNEL = 'beta';
249+
process.env.UPDATES_CHECK_INTERVAL_HOURS = '24';
250+
process.env.UPDATES_GITHUB_REPO = 'acme/etherpad-fork';
251+
process.env.UPDATES_REQUIRE_ADMIN_FOR_STATUS = 'true';
252+
process.env.UPDATE_SERVER = 'https://mirror.internal/ep_infos';
253+
const s = exportedForTestingOnly.parseSettings(dockerSettings, true);
254+
assert.strictEqual(s!.updates.source, 'gitlab');
255+
assert.strictEqual(s!.updates.channel, 'beta');
256+
assert.strictEqual(s!.updates.checkIntervalHours, 24); // numeric coercion
257+
assert.strictEqual(s!.updates.githubRepo, 'acme/etherpad-fork');
258+
assert.strictEqual(s!.updates.requireAdminForStatus, true); // boolean coercion
259+
assert.strictEqual(s!.updateServer, 'https://mirror.internal/ep_infos');
260+
});
261+
262+
// The source-install template carries the same placeholders so non-Docker
263+
// deployments get the offline knobs too.
264+
it('settings.json.template exposes the same offline overrides', function () {
265+
for (const v of envVars) delete process.env[v];
266+
const dflt = exportedForTestingOnly.parseSettings(templateSettings, true);
267+
assert.strictEqual(dflt!.privacy.updateCheck, true);
268+
assert.strictEqual(dflt!.privacy.pluginCatalog, true);
269+
assert.strictEqual(dflt!.updates.tier, 'notify');
270+
assert.strictEqual(dflt!.updateServer, 'https://etherpad.org/ep_infos');
271+
272+
process.env.UPDATES_TIER = 'off';
273+
process.env.PRIVACY_UPDATE_CHECK = 'false';
274+
process.env.PRIVACY_PLUGIN_CATALOG = 'false';
275+
const over = exportedForTestingOnly.parseSettings(templateSettings, true);
276+
assert.strictEqual(over!.updates.tier, 'off');
277+
assert.strictEqual(over!.privacy.updateCheck, false);
278+
assert.strictEqual(over!.privacy.pluginCatalog, false);
279+
});
280+
});
200281
});

0 commit comments

Comments
 (0)