eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1package tunnel
2
3import (
4 "context"
5 "crypto/rand"
6 "encoding/base64"
7 "encoding/json"
8 "fmt"
9 "log"
10 "math/big"
11 "net"
12 "net/http"
13 "net/http/httputil"
14 "strings"
15 "sync"
16 "sync/atomic"
17 "time"
18
19 "github.com/coder/websocket"
20)
21
22// Gateway terminates the public side of every tunnel. It serves two kinds of
23// host: its own control host (register/conn endpoints) and the try-* slug
24// hosts, which it reverse-proxies down the matching tunnel. Route
25// registration is delegated through OnRegister/OnClose so this package never
26// touches the router's registry directly.
27type Gateway struct {
28 ControlHost string // e.g. "tunnel.11ch.at" — where clients register
29 Domain string // e.g. "11ch.at" — slugs become try-<slug>.<domain>
30
31 // OnRegister makes the public hostname routable (a registry row pointing
32 // back at this gateway's listener); OnClose removes it. Both must be safe
33 // to call from multiple goroutines.
34 OnRegister func(host string) error
35 OnClose func(host string)
36
37 // Relay mode: stable keypair-claimed hostnames (docs/relay-mode.md).
38 // Off unless RelayDomain and ClaimsDB are both set — the gateway then
39 // refuses "relay" hellos, and trials behave exactly as before.
40 RelayDomain string // e.g. "example-relay.net" — relay hosts are r-<slug>.<RelayDomain>
41 ClaimsDB *Claims // claim persistence (gateway-owned sqlite)
42 MaxRelays int // claimed hostnames (0 = default 200)
43 RelayGrace time.Duration // route lifetime after a relay disconnects (0 = default 15m)
44 ClaimIdle time.Duration // free claims idle this long (0 = default 45 days)
45
46 MaxTunnels int // concurrent tunnels (0 = default 50)
47 MaxConns int // concurrent data connections per tunnel (0 = default 32)
48 MaxPerIP int // new tunnels per IP per hour (0 = default 5)
49 MaxAge time.Duration // tunnel lifetime (0 = default 24h)
50 DialTimeout time.Duration // client dial-back wait (0 = default 15s)
51
52 mu sync.Mutex
53 tunnels map[string]*liveTunnel // by public host
54 pending map[string]chan net.Conn
55 byIP map[string][]time.Time // register timestamps, pruned hourly
56}
57
58type liveTunnel struct {
59 host string
60 control *websocket.Conn
61 proxy *httputil.ReverseProxy
62 created time.Time
63 closed chan struct{}
64 once sync.Once
65 // relay tunnels survive a disconnect: sleeping keeps the hostname
66 // routable (answering 503) for RelayGrace so a home connection blip
67 // isn't an outage; reattach cancels the grace timer.
68 relay bool
69 pubkey string
70 sleeping bool
71 grace *time.Timer
72 // conns counts live + in-flight data connections, bounding how much of the
73 // trial machine's uplink one hostile visitor can claim.
74 conns atomic.Int32
75}
76
77func (g *Gateway) maxTunnels() int { return orInt(g.MaxTunnels, 50) }
78func (g *Gateway) maxConns() int { return orInt(g.MaxConns, 32) }
79func (g *Gateway) maxPerIP() int { return orInt(g.MaxPerIP, 5) }
80func (g *Gateway) maxAge() time.Duration {
81 if g.MaxAge > 0 {
82 return g.MaxAge
83 }
84 return 24 * time.Hour
85}
86func (g *Gateway) dialTimeout() time.Duration {
87 if g.DialTimeout > 0 {
88 return g.DialTimeout
89 }
90 return 15 * time.Second
91}
92func orInt(v, def int) int {
93 if v > 0 {
94 return v
95 }
96 return def
97}
98
99func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
100 host := r.Host
101 if h, _, err := net.SplitHostPort(host); err == nil {
102 host = h
103 }
104 if host == g.ControlHost {
105 switch {
106 case r.URL.Path == "/api/tunnel/register":
107 g.handleRegister(w, r)
108 case strings.HasPrefix(r.URL.Path, "/api/tunnel/conn/"):
109 g.handleConn(w, r)
110 case r.URL.Path == "/healthz":
111 fmt.Fprintln(w, "ok")
112 default:
113 // A human wandered in; point them somewhere useful.
114 http.Redirect(w, r, "https://elevenmessenger.com/self-host/", http.StatusFound)
115 }
116 return
117 }
118 g.mu.Lock()
119 t := g.tunnels[host]
120 g.mu.Unlock()
121 if t == nil {
122 http.Error(w, "this trial space isn't running — its lchat try command has stopped", http.StatusBadGateway)
123 return
124 }
125 g.mu.Lock()
126 asleep := t.sleeping
127 g.mu.Unlock()
128 if asleep {
129 http.Error(w, "this space's machine is offline right now — it reconnects automatically when it's back", http.StatusServiceUnavailable)
130 return
131 }
132 t.proxy.ServeHTTP(w, r)
133}
134
135// handleRegister accepts a client's control connection, assigns it a slug,
136// makes the host routable, and then sits in a read loop until the client goes
137// away (the read also services the client's keepalive pings).
138func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
139 ip := clientIP(r)
140 if err := g.admit(ip); err != nil {
141 http.Error(w, err.Error(), http.StatusTooManyRequests)
142 return
143 }
144 c, err := websocket.Accept(w, r, nil)
145 if err != nil {
146 return
147 }
148 defer c.CloseNow()
149 c.SetReadLimit(1 << 16)
150
151 ctx := r.Context()
152 var hello ControlMsg
153 if err := readJSON(ctx, c, &hello); err != nil || hello.Type != "hello" {
154 writeJSON(ctx, c, ControlMsg{Type: "error", Error: "expected hello"})
155 return
156 }
157
158 if hello.Mode == "relay" {
159 g.handleRelayRegister(ctx, c, ip)
160 return
161 }
162
163 host, err := g.newHost()
164 if err != nil {
165 writeJSON(ctx, c, ControlMsg{Type: "error", Error: err.Error()})
166 return
167 }
168 t := &liveTunnel{host: host, control: c, created: time.Now(), closed: make(chan struct{})}
169 t.proxy = g.proxyFor(t)
170
171 g.mu.Lock()
172 g.tunnels[host] = t
173 g.byIP[ip] = append(g.byIP[ip], time.Now())
174 g.mu.Unlock()
175
176 // The hostname goes public only after the backend proves it's an Eleven
177 // instance (the client serves dial-backs from the moment it says hello).
178 if err := g.probeBackend(ctx, t); err != nil {
179 msg := "only Eleven instances can be tunnelled: " + err.Error()
180 writeJSON(ctx, c, ControlMsg{Type: "error", Error: msg})
181 // Graceful close so the error frame is delivered before the socket
182 // dies (CloseNow would race it and the client would just see EOF).
183 c.Close(websocket.StatusPolicyViolation, "refused")
184 g.drop(t)
185 log.Printf("tunnel: %s refused (probe): %v", host, err)
186 return
187 }
188
189 if g.OnRegister != nil {
190 if err := g.OnRegister(host); err != nil {
191 g.drop(t)
192 writeJSON(ctx, c, ControlMsg{Type: "error", Error: "couldn't publish the address, try again"})
193 log.Printf("tunnel: register %s: %v", host, err)
194 return
195 }
196 }
197 log.Printf("tunnel: %s open (from %s)", host, ip)
198 if err := writeJSON(ctx, c, ControlMsg{Type: "ready", Host: host}); err != nil {
199 g.drop(t)
200 return
201 }
202
203 // Hold the connection until the client leaves or the tunnel ages out. The
204 // read loop keeps ping/pong serviced; clients never send data messages.
205 ageOut := time.NewTimer(g.maxAge())
206 defer ageOut.Stop()
207 // Keep re-checking that the backend is still an Eleven instance; two
208 // consecutive failures drop the tunnel (a client can't swap in a
209 // different local server after passing the door check).
210 reprobe := time.NewTicker(10 * time.Minute)
211 defer reprobe.Stop()
212 probeFails := 0
213 readErr := make(chan error, 1)
214 go func() {
215 for {
216 if _, _, err := c.Read(ctx); err != nil {
217 readErr <- err
218 return
219 }
220 }
221 }()
222 for done := false; !done; {
223 select {
224 case <-readErr:
225 done = true
226 case <-reprobe.C:
227 if err := g.probeBackend(ctx, t); err != nil {
228 probeFails++
229 log.Printf("tunnel: %s re-probe failed (%d): %v", host, probeFails, err)
230 if probeFails >= 2 {
231 writeJSON(ctx, c, ControlMsg{Type: "error", Error: "the tunnelled backend stopped looking like an Eleven instance"})
232 done = true
233 }
234 } else {
235 probeFails = 0
236 }
237 case <-ageOut.C:
238 writeJSON(ctx, c, ControlMsg{Type: "error", Error: "trial tunnel expired (24h) — run lchat try again for a fresh address"})
239 done = true
240 case <-t.closed:
241 done = true
242 case <-ctx.Done():
243 done = true
244 }
245 }
246 g.drop(t)
247 log.Printf("tunnel: %s closed", host)
248}
249
250// handleConn is the client dialling back with the data connection for one
251// pending id; it becomes a raw byte pipe paired with a waiting proxy dial.
252func (g *Gateway) handleConn(w http.ResponseWriter, r *http.Request) {
253 id := strings.TrimPrefix(r.URL.Path, "/api/tunnel/conn/")
254 g.mu.Lock()
255 ch := g.pending[id]
256 delete(g.pending, id)
257 g.mu.Unlock()
258 if ch == nil {
259 http.Error(w, "unknown conn id", http.StatusNotFound)
260 return
261 }
262 c, err := websocket.Accept(w, r, nil)
263 if err != nil {
264 close(ch)
265 return
266 }
267 c.SetReadLimit(-1)
268 // Lifetime belongs to the proxied exchange now, not this handler's request
269 // context — NetConn with Background keeps it open until either side closes.
270 conn := websocket.NetConn(context.Background(), c, websocket.MessageBinary)
271 select {
272 case ch <- conn:
273 // The proxy owns it now; block here isn't needed — Accept hijacked the
274 // HTTP connection, and returning is fine.
275 case <-time.After(5 * time.Second):
276 conn.Close()
277 }
278}
279
280// proxyFor builds the reverse proxy whose transport dials the client through
281// the control channel: every new backend connection is one "dial" message and
282// one dialled-back WebSocket.
283func (g *Gateway) proxyFor(t *liveTunnel) *httputil.ReverseProxy {
284 // A browser's first paint of the no-build shell is a burst of 40+ module
285 // fetches over HTTP/2 — wider than the per-tunnel dial-back cap. Bounding
286 // the transport just under the cap makes the overflow queue for a free
287 // connection (as browsers always did against HTTP/1.1's 6-connection
288 // limit) instead of erroring into 502s, any one of which permanently
289 // fails the page's ES module graph. Two slots stay free for the gateway's
290 // own liveness probes, which dial back outside this transport; the idle
291 // pool matches so a finished request hands its connection to a queued one.
292 perHost := max(1, g.maxConns()-2)
293 transport := &http.Transport{
294 DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
295 return g.dialBack(ctx, t)
296 },
297 MaxConnsPerHost: perHost,
298 MaxIdleConns: perHost,
299 IdleConnTimeout: 60 * time.Second,
300 MaxIdleConnsPerHost: perHost,
301 }
302 return &httputil.ReverseProxy{
303 Rewrite: func(pr *httputil.ProxyRequest) {
304 pr.SetXForwarded()
305 pr.Out.URL.Scheme = "http"
306 pr.Out.URL.Host = "tunnel-local"
307 pr.Out.Host = pr.In.Host // the local instance sees its public hostname
308 },
309 Transport: transport,
310 ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
311 http.Error(w, "the trial space didn't answer — is lchat try still running?", http.StatusBadGateway)
312 },
313 }
314}
315
316func (g *Gateway) dialBack(ctx context.Context, t *liveTunnel) (net.Conn, error) {
317 if int(t.conns.Add(1)) > g.maxConns() {
318 t.conns.Add(-1)
319 return nil, fmt.Errorf("tunnel connection cap reached")
320 }
321 release := func() { t.conns.Add(-1) }
322 id, err := randToken(18)
323 if err != nil {
324 release()
325 return nil, err
326 }
327 ch := make(chan net.Conn, 1)
328 g.mu.Lock()
329 g.pending[id] = ch
330 g.mu.Unlock()
331 cleanup := func() {
332 g.mu.Lock()
333 delete(g.pending, id)
334 g.mu.Unlock()
335 }
336 // The dial message rides the shared control channel, so its write is
337 // bounded by the tunnel's own life, never by one visitor's request ctx:
338 // the websocket library abandons the whole connection when a write is
339 // cancelled mid-frame, which would let a single reload mid-load kill the
340 // trial for everyone (writeJSON supplies its own 10s bound). A write
341 // error therefore really does mean the control channel is dead.
342 if err := writeJSON(context.Background(), t.control, ControlMsg{Type: "dial", ID: id}); err != nil {
343 cleanup()
344 release()
345 g.drop(t) // control channel is dead — the tunnel is gone
346 return nil, fmt.Errorf("tunnel control write: %w", err)
347 }
348 select {
349 case conn, ok := <-ch:
350 if !ok || conn == nil {
351 cleanup()
352 release()
353 return nil, fmt.Errorf("tunnel dial-back failed")
354 }
355 return &countedConn{Conn: conn, release: release}, nil
356 case <-time.After(g.dialTimeout()):
357 cleanup()
358 release()
359 return nil, fmt.Errorf("tunnel dial-back timed out")
360 case <-ctx.Done():
361 cleanup()
362 release()
363 return nil, ctx.Err()
364 }
365}
366
367// countedConn releases its tunnel's connection slot exactly once on close.
368type countedConn struct {
369 net.Conn
370 release func()
371 once sync.Once
372}
373
374func (c *countedConn) Close() error {
375 c.once.Do(c.release)
376 return c.Conn.Close()
377}
378
379// probeBackend asks the tunnelled backend to prove it's an lchat instance
380// before (and periodically after) its hostname goes public: GET /api/version
381// through the tunnel must return 200 and a version. This keeps the trial
382// domain lending its name — and its TLS — only to actual Eleven instances,
383// not to whatever a modified client points --local at.
384func (g *Gateway) probeBackend(ctx context.Context, t *liveTunnel) error {
385 client := &http.Client{
386 Timeout: 10 * time.Second,
387 Transport: &http.Transport{
388 DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
389 return g.dialBack(ctx, t)
390 },
391 },
392 }
393 req, err := http.NewRequestWithContext(ctx, "GET", "http://tunnel-local/api/version", nil)
394 if err != nil {
395 return err
396 }
397 req.Host = t.host
398 resp, err := client.Do(req)
399 if err != nil {
400 return fmt.Errorf("backend didn't answer: %w", err)
401 }
402 defer resp.Body.Close()
403 if resp.StatusCode != http.StatusOK {
404 return fmt.Errorf("backend /api/version: HTTP %d", resp.StatusCode)
405 }
406 var v struct {
407 Version string `json:"version"`
408 }
409 if err := json.NewDecoder(resp.Body).Decode(&v); err != nil || v.Version == "" {
410 return fmt.Errorf("backend isn't an Eleven instance")
411 }
412 return nil
413}
414
415// drop removes the tunnel everywhere, once. The map guard matters for
416// relays: a superseded tunnel dropping late must not take down the
417// reattached one that now owns the hostname.
418func (g *Gateway) drop(t *liveTunnel) {
419 t.once.Do(func() {
420 close(t.closed)
421 g.mu.Lock()
422 owns := g.tunnels[t.host] == t
423 if owns {
424 delete(g.tunnels, t.host)
425 }
426 g.mu.Unlock()
427 if owns && g.OnClose != nil {
428 g.OnClose(t.host)
429 }
430 t.control.CloseNow()
431 })
432}
433
434var errNoFreeHost = fmt.Errorf("couldn't find a free address")
435
436// admit enforces the per-IP rate and the global cap. Guardrails, not a wall:
437// this is a free trial surface on a shared box, and the failure mode we're
438// preventing is someone scripting a thousand tunnels, not a family trying it
439// twice.
440func (g *Gateway) admit(ip string) error {
441 g.mu.Lock()
442 defer g.mu.Unlock()
443 if g.tunnels == nil {
444 g.tunnels = map[string]*liveTunnel{}
445 g.pending = map[string]chan net.Conn{}
446 g.byIP = map[string][]time.Time{}
447 }
448 if len(g.tunnels) >= g.maxTunnels() {
449 return fmt.Errorf("the trial gateway is full right now — please try again later")
450 }
451 cutoff := time.Now().Add(-time.Hour)
452 kept := g.byIP[ip][:0]
453 for _, ts := range g.byIP[ip] {
454 if ts.After(cutoff) {
455 kept = append(kept, ts)
456 }
457 }
458 g.byIP[ip] = kept
459 if len(kept) >= g.maxPerIP() {
460 return fmt.Errorf("too many trial spaces from this address — please try again in an hour")
461 }
462 return nil
463}
464
465func (g *Gateway) newHost() (string, error) {
466 for range 20 {
467 slug := make([]byte, 5)
468 for i := range slug {
469 n, err := rand.Int(rand.Reader, big.NewInt(int64(len(slugAlphabet))))
470 if err != nil {
471 return "", err
472 }
473 slug[i] = slugAlphabet[n.Int64()]
474 }
475 host := "try-" + string(slug) + "." + g.Domain
476 g.mu.Lock()
477 _, taken := g.tunnels[host]
478 g.mu.Unlock()
479 if !taken {
480 return host, nil
481 }
482 }
483 return "", fmt.Errorf("couldn't find a free address")
484}
485
486// clientIP prefers the router's X-Forwarded-For (it fronts us over a unix
487// socket, so RemoteAddr is meaningless).
488func clientIP(r *http.Request) string {
489 if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
490 return strings.TrimSpace(strings.Split(xff, ",")[0])
491 }
492 if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
493 return h
494 }
495 return r.RemoteAddr
496}
497
498func randToken(n int) (string, error) {
499 b := make([]byte, n)
500 if _, err := rand.Read(b); err != nil {
501 return "", err
502 }
503 return base64.RawURLEncoding.EncodeToString(b), nil
504}
505
506func readJSON(ctx context.Context, c *websocket.Conn, v any) error {
507 _, data, err := c.Read(ctx)
508 if err != nil {
509 return err
510 }
511 return json.Unmarshal(data, v)
512}
513
514func writeJSON(ctx context.Context, c *websocket.Conn, v any) error {
515 data, err := json.Marshal(v)
516 if err != nil {
517 return err
518 }
519 wctx, cancel := context.WithTimeout(ctx, 10*time.Second)
520 defer cancel()
521 return c.Write(wctx, websocket.MessageText, data)
522}
523