eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// The cloud provisioner: the public, policy-enforcing face of instance creation
4// (→ cloud.elevenmessenger.com). Callers are home servers holding a minted
5// bearer token; each request names an opaque requester (the home server's
6// random user id) so a per-user quota can hold. The actual creation happens in
7// the control daemon (controld.go) over its local socket — this service is
8// deliberately unprivileged and sandboxed, holding policy (tokens, quotas,
9// sqid addressing) and no root powers. Any box running the lchat router can
10// enable it. See docs/cloud-provisioner.md.
11
12import (
13 "bytes"
14 "context"
15 "encoding/json"
16 "flag"
17 "fmt"
18 "io"
19 "log"
20 "net"
21 "net/http"
22 "os"
23 "path/filepath"
24 "strings"
25 "time"
26
27 sqids "github.com/sqids/sqids-go"
28
29 "lchat/internal/idalloc"
30 "sync"
31)
32
33// spaceUnavailableMsg is the member-facing "we're full" message, relayed
34// verbatim by home servers and shown by clients.
35const spaceUnavailableMsg = "We can't create a new Eleven space right now — please try again later."
36
37// Space subdomains are sqids of the space's integer id (its DB primary key), so
38// the hostname is short AND reversibly encodes the id — given a4f.<domain> you
39// can recover "server 42". Lowercase base36 keeps labels DNS-safe (case-
40// insensitive); minLength 3 gives a4f-length labels that grow only as ids do;
41// the default blocklist keeps rude combinations from ever being minted. The
42// alphabet is shuffled internally per-id, so consecutive ids don't produce
43// consecutive labels.
44var spaceSqids = mustSqids()
45
46func mustSqids() *sqids.Sqids {
47 s, err := sqids.New(sqids.Options{
48 Alphabet: "0123456789abcdefghijklmnopqrstuvwxyz",
49 MinLength: 3,
50 })
51 if err != nil {
52 panic("cloud: sqids init: " + err.Error())
53 }
54 return s
55}
56
57// spaceSlug encodes a space id to its subdomain label.
58func spaceSlug(id int64) (string, error) {
59 return spaceSqids.Encode([]uint64{uint64(id)})
60}
61
62// spaceID decodes a subdomain label back to its space id. ok=false if the label
63// isn't a canonical encoding of a single id (sqids will decode some non-canonical
64// strings, so we re-encode and compare — the standard round-trip guard).
65func spaceID(slug string) (id int64, ok bool) {
66 nums := spaceSqids.Decode(slug)
67 if len(nums) != 1 {
68 return 0, false
69 }
70 if enc, err := spaceSqids.Encode(nums); err != nil || enc != slug {
71 return 0, false
72 }
73 return int64(nums[0]), true
74}
75
76// cloudServer is the provisioner's HTTP surface.
77type cloudServer struct {
78 db *cloudDB
79 adminHash string // sha256 of LCHAT_ADMIN_TOKEN; gates token mint/list/revoke
80 controlToken string
81 control *http.Client // dials the control daemon's unix socket
82 baseDomain string // spaces land at <slug>.<baseDomain>
83 // region (LCHAT_CLOUD_REGION) is this box's placement region, stamped as
84 // every new space's home_region so the mesh places it by region (multi-edge
85 // Phase 1, docs/multi-edge.md). Empty = the single implicit region: spaces
86 // are born with home_region="" and hash across the whole fleet, exactly as
87 // before regions existed. Addressing is unaffected — the host still forms
88 // from baseDomain; region is a mutable property, never part of the name.
89 region string
90 // ids is the shared, global, S3-backed id allocator (internal/idalloc)
91 // behind region-neutral space addressing (multi-edge Phase 1). Set only when
92 // the deliberate opt-in LCHAT_CLOUD_ID_LEDGER is on (its counter then lives in
93 // the shared LCHAT_HIBERNATE_* bucket, the mesh's S3 substrate). When set, a
94 // new space's HOSTNAME id is drawn from the fleet-wide counter instead of this
95 // cloud.db's local PK, so two region clouds minting into ONE region-neutral
96 // namespace (<sqid>.<baseDomain>, no country label) can never collide. Nil —
97 // production's single provisioner, every self-hoster, any box without the flag
98 // — keeps today's cloud.db-PK hostname, byte for byte. Only the address's id
99 // source moves; the PK stays the ledger key.
100 ids *idalloc.Allocator
101 quota int // max spaces per (caller, requester)
102 reapAfter time.Duration // reclaim never-claimed spaces older than this (0 = reaper off)
103 reapDryRun bool // log what the reaper would reclaim, delete nothing
104 reapMax int // max reclaims per sweep (blast-radius cap; 0 = unlimited)
105 billing *stripeBilling // optional space upgrades (billing.go); nil = not offered
106 // enterprise is the Enterprise Edition page's public checkout
107 // (enterprise.go) — its own Stripe key, deliberately separate from
108 // billing's so it can run against the sandbox; nil = off.
109 enterprise *enterpriseCheckout
110 // donate is the /donate page's public checkout (donate.go) — same shape,
111 // its own key, off until LCHAT_STRIPE_DONATE_KEY is set.
112 donate *donateCheckout
113 storageCaps map[string]int64 // tier key (+ "free") → storage cap pushed to instances; empty = no enforcement
114 // seatCaps holds the per-seat entries of LCHAT_CLOUD_STORAGE_CAPS
115 // ("business=11GB/seat"): cap = value × the space's live head count,
116 // pooled across the space (docs/tier-economics.md § Per-seat).
117 seatCaps map[string]int64
118 // replicationClasses maps tier keys (+ "free") to durability-ladder classes
119 // (cloudreplication.go); empty = classes never pushed, every space streams.
120 replicationClasses map[string]string
121 demoTTL time.Duration // demo lifetime (demo.go); 0 = demos not offered
122 demoMaxLive int // live-demo cap, pending rows included
123 createBudget int // max creates fleet-wide per trailing 24h (0 = uncapped)
124 // usageURL is this service's own public /api/usage URL, pushed into each
125 // instance's env (LCHAT_METER_URL) alongside a minted meter token so
126 // sidecar usage reports can flow (#857). Empty = the usage pipe is off.
127 usageURL string
128 pool poolConfig // free-pool gauge (pool.go); maxShare 0 = off
129 // The AI budget (cloud_budget.go, phase 3): per-model EUR rates, the
130 // per-space monthly allowance, and the fleet-wide monthly pool. All three
131 // unset = measure-only (every flush answers "ok").
132 aiRates map[string]aiRates
133 aiAllowanceEUR float64 // LCHAT_CLOUD_AI_ALLOWANCE_EUR
134 aiPoolEUR float64 // LCHAT_CLOUD_AI_POOL_EUR
135 // claimLinks parks a pay-first create's one-time setup link between the
136 // provision and the redirect landing (billing.go claimCreateHandler) so a
137 // claim retry after a dropped response can still hand it over. Memory only,
138 // short-lived, single process — the link is never persisted anywhere.
139 claimLinksMu sync.Mutex
140 claimLinks map[string]claimLink
141}
142
143// claimLink is one parked setup link (claimLinks); At bounds its life.
144type claimLink struct {
145 Link string
146 At time.Time
147}
148
149func (s *cloudServer) handler() http.Handler {
150 mux := http.NewServeMux()
151 // The provisioning surface: bearer caller token (a home server).
152 mux.HandleFunc("POST /api/instances", s.callerAuth(s.createSpaceHandler))
153 mux.HandleFunc("GET /api/instances", s.callerAuth(s.listSpacesHandler))
154 // The price book alone, without a requester's ledger — home republishes it
155 // unauthenticated for the setup conversation's plan step (#1374).
156 mux.HandleFunc("GET /api/pricebook", s.callerAuth(s.pricebookHandler))
157 mux.HandleFunc("DELETE /api/instances/{host}", s.callerAuth(s.deleteSpaceHandler))
158 // Billing (optional — 501 unless Stripe is configured; see billing.go). The
159 // webhook is signature-gated rather than token-gated: Stripe calls it.
160 mux.HandleFunc("POST /api/instances/{host}/checkout", s.callerAuth(s.checkoutHandler))
161 // Pay-before-create (billing.go): mint a checkout for a space that doesn't
162 // exist yet, then provision it once the payment confirms.
163 mux.HandleFunc("POST /api/instances/checkout-create", s.callerAuth(s.checkoutCreateHandler))
164 mux.HandleFunc("POST /api/instances/claim-create", s.callerAuth(s.claimCreateHandler))
165 // Claiming a demo (demo.go) — additive, so caller-token gated like creation.
166 mux.HandleFunc("POST /api/instances/{host}/claim", s.callerAuth(s.claimDemoHandler))
167 // The countdown banner's live "still a demo?" check (demo.go) — read-only,
168 // relayed to browsers by home, so it discloses demo-ness and nothing else.
169 mux.HandleFunc("GET /api/instances/{host}/demo-status", s.callerAuth(s.demoStatusHandler))
170 mux.HandleFunc("POST /api/instances/{host}/portal", s.callerAuth(s.portalHandler))
171 mux.HandleFunc("POST /api/stripe/webhook", s.stripeWebhookHandler)
172 // The Enterprise Edition page's checkout (enterprise.go): public but
173 // CORS-scoped to the marketing origin — the static site's browser JS is
174 // the caller, so there is no token to gate with; amounts are compiled in.
175 mux.HandleFunc("POST /api/enterprise/checkout", s.enterpriseCheckoutHandler)
176 mux.HandleFunc("OPTIONS /api/enterprise/checkout", s.enterprisePreflightHandler)
177 // The /donate page's checkout (donate.go): same public posture.
178 mux.HandleFunc("POST /api/donate/checkout", s.donateCheckoutHandler)
179 mux.HandleFunc("OPTIONS /api/donate/checkout", s.donatePreflightHandler)
180 // Usage ingest (#857): meter-token-gated, not caller-gated — an instance
181 // can only ever report as itself.
182 mux.HandleFunc("POST /api/usage", s.usageHandler)
183 // AI opt-in (#858): same meter-token gate — an instance whose admin
184 // switched AI on asks for its sidecar; idempotent all the way down.
185 mux.HandleFunc("POST /api/ai-optin", s.aiOptInHandler)
186 // Admin surface: mint / list / revoke caller tokens at runtime (mirrors the
187 // push relay's admin API).
188 mux.HandleFunc("POST /api/admin/tokens", s.admin(s.mintTokenHandler))
189 mux.HandleFunc("GET /api/admin/tokens", s.admin(s.listTokensHandler))
190 mux.HandleFunc("DELETE /api/admin/tokens/{id}", s.admin(s.revokeTokenHandler))
191 // The free-pool gauge (pool.go) — read-only, operator-facing.
192 mux.HandleFunc("GET /api/pool", s.admin(s.poolHandler))
193 mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })
194 return mux
195}
196
197// callerAuth resolves the bearer token to a caller id and passes it via the
198// request context.
199type callerKey struct{}
200
201func (s *cloudServer) callerAuth(next http.HandlerFunc) http.HandlerFunc {
202 return func(w http.ResponseWriter, r *http.Request) {
203 id, ok := s.db.authCaller(bearerTokenHeader(r))
204 if !ok {
205 writeControlErr(w, http.StatusUnauthorized, "unauthorized", "bad or missing caller token")
206 return
207 }
208 next(w, r.WithContext(context.WithValue(r.Context(), callerKey{}, id)))
209 }
210}
211
212func (s *cloudServer) admin(next http.HandlerFunc) http.HandlerFunc {
213 return func(w http.ResponseWriter, r *http.Request) {
214 if !tokenHashEqual(bearerTokenHeader(r), s.adminHash) {
215 writeControlErr(w, http.StatusUnauthorized, "unauthorized", "bad or missing admin token")
216 return
217 }
218 next(w, r)
219 }
220}
221
222// spaceHostID returns the id that feeds a new space's sqid hostname — the whole
223// of this slice's fork. With the shared global ledger enabled (s.ids != nil, via
224// the deliberate LCHAT_CLOUD_ID_LEDGER opt-in) it draws the next fleet-unique id
225// from idalloc, so two region clouds minting into ONE region-neutral namespace
226// never collide. Without it — the default everywhere the flag isn't set:
227// production's single provisioner, self-hosters, any un-flagged box — it returns
228// the space's own cloud.db PK, so the hostname is byte-for-byte what it is today.
229// The PK stays the ledger key either way (quota, entitlement, usage, billing all
230// key on it and never on this id), so only the address's id source moves.
231func (s *cloudServer) spaceHostID(ctx context.Context, pk int64) (int64, error) {
232 if s.ids == nil {
233 return pk, nil
234 }
235 gid, err := s.ids.Allocate(ctx)
236 if err != nil {
237 return 0, err
238 }
239 // idalloc hands out uint64; the sqid/PK id space is int64. The counter is
240 // 1-based and gaps are allowed, so int64 has decades of headroom.
241 return int64(gid), nil
242}
243
244// createSpaceHandler provisions one instance for a requester: reserve the next
245// id (quota-checked), turn it into a sqid hostname, ask the control daemon to
246// create it, then finalize the ledger. No slug re-roll loop — the id is unique
247// by construction, so its sqid hostname is always fresh.
248func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request) {
249 callerID := r.Context().Value(callerKey{}).(string)
250 var req struct {
251 Requester string `json:"requester"`
252 Kind string `json:"kind"` // "" = a free space (the default); "demo" = a 24h demo (demo.go)
253 // Name optionally seeds the space's display name (plaintext branding by
254 // design — never member content). Sanitized again at the control seam.
255 Name string `json:"name"`
256 }
257 if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil {
258 writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
259 return
260 }
261 req.Requester = strings.TrimSpace(req.Requester)
262 if req.Requester == "" || len(req.Requester) > 128 {
263 writeControlErr(w, http.StatusBadRequest, "bad_request", "requester required (opaque id, ≤128 chars)")
264 return
265 }
266 if req.Kind != "" && req.Kind != entDemo {
267 writeControlErr(w, http.StatusBadRequest, "bad_request", "unknown kind (want empty or \"demo\")")
268 return
269 }
270 req.Name = strings.TrimSpace(req.Name)
271 if len(req.Name) > 60 {
272 writeControlErr(w, http.StatusBadRequest, "bad_request", "name must be at most 60 characters")
273 return
274 }
275
276 // The fleet-wide circuit breaker: a trailing-24h creation budget, counted
277 // against an append-only log so create-and-delete can't reset it. Insurance
278 // for the pre-production era — the per-account gates do the fine-grained work.
279 if s.createBudget > 0 {
280 if n, err := s.db.createsSince(time.Now().Add(-24 * time.Hour).Unix()); err == nil && n >= s.createBudget {
281 log.Printf("cloud: creation budget exhausted (%d/%d in 24h) — refusing create", n, s.createBudget)
282 writeControlErr(w, http.StatusServiceUnavailable, "at_capacity", spaceUnavailableMsg)
283 return
284 }
285 }
286
287 // Demos branch off past the shared breaker: their own cap + TTL bound them
288 // instead of the free-space quota (demo.go).
289 if req.Kind == entDemo {
290 s.createDemoSpace(r.Context(), w, callerID, req.Requester)
291 return
292 }
293
294 // The free pool's admission gate (pool.go): new free creates pause while
295 // the last gauge reading shows red pressure. Fails open on any
296 // uncertainty; paid creates are never gated (they fund the pool), and
297 // existing spaces are never touched.
298 if !s.poolAdmitFree() {
299 writeControlErr(w, http.StatusServiceUnavailable, "free_pool_full", freePoolFullMsg)
300 return
301 }
302
303 // Reserve the id (fails closed if over quota). The row is pending (NULL host)
304 // until the instance is up; on any failure below we delete it so it doesn't
305 // hold a quota slot.
306 id, overQuota, err := s.db.allocateSpace(callerID, req.Requester, s.quota)
307 if err != nil {
308 writeControlErr(w, http.StatusInternalServerError, "create_failed", "db error")
309 return
310 }
311 if overQuota {
312 // Before refusing, try to free the slot ourselves: the requester's own
313 // abandoned space (a create that died mid-flight, or one they never set
314 // up) shouldn't block their fresh start for the reaper's 14 days. Only
315 // ever reclaims under the reaper's own never-used guards + an age gate,
316 // so a space mid-setup or in real use is never eaten.
317 if s.reclaimOwnSpace(callerID, req.Requester) {
318 id, overQuota, err = s.db.allocateSpace(callerID, req.Requester, s.quota)
319 if err != nil {
320 writeControlErr(w, http.StatusInternalServerError, "create_failed", "db error")
321 return
322 }
323 }
324 }
325 if overQuota {
326 noun := "spaces"
327 if s.quota == 1 {
328 noun = "space"
329 }
330 writeControlErr(w, http.StatusConflict, "quota_exceeded",
331 fmt.Sprintf("this account already has %d free %s (the limit) — upgrading a space frees its slot, and so does deleting one you no longer use", s.quota, noun))
332 return
333 }
334
335 // The hostname's id: the shared global ledger when configured, else this
336 // space's cloud.db PK (id) — today's behaviour unchanged. On failure the
337 // pending row is rolled back like any other, so no quota slot leaks.
338 hostID, err := s.spaceHostID(r.Context(), id)
339 if err != nil {
340 s.db.deleteSpaceByID(id)
341 log.Printf("cloud: id ledger allocate for id %d: %v", id, err)
342 writeControlErr(w, http.StatusServiceUnavailable, "create_failed", "address allocation is unavailable")
343 return
344 }
345 slug, err := spaceSlug(hostID)
346 if err != nil {
347 s.db.deleteSpaceByID(id)
348 writeControlErr(w, http.StatusInternalServerError, "create_failed", "address generation failed")
349 return
350 }
351 host := slug + "." + s.baseDomain
352
353 status, body, err := s.controlCreate(host, entFree, req.Name)
354 if err != nil {
355 s.db.deleteSpaceByID(id)
356 log.Printf("cloud: control call for %s: %v", host, err)
357 writeControlErr(w, http.StatusBadGateway, "create_failed", "the box's control daemon is unreachable")
358 return
359 }
360 switch {
361 case status == http.StatusCreated:
362 if err := s.db.finalizeSpace(id, host); err != nil {
363 // The instance is up but recording its host failed — log loudly; the
364 // requester keeps the space, and the pending row still holds the slot.
365 log.Printf("cloud: ledger finalize for %s (id %d) failed: %v", host, id, err)
366 }
367 log.Printf("cloud: created %s (id %d, caller %.8s…)", host, id, callerID)
368 s.pushQuota(id, host, entFree, "", 0) // insurance cap for a fresh free space (no-op unless caps configured)
369 if s.usageURL != "" {
370 // Meter token from first boot (#858): the usage pipe AND the AI
371 // opt-in channel — a fresh space's onboarding opt-in must not
372 // wait for the sweep to mint its credential.
373 s.pushMeterToken(id, host)
374 }
375 writeJSON(w, http.StatusCreated, map[string]any{
376 "host": host,
377 "url": body["url"],
378 "setup_link": body["setup_link"],
379 })
380 case status == http.StatusServiceUnavailable:
381 // The box is full: relay the stable code with the member-facing message.
382 s.db.deleteSpaceByID(id)
383 writeControlErr(w, http.StatusServiceUnavailable, "at_capacity", spaceUnavailableMsg)
384 default:
385 s.db.deleteSpaceByID(id)
386 msg, _ := body["message"].(string)
387 log.Printf("cloud: control create %s: HTTP %d %s", host, status, msg)
388 writeControlErr(w, http.StatusBadGateway, "create_failed", "space creation failed")
389 }
390}
391
392// controlCreate posts one creation request to the control daemon. The
393// entitlement ("free", or "demo" for the 24h tier) lands in the instance env
394// so its UI can gate affordances; billing flips re-push via the quota path.
395// The replication class rides along so the space is born on its plan's rung
396// of the durability ladder (billing flips re-push via pushReplicationByID);
397// a demo's class is always empty — controld keeps demos off the activator
398// entirely, so they never replicate (docs/demo-spaces.md: no backups).
399func (s *cloudServer) controlCreate(host, entitlement, name string) (int, map[string]any, error) {
400 payload, _ := json.Marshal(controlCreateReq{Host: host, Entitlement: entitlement, Name: name,
401 Replication: s.replicationFor(entitlement, ""),
402 // Stamp this box's region as the new space's home_region (multi-edge
403 // Phase 1) — the same on every create, regular or demo, since both reach
404 // here. Empty when LCHAT_CLOUD_REGION is unset: the space stays
405 // region-agnostic, today's behaviour unchanged.
406 HomeRegion: s.region})
407 req, err := http.NewRequest("POST", "http://control/instances", bytes.NewReader(payload))
408 if err != nil {
409 return 0, nil, err
410 }
411 req.Header.Set("Authorization", "Bearer "+s.controlToken)
412 req.Header.Set("Content-Type", "application/json")
413 resp, err := s.control.Do(req)
414 if err != nil {
415 return 0, nil, err
416 }
417 defer resp.Body.Close()
418 var body map[string]any
419 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
420 return resp.StatusCode, map[string]any{}, nil
421 }
422 return resp.StatusCode, body, nil
423}
424
425// deleteSpaceHandler permanently deprovisions a space — gated on ownership: the
426// (caller, requester) pair must be the one that provisioned this exact host, so
427// nothing outside the ledger is deletable through this surface. The heavy human
428// confirmation (typed name + fresh passkey ceremony) lives upstream in the home
429// server; here the ledger is the authority. controld archives the data to
430// /data/trash rather than destroying it.
431func (s *cloudServer) deleteSpaceHandler(w http.ResponseWriter, r *http.Request) {
432 callerID := r.Context().Value(callerKey{}).(string)
433 host := strings.ToLower(strings.TrimSpace(r.PathValue("host")))
434 requester := strings.TrimSpace(r.URL.Query().Get("requester"))
435 if requester == "" {
436 writeControlErr(w, http.StatusBadRequest, "bad_request", "requester query param required")
437 return
438 }
439 id, owned, err := s.db.spaceOwned(callerID, requester, host)
440 if err != nil {
441 writeControlErr(w, http.StatusInternalServerError, "delete_failed", "db error")
442 return
443 }
444 if !owned {
445 writeControlErr(w, http.StatusNotFound, "not_found", "no such space for this account")
446 return
447 }
448 // A paid space's subscription dies with it — otherwise Stripe keeps
449 // charging for a space that no longer exists, and destroying the ledger row
450 // severs the payer's only route to the Billing Portal (Manage is per-space).
451 // Cancel-first, and refuse the destroy if the cancel fails, so a retry can
452 // still reach the subscription. Entitlement gates the attempt: a lapsed
453 // space keeps its old subscription id, and re-cancelling a canceled
454 // subscription is a Stripe error, not a no-op.
455 if ent, subID, err := s.db.spaceSubscription(id); err != nil {
456 writeControlErr(w, http.StatusInternalServerError, "delete_failed", "db error")
457 return
458 } else if ent == entPaid && subID != "" {
459 if s.billing == nil {
460 writeControlErr(w, http.StatusConflict, "billing_unavailable",
461 "this space has an active subscription but billing is not configured — cancel it in Stripe first")
462 return
463 }
464 if err := s.billing.cancelSubscription(subID); err != nil {
465 log.Printf("cloud: cancel subscription %s for %s (id %d): %v", subID, host, id, err)
466 writeControlErr(w, http.StatusBadGateway, "cancel_failed",
467 "couldn't cancel this space's subscription — nothing was deleted; please try again")
468 return
469 }
470 log.Printf("cloud: canceled subscription %s for %s (id %d) ahead of destroy", subID, host, id)
471 }
472 status, body, err := s.controlDelete(host, false)
473 if err != nil {
474 log.Printf("cloud: control delete for %s: %v", host, err)
475 writeControlErr(w, http.StatusBadGateway, "delete_failed", "the box's control daemon is unreachable")
476 return
477 }
478 // unknown_host means the instance is already gone (e.g. removed by an
479 // operator) — still clear the ledger row so the account's quota frees.
480 if status != http.StatusOK && body["error"] != "unknown_host" {
481 msg, _ := body["message"].(string)
482 log.Printf("cloud: control delete %s: HTTP %d %s", host, status, msg)
483 writeControlErr(w, http.StatusBadGateway, "delete_failed", "space deletion failed")
484 return
485 }
486 if err := s.db.deleteSpaceByID(id); err != nil {
487 log.Printf("cloud: ledger delete for %s (id %d) failed: %v", host, id, err)
488 }
489 log.Printf("cloud: deleted %s (id %d, caller %.8s…)", host, id, callerID)
490 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
491}
492
493// controlDelete sends one deletion to the control daemon.
494// controlDelete deprovisions one instance. discard=true skips the trash
495// archive — demo-only (docs/demo-spaces.md), never for a real space.
496func (s *cloudServer) controlDelete(host string, discard bool) (int, map[string]any, error) {
497 url := "http://control/instances/" + host
498 if discard {
499 url += "?discard=1"
500 }
501 req, err := http.NewRequest("DELETE", url, nil)
502 if err != nil {
503 return 0, nil, err
504 }
505 req.Header.Set("Authorization", "Bearer "+s.controlToken)
506 resp, err := s.control.Do(req)
507 if err != nil {
508 return 0, nil, err
509 }
510 defer resp.Body.Close()
511 var body map[string]any
512 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
513 return resp.StatusCode, map[string]any{}, nil
514 }
515 return resp.StatusCode, body, nil
516}
517
518func (s *cloudServer) listSpacesHandler(w http.ResponseWriter, r *http.Request) {
519 callerID := r.Context().Value(callerKey{}).(string)
520 requester := strings.TrimSpace(r.URL.Query().Get("requester"))
521 if requester == "" {
522 writeControlErr(w, http.StatusBadRequest, "bad_request", "requester query param required")
523 return
524 }
525 spaces, err := s.db.listSpaces(callerID, requester)
526 if err != nil {
527 writeControlErr(w, http.StatusInternalServerError, "list_failed", "db error")
528 return
529 }
530 // billing carries the tier picker's price book (or false — no Upgrade UI).
531 billing := any(false)
532 if s.billing != nil {
533 billing = map[string]any{"tiers": s.billing.publicTiers()}
534 }
535 writeJSON(w, http.StatusOK, map[string]any{"spaces": spaces, "billing": billing})
536}
537
538// pricebookHandler: GET /api/pricebook — the public price book, same shape as
539// the listing's billing key ({tiers} or false when billing is off). It exists
540// so home can serve the book WITHOUT a signed-in requester: the conversational
541// plan step (#1374) runs on the space origin, which holds no home session.
542func (s *cloudServer) pricebookHandler(w http.ResponseWriter, r *http.Request) {
543 billing := any(false)
544 if s.billing != nil {
545 billing = map[string]any{"tiers": s.billing.publicTiers()}
546 }
547 writeJSON(w, http.StatusOK, map[string]any{"billing": billing})
548}
549
550// ---- admin: caller tokens ---------------------------------------------------
551
552func (s *cloudServer) mintTokenHandler(w http.ResponseWriter, r *http.Request) {
553 var req struct {
554 Label string `json:"label"`
555 }
556 if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil || strings.TrimSpace(req.Label) == "" {
557 writeControlErr(w, http.StatusBadRequest, "bad_request", "label required")
558 return
559 }
560 id, token, err := s.db.mintCallerToken(strings.TrimSpace(req.Label))
561 if err != nil {
562 writeControlErr(w, http.StatusInternalServerError, "mint_failed", "db error")
563 return
564 }
565 // The one time the plaintext token is ever visible.
566 writeJSON(w, http.StatusOK, map[string]any{"id": id, "label": req.Label, "token": token})
567}
568
569func (s *cloudServer) listTokensHandler(w http.ResponseWriter, r *http.Request) {
570 tokens, err := s.db.listCallerTokens()
571 if err != nil {
572 writeControlErr(w, http.StatusInternalServerError, "list_failed", "db error")
573 return
574 }
575 writeJSON(w, http.StatusOK, map[string]any{"tokens": tokens})
576}
577
578func (s *cloudServer) revokeTokenHandler(w http.ResponseWriter, r *http.Request) {
579 ok, err := s.db.revokeCallerToken(r.PathValue("id"))
580 if err != nil {
581 writeControlErr(w, http.StatusInternalServerError, "revoke_failed", "db error")
582 return
583 }
584 if !ok {
585 writeControlErr(w, http.StatusNotFound, "not_found", "no such token")
586 return
587 }
588 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
589}
590
591// readControlToken finds the control bearer token: systemd LoadCredential first
592// (how the lchat-cloud unit receives it without the file being readable by the
593// lchat user), then an explicit file path (dev).
594func readControlToken() (string, error) {
595 if dir := os.Getenv("CREDENTIALS_DIRECTORY"); dir != "" {
596 if b, err := os.ReadFile(filepath.Join(dir, "control-token")); err == nil {
597 return string(trimNL(b)), nil
598 }
599 }
600 if p := os.Getenv("LCHAT_CONTROL_TOKEN_FILE"); p != "" {
601 b, err := os.ReadFile(p)
602 if err != nil {
603 return "", err
604 }
605 return string(trimNL(b)), nil
606 }
607 return "", fmt.Errorf("no control token: need systemd LoadCredential=control-token:… or LCHAT_CONTROL_TOKEN_FILE")
608}
609
610// newCloudLedger builds the global id allocator (internal/idalloc) behind
611// region-neutral minting — but ONLY on the deliberate LCHAT_CLOUD_ID_LEDGER
612// opt-in (truthy 1/true). It must never activate merely because
613// LCHAT_HIBERNATE_BUCKET is present: that var exists for a different reason (the
614// mesh/activator S3 substrate), and the counter starts low, so a silent
615// activation would mint sqids colliding with existing spaces' hostnames. The
616// flag is the switch; the *location* still comes from the shared LCHAT_HIBERNATE_*
617// bucket, because the counter MUST live in the one bucket every cloud + home
618// reaches or it isn't a global counter.
619//
620// Off (the default: production's single provisioner, self-hosters, any un-flagged
621// box) → (nil, nil): cloud.db-PK hostnames, byte-for-byte today's behaviour. On
622// but no bucket → a hard error (misconfig, fail fast — never silent-off).
623//
624// ⚠️ Before flipping this on for a namespace that ALREADY has spaces, the
625// operator MUST first seed the counter object above that namespace's max existing
626// id, or ids 1,2,3… re-mint live hostnames. Enabling the flag and seeding the
627// counter are one action, not two.
628func newCloudLedger(ctx context.Context) (*idalloc.Allocator, error) {
629 if v := strings.ToLower(strings.TrimSpace(os.Getenv("LCHAT_CLOUD_ID_LEDGER"))); v != "1" && v != "true" {
630 return nil, nil
631 }
632 bucket := strings.TrimSpace(os.Getenv("LCHAT_HIBERNATE_BUCKET"))
633 if bucket == "" {
634 return nil, fmt.Errorf("LCHAT_CLOUD_ID_LEDGER is set but LCHAT_HIBERNATE_BUCKET is empty — the global id counter needs the shared bucket to live in")
635 }
636 return idalloc.New(ctx, idalloc.Config{
637 Bucket: bucket,
638 Prefix: strings.Trim(strings.TrimSpace(os.Getenv("LCHAT_HIBERNATE_PREFIX")), "/"),
639 Region: strings.TrimSpace(os.Getenv("LCHAT_HIBERNATE_REGION")),
640 Endpoint: strings.TrimSpace(os.Getenv("LCHAT_HIBERNATE_ENDPOINT")),
641 })
642}
643
644// runCloud: `lchat cloud` — serve the public provisioning API (fronted by the
645// router as a 'service' route, e.g. at cloud.elevenmessenger.com).
646func runCloud(args []string) {
647 fs := flag.NewFlagSet("cloud", flag.ExitOnError)
648 addr := fs.String("addr", envOr("ADDR", ":1749"), "TCP listen address (dev; production uses --socket)")
649 socketPath := fs.String("socket", envOr("LCHAT_SOCKET", ""), "unix socket to serve on instead of -addr")
650 dbPath := fs.String("db", envOr("LCHAT_CLOUD_DB", "cloud.db"), "provisioner SQLite path")
651 baseDomain := fs.String("base-domain", os.Getenv("LCHAT_CLOUD_BASE_DOMAIN"), "domain spaces are created under (required; needs wildcard DNS at this box)")
652 region := fs.String("region", os.Getenv("LCHAT_CLOUD_REGION"), "placement region stamped as every new space's home_region (multi-edge, docs/multi-edge.md); empty = the single implicit region (home_region stays \"\")")
653 quota := fs.Int("quota", envInt("LCHAT_CLOUD_QUOTA", 1), "max free spaces per caller+requester (paid spaces never count)")
654 controlSocket := fs.String("control-socket", envOr("LCHAT_CONTROL_SOCKET", "/run/lchat/control.sock"), "the control daemon's unix socket")
655 reapAfterDays := fs.Int("reap-after-days", envInt("LCHAT_CLOUD_REAP_AFTER_DAYS", 0), "reclaim never-claimed (zero-member) spaces older than this many days; 0 disables the reaper")
656 reapIntervalHours := fs.Int("reap-interval-hours", envInt("LCHAT_CLOUD_REAP_INTERVAL_HOURS", 6), "how often the reaper sweeps")
657 reapDryRun := fs.Bool("reap-dry-run", os.Getenv("LCHAT_CLOUD_REAP_DRY_RUN") != "", "log what the reaper would reclaim without deleting anything")
658 reapMax := fs.Int("reap-max-per-sweep", envInt("LCHAT_CLOUD_REAP_MAX_PER_SWEEP", 5), "cap reclaims per reaper sweep (blast-radius bound; 0 = unlimited)")
659 meterIntervalHours := fs.Int("meter-interval-hours", envInt("LCHAT_CLOUD_METER_INTERVAL_HOURS", 6), "how often the storage meter sweeps (0 disables)")
660 createBudget := fs.Int("max-creates-per-day", envInt("LCHAT_CLOUD_MAX_CREATES_PER_DAY", 0), "fleet-wide creation circuit breaker: max spaces per trailing 24h (0 = uncapped)")
661 demoTTLHours := fs.Int("demo-ttl-hours", envInt("LCHAT_DEMO_TTL_HOURS", 0), "demo-space lifetime; 0 = demos not offered")
662 demoMaxLive := fs.Int("demo-max-live", envInt("LCHAT_DEMO_MAX_LIVE", 10), "max live demo spaces at once")
663 poolMaxShare := fs.Int("pool-max-share", envInt("LCHAT_POOL_MAX_SHARE", 0), "free-pool ceiling as % of total cost (0 = pool gauge off)")
664 poolRevenueShare := fs.Int("pool-revenue-share", envInt("LCHAT_POOL_REVENUE_SHARE", 30), "% of net revenue that funds the free pool")
665 poolBaseEURDay := fs.Float64("pool-base-eur-day", envFloat("LCHAT_POOL_BASE_EUR_DAY", 0.20), "operator floor: pool budget in EUR/day at zero revenue")
666 poolDemoShare := fs.Int("pool-demo-share", envInt("LCHAT_POOL_DEMO_SHARE", 20), "% of the pool reserved for demo spaces")
667 poolDryRun := fs.Bool("pool-dry-run", os.Getenv("LCHAT_POOL_DRY_RUN") != "", "compute and log the pool's admission decisions without refusing anything")
668 _ = fs.Parse(args)
669
670 if *baseDomain == "" || !validHost("x."+*baseDomain) {
671 die("cloud: --base-domain (env LCHAT_CLOUD_BASE_DOMAIN) is required, e.g. elevenmessenger.com")
672 }
673 admin := os.Getenv("LCHAT_ADMIN_TOKEN")
674 if admin == "" {
675 die("cloud: LCHAT_ADMIN_TOKEN is required (gates caller-token minting)")
676 }
677 controlToken, err := readControlToken()
678 if err != nil {
679 die("cloud: %v", err)
680 }
681 db, err := openCloudDB(*dbPath)
682 if err != nil {
683 die("cloud: open db: %v", err)
684 }
685 // The shared global id ledger (multi-edge Phase 1), built only on the
686 // deliberate LCHAT_CLOUD_ID_LEDGER opt-in — see newCloudLedger.
687 ids, err := newCloudLedger(context.Background())
688 if err != nil {
689 die("cloud: id ledger: %v", err)
690 }
691 storageCaps, seatCaps, err := parseStorageCaps(os.Getenv("LCHAT_CLOUD_STORAGE_CAPS"))
692 if err != nil {
693 die("cloud: LCHAT_CLOUD_STORAGE_CAPS: %v", err)
694 }
695 replicationClasses, err := parseReplicationClasses(os.Getenv("LCHAT_CLOUD_REPLICATION_CLASSES"))
696 if err != nil {
697 die("cloud: LCHAT_CLOUD_REPLICATION_CLASSES: %v", err)
698 }
699 billing, err := newStripeBilling(
700 os.Getenv("LCHAT_STRIPE_SECRET_KEY"),
701 os.Getenv("LCHAT_STRIPE_WEBHOOK_SECRET"),
702 os.Getenv("LCHAT_STRIPE_PRICES"),
703 envOr("LCHAT_STRIPE_API_BASE", "https://api.stripe.com"),
704 )
705 if err != nil {
706 die("cloud: %v", err)
707 }
708 if billing != nil {
709 billing.portalConfig = os.Getenv("LCHAT_STRIPE_PORTAL_CONFIG")
710 // Per-band portal configurations (docs/pricing-bands.md § Stripe
711 // implementation) — optional JSON {band: configuration id}.
712 if raw := os.Getenv("LCHAT_STRIPE_PORTAL_CONFIGS"); raw != "" {
713 if err := json.Unmarshal([]byte(raw), &billing.portalConfigs); err != nil {
714 die("cloud: LCHAT_STRIPE_PORTAL_CONFIGS: %v (want JSON {band: id})", err)
715 }
716 }
717 // Stripe Tax rides an operator knob, flipped with the band-price
718 // rollout (every live Price must resolve a tax_behavior first).
719 billing.automaticTax = os.Getenv("LCHAT_STRIPE_AUTOMATIC_TAX") == "1"
720 }
721 // The Enterprise Edition page's checkout: off until its own key is set
722 // (sandbox first — the live flip is swapping that one value; enterprise.go).
723 enterprise := newEnterpriseCheckout(
724 os.Getenv("LCHAT_STRIPE_ENTERPRISE_KEY"),
725 envOr("LCHAT_STRIPE_API_BASE", "https://api.stripe.com"),
726 envOr("LCHAT_ENTERPRISE_ORIGIN", "https://elevenmessenger.com"),
727 )
728 // The /donate page's checkout: off until its own key is set (donate.go).
729 donate := newDonateCheckout(
730 os.Getenv("LCHAT_STRIPE_DONATE_KEY"),
731 envOr("LCHAT_STRIPE_API_BASE", "https://api.stripe.com"),
732 envOr("LCHAT_DONATE_ORIGIN", "https://elevenmessenger.com"),
733 )
734 poolCosts, err := parsePoolCosts(os.Getenv("LCHAT_POOL_COSTS"))
735 if err != nil {
736 die("cloud: LCHAT_POOL_COSTS: %v", err)
737 }
738
739 s := &cloudServer{
740 db: db,
741 adminHash: hashToken(admin),
742 controlToken: controlToken,
743 control: controlClient(*controlSocket),
744 baseDomain: *baseDomain,
745 region: strings.TrimSpace(*region),
746 ids: ids,
747 quota: *quota,
748 reapAfter: time.Duration(*reapAfterDays) * 24 * time.Hour,
749 reapDryRun: *reapDryRun,
750 reapMax: *reapMax,
751 billing: billing,
752 enterprise: enterprise,
753 donate: donate,
754 storageCaps: storageCaps,
755 seatCaps: seatCaps,
756 replicationClasses: replicationClasses,
757 createBudget: *createBudget,
758 demoTTL: time.Duration(*demoTTLHours) * time.Hour,
759 demoMaxLive: *demoMaxLive,
760 usageURL: strings.TrimSpace(os.Getenv("LCHAT_CLOUD_USAGE_URL")),
761 pool: poolConfig{
762 maxShare: *poolMaxShare,
763 revenueShare: *poolRevenueShare,
764 baseEURDay: *poolBaseEURDay,
765 demoShare: *poolDemoShare,
766 dryRun: *poolDryRun,
767 costs: poolCosts,
768 },
769 }
770 if s.usageURL != "" {
771 log.Printf("cloud: usage pipe on — meter tokens push %s", s.usageURL)
772 }
773 if s.region != "" {
774 log.Printf("cloud: region %q — new spaces are stamped home_region=%q", s.region, s.region)
775 }
776 if s.ids != nil {
777 log.Printf("cloud: global id ledger on — space hostnames draw from the shared counter (region-neutral namespace)")
778 }
779 aiRatesCfg, err := parseAIRates(strings.TrimSpace(os.Getenv("LCHAT_CLOUD_AI_RATES")))
780 if err != nil {
781 die("cloud: %v", err)
782 }
783 s.aiRates = aiRatesCfg
784 s.aiAllowanceEUR = envFloat("LCHAT_CLOUD_AI_ALLOWANCE_EUR", 0)
785 s.aiPoolEUR = envFloat("LCHAT_CLOUD_AI_POOL_EUR", 0)
786 if len(s.aiRates) > 0 && s.aiAllowanceEUR > 0 {
787 log.Printf("cloud: AI budget on — allowance €%.2f/space/month, pool €%.2f/month (%d models priced)",
788 s.aiAllowanceEUR, s.aiPoolEUR, len(s.aiRates))
789 }
790 if *createBudget > 0 {
791 log.Printf("cloud: creation budget on (%d/day fleet-wide)", *createBudget)
792 }
793 if s.demoTTL > 0 {
794 log.Printf("cloud: demo spaces on (TTL %s, max %d live, discarded on expiry)", s.demoTTL, s.demoMaxLive)
795 }
796 if s.pool.enabled() {
797 mode := "admission armed"
798 if s.pool.dryRun {
799 mode = "dry-run, gates nothing"
800 }
801 log.Printf("cloud: free pool on (%s — ceiling %d%% of cost, %d%% of revenue, floor €%.2f/day, demo share %d%%)",
802 mode, s.pool.maxShare, s.pool.revenueShare, s.pool.baseEURDay, s.pool.demoShare)
803 if billing != nil {
804 for _, t := range billing.tiers {
805 if t.NetEURYear <= 0 {
806 log.Printf("cloud: pool: tier %q has no net_eur_year in LCHAT_STRIPE_PRICES — its spaces fund the pool as €0", t.Key)
807 }
808 }
809 }
810 }
811 if len(storageCaps)+len(seatCaps) > 0 {
812 log.Printf("cloud: storage caps on (%d flat, %d per-seat)", len(storageCaps), len(seatCaps))
813 }
814 if len(replicationClasses) > 0 {
815 log.Printf("cloud: replication classes on (%d entries)", len(replicationClasses))
816 }
817 if billing != nil {
818 log.Printf("cloud: billing on (%d tiers)", len(billing.tiers))
819 }
820 go s.reapLoop(time.Duration(*reapIntervalHours) * time.Hour)
821 go s.demoReapLoop()
822 go s.meterLoop(time.Duration(*meterIntervalHours) * time.Hour)
823 ln, where, err := listen(*addr, *socketPath)
824 if err != nil {
825 die("cloud: listen: %v", err)
826 }
827 log.Printf("cloud: provisioning %s spaces on %s (db %s, quota %d)", *baseDomain, where, *dbPath, *quota)
828 srv := &http.Server{Handler: s.handler(), ReadHeaderTimeout: 10 * time.Second}
829 if err := srv.Serve(ln); err != nil {
830 log.Fatal(err)
831 }
832}
833
834// controlClient dials the control daemon's unix socket. Creation waits on a
835// real instance start + health check, so the timeout is generous.
836func controlClient(socket string) *http.Client {
837 return &http.Client{
838 Timeout: 30 * time.Second,
839 Transport: &http.Transport{
840 DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
841 return (&net.Dialer{}).DialContext(ctx, "unix", socket)
842 },
843 },
844 }
845}
846
847// controldMaterializeFunc builds the router's s3Backend.materialize hook
848// (activator.go): a POST to controld's privileged /instances/{host}/
849// materialize endpoint over its local socket, instead of staging the binary
850// and pointing the live symlink directly — the router runs sandboxed
851// (ProtectSystem=strict, no write access outside /data and /run/lchat) and
852// can't do that write itself; controld always runs unsandboxed as root.
853//
854// Returns nil when the control token isn't available — no systemd
855// LoadCredential=control-token:… on this unit, the case for any box that
856// doesn't run controld (self-hosters, and any box before it's specifically
857// opted into this via the drop-in — see
858// deploy/systemd/lchat-router-control-token.conf). A nil hook means
859// s3Backend.start falls back to the direct calls, today's behavior,
860// unchanged — so this is purely additive for boxes that don't opt in.
861func controldMaterializeFunc() func(ctx context.Context, host string) error {
862 token, err := readControlToken()
863 if err != nil {
864 return nil
865 }
866 socket := envOr("LCHAT_CONTROL_SOCKET", "/run/lchat/control.sock")
867 client := controlClient(socket)
868 return func(ctx context.Context, host string) error {
869 req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://control/instances/"+host+"/materialize", nil)
870 if err != nil {
871 return err
872 }
873 req.Header.Set("Authorization", "Bearer "+token)
874 resp, err := client.Do(req)
875 if err != nil {
876 return err
877 }
878 defer resp.Body.Close()
879 if resp.StatusCode != http.StatusOK {
880 body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<12))
881 return fmt.Errorf("controld materialize %s: HTTP %d %s", host, resp.StatusCode, body)
882 }
883 return nil
884 }
885}
886