package main // The cloud provisioner: the public, policy-enforcing face of instance creation // (→ cloud.elevenmessenger.com). Callers are home servers holding a minted // bearer token; each request names an opaque requester (the home server's // random user id) so a per-user quota can hold. The actual creation happens in // the control daemon (controld.go) over its local socket — this service is // deliberately unprivileged and sandboxed, holding policy (tokens, quotas, // sqid addressing) and no root powers. Any box running the lchat router can // enable it. See docs/cloud-provisioner.md. import ( "bytes" "context" "encoding/json" "flag" "fmt" "io" "log" "net" "net/http" "os" "path/filepath" "strings" "time" sqids "github.com/sqids/sqids-go" "lchat/internal/idalloc" "sync" ) // spaceUnavailableMsg is the member-facing "we're full" message, relayed // verbatim by home servers and shown by clients. const spaceUnavailableMsg = "We can't create a new Eleven space right now — please try again later." // Space subdomains are sqids of the space's integer id (its DB primary key), so // the hostname is short AND reversibly encodes the id — given a4f. you // can recover "server 42". Lowercase base36 keeps labels DNS-safe (case- // insensitive); minLength 3 gives a4f-length labels that grow only as ids do; // the default blocklist keeps rude combinations from ever being minted. The // alphabet is shuffled internally per-id, so consecutive ids don't produce // consecutive labels. var spaceSqids = mustSqids() func mustSqids() *sqids.Sqids { s, err := sqids.New(sqids.Options{ Alphabet: "0123456789abcdefghijklmnopqrstuvwxyz", MinLength: 3, }) if err != nil { panic("cloud: sqids init: " + err.Error()) } return s } // spaceSlug encodes a space id to its subdomain label. func spaceSlug(id int64) (string, error) { return spaceSqids.Encode([]uint64{uint64(id)}) } // spaceID decodes a subdomain label back to its space id. ok=false if the label // isn't a canonical encoding of a single id (sqids will decode some non-canonical // strings, so we re-encode and compare — the standard round-trip guard). func spaceID(slug string) (id int64, ok bool) { nums := spaceSqids.Decode(slug) if len(nums) != 1 { return 0, false } if enc, err := spaceSqids.Encode(nums); err != nil || enc != slug { return 0, false } return int64(nums[0]), true } // cloudServer is the provisioner's HTTP surface. type cloudServer struct { db *cloudDB adminHash string // sha256 of LCHAT_ADMIN_TOKEN; gates token mint/list/revoke controlToken string control *http.Client // dials the control daemon's unix socket baseDomain string // spaces land at . // region (LCHAT_CLOUD_REGION) is this box's placement region, stamped as // every new space's home_region so the mesh places it by region (multi-edge // Phase 1, docs/multi-edge.md). Empty = the single implicit region: spaces // are born with home_region="" and hash across the whole fleet, exactly as // before regions existed. Addressing is unaffected — the host still forms // from baseDomain; region is a mutable property, never part of the name. region string // ids is the shared, global, S3-backed id allocator (internal/idalloc) // behind region-neutral space addressing (multi-edge Phase 1). Set only when // the deliberate opt-in LCHAT_CLOUD_ID_LEDGER is on (its counter then lives in // the shared LCHAT_HIBERNATE_* bucket, the mesh's S3 substrate). When set, a // new space's HOSTNAME id is drawn from the fleet-wide counter instead of this // cloud.db's local PK, so two region clouds minting into ONE region-neutral // namespace (., no country label) can never collide. Nil — // production's single provisioner, every self-hoster, any box without the flag // — keeps today's cloud.db-PK hostname, byte for byte. Only the address's id // source moves; the PK stays the ledger key. ids *idalloc.Allocator quota int // max spaces per (caller, requester) reapAfter time.Duration // reclaim never-claimed spaces older than this (0 = reaper off) reapDryRun bool // log what the reaper would reclaim, delete nothing reapMax int // max reclaims per sweep (blast-radius cap; 0 = unlimited) billing *stripeBilling // optional space upgrades (billing.go); nil = not offered // enterprise is the Enterprise Edition page's public checkout // (enterprise.go) — its own Stripe key, deliberately separate from // billing's so it can run against the sandbox; nil = off. enterprise *enterpriseCheckout // donate is the /donate page's public checkout (donate.go) — same shape, // its own key, off until LCHAT_STRIPE_DONATE_KEY is set. donate *donateCheckout storageCaps map[string]int64 // tier key (+ "free") → storage cap pushed to instances; empty = no enforcement // seatCaps holds the per-seat entries of LCHAT_CLOUD_STORAGE_CAPS // ("business=11GB/seat"): cap = value × the space's live head count, // pooled across the space (docs/tier-economics.md § Per-seat). seatCaps map[string]int64 // replicationClasses maps tier keys (+ "free") to durability-ladder classes // (cloudreplication.go); empty = classes never pushed, every space streams. replicationClasses map[string]string demoTTL time.Duration // demo lifetime (demo.go); 0 = demos not offered demoMaxLive int // live-demo cap, pending rows included createBudget int // max creates fleet-wide per trailing 24h (0 = uncapped) // usageURL is this service's own public /api/usage URL, pushed into each // instance's env (LCHAT_METER_URL) alongside a minted meter token so // sidecar usage reports can flow (#857). Empty = the usage pipe is off. usageURL string pool poolConfig // free-pool gauge (pool.go); maxShare 0 = off // The AI budget (cloud_budget.go, phase 3): per-model EUR rates, the // per-space monthly allowance, and the fleet-wide monthly pool. All three // unset = measure-only (every flush answers "ok"). aiRates map[string]aiRates aiAllowanceEUR float64 // LCHAT_CLOUD_AI_ALLOWANCE_EUR aiPoolEUR float64 // LCHAT_CLOUD_AI_POOL_EUR // claimLinks parks a pay-first create's one-time setup link between the // provision and the redirect landing (billing.go claimCreateHandler) so a // claim retry after a dropped response can still hand it over. Memory only, // short-lived, single process — the link is never persisted anywhere. claimLinksMu sync.Mutex claimLinks map[string]claimLink } // claimLink is one parked setup link (claimLinks); At bounds its life. type claimLink struct { Link string At time.Time } func (s *cloudServer) handler() http.Handler { mux := http.NewServeMux() // The provisioning surface: bearer caller token (a home server). mux.HandleFunc("POST /api/instances", s.callerAuth(s.createSpaceHandler)) mux.HandleFunc("GET /api/instances", s.callerAuth(s.listSpacesHandler)) // The price book alone, without a requester's ledger — home republishes it // unauthenticated for the setup conversation's plan step (#1374). mux.HandleFunc("GET /api/pricebook", s.callerAuth(s.pricebookHandler)) mux.HandleFunc("DELETE /api/instances/{host}", s.callerAuth(s.deleteSpaceHandler)) // Billing (optional — 501 unless Stripe is configured; see billing.go). The // webhook is signature-gated rather than token-gated: Stripe calls it. mux.HandleFunc("POST /api/instances/{host}/checkout", s.callerAuth(s.checkoutHandler)) // Pay-before-create (billing.go): mint a checkout for a space that doesn't // exist yet, then provision it once the payment confirms. mux.HandleFunc("POST /api/instances/checkout-create", s.callerAuth(s.checkoutCreateHandler)) mux.HandleFunc("POST /api/instances/claim-create", s.callerAuth(s.claimCreateHandler)) // Claiming a demo (demo.go) — additive, so caller-token gated like creation. mux.HandleFunc("POST /api/instances/{host}/claim", s.callerAuth(s.claimDemoHandler)) // The countdown banner's live "still a demo?" check (demo.go) — read-only, // relayed to browsers by home, so it discloses demo-ness and nothing else. mux.HandleFunc("GET /api/instances/{host}/demo-status", s.callerAuth(s.demoStatusHandler)) mux.HandleFunc("POST /api/instances/{host}/portal", s.callerAuth(s.portalHandler)) mux.HandleFunc("POST /api/stripe/webhook", s.stripeWebhookHandler) // The Enterprise Edition page's checkout (enterprise.go): public but // CORS-scoped to the marketing origin — the static site's browser JS is // the caller, so there is no token to gate with; amounts are compiled in. mux.HandleFunc("POST /api/enterprise/checkout", s.enterpriseCheckoutHandler) mux.HandleFunc("OPTIONS /api/enterprise/checkout", s.enterprisePreflightHandler) // The /donate page's checkout (donate.go): same public posture. mux.HandleFunc("POST /api/donate/checkout", s.donateCheckoutHandler) mux.HandleFunc("OPTIONS /api/donate/checkout", s.donatePreflightHandler) // Usage ingest (#857): meter-token-gated, not caller-gated — an instance // can only ever report as itself. mux.HandleFunc("POST /api/usage", s.usageHandler) // AI opt-in (#858): same meter-token gate — an instance whose admin // switched AI on asks for its sidecar; idempotent all the way down. mux.HandleFunc("POST /api/ai-optin", s.aiOptInHandler) // Admin surface: mint / list / revoke caller tokens at runtime (mirrors the // push relay's admin API). mux.HandleFunc("POST /api/admin/tokens", s.admin(s.mintTokenHandler)) mux.HandleFunc("GET /api/admin/tokens", s.admin(s.listTokensHandler)) mux.HandleFunc("DELETE /api/admin/tokens/{id}", s.admin(s.revokeTokenHandler)) // The free-pool gauge (pool.go) — read-only, operator-facing. mux.HandleFunc("GET /api/pool", s.admin(s.poolHandler)) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") }) return mux } // callerAuth resolves the bearer token to a caller id and passes it via the // request context. type callerKey struct{} func (s *cloudServer) callerAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, ok := s.db.authCaller(bearerTokenHeader(r)) if !ok { writeControlErr(w, http.StatusUnauthorized, "unauthorized", "bad or missing caller token") return } next(w, r.WithContext(context.WithValue(r.Context(), callerKey{}, id))) } } func (s *cloudServer) admin(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !tokenHashEqual(bearerTokenHeader(r), s.adminHash) { writeControlErr(w, http.StatusUnauthorized, "unauthorized", "bad or missing admin token") return } next(w, r) } } // spaceHostID returns the id that feeds a new space's sqid hostname — the whole // of this slice's fork. With the shared global ledger enabled (s.ids != nil, via // the deliberate LCHAT_CLOUD_ID_LEDGER opt-in) it draws the next fleet-unique id // from idalloc, so two region clouds minting into ONE region-neutral namespace // never collide. Without it — the default everywhere the flag isn't set: // production's single provisioner, self-hosters, any un-flagged box — it returns // the space's own cloud.db PK, so the hostname is byte-for-byte what it is today. // The PK stays the ledger key either way (quota, entitlement, usage, billing all // key on it and never on this id), so only the address's id source moves. func (s *cloudServer) spaceHostID(ctx context.Context, pk int64) (int64, error) { if s.ids == nil { return pk, nil } gid, err := s.ids.Allocate(ctx) if err != nil { return 0, err } // idalloc hands out uint64; the sqid/PK id space is int64. The counter is // 1-based and gaps are allowed, so int64 has decades of headroom. return int64(gid), nil } // createSpaceHandler provisions one instance for a requester: reserve the next // id (quota-checked), turn it into a sqid hostname, ask the control daemon to // create it, then finalize the ledger. No slug re-roll loop — the id is unique // by construction, so its sqid hostname is always fresh. func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request) { callerID := r.Context().Value(callerKey{}).(string) var req struct { Requester string `json:"requester"` Kind string `json:"kind"` // "" = a free space (the default); "demo" = a 24h demo (demo.go) // Name optionally seeds the space's display name (plaintext branding by // design — never member content). Sanitized again at the control seam. Name string `json:"name"` } if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil { writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") return } req.Requester = strings.TrimSpace(req.Requester) if req.Requester == "" || len(req.Requester) > 128 { writeControlErr(w, http.StatusBadRequest, "bad_request", "requester required (opaque id, ≤128 chars)") return } if req.Kind != "" && req.Kind != entDemo { writeControlErr(w, http.StatusBadRequest, "bad_request", "unknown kind (want empty or \"demo\")") return } req.Name = strings.TrimSpace(req.Name) if len(req.Name) > 60 { writeControlErr(w, http.StatusBadRequest, "bad_request", "name must be at most 60 characters") return } // The fleet-wide circuit breaker: a trailing-24h creation budget, counted // against an append-only log so create-and-delete can't reset it. Insurance // for the pre-production era — the per-account gates do the fine-grained work. if s.createBudget > 0 { if n, err := s.db.createsSince(time.Now().Add(-24 * time.Hour).Unix()); err == nil && n >= s.createBudget { log.Printf("cloud: creation budget exhausted (%d/%d in 24h) — refusing create", n, s.createBudget) writeControlErr(w, http.StatusServiceUnavailable, "at_capacity", spaceUnavailableMsg) return } } // Demos branch off past the shared breaker: their own cap + TTL bound them // instead of the free-space quota (demo.go). if req.Kind == entDemo { s.createDemoSpace(r.Context(), w, callerID, req.Requester) return } // The free pool's admission gate (pool.go): new free creates pause while // the last gauge reading shows red pressure. Fails open on any // uncertainty; paid creates are never gated (they fund the pool), and // existing spaces are never touched. if !s.poolAdmitFree() { writeControlErr(w, http.StatusServiceUnavailable, "free_pool_full", freePoolFullMsg) return } // Reserve the id (fails closed if over quota). The row is pending (NULL host) // until the instance is up; on any failure below we delete it so it doesn't // hold a quota slot. id, overQuota, err := s.db.allocateSpace(callerID, req.Requester, s.quota) if err != nil { writeControlErr(w, http.StatusInternalServerError, "create_failed", "db error") return } if overQuota { // Before refusing, try to free the slot ourselves: the requester's own // abandoned space (a create that died mid-flight, or one they never set // up) shouldn't block their fresh start for the reaper's 14 days. Only // ever reclaims under the reaper's own never-used guards + an age gate, // so a space mid-setup or in real use is never eaten. if s.reclaimOwnSpace(callerID, req.Requester) { id, overQuota, err = s.db.allocateSpace(callerID, req.Requester, s.quota) if err != nil { writeControlErr(w, http.StatusInternalServerError, "create_failed", "db error") return } } } if overQuota { noun := "spaces" if s.quota == 1 { noun = "space" } writeControlErr(w, http.StatusConflict, "quota_exceeded", 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)) return } // The hostname's id: the shared global ledger when configured, else this // space's cloud.db PK (id) — today's behaviour unchanged. On failure the // pending row is rolled back like any other, so no quota slot leaks. hostID, err := s.spaceHostID(r.Context(), id) if err != nil { s.db.deleteSpaceByID(id) log.Printf("cloud: id ledger allocate for id %d: %v", id, err) writeControlErr(w, http.StatusServiceUnavailable, "create_failed", "address allocation is unavailable") return } slug, err := spaceSlug(hostID) if err != nil { s.db.deleteSpaceByID(id) writeControlErr(w, http.StatusInternalServerError, "create_failed", "address generation failed") return } host := slug + "." + s.baseDomain status, body, err := s.controlCreate(host, entFree, req.Name) if err != nil { s.db.deleteSpaceByID(id) log.Printf("cloud: control call for %s: %v", host, err) writeControlErr(w, http.StatusBadGateway, "create_failed", "the box's control daemon is unreachable") return } switch { case status == http.StatusCreated: if err := s.db.finalizeSpace(id, host); err != nil { // The instance is up but recording its host failed — log loudly; the // requester keeps the space, and the pending row still holds the slot. log.Printf("cloud: ledger finalize for %s (id %d) failed: %v", host, id, err) } log.Printf("cloud: created %s (id %d, caller %.8s…)", host, id, callerID) s.pushQuota(id, host, entFree, "", 0) // insurance cap for a fresh free space (no-op unless caps configured) if s.usageURL != "" { // Meter token from first boot (#858): the usage pipe AND the AI // opt-in channel — a fresh space's onboarding opt-in must not // wait for the sweep to mint its credential. s.pushMeterToken(id, host) } writeJSON(w, http.StatusCreated, map[string]any{ "host": host, "url": body["url"], "setup_link": body["setup_link"], }) case status == http.StatusServiceUnavailable: // The box is full: relay the stable code with the member-facing message. s.db.deleteSpaceByID(id) writeControlErr(w, http.StatusServiceUnavailable, "at_capacity", spaceUnavailableMsg) default: s.db.deleteSpaceByID(id) msg, _ := body["message"].(string) log.Printf("cloud: control create %s: HTTP %d %s", host, status, msg) writeControlErr(w, http.StatusBadGateway, "create_failed", "space creation failed") } } // controlCreate posts one creation request to the control daemon. The // entitlement ("free", or "demo" for the 24h tier) lands in the instance env // so its UI can gate affordances; billing flips re-push via the quota path. // The replication class rides along so the space is born on its plan's rung // of the durability ladder (billing flips re-push via pushReplicationByID); // a demo's class is always empty — controld keeps demos off the activator // entirely, so they never replicate (docs/demo-spaces.md: no backups). func (s *cloudServer) controlCreate(host, entitlement, name string) (int, map[string]any, error) { payload, _ := json.Marshal(controlCreateReq{Host: host, Entitlement: entitlement, Name: name, Replication: s.replicationFor(entitlement, ""), // Stamp this box's region as the new space's home_region (multi-edge // Phase 1) — the same on every create, regular or demo, since both reach // here. Empty when LCHAT_CLOUD_REGION is unset: the space stays // region-agnostic, today's behaviour unchanged. HomeRegion: s.region}) req, err := http.NewRequest("POST", "http://control/instances", bytes.NewReader(payload)) if err != nil { return 0, nil, err } req.Header.Set("Authorization", "Bearer "+s.controlToken) req.Header.Set("Content-Type", "application/json") resp, err := s.control.Do(req) if err != nil { return 0, nil, err } defer resp.Body.Close() var body map[string]any if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return resp.StatusCode, map[string]any{}, nil } return resp.StatusCode, body, nil } // deleteSpaceHandler permanently deprovisions a space — gated on ownership: the // (caller, requester) pair must be the one that provisioned this exact host, so // nothing outside the ledger is deletable through this surface. The heavy human // confirmation (typed name + fresh passkey ceremony) lives upstream in the home // server; here the ledger is the authority. controld archives the data to // /data/trash rather than destroying it. func (s *cloudServer) deleteSpaceHandler(w http.ResponseWriter, r *http.Request) { callerID := r.Context().Value(callerKey{}).(string) host := strings.ToLower(strings.TrimSpace(r.PathValue("host"))) requester := strings.TrimSpace(r.URL.Query().Get("requester")) if requester == "" { writeControlErr(w, http.StatusBadRequest, "bad_request", "requester query param required") return } id, owned, err := s.db.spaceOwned(callerID, requester, host) if err != nil { writeControlErr(w, http.StatusInternalServerError, "delete_failed", "db error") return } if !owned { writeControlErr(w, http.StatusNotFound, "not_found", "no such space for this account") return } // A paid space's subscription dies with it — otherwise Stripe keeps // charging for a space that no longer exists, and destroying the ledger row // severs the payer's only route to the Billing Portal (Manage is per-space). // Cancel-first, and refuse the destroy if the cancel fails, so a retry can // still reach the subscription. Entitlement gates the attempt: a lapsed // space keeps its old subscription id, and re-cancelling a canceled // subscription is a Stripe error, not a no-op. if ent, subID, err := s.db.spaceSubscription(id); err != nil { writeControlErr(w, http.StatusInternalServerError, "delete_failed", "db error") return } else if ent == entPaid && subID != "" { if s.billing == nil { writeControlErr(w, http.StatusConflict, "billing_unavailable", "this space has an active subscription but billing is not configured — cancel it in Stripe first") return } if err := s.billing.cancelSubscription(subID); err != nil { log.Printf("cloud: cancel subscription %s for %s (id %d): %v", subID, host, id, err) writeControlErr(w, http.StatusBadGateway, "cancel_failed", "couldn't cancel this space's subscription — nothing was deleted; please try again") return } log.Printf("cloud: canceled subscription %s for %s (id %d) ahead of destroy", subID, host, id) } status, body, err := s.controlDelete(host, false) if err != nil { log.Printf("cloud: control delete for %s: %v", host, err) writeControlErr(w, http.StatusBadGateway, "delete_failed", "the box's control daemon is unreachable") return } // unknown_host means the instance is already gone (e.g. removed by an // operator) — still clear the ledger row so the account's quota frees. if status != http.StatusOK && body["error"] != "unknown_host" { msg, _ := body["message"].(string) log.Printf("cloud: control delete %s: HTTP %d %s", host, status, msg) writeControlErr(w, http.StatusBadGateway, "delete_failed", "space deletion failed") return } if err := s.db.deleteSpaceByID(id); err != nil { log.Printf("cloud: ledger delete for %s (id %d) failed: %v", host, id, err) } log.Printf("cloud: deleted %s (id %d, caller %.8s…)", host, id, callerID) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // controlDelete sends one deletion to the control daemon. // controlDelete deprovisions one instance. discard=true skips the trash // archive — demo-only (docs/demo-spaces.md), never for a real space. func (s *cloudServer) controlDelete(host string, discard bool) (int, map[string]any, error) { url := "http://control/instances/" + host if discard { url += "?discard=1" } req, err := http.NewRequest("DELETE", url, nil) if err != nil { return 0, nil, err } req.Header.Set("Authorization", "Bearer "+s.controlToken) resp, err := s.control.Do(req) if err != nil { return 0, nil, err } defer resp.Body.Close() var body map[string]any if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return resp.StatusCode, map[string]any{}, nil } return resp.StatusCode, body, nil } func (s *cloudServer) listSpacesHandler(w http.ResponseWriter, r *http.Request) { callerID := r.Context().Value(callerKey{}).(string) requester := strings.TrimSpace(r.URL.Query().Get("requester")) if requester == "" { writeControlErr(w, http.StatusBadRequest, "bad_request", "requester query param required") return } spaces, err := s.db.listSpaces(callerID, requester) if err != nil { writeControlErr(w, http.StatusInternalServerError, "list_failed", "db error") return } // billing carries the tier picker's price book (or false — no Upgrade UI). billing := any(false) if s.billing != nil { billing = map[string]any{"tiers": s.billing.publicTiers()} } writeJSON(w, http.StatusOK, map[string]any{"spaces": spaces, "billing": billing}) } // pricebookHandler: GET /api/pricebook — the public price book, same shape as // the listing's billing key ({tiers} or false when billing is off). It exists // so home can serve the book WITHOUT a signed-in requester: the conversational // plan step (#1374) runs on the space origin, which holds no home session. func (s *cloudServer) pricebookHandler(w http.ResponseWriter, r *http.Request) { billing := any(false) if s.billing != nil { billing = map[string]any{"tiers": s.billing.publicTiers()} } writeJSON(w, http.StatusOK, map[string]any{"billing": billing}) } // ---- admin: caller tokens --------------------------------------------------- func (s *cloudServer) mintTokenHandler(w http.ResponseWriter, r *http.Request) { var req struct { Label string `json:"label"` } if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil || strings.TrimSpace(req.Label) == "" { writeControlErr(w, http.StatusBadRequest, "bad_request", "label required") return } id, token, err := s.db.mintCallerToken(strings.TrimSpace(req.Label)) if err != nil { writeControlErr(w, http.StatusInternalServerError, "mint_failed", "db error") return } // The one time the plaintext token is ever visible. writeJSON(w, http.StatusOK, map[string]any{"id": id, "label": req.Label, "token": token}) } func (s *cloudServer) listTokensHandler(w http.ResponseWriter, r *http.Request) { tokens, err := s.db.listCallerTokens() if err != nil { writeControlErr(w, http.StatusInternalServerError, "list_failed", "db error") return } writeJSON(w, http.StatusOK, map[string]any{"tokens": tokens}) } func (s *cloudServer) revokeTokenHandler(w http.ResponseWriter, r *http.Request) { ok, err := s.db.revokeCallerToken(r.PathValue("id")) if err != nil { writeControlErr(w, http.StatusInternalServerError, "revoke_failed", "db error") return } if !ok { writeControlErr(w, http.StatusNotFound, "not_found", "no such token") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // readControlToken finds the control bearer token: systemd LoadCredential first // (how the lchat-cloud unit receives it without the file being readable by the // lchat user), then an explicit file path (dev). func readControlToken() (string, error) { if dir := os.Getenv("CREDENTIALS_DIRECTORY"); dir != "" { if b, err := os.ReadFile(filepath.Join(dir, "control-token")); err == nil { return string(trimNL(b)), nil } } if p := os.Getenv("LCHAT_CONTROL_TOKEN_FILE"); p != "" { b, err := os.ReadFile(p) if err != nil { return "", err } return string(trimNL(b)), nil } return "", fmt.Errorf("no control token: need systemd LoadCredential=control-token:… or LCHAT_CONTROL_TOKEN_FILE") } // newCloudLedger builds the global id allocator (internal/idalloc) behind // region-neutral minting — but ONLY on the deliberate LCHAT_CLOUD_ID_LEDGER // opt-in (truthy 1/true). It must never activate merely because // LCHAT_HIBERNATE_BUCKET is present: that var exists for a different reason (the // mesh/activator S3 substrate), and the counter starts low, so a silent // activation would mint sqids colliding with existing spaces' hostnames. The // flag is the switch; the *location* still comes from the shared LCHAT_HIBERNATE_* // bucket, because the counter MUST live in the one bucket every cloud + home // reaches or it isn't a global counter. // // Off (the default: production's single provisioner, self-hosters, any un-flagged // box) → (nil, nil): cloud.db-PK hostnames, byte-for-byte today's behaviour. On // but no bucket → a hard error (misconfig, fail fast — never silent-off). // // ⚠️ Before flipping this on for a namespace that ALREADY has spaces, the // operator MUST first seed the counter object above that namespace's max existing // id, or ids 1,2,3… re-mint live hostnames. Enabling the flag and seeding the // counter are one action, not two. func newCloudLedger(ctx context.Context) (*idalloc.Allocator, error) { if v := strings.ToLower(strings.TrimSpace(os.Getenv("LCHAT_CLOUD_ID_LEDGER"))); v != "1" && v != "true" { return nil, nil } bucket := strings.TrimSpace(os.Getenv("LCHAT_HIBERNATE_BUCKET")) if bucket == "" { 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") } return idalloc.New(ctx, idalloc.Config{ Bucket: bucket, Prefix: strings.Trim(strings.TrimSpace(os.Getenv("LCHAT_HIBERNATE_PREFIX")), "/"), Region: strings.TrimSpace(os.Getenv("LCHAT_HIBERNATE_REGION")), Endpoint: strings.TrimSpace(os.Getenv("LCHAT_HIBERNATE_ENDPOINT")), }) } // runCloud: `lchat cloud` — serve the public provisioning API (fronted by the // router as a 'service' route, e.g. at cloud.elevenmessenger.com). func runCloud(args []string) { fs := flag.NewFlagSet("cloud", flag.ExitOnError) addr := fs.String("addr", envOr("ADDR", ":1749"), "TCP listen address (dev; production uses --socket)") socketPath := fs.String("socket", envOr("LCHAT_SOCKET", ""), "unix socket to serve on instead of -addr") dbPath := fs.String("db", envOr("LCHAT_CLOUD_DB", "cloud.db"), "provisioner SQLite path") baseDomain := fs.String("base-domain", os.Getenv("LCHAT_CLOUD_BASE_DOMAIN"), "domain spaces are created under (required; needs wildcard DNS at this box)") 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 \"\")") quota := fs.Int("quota", envInt("LCHAT_CLOUD_QUOTA", 1), "max free spaces per caller+requester (paid spaces never count)") controlSocket := fs.String("control-socket", envOr("LCHAT_CONTROL_SOCKET", "/run/lchat/control.sock"), "the control daemon's unix socket") 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") reapIntervalHours := fs.Int("reap-interval-hours", envInt("LCHAT_CLOUD_REAP_INTERVAL_HOURS", 6), "how often the reaper sweeps") reapDryRun := fs.Bool("reap-dry-run", os.Getenv("LCHAT_CLOUD_REAP_DRY_RUN") != "", "log what the reaper would reclaim without deleting anything") 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)") meterIntervalHours := fs.Int("meter-interval-hours", envInt("LCHAT_CLOUD_METER_INTERVAL_HOURS", 6), "how often the storage meter sweeps (0 disables)") 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)") demoTTLHours := fs.Int("demo-ttl-hours", envInt("LCHAT_DEMO_TTL_HOURS", 0), "demo-space lifetime; 0 = demos not offered") demoMaxLive := fs.Int("demo-max-live", envInt("LCHAT_DEMO_MAX_LIVE", 10), "max live demo spaces at once") poolMaxShare := fs.Int("pool-max-share", envInt("LCHAT_POOL_MAX_SHARE", 0), "free-pool ceiling as % of total cost (0 = pool gauge off)") poolRevenueShare := fs.Int("pool-revenue-share", envInt("LCHAT_POOL_REVENUE_SHARE", 30), "% of net revenue that funds the free pool") 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") poolDemoShare := fs.Int("pool-demo-share", envInt("LCHAT_POOL_DEMO_SHARE", 20), "% of the pool reserved for demo spaces") poolDryRun := fs.Bool("pool-dry-run", os.Getenv("LCHAT_POOL_DRY_RUN") != "", "compute and log the pool's admission decisions without refusing anything") _ = fs.Parse(args) if *baseDomain == "" || !validHost("x."+*baseDomain) { die("cloud: --base-domain (env LCHAT_CLOUD_BASE_DOMAIN) is required, e.g. elevenmessenger.com") } admin := os.Getenv("LCHAT_ADMIN_TOKEN") if admin == "" { die("cloud: LCHAT_ADMIN_TOKEN is required (gates caller-token minting)") } controlToken, err := readControlToken() if err != nil { die("cloud: %v", err) } db, err := openCloudDB(*dbPath) if err != nil { die("cloud: open db: %v", err) } // The shared global id ledger (multi-edge Phase 1), built only on the // deliberate LCHAT_CLOUD_ID_LEDGER opt-in — see newCloudLedger. ids, err := newCloudLedger(context.Background()) if err != nil { die("cloud: id ledger: %v", err) } storageCaps, seatCaps, err := parseStorageCaps(os.Getenv("LCHAT_CLOUD_STORAGE_CAPS")) if err != nil { die("cloud: LCHAT_CLOUD_STORAGE_CAPS: %v", err) } replicationClasses, err := parseReplicationClasses(os.Getenv("LCHAT_CLOUD_REPLICATION_CLASSES")) if err != nil { die("cloud: LCHAT_CLOUD_REPLICATION_CLASSES: %v", err) } billing, err := newStripeBilling( os.Getenv("LCHAT_STRIPE_SECRET_KEY"), os.Getenv("LCHAT_STRIPE_WEBHOOK_SECRET"), os.Getenv("LCHAT_STRIPE_PRICES"), envOr("LCHAT_STRIPE_API_BASE", "https://api.stripe.com"), ) if err != nil { die("cloud: %v", err) } if billing != nil { billing.portalConfig = os.Getenv("LCHAT_STRIPE_PORTAL_CONFIG") // Per-band portal configurations (docs/pricing-bands.md § Stripe // implementation) — optional JSON {band: configuration id}. if raw := os.Getenv("LCHAT_STRIPE_PORTAL_CONFIGS"); raw != "" { if err := json.Unmarshal([]byte(raw), &billing.portalConfigs); err != nil { die("cloud: LCHAT_STRIPE_PORTAL_CONFIGS: %v (want JSON {band: id})", err) } } // Stripe Tax rides an operator knob, flipped with the band-price // rollout (every live Price must resolve a tax_behavior first). billing.automaticTax = os.Getenv("LCHAT_STRIPE_AUTOMATIC_TAX") == "1" } // The Enterprise Edition page's checkout: off until its own key is set // (sandbox first — the live flip is swapping that one value; enterprise.go). enterprise := newEnterpriseCheckout( os.Getenv("LCHAT_STRIPE_ENTERPRISE_KEY"), envOr("LCHAT_STRIPE_API_BASE", "https://api.stripe.com"), envOr("LCHAT_ENTERPRISE_ORIGIN", "https://elevenmessenger.com"), ) // The /donate page's checkout: off until its own key is set (donate.go). donate := newDonateCheckout( os.Getenv("LCHAT_STRIPE_DONATE_KEY"), envOr("LCHAT_STRIPE_API_BASE", "https://api.stripe.com"), envOr("LCHAT_DONATE_ORIGIN", "https://elevenmessenger.com"), ) poolCosts, err := parsePoolCosts(os.Getenv("LCHAT_POOL_COSTS")) if err != nil { die("cloud: LCHAT_POOL_COSTS: %v", err) } s := &cloudServer{ db: db, adminHash: hashToken(admin), controlToken: controlToken, control: controlClient(*controlSocket), baseDomain: *baseDomain, region: strings.TrimSpace(*region), ids: ids, quota: *quota, reapAfter: time.Duration(*reapAfterDays) * 24 * time.Hour, reapDryRun: *reapDryRun, reapMax: *reapMax, billing: billing, enterprise: enterprise, donate: donate, storageCaps: storageCaps, seatCaps: seatCaps, replicationClasses: replicationClasses, createBudget: *createBudget, demoTTL: time.Duration(*demoTTLHours) * time.Hour, demoMaxLive: *demoMaxLive, usageURL: strings.TrimSpace(os.Getenv("LCHAT_CLOUD_USAGE_URL")), pool: poolConfig{ maxShare: *poolMaxShare, revenueShare: *poolRevenueShare, baseEURDay: *poolBaseEURDay, demoShare: *poolDemoShare, dryRun: *poolDryRun, costs: poolCosts, }, } if s.usageURL != "" { log.Printf("cloud: usage pipe on — meter tokens push %s", s.usageURL) } if s.region != "" { log.Printf("cloud: region %q — new spaces are stamped home_region=%q", s.region, s.region) } if s.ids != nil { log.Printf("cloud: global id ledger on — space hostnames draw from the shared counter (region-neutral namespace)") } aiRatesCfg, err := parseAIRates(strings.TrimSpace(os.Getenv("LCHAT_CLOUD_AI_RATES"))) if err != nil { die("cloud: %v", err) } s.aiRates = aiRatesCfg s.aiAllowanceEUR = envFloat("LCHAT_CLOUD_AI_ALLOWANCE_EUR", 0) s.aiPoolEUR = envFloat("LCHAT_CLOUD_AI_POOL_EUR", 0) if len(s.aiRates) > 0 && s.aiAllowanceEUR > 0 { log.Printf("cloud: AI budget on — allowance €%.2f/space/month, pool €%.2f/month (%d models priced)", s.aiAllowanceEUR, s.aiPoolEUR, len(s.aiRates)) } if *createBudget > 0 { log.Printf("cloud: creation budget on (%d/day fleet-wide)", *createBudget) } if s.demoTTL > 0 { log.Printf("cloud: demo spaces on (TTL %s, max %d live, discarded on expiry)", s.demoTTL, s.demoMaxLive) } if s.pool.enabled() { mode := "admission armed" if s.pool.dryRun { mode = "dry-run, gates nothing" } log.Printf("cloud: free pool on (%s — ceiling %d%% of cost, %d%% of revenue, floor €%.2f/day, demo share %d%%)", mode, s.pool.maxShare, s.pool.revenueShare, s.pool.baseEURDay, s.pool.demoShare) if billing != nil { for _, t := range billing.tiers { if t.NetEURYear <= 0 { log.Printf("cloud: pool: tier %q has no net_eur_year in LCHAT_STRIPE_PRICES — its spaces fund the pool as €0", t.Key) } } } } if len(storageCaps)+len(seatCaps) > 0 { log.Printf("cloud: storage caps on (%d flat, %d per-seat)", len(storageCaps), len(seatCaps)) } if len(replicationClasses) > 0 { log.Printf("cloud: replication classes on (%d entries)", len(replicationClasses)) } if billing != nil { log.Printf("cloud: billing on (%d tiers)", len(billing.tiers)) } go s.reapLoop(time.Duration(*reapIntervalHours) * time.Hour) go s.demoReapLoop() go s.meterLoop(time.Duration(*meterIntervalHours) * time.Hour) ln, where, err := listen(*addr, *socketPath) if err != nil { die("cloud: listen: %v", err) } log.Printf("cloud: provisioning %s spaces on %s (db %s, quota %d)", *baseDomain, where, *dbPath, *quota) srv := &http.Server{Handler: s.handler(), ReadHeaderTimeout: 10 * time.Second} if err := srv.Serve(ln); err != nil { log.Fatal(err) } } // controlClient dials the control daemon's unix socket. Creation waits on a // real instance start + health check, so the timeout is generous. func controlClient(socket string) *http.Client { return &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", socket) }, }, } } // controldMaterializeFunc builds the router's s3Backend.materialize hook // (activator.go): a POST to controld's privileged /instances/{host}/ // materialize endpoint over its local socket, instead of staging the binary // and pointing the live symlink directly — the router runs sandboxed // (ProtectSystem=strict, no write access outside /data and /run/lchat) and // can't do that write itself; controld always runs unsandboxed as root. // // Returns nil when the control token isn't available — no systemd // LoadCredential=control-token:… on this unit, the case for any box that // doesn't run controld (self-hosters, and any box before it's specifically // opted into this via the drop-in — see // deploy/systemd/lchat-router-control-token.conf). A nil hook means // s3Backend.start falls back to the direct calls, today's behavior, // unchanged — so this is purely additive for boxes that don't opt in. func controldMaterializeFunc() func(ctx context.Context, host string) error { token, err := readControlToken() if err != nil { return nil } socket := envOr("LCHAT_CONTROL_SOCKET", "/run/lchat/control.sock") client := controlClient(socket) return func(ctx context.Context, host string) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://control/instances/"+host+"/materialize", nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+token) resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<12)) return fmt.Errorf("controld materialize %s: HTTP %d %s", host, resp.StatusCode, body) } return nil } }