package calls import ( "errors" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "amadan.net/oficina/calls/internal/calls/hub" "amadan.net/oficina/calls/internal/calls/relay" ) const ( // iceLife and iceRenewAfter are §5.2's numbers: a call's credentials // are minted for three hours, inside the platform contract's // four-hour maximum, and the browser is told to renew at 150 // minutes (§5.3 step 4) — well ahead of expiry, so a slow renewal // never runs out mid-call. iceLife = 3 * time.Hour iceRenewAfter = 150 * time.Minute // testIceHeader shortens one answer's life, and only in development // (like stream.go's testTTLHeader): a plain GET can set it, unlike // EventSource, so this one needs no browser gate to reach — the // development gate is what keeps it off production. testIceHeader = "X-Calls-Test-Ice-TTL" minIceLife = 2 * time.Second ) // ice answers GET /m/{sqid}/ice?peer= with the RTCIceServer list for one // call (§5.2). Authority is the §2.5 last row — an admitted // participation bound to this session — through hub.Authorize, which // also applies the member re-derivation of §15.12. Every refusal is the // same 404 as /signal's, so nothing about another session's // participation is observable here. Nothing is logged on success: the // answer carries a credential. func (a *app) ice(w http.ResponseWriter, r *http.Request) { sqid := chi.URLParam(r, "sqid") session, ok := a.sessionKey(r) if !ok { a.logger.Error("calls: ice with no session subject", "path", r.URL.Path) streamJSON(w, http.StatusInternalServerError, nil) return } if !validSqid(sqid) { a.logger.Warn("calls: ice refused", "room", "invalid", "peer", "", "outcome", "shape") streamJSON(w, http.StatusNotFound, a.notFoundBody()) return } peer := r.URL.Query().Get("peer") if !validPeerID(hub.PeerID(peer)) { // peer is never logged here, exactly like signal.go's refusal: // it has not been validated as bounded, printable text (review // Important 2's rule, carried over). a.logger.Warn("calls: ice refused", "room", sqid, "peer", "", "outcome", "shape") streamJSON(w, http.StatusNotFound, a.notFoundBody()) return } // A session with no live participation in this exact room is // refused the identical not-found answer before the store or the // limiter is ever touched — signal.go's own LiveForSession gate // (review Task 5 item 1), carried over here: nothing is spent and // nothing is read for a room this session holds nothing in. if _, live := a.hub.LiveForSession(sqid, session); !live { a.logger.Warn("calls: ice refused", "room", sqid, "peer", "", "outcome", "unbound") streamJSON(w, http.StatusNotFound, a.notFoundBody()) return } // Rate limiting shares /signal's own bucket: session+room, never // anything the request claims, so a burst against either endpoint // from one session spends the one budget it can ever spend. if allowed, retry := a.signalLimiter.allow(session+"|"+sqid, a.store.now()); !allowed { w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1)) a.logger.Warn("calls: ice refused", "room", sqid, "peer", peer, "outcome", "rate") streamJSON(w, http.StatusTooManyRequests, map[string]any{"error": "rate"}) return } // The meeting itself must be live: a fresh process holds no hub // state for a room nobody has opened, so ended_at is the only // authority it has — stream.go's own fresh-open precedent. This is // checked independently of the hub's own admitted state: the hub // may still think a participation here is live and admitted (ruling // 14 keeps an admitted guest through a lapsed grant, and nothing // tells the hub a meeting ended except the End call an ending // request makes), so this store read is what a still-admitted // participation is refused through once the meeting is over. m, found, err := a.store.meetingBySqid(r.Context(), sqid) if err != nil { a.logger.Error("calls: ice: load meeting", "err", err) streamJSON(w, http.StatusInternalServerError, nil) return } if !found || m.EndedAt != nil { a.logger.Warn("calls: ice refused", "room", sqid, "peer", peer, "outcome", "meeting") streamJSON(w, http.StatusNotFound, a.notFoundBody()) return } switch err := a.hub.Authorize(r.Context(), sqid, session, hub.PeerID(peer)); { case err == nil: case errors.Is(err, hub.ErrUnknownPeer), errors.Is(err, hub.ErrNotAdmitted), errors.Is(err, hub.ErrNotYours): a.logger.Warn("calls: ice refused", "room", sqid, "peer", peer, "outcome", "authority") streamJSON(w, http.StatusNotFound, a.notFoundBody()) return default: a.logger.Error("calls: ice: authorize", "room", sqid, "peer", peer, "outcome", "internal", "err", err) streamJSON(w, http.StatusInternalServerError, nil) return } now := a.store.now() life, renew := iceLife, iceRenewAfter if a.cfg.development() { if d := time.Duration(a.iceLifeTest.Load()); d > 0 { life = min(max(d, minIceLife), iceLife) renew = life * 5 / 6 } if d, err := time.ParseDuration(r.Header.Get(testIceHeader)); err == nil { // Clamped both ways: a header cannot hand time.NewTimer a // zero or a negative any more than stream.go's own // testTTLHeader can (the floor), and cannot mint credentials // that outlive the platform's own four-hour ceiling either // (the cap, at this handler's three-hour default). life = min(max(d, minIceLife), iceLife) renew = life * 5 / 6 } } regions := a.relayRegions() if len(regions) == 0 { streamJSON(w, http.StatusOK, map[string]any{"iceServers": []relay.ICEServer{}, "expiresAt": 0, "renewAt": 0}) return } expiry := now.Add(life) streamJSON(w, http.StatusOK, map[string]any{ "iceServers": relay.Mint(regions, expiry), "expiresAt": expiry.Unix(), "renewAt": now.Add(renew).Unix(), }) }