eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– The public-web fetch service (`lchat fetch run`)
2
3A shared, identity-free service that fronts **public web content** for
4clients: OpenGraph unfurls and a camo-style signed image proxy for the cards'
5images. One binary subcommand (`fetch.go`), one sqlite file, fronted by the
6instance router like links/push/home. Born in the unfurl-through-Finland
7discussion (PRs #1237/#1240 β†’ this): if a service is going to see posted
8URLs at all, it should be a service that knows *nothing else*.
9
10## Why it exists
11
12Unfurling is the messenger's one documented E2E exception: to build a link
13card, *some* server must see the URL. Today that server is the member's own
14instance. Moving the fetch to the shared service buys, in rough order of value:
15
161. **The instance can stop seeing URLs entirely** (once clients call the service
17 directly β€” the phase-2 flip below). The instance server then stores and
18 relays only ciphertext, full stop; the unfurl exception moves to a service
19 that holds no members, threads, tokens, or instance list.
202. **Viewers stop leaking their IPs to card-image hosts.** The web client
21 sets `img.src` to the raw `og:image` URL today, so every *viewer* of a
22 card fetches from a third-party host; `http:`-only images die silently as
23 mixed content. Its `/img` proxy fixes both.
243. **Self-hosters stop exposing their egress IP.** A member can post a link
25 to a server they control and learn the instance's IP β€” for a home
26 self-hoster, their home IP. Pointing unfurl at the shared service hides it; running
27 their own keeps full autonomy.
284. **A shared warm cache.** Popular links unfurl once per language across
29 every space that uses the service, and the poster/recipient in-flight window
30 mostly disappears because the cache is usually already warm.
31
32## Trust model
33
34- **Sees:** posted URLs + an `Accept-Language` tag, transiently, attached to
35 nothing β€” no token, no instance id, no member id. Behind the router it
36 doesn't even see client IPs (nothing forwards them, deliberately).
37- **Stores:** the sealed preview cache β€” same convergent-encryption scheme as
38 the instance's (`unfurl.go`): row key = salted hash of language+URL, blob
39 opens only with a key derived from language+URL, service-wide salt (minted
40 on first boot, lives only in the service's DB) defeats precomputed
41 dictionaries. The cache at rest reveals nothing a reader doesn't already
42 know the URL for. No request logs; the only log line is startup.
43- **Never:** member content. Member photos/files are encrypted client-side
44 (`processImage` + `encryptBytes`) and are categorically out of scope β€”
45 its remit is content already on the public web. Any future endpoint that
46 would touch member bytes is a design error; say no.
47- **Not an open proxy:** `/img` serves only URLs the service itself HMAC-signed
48 while building a card (GitHub-camo shape), so third parties can't drive it
49 as a generic fetcher. Signing is at response time; the cache stores raw
50 URLs, so the key could rotate without invalidating cached cards.
51- **SSRF:** every outbound dial (unfurl page + image, every redirect hop)
52 re-validates the resolved IP via `guardDialControl` β€” same guard as the
53 instance, links, and push.
54
55## Abuse posture β€” hashcash admission (shipped, off by default)
56
57Set `LCHAT_FETCH_POW_BITS` (0/unset = off) and `/api/unfurl` + `/api/gif`
58demand proof of work β€” home's space-creation scheme (`fetchpow.go`, kept in
59step with `home/pow.go`): a 401 hands out a sealed single-use challenge,
60the client grinds SHA-256 to that many leading zero bits ONCE, the solved
61request is served and answered with a sealed ~1h **ticket**, and every call
62after rides (and is rate-bucketed by) the ticket β€” one solve per client per
63hour, never per unfurl. Admission travels in query params so cross-origin
64callers pay no CORS preflight; `/img` needs none (its HMAC signature is the
65admission). Both web (`fetchServiceGET`, shell.js) and native
66(`LChatAPI.fetchServiceGET` + the `FetchService` registry) solve and hold
67tickets. Ungated, the per-IP buckets remain β€” which behind the router
68collapse into global buckets (no forwarded IPs β€” a privacy feature): arm
69the gate before the shared service is load-bearing for the hosted fleet.
70
71## Deployment
72
73Standard sibling-service shape: `lchat fetch run` behind the router on a unix
74socket (`-socket /run/lchat/fetch.sock`), DB at `/data/fetch.db`
75(`LCHAT_FETCH_DB`), service route + `/etc/hosts` pin like cloud/home. Not
76deployed anywhere yet; when it ships, the planet-scale guide gains its row.
77No secrets in env β€” the salt and signing key mint themselves into the DB on
78first boot.
79
80## The client flip (shipped, env-gated)
81
82`LCHAT_FETCH_URL` on an instance routes its clients here: the chat page CSP
83grows the origin in `connect-src` + `window.__fetchURL` injects it (web), and
84`fetch_url` rides `/api/group`'s member-gated read (native β€” `FeatureGates`,
85latched so a failed read never reroutes). Both platforms prefer the service
86with instance-unfurl fallback on transport failure. Self-host default stays
87*local fetch* β€” a self-hosted instance silently shipping its members' URLs to
88our shared service would betray the self-host story; pointing at the shared
89service (or their own) is opt-in config.
90
91## Resize (shipped)
92
93`/img` re-encodes oversized JPEG/PNG card images down to card width
94(`shrinkCardImage`: >1600px wide β†’ 1200px, CatmullRom, same format) β€” an
95og:image is routinely thousands of px for a card rendering at a few hundred,
96and every viewer pays that transfer. GIFs pass through (resizing freezes the
97animation), as does anything that fails to decode, claims bomb-scale pixel
98dims, or comes out bigger "shrunk" β€” polish never breaks a card. Public web
99images only; member photos keep their client-side pipeline
100(`processImage`), always.
101
102## Giphy (`GET /api/gif`)
103
104The authenticated-upstream sibling: search/trending fronted with the
105**platform's** Giphy key (`LCHAT_GIPHY_KEY` in the unit's env file β€” the one
106env secret this service has), so a space needs no key of its own and the
107get-an-API-key wall disappears. The response is Giphy's own page shape with
108every giphy-hosted media URL rewritten to the signed `/img` proxy, so the
109browser never talks to Giphy: previews and the posted GIF's bytes all ride
110this service's origin, then encrypt client-side and post as ordinary
111attachments exactly as before.
112
113Honest trade vs the old path: direct client→Giphy meant *we* saw nothing and
114Giphy saw the member's IP + query; via the service, the service sees search
115terms (identity-free, unlogged) and Giphy sees only the service. No key
116configured β†’ `/api/gif` 404s and the client falls back to the space's
117bring-your-own-key path (docs/app-credentials.md), which remains the
118self-host/override story.
119