eleven / messenger Public
π€ 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:
- 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.
- Viewers stop leaking their IPs to card-image hosts. The web client sets
img.srcto the rawog:imageURL today, so every viewer of a card fetches from a third-party host;http:-only images die silently as mixed content. Its/imgproxy fixes both. - 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.
- 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-Languagetag, 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:
/imgserves 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.