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// Demo spaces, slice 1 (docs/demo-spaces.md): the ledger tier, the
4// unconditional TTL reap, and no-backups-by-construction. A demo is a real
5// instance anyone will be able to try without an account, reaped 24 hours
6// after creation no matter what's in it — the death is the contract, disclosed
7// at birth.
8//
9// This slice ships DARK: nothing sends kind=demo yet (home doesn't offer it,
10// and the whole path answers demo_unavailable unless LCHAT_DEMO_TTL_HOURS is
11// set). The abuse gates that make the path safe to expose — per-IP semaphore +
12// cooldown in home, adaptive proof-of-work — are the next slice; the only
13// gates here are the live-demo cap and the shared creates_log budget, enough
14// to bound the dark path.
15//
16// No backups is structural, not configured away: controld forces
17// Hibernate=false for a demo create, so a demo is never activator-managed —
18// no S3 lease, no litestream replica, no parked prefix, ever (the request-cost
19// class #836 killed stays killed). The price is that a demo holds a
20// materialized slot for its 24h life; the live-demo cap bounds how many. And
21// the reap DISCARDS instead of archiving to /data/trash — the one deliberate
22// exception to archive-everything, safe because the 24h death was disclosed
23// from the first second.
24
25import (
26 "context"
27 "encoding/json"
28 "fmt"
29 "io"
30 "log"
31 "net/http"
32 "strings"
33 "time"
34)
35
36// createDemoSpace is createSpaceHandler's kind=demo branch: no quota, no
37// self-reclaim — a demo's bounds are its own cap and its TTL.
38func (s *cloudServer) createDemoSpace(ctx context.Context, w http.ResponseWriter, callerID, requester string) {
39 if s.demoTTL <= 0 {
40 writeControlErr(w, http.StatusNotImplemented, "demo_unavailable", "demo spaces aren't offered here")
41 return
42 }
43 n, err := s.db.countDemos()
44 if err != nil {
45 writeControlErr(w, http.StatusInternalServerError, "create_failed", "db error")
46 return
47 }
48 if n >= s.demoMaxLive {
49 writeControlErr(w, http.StatusServiceUnavailable, "demo_busy",
50 "every demo slot is taken right now — they free up quickly, try again in a few minutes")
51 return
52 }
53
54 id, err := s.db.allocateDemoSpace(callerID, requester)
55 if err != nil {
56 writeControlErr(w, http.StatusInternalServerError, "create_failed", "db error")
57 return
58 }
59 // Same fork as the regular create path (cloud.go spaceHostID): the shared
60 // global ledger when configured, else this demo row's cloud.db PK — today's
61 // behaviour unchanged on any box without the shared bucket. The PK still
62 // keys the row for the reaper/claim/finalize; only the address's id moves.
63 hostID, err := s.spaceHostID(ctx, id)
64 if err != nil {
65 s.db.deleteSpaceByID(id)
66 log.Printf("cloud: id ledger allocate for demo id %d: %v", id, err)
67 writeControlErr(w, http.StatusServiceUnavailable, "create_failed", "address allocation is unavailable")
68 return
69 }
70 slug, err := spaceSlug(hostID)
71 if err != nil {
72 s.db.deleteSpaceByID(id)
73 writeControlErr(w, http.StatusInternalServerError, "create_failed", "address generation failed")
74 return
75 }
76 host := slug + "." + s.baseDomain
77
78 status, body, err := s.controlCreate(host, entDemo, "")
79 if err != nil {
80 s.db.deleteSpaceByID(id)
81 log.Printf("cloud: control call for demo %s: %v", host, err)
82 writeControlErr(w, http.StatusBadGateway, "create_failed", "the box's control daemon is unreachable")
83 return
84 }
85 switch {
86 case status == http.StatusCreated:
87 if err := s.db.finalizeSpace(id, host); err != nil {
88 log.Printf("cloud: ledger finalize for demo %s (id %d) failed: %v", host, id, err)
89 }
90 log.Printf("cloud: created demo %s (id %d, caller %.8s…, dies in %s)", host, id, callerID, s.demoTTL)
91 s.pushQuota(id, host, entDemo, "", 0) // the tiny demo cap (no-op unless configured)
92 writeJSON(w, http.StatusCreated, map[string]any{
93 "host": host,
94 "url": body["url"],
95 "setup_link": body["setup_link"],
96 "kind": entDemo,
97 "expires_at": time.Now().Add(s.demoTTL).Unix(),
98 })
99 case status == http.StatusServiceUnavailable:
100 s.db.deleteSpaceByID(id)
101 writeControlErr(w, http.StatusServiceUnavailable, "at_capacity", spaceUnavailableMsg)
102 default:
103 s.db.deleteSpaceByID(id)
104 msg, _ := body["message"].(string)
105 log.Printf("cloud: control create demo %s: HTTP %d %s", host, status, msg)
106 writeControlErr(w, http.StatusBadGateway, "create_failed", "space creation failed")
107 }
108}
109
110// demoReapLoop sweeps expired demos. Hourly and separate from the main
111// reaper's (default 6h) cadence: the countdown banner promises "24 hours", so
112// the overshoot should be minutes-to-an-hour, not a workday.
113func (s *cloudServer) demoReapLoop() {
114 if s.demoTTL <= 0 {
115 return
116 }
117 mode := ""
118 if s.reapDryRun {
119 mode = " (dry-run — logs only, deletes nothing)"
120 }
121 log.Printf("cloud: demo reaper on%s — demos die %s after creation, swept hourly, discarded (no trash archive)", mode, s.demoTTL)
122 for {
123 s.demoReapOnce(time.Now())
124 time.Sleep(time.Hour)
125 }
126}
127
128// demoReapOnce deletes every demo past its TTL. UNCONDITIONALLY — no stats
129// call, no guards: content does not protect a demo (deliberate inversion of
130// the main reaper's three guards, per docs/demo-spaces.md — the death was
131// disclosed at birth and on a countdown banner ever since). The scope is
132// bounded by construction: the query selects entitlement='demo' only, so the
133// worst a bug here can cost is demos dying early. Data is discarded, not
134// archived (the demo contract's one exception to archive-everything).
135func (s *cloudServer) demoReapOnce(now time.Time) (reaped int) {
136 cutoff := now.Add(-s.demoTTL).Unix()
137 cands, err := s.db.demosPastTTL(cutoff)
138 if err != nil {
139 log.Printf("cloud: demo reaper: ledger query: %v", err)
140 return 0
141 }
142 for _, c := range cands {
143 if s.reapDryRun {
144 log.Printf("cloud: demo reaper: WOULD discard %s (id %d)", c.Host, c.ID)
145 continue
146 }
147 // A pending row (create died between allocate and finalize) holds a
148 // demo slot with no instance behind it — ledger-only cleanup.
149 if c.Host == "" {
150 if err := s.db.deleteSpaceByID(c.ID); err != nil {
151 log.Printf("cloud: demo reaper: ledger delete pending id %d: %v", c.ID, err)
152 }
153 continue
154 }
155 status, body, err := s.controlDelete(c.Host, true)
156 if err != nil {
157 log.Printf("cloud: demo reaper: delete %s failed: %v", c.Host, err)
158 continue
159 }
160 // unknown_host = already gone (an operator removed it); still clear the row.
161 if status != http.StatusOK && body["error"] != "unknown_host" {
162 log.Printf("cloud: demo reaper: delete %s: HTTP %d", c.Host, status)
163 continue
164 }
165 if err := s.db.deleteSpaceByID(c.ID); err != nil {
166 log.Printf("cloud: demo reaper: ledger delete %s (id %d): %v", c.Host, c.ID, err)
167 }
168 log.Printf("cloud: demo reaper: discarded expired demo %s (id %d, lived its %s)", c.Host, c.ID, s.demoTTL)
169 reaped++
170 }
171 return reaped
172}
173
174// claimDemoHandler: POST /api/instances/{host}/claim — convert a demo into
175// the claiming account's free space (docs/demo-spaces.md, the happy exit).
176// Ownership TRANSFERS: a demo belongs to a one-shot anonymous requester until
177// someone signs in and claims it; from then on it's an ordinary free space —
178// it counts against the account's quota, the demo reaper no longer sees it
179// (the entitlement flip is the TTL clear), and the free tier's rules apply.
180// No pool gate here: the space's spend was already counted while it was a
181// demo, so a claim adds no net-new cost — refusing one at red would only
182// destroy data the pool has already paid for.
183func (s *cloudServer) claimDemoHandler(w http.ResponseWriter, r *http.Request) {
184 callerID := r.Context().Value(callerKey{}).(string)
185 host := strings.ToLower(strings.TrimSpace(r.PathValue("host")))
186 var req struct {
187 Requester string `json:"requester"`
188 }
189 if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil {
190 writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
191 return
192 }
193 req.Requester = strings.TrimSpace(req.Requester)
194 if req.Requester == "" || len(req.Requester) > 128 {
195 writeControlErr(w, http.StatusBadRequest, "bad_request", "requester required (opaque id, ≤128 chars)")
196 return
197 }
198
199 id, entitlement, createdAt, ok, err := s.db.spaceByHost(host)
200 if err != nil {
201 writeControlErr(w, http.StatusInternalServerError, "claim_failed", "db error")
202 return
203 }
204 if !ok {
205 writeControlErr(w, http.StatusNotFound, "not_found", "no such space")
206 return
207 }
208 if entitlement != entDemo {
209 writeControlErr(w, http.StatusConflict, "not_demo", "this space isn't a demo")
210 return
211 }
212 // Racing the reaper: a demo past its lifetime is already dead by
213 // contract, even if the sweep hasn't collected it yet.
214 if s.demoTTL > 0 && time.Now().Unix() > createdAt+int64(s.demoTTL/time.Second) {
215 writeControlErr(w, http.StatusGone, "demo_expired", "this demo's 24 hours are up — it can no longer be claimed")
216 return
217 }
218 // The claim consumes the account's free slot, same rules as a create —
219 // including the create path's courtesy reclaim of their own dead weight.
220 if n, err := s.db.countFreeSpaces(callerID, req.Requester); err != nil {
221 writeControlErr(w, http.StatusInternalServerError, "claim_failed", "db error")
222 return
223 } else if n >= s.quota {
224 if !s.reclaimOwnSpace(callerID, req.Requester) {
225 noun := "spaces"
226 if s.quota == 1 {
227 noun = "space"
228 }
229 writeControlErr(w, http.StatusConflict, "quota_exceeded",
230 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))
231 return
232 }
233 }
234
235 claimed, err := s.db.claimDemoSpace(id, callerID, req.Requester)
236 if err != nil {
237 writeControlErr(w, http.StatusInternalServerError, "claim_failed", "db error")
238 return
239 }
240 if !claimed {
241 // Lost a race — another claim or the reaper got there first.
242 writeControlErr(w, http.StatusConflict, "not_demo", "this demo was just claimed or expired")
243 return
244 }
245 log.Printf("cloud: demo %s (id %d) claimed by caller %.8s… — now a free space", host, id, callerID)
246 // Flip the instance's own view: entitlement env + the free tier's storage
247 // cap, and record the free replication class for when the space is later
248 // flipped onto the activator (hibernation/backups are an operator or
249 // follow-up concern — the claim itself never restarts anything).
250 s.pushQuota(id, host, entFree, "", 0)
251 s.pushReplication(host, entFree, "")
252 writeJSON(w, http.StatusOK, map[string]any{
253 "host": host, "url": "https://" + host, "entitlement": entFree,
254 })
255}
256
257// demoStatusHandler: GET /api/instances/{host}/demo-status — the countdown
258// banner's live truth. A claim flips the ledger WITHOUT restarting the
259// instance (deliberate — see claimDemoHandler), so the instance's boot-time
260// LCHAT_ENTITLEMENT can read stale-demo for a space that's already safe; the
261// banner asks here (via home's public relay) before promising anyone a death.
262// One shape for claimed, ordinary, and never-existed hosts — the answer is
263// only ever "still a demo, and until when", so the endpoint can't be used to
264// probe which hosts exist or what anyone pays.
265func (s *cloudServer) demoStatusHandler(w http.ResponseWriter, r *http.Request) {
266 host := strings.ToLower(strings.TrimSpace(r.PathValue("host")))
267 _, entitlement, createdAt, ok, err := s.db.spaceByHost(host)
268 if err != nil {
269 writeControlErr(w, http.StatusInternalServerError, "status_failed", "db error")
270 return
271 }
272 if !ok || entitlement != entDemo {
273 writeJSON(w, http.StatusOK, map[string]any{"demo": false})
274 return
275 }
276 out := map[string]any{"demo": true}
277 // TTL unset (demos since turned off, row predates that) leaves the expiry
278 // unknown rather than inventing one; the banner falls back to generic copy.
279 if s.demoTTL > 0 {
280 out["expires_at"] = createdAt + int64(s.demoTTL/time.Second)
281 }
282 writeJSON(w, http.StatusOK, out)
283}
284