| 1 | package 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 | |
| 12 | import ( |
| 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. |
| 35 | const 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. |
| 44 | var spaceSqids = mustSqids() |
| 45 | |
| 46 | func 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. |
| 58 | func 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). |
| 65 | func 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. |
| 77 | type 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. |
| 144 | type claimLink struct { |
| 145 | Link string |
| 146 | At time.Time |
| 147 | } |
| 148 | |
| 149 | func (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. |
| 199 | type callerKey struct{} |
| 200 | |
| 201 | func (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 | |
| 212 | func (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. |
| 231 | func (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. |
| 248 | func (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). |
| 399 | func (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. |
| 431 | func (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. |
| 496 | func (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 | |
| 518 | func (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. |
| 542 | func (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 | |
| 552 | func (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 | |
| 569 | func (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 | |
| 578 | func (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). |
| 594 | func 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. |
| 628 | func 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). |
| 646 | func 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. |
| 836 | func 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. |
| 861 | func 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 | |