// The Eleven push relay: a small, content-blind APNs signing service. Every // messenger instance pushes to the SAME Apple app via the SAME team's .p8, so // rather than copy that signing key onto every instance (a wide blast radius, // and a blocker for third-party self-hosting), the key lives ONLY here. An // instance authenticates with a per-instance bearer token and POSTs the same // {device_token, sandbox, thread, blob} it would send to Apple; the relay signs // the provider JWT and forwards it. Instances never hold signing capability. // // It preserves end-to-end encryption: the relay sees a device token, an opaque // thread id, and the ciphertext blob — a strict subset of what Apple already // sees per notification — never plaintext and never a message-decryption key. // The .p8 is Apple *auth*, not a decryption key. See docs/push-relay.md. package main import ( "crypto/subtle" "encoding/json" "errors" "flag" "fmt" "log" "net" "net/http" "os" "strings" "sync" "time" "lchat/internal/apns" ) // maxPushBytes caps a push request body. Apple rejects alert payloads over // ~4 KB; this leaves generous headroom for the ciphertext blob + envelope and // fails oversized requests fast at the relay instead of after a wasted Apple // round-trip. const maxPushBytes = 8 << 10 type Server struct { db *DB apns *apns.Client adminHash string // sha256 of LCHAT_ADMIN_TOKEN; compared in constant time // Rate limits. perInstance keys by instance label so one instance can't // exhaust another's budget (never by IP — instances egress from shared // platform IPs; never by device token — unbounded bucket growth). global is // a fleet-wide backstop against a single compromised token flooding Apple. perInstance *rateLimiter global *rateLimiter } func main() { addr := flag.String("addr", envOr("ADDR", ":1749"), "listen address (env: ADDR)") dbPath := flag.String("db", envOr("LCHAT_PUSH_DB", "push.db"), "SQLite path (env: LCHAT_PUSH_DB)") flag.Parse() // Fail loud: a misconfigured relay is a fleet-wide iOS-push outage, so a // missing/bad key or admin token must stop the boot, not silently disable. admin := os.Getenv("LCHAT_ADMIN_TOKEN") if admin == "" { log.Fatal("push: LCHAT_ADMIN_TOKEN is required") } client, err := apns.New( os.Getenv("LCHAT_APNS_KEY"), os.Getenv("LCHAT_APNS_KEY_ID"), os.Getenv("LCHAT_APNS_TEAM_ID"), os.Getenv("LCHAT_APNS_TOPIC"), ) if err != nil { log.Fatalf("push: APNs config: %v", err) } db, err := openDB(*dbPath) if err != nil { log.Fatalf("open db: %v", err) } s := &Server{ db: db, apns: client, adminHash: hashToken(admin), // Generous: a legitimate large-group fan-out is a burst of hundreds. The // limit is a runaway/quota backstop, not a tight boundary (a token can // only reach device tokens its instance already holds). perInstance: newRateLimiter(1000, 500), global: newRateLimiter(4000, 2000), } log.Printf("push relay listening on %s (db: %s, topic: %s)", *addr, *dbPath, client.Topic()) if err := serve(*addr, s.handler()); err != nil { log.Fatal(err) } } // serve listens on addr and serves h. A path-like addr (starts with "/") is a // unix socket — how the instance router fronts this service, reverse-proxying to // a socket rather than a TCP port; anything else is a TCP address (the default, // e.g. on a container platform that health-checks over HTTP). On the socket path // it clobbers only a stale socket (nothing listening) and locks it to 0600, // mirroring the main binary's listen(). func serve(addr string, h http.Handler) error { // ReadHeaderTimeout caps how long a client may dribble request headers // (Slowloris): the TCP branch binds straight to public TCP on a container // platform. Same 10s the messenger's own listeners use (main.go). srv := &http.Server{Handler: h, ReadHeaderTimeout: 10 * time.Second} if !strings.HasPrefix(addr, "/") { srv.Addr = addr return srv.ListenAndServe() } if c, err := net.DialTimeout("unix", addr, 200*time.Millisecond); err == nil { c.Close() return fmt.Errorf("socket %s is already in use by another process", addr) } if err := os.Remove(addr); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove stale socket %s: %w", addr, err) } ln, err := net.Listen("unix", addr) if err != nil { return err } defer ln.Close() if err := os.Chmod(addr, 0o600); err != nil { return fmt.Errorf("chmod socket %s: %w", addr, err) } return srv.Serve(ln) } // handler wires the routes. A method so tests exercise the real router via // httptest. func (s *Server) handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) mux.HandleFunc("GET /", s.root) // The one data endpoint: an instance forwards a push. Bearer instance token. mux.HandleFunc("POST /api/push", s.pushHandler) // Admin surface: mint / list / revoke per-instance tokens at runtime // (rotation needs no redeploy). Gated by LCHAT_ADMIN_TOKEN. mux.HandleFunc("POST /api/admin/tokens", s.admin(s.mintHandler)) mux.HandleFunc("GET /api/admin/tokens", s.admin(s.listHandler)) mux.HandleFunc("DELETE /api/admin/tokens/{id}", s.admin(s.revokeHandler)) return securityHeaders(mux) } func (s *Server) root(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Write([]byte("Eleven push relay. Content-blind APNs signer. See docs/push-relay.md.\n")) } // ---- push ------------------------------------------------------------------ func (s *Server) pushHandler(w http.ResponseWriter, r *http.Request) { // Order matters: auth → per-instance limit → global limit → size → shape. label, ok := s.db.authInstance(bearerToken(r)) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if !s.perInstance.allow(label) || !s.global.allow("*") { http.Error(w, "rate limited", http.StatusTooManyRequests) return } var body struct { DeviceToken string `json:"device_token"` Sandbox bool `json:"sandbox"` Thread string `json:"thread"` Blob string `json:"blob"` // Message id + created_at, forwarded verbatim into the aps envelope so // the device's NSE can ingest the message into its local store. Still // content-blind: integers the instance already exposes to Apple's // delivery timing. Optional — older instances omit them. ID int64 `json:"id"` At int64 `json:"at"` // A changelog announcement (announce.go in the messenger): a PLAINTEXT // banner instead of a ciphertext blob. The one non-blind payload the // relay carries, and deliberately so — release notes are branding-class // text baked into every binary, never user content. Mutually exclusive // with thread/blob. Changelog bool `json:"changelog"` Title string `json:"title"` Body string `json:"body"` } r.Body = http.MaxBytesReader(w, r.Body, maxPushBytes) if err := json.NewDecoder(r.Body).Decode(&body); err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { http.Error(w, "payload too large", http.StatusRequestEntityTooLarge) return } http.Error(w, "bad request", http.StatusBadRequest) return } if !apns.ValidDeviceToken(body.DeviceToken) { http.Error(w, "bad device token", http.StatusBadRequest) return } start := time.Now() var err error if body.Changelog { err = s.apns.SendAlert(body.DeviceToken, body.Sandbox, body.Title, body.Body) } else { err = s.apns.Send(body.DeviceToken, body.Sandbox, body.Thread, body.Blob, body.ID, body.At) } latency := time.Since(start).Milliseconds() switch { case err == nil: // Content-blind logging: instance label + outcome + latency ONLY — never // the device token, thread, blob, or any key material. log.Printf("push[%s]: ok (%dms)", label, latency) w.WriteHeader(http.StatusOK) case errors.Is(err, apns.ErrGone): // Normalise "token dead" to a bare 410 so the instance prunes it. The // body must NOT contain "BadDeviceToken" — the instance also sniffs that // string, so echoing it from a non-gone path would trigger a false prune. log.Printf("push[%s]: gone (%dms)", label, latency) http.Error(w, "gone", http.StatusGone) default: // Any other failure: log for us (the apns error carries Apple's status // but no device token), return a generic 502 that is neither 410 nor // contains "BadDeviceToken", so the instance does not prune a live token. log.Printf("push[%s]: upstream error (%dms): %v", label, latency, err) http.Error(w, "upstream error", http.StatusBadGateway) } } // ---- admin ----------------------------------------------------------------- // admin gates a handler behind the relay admin token (constant-time hash // compare, matching the messenger's checkAdmin). func (s *Server) admin(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if subtle.ConstantTimeCompare([]byte(hashToken(bearerToken(r))), []byte(s.adminHash)) != 1 { http.Error(w, "unauthorized", http.StatusUnauthorized) return } next(w, r) } } func (s *Server) mintHandler(w http.ResponseWriter, r *http.Request) { var body struct { Label string `json:"label"` } if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&body) != nil || body.Label == "" { http.Error(w, "label required", http.StatusBadRequest) return } id, token, err := s.db.mintToken(body.Label) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } // The plaintext token is returned exactly once; it is unrecoverable after. writeJSON(w, http.StatusOK, map[string]any{"id": id, "label": body.Label, "token": token}) } func (s *Server) listHandler(w http.ResponseWriter, r *http.Request) { tokens, err := s.db.listTokens() if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]any{"tokens": tokens}) } func (s *Server) revokeHandler(w http.ResponseWriter, r *http.Request) { deleted, err := s.db.revokeToken(r.PathValue("id")) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } if !deleted { http.Error(w, "no such token", http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // ---- middleware & helpers -------------------------------------------------- // securityHeaders mirrors the messenger's / home's defense-in-depth headers. func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "no-referrer") h.Set("Strict-Transport-Security", "max-age=31536000") next.ServeHTTP(w, r) }) } // bearerToken pulls the token out of "Authorization: Bearer ". func bearerToken(r *http.Request) string { const p = "Bearer " h := r.Header.Get("Authorization") if len(h) > len(p) && h[:len(p)] == p { return h[len(p):] } return "" } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(v); err != nil { log.Printf("write json: %v", err) } } func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } // ---- rate limiting (per-key token bucket, copied from home/main.go) -------- type rateLimiter struct { mu sync.Mutex buckets map[string]*bucket burst float64 perSec float64 } type bucket struct { tokens float64 last time.Time } func newRateLimiter(burst, perSec float64) *rateLimiter { return &rateLimiter{buckets: map[string]*bucket{}, burst: burst, perSec: perSec} } func (rl *rateLimiter) allow(key string) bool { rl.mu.Lock() defer rl.mu.Unlock() now := time.Now() if len(rl.buckets) > 10000 { for k, b := range rl.buckets { if b.tokens >= rl.burst || now.Sub(b.last) > time.Hour { delete(rl.buckets, k) } } } b, ok := rl.buckets[key] if !ok { b = &bucket{tokens: rl.burst, last: now} rl.buckets[key] = b } b.tokens += now.Sub(b.last).Seconds() * rl.perSec if b.tokens > rl.burst { b.tokens = rl.burst } b.last = now if b.tokens < 1 { return false } b.tokens-- return true }