Skip to content

Add a personal Etherpad instance at pads.mathewcsims.uk - #57

Closed
mathewcsims wants to merge 3 commits into
mainfrom
add-personal-etherpad-pads
Closed

Add a personal Etherpad instance at pads.mathewcsims.uk#57
mathewcsims wants to merge 3 commits into
mainfrom
add-personal-etherpad-pads

Conversation

@mathewcsims

Copy link
Copy Markdown
Owner

A LAN/tailnet-gated document-authoring space with a pad directory at /list,
behind per-user accounts. Family on the tailnet can be given accounts later.

It has both a LAN gate and its own login, and that combination is the point
rather than belt-and-braces: the Caddy gate admits any device on the LAN or
the tailnet, which is the availability wanted, but it is not an identity — it
cannot tell one tailnet device from another, and these are personal drafts.
Etherpad's requireAuthentication supplies the identity, with bcrypt accounts
via ep_hash_auth. This is the repo's first app to pair the LAN-gate idiom with
an app-level login for that reason.

Deliberately a shared space: requireAuthorization is false, so every account
can open every pad. Etherpad has no per-pad ACL. ep_mypads was the only plugin
that ever offered one and it is a dead end — on Etherpad 3.x its storage layer
passes callbacks to ueberdb2 v6, which is promise-only, so startup hangs
silently with no error; and its pad passwords ride in the URL rather than a
session. If per-person separation is ever needed it means separate instances.

OIDC via Infomaniak was planned and dropped. It would have added a cloud
dependency to a LAN-only service, an IK-AUTH registration and a client secret,
and it made admin rights awkward: ep_openid_connect can only set is_admin from
an IdP claim, and Infomaniak advertises no roles or groups claim to key on.
With local accounts is_admin is just a flag, and hash_adm:false keeps
file-based accounts non-admin unless they get an explicit .adm file.

Two things were found by running it rather than reading about it, and both are
recorded where they will be re-encountered:

  • Bind-mounting a host directory over Etherpad's var/ hides
    installed_plugins.json and silently disables every build-time plugin. The
    only symptom is "Loaded 1 plugins" and an empty "Installed plugins:" line.
    The database therefore lives at pads/data/ with an absolute dbSettings path,
    not Etherpad's default var/*.sq3.

  • bcrypt hashes must be $2a$ or $2b$. htpasswd emits $2y$, which node's bcrypt
    does not understand: it returns false for every password with no error and
    no log line, so the account simply never authenticates. Verified directly
    against the bcrypt build inside the image ($2y$ false, $2a$ true, same
    password and cost). Both scripts rewrite the prefix.

Verified live before committing: both plugins load; /health is public 200
while /, /list and /admin are 401; a wrong password is refused; a file-based
user authenticates with no restart and is correctly refused at /admin-auth
(403 against the admin's 200); pad content survives a container restart; and
/list lists pads. The Caddyfile passes the same caddy validate CI runs.

Note /admin/* serves its HTML shell to any authenticated user — Etherpad gates
on req.path.startsWith('/admin-auth') — so a non-admin sees an empty dashboard
whose API calls are refused. Documented so it is not mistaken for a hole.

Not yet done, and deliberately left: the Pass item (agent tokens are read-only
for item creation, so that script must be run under Mathew's own session), the
DNS records, and copying the Caddyfile to the Pi.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com


🤖 Generated with Claude Code

mathewcsims and others added 3 commits August 6, 2026 18:28
A LAN/tailnet-gated document-authoring space with a pad directory at /list,
behind per-user accounts. Family on the tailnet can be given accounts later.

It has both a LAN gate and its own login, and that combination is the point
rather than belt-and-braces: the Caddy gate admits any device on the LAN or
the tailnet, which is the availability wanted, but it is not an identity — it
cannot tell one tailnet device from another, and these are personal drafts.
Etherpad's requireAuthentication supplies the identity, with bcrypt accounts
via ep_hash_auth. This is the repo's first app to pair the LAN-gate idiom with
an app-level login for that reason.

Deliberately a shared space: requireAuthorization is false, so every account
can open every pad. Etherpad has no per-pad ACL. ep_mypads was the only plugin
that ever offered one and it is a dead end — on Etherpad 3.x its storage layer
passes callbacks to ueberdb2 v6, which is promise-only, so startup hangs
silently with no error; and its pad passwords ride in the URL rather than a
session. If per-person separation is ever needed it means separate instances.

OIDC via Infomaniak was planned and dropped. It would have added a cloud
dependency to a LAN-only service, an IK-AUTH registration and a client secret,
and it made admin rights awkward: ep_openid_connect can only set is_admin from
an IdP claim, and Infomaniak advertises no roles or groups claim to key on.
With local accounts is_admin is just a flag, and hash_adm:false keeps
file-based accounts non-admin unless they get an explicit .adm file.

Two things were found by running it rather than reading about it, and both are
recorded where they will be re-encountered:

- Bind-mounting a host directory over Etherpad's var/ hides
  installed_plugins.json and silently disables every build-time plugin. The
  only symptom is "Loaded 1 plugins" and an empty "Installed plugins:" line.
  The database therefore lives at pads/data/ with an absolute dbSettings path,
  not Etherpad's default var/*.sq3.

- bcrypt hashes must be $2a$ or $2b$. htpasswd emits $2y$, which node's bcrypt
  does not understand: it returns false for every password with no error and
  no log line, so the account simply never authenticates. Verified directly
  against the bcrypt build inside the image ($2y$ false, $2a$ true, same
  password and cost). Both scripts rewrite the prefix.

Verified live before committing: both plugins load; /health is public 200
while /, /list and /admin are 401; a wrong password is refused; a file-based
user authenticates with no restart and is correctly refused at /admin-auth
(403 against the admin's 200); pad content survives a container restart; and
/list lists pads. The Caddyfile passes the same caddy validate CI runs.

Note /admin/* serves its HTML shell to any authenticated user — Etherpad gates
on req.path.startsWith('/admin-auth') — so a non-admin sees an empty dashboard
whose API calls are refused. Documented so it is not mistaken for a hole.

Not yet done, and deliberately left: the Pass item (agent tokens are read-only
for item creation, so that script must be run under Mathew's own session), the
DNS records, and copying the Caddyfile to the Pi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ditor iframes

The first deploy left every pad hanging on "Loading..." behind Etherpad's
generic red error box. The cause was in the Etherpad container log, which
collects the client-side exception:

  SecurityError: Failed to read a named property 'removeEventListener' from
  'Window': Blocked a frame with origin "https://pads.mathewcsims.uk" from
  accessing a cross-origin frame.

Etherpad's editor is nested same-origin iframes (ace_outer inside ace_inner),
and security_headers sets X-Frame-Options: DENY, which blocks all framing
including same-origin. The browser replaces each editor frame with an
opaque-origin error document, so the parent's first access to it throws.

Same fix as the Ghost block, and the simple `header` form is enough here
rather than Owl's `header_down` workaround: Etherpad sends no
X-Frame-Options of its own, verified with curl straight at :9001, so there is
no upstream value for reverse_proxy's Add-not-Set copy to double up with.

Confirmed by A/B test before deploying rather than by reasoning: the same
image and credentials behind two local proxies differing only in this header.
DENY reproduced the stuck "Loading..." and the identical error box;
SAMEORIGIN loaded the pad. Production now returns SAMEORIGIN for this host
while every other site still returns DENY.

This was called out as a risk in the plan's Phase 0 and then asserted to be
fine without being tested — the local verification ran against the container
directly, which never sees the header Caddy adds. The SETUP.md note now says
explicitly to test through the proxy, because this whole class of bug is
invisible otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Etherpad computes maxAge as `1000 * 60 * 60 * 6` — commented "6 hours", i.e.
milliseconds — then emits it verbatim into
`Cache-Control: public, max-age=${settings.maxAge}` (static.ts:53,
specialpages.ts:116). Cache-Control's max-age is in SECONDS, so the intended
6 hours ships as 21,600,000 seconds: 250 days.

That is not cosmetic. It bit within minutes of this instance going live: the
X-Frame-Options DENY -> SAMEORIGIN fix could not reach a browser that had
already cached /static/empty.html, because a response cached with a 250-day
max-age is reused without revalidation. Nothing server-side can invalidate
it — the browser simply never asks again — so a correct fix looked like a
failed one.

Setting maxAge to 21600 (seconds) gives the 6 hours upstream intended, and
means any future header or asset change propagates the same day.

Verified after deploy: Cache-Control: max-age=21600, Expires now tomorrow
morning rather than April 2027.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mathewcsims

Copy link
Copy Markdown
Owner Author

Closing unmerged. Etherpad has been torn down entirely — it is not suitable for the use cases this was built for. See the discussion: its access control is HTTP Basic only (no logout, browser-cached credentials), it has no per-pad ACL, and its built-in OIDC provider cannot serve pad login. Nothing from this branch is deployed.

@mathewcsims mathewcsims closed this Aug 6, 2026
@mathewcsims
mathewcsims deleted the add-personal-etherpad-pads branch August 6, 2026 18:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant