eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

πŸ€– The public-web fetch service (lchat fetch run)

A shared, identity-free service that fronts public web content for clients: OpenGraph unfurls and a camo-style signed image proxy for the cards' images. One binary subcommand (fetch.go), one sqlite file, fronted by the instance router like links/push/home. Born in the unfurl-through-Finland discussion (PRs #1237/#1240 β†’ this): if a service is going to see posted URLs at all, it should be a service that knows nothing else.

Why it exists

Unfurling is the messenger's one documented E2E exception: to build a link card, some server must see the URL. Today that server is the member's own instance. Moving the fetch to the shared service buys, in rough order of value:

  1. The instance can stop seeing URLs entirely (once clients call the service directly β€” the phase-2 flip below). The instance server then stores and relays only ciphertext, full stop; the unfurl exception moves to a service that holds no members, threads, tokens, or instance list.
  2. Viewers stop leaking their IPs to card-image hosts. The web client sets img.src to the raw og:image URL today, so every viewer of a card fetches from a third-party host; http:-only images die silently as mixed content. Its /img proxy fixes both.
  3. Self-hosters stop exposing their egress IP. A member can post a link to a server they control and learn the instance's IP β€” for a home self-hoster, their home IP. Pointing unfurl at the shared service hides it; running their own keeps full autonomy.
  4. A shared warm cache. Popular links unfurl once per language across every space that uses the service, and the poster/recipient in-flight window mostly disappears because the cache is usually already warm.

Trust model

  • Sees: posted URLs + an Accept-Language tag, transiently, attached to nothing β€” no token, no instance id, no member id. Behind the router it doesn't even see client IPs (nothing forwards them, deliberately).
  • Stores: the sealed preview cache β€” same convergent-encryption scheme as the instance's (unfurl.go): row key = salted hash of language+URL, blob opens only with a key derived from language+URL, service-wide salt (minted on first boot, lives only in the service's DB) defeats precomputed dictionaries. The cache at rest reveals nothing a reader doesn't already know the URL for. No request logs; the only log line is startup.
  • Never: member content. Member photos/files are encrypted client-side (processImage + encryptBytes) and are categorically out of scope β€” its remit is content already on the public web. Any future endpoint that would touch member bytes is a design error; say no.
  • Not an open proxy: /img serves only URLs the service itself HMAC-signed while building a card (GitHub-camo shape), so third parties can't drive it as a generic fetcher. Signing is at response time; the cache stores raw URLs, so the key could rotate without invalidating cached cards.
  • SSRF: every outbound dial (unfurl page + image, every redirect hop) re-validates the resolved IP via guardDialControl β€” same guard as the instance, links, and push.

Abuse posture β€” hashcash admission (shipped, off by default)

Set LCHAT_FETCH_POW_BITS (0/unset = off) and /api/unfurl + /api/gif demand proof of work β€” home's space-creation scheme (fetchpow.go, kept in step with home/pow.go): a 401 hands out a sealed single-use challenge, the client grinds SHA-256 to that many leading zero bits ONCE, the solved request is served and answered with a sealed ~1h ticket, and every call after rides (and is rate-bucketed by) the ticket β€” one solve per client per hour, never per unfurl. Admission travels in query params so cross-origin callers pay no CORS preflight; /img needs none (its HMAC signature is the admission). Both web (fetchServiceGET, shell.js) and native (LChatAPI.fetchServiceGET + the FetchService registry) solve and hold tickets. Ungated, the per-IP buckets remain β€” which behind the router collapse into global buckets (no forwarded IPs β€” a privacy feature): arm the gate before the shared service is load-bearing for the hosted fleet.

Deployment

Standard sibling-service shape: lchat fetch run behind the router on a unix socket (-socket /run/lchat/fetch.sock), DB at /data/fetch.db (LCHAT_FETCH_DB), service route + /etc/hosts pin like cloud/home. Not deployed anywhere yet; when it ships, the planet-scale guide gains its row. No secrets in env β€” the salt and signing key mint themselves into the DB on first boot.

The client flip (shipped, env-gated)

LCHAT_FETCH_URL on an instance routes its clients here: the chat page CSP grows the origin in connect-src + window.__fetchURL injects it (web), and fetch_url rides /api/group's member-gated read (native β€” FeatureGates, latched so a failed read never reroutes). Both platforms prefer the service with instance-unfurl fallback on transport failure. Self-host default stays local fetch β€” a self-hosted instance silently shipping its members' URLs to our shared service would betray the self-host story; pointing at the shared service (or their own) is opt-in config.

Resize (shipped)

/img re-encodes oversized JPEG/PNG card images down to card width (shrinkCardImage: >1600px wide β†’ 1200px, CatmullRom, same format) β€” an og:image is routinely thousands of px for a card rendering at a few hundred, and every viewer pays that transfer. GIFs pass through (resizing freezes the animation), as does anything that fails to decode, claims bomb-scale pixel dims, or comes out bigger "shrunk" β€” polish never breaks a card. Public web images only; member photos keep their client-side pipeline (processImage), always.

Giphy (GET /api/gif)

The authenticated-upstream sibling: search/trending fronted with the platform's Giphy key (LCHAT_GIPHY_KEY in the unit's env file β€” the one env secret this service has), so a space needs no key of its own and the get-an-API-key wall disappears. The response is Giphy's own page shape with every giphy-hosted media URL rewritten to the signed /img proxy, so the browser never talks to Giphy: previews and the posted GIF's bytes all ride this service's origin, then encrypt client-side and post as ordinary attachments exactly as before.

Honest trade vs the old path: direct client→Giphy meant we saw nothing and Giphy saw the member's IP + query; via the service, the service sees search terms (identity-free, unlogged) and Giphy sees only the service. No key configured → /api/gif 404s and the client falls back to the space's bring-your-own-key path (docs/app-credentials.md), which remains the self-host/override story.