carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3import (
4 "context"
5 "net"
6 "net/http"
7 "net/url"
8 "strings"
9 "time"
10
11 "github.com/carlosframework/platform/internal/analytics"
12 "github.com/carlosframework/platform/internal/registry"
13 "github.com/carlosframework/platform/internal/release"
14)
15
16// AnalyticsMeter is where the edge files one observation per response
17// for an app whose analytics are switched on (spec §3.3). analytics.Meter
18// satisfies it; the interface keeps the dependency on the contract.
19type AnalyticsMeter interface {
20 Observe(analytics.Obs)
21}
22
23type analyticsMeterRef struct{ AnalyticsMeter }
24
25// EnableAnalytics wires the meter in; nil is a valid no-op.
26func (e *Edge) EnableAnalytics(m AnalyticsMeter) {
27 if m == nil {
28 e.analyticsMeter.Store(nil)
29 return
30 }
31 e.analyticsMeter.Store(&analyticsMeterRef{m})
32}
33
34// SetAnalyticsOn replaces the switch map: sqid+"/"+app → counted. The
35// host's telemetry pass pushes it on every serial-triggered full pass and
36// every five minutes (spec §3.3, "converged by boxes on the next pass").
37// The map is COPIED before it is stored, so a caller may keep and reuse
38// the map it passed: the stored copy is read by every serving goroutine
39// without a lock, and a caller that went on writing to a shared map would
40// turn that into Go's fatal concurrent map read-and-write — an edge-wide
41// crash, not a race one recovers from.
42func (e *Edge) SetAnalyticsOn(on map[string]bool) {
43 if on == nil {
44 e.analyticsOn.Store(nil)
45 return
46 }
47 cp := make(map[string]bool, len(on))
48 for k, v := range on {
49 cp[k] = v
50 }
51 e.analyticsOn.Store(&cp)
52}
53
54func (e *Edge) analyticsEnabled(rt registry.Route) bool {
55 m := e.analyticsOn.Load()
56 if m == nil {
57 return false
58 }
59 return (*m)[release.RouteAccount(rt)+"/"+rt.App]
60}
61
62// withHopVerified marks ctx as a request that arrived over a proven mesh
63// hop, exactly as acceptHop does (homemap.go). Test-only in practice —
64// nothing on the serving path may put this mark on a request, which is the
65// whole property clientIP's X-Forwarded-For trust rests on.
66func withHopVerified(ctx context.Context) context.Context {
67 return context.WithValue(ctx, hopVerifiedKey{}, true)
68}
69
70// clientIP is the address the visitor hash uses (spec §3.3): the last
71// element of the verified inbound X-Forwarded-For chain on a proven hop,
72// otherwise RemoteAddr's host. X-Forwarded-For is read ONLY under a proven
73// hop, whose mark nothing outside acceptHop can put on a request; on every
74// other request the header is ignored whether it is present or not, so a
75// header a client set can never reach the visitor hash.
76func clientIP(r *http.Request) string {
77 if hopVerified(r.Context()) {
78 chain := strings.Join(r.Header.Values("X-Forwarded-For"), ", ")
79 if i := strings.LastIndexByte(chain, ','); i >= 0 {
80 chain = chain[i+1:]
81 }
82 if ip := strings.TrimSpace(chain); ip != "" {
83 return ip
84 }
85 }
86 host, _, err := net.SplitHostPort(r.RemoteAddr)
87 if err != nil {
88 return r.RemoteAddr
89 }
90 return host
91}
92
93// isPrefetchPurpose reports whether a Sec-Purpose (or legacy Purpose)
94// header value says this request is a prefetch. The value is a token list:
95// Chrome sends a bare "prefetch" for link prefetches and
96// "prefetch;prerender" for a speculation-rules prerender, which also
97// carries Sec-Fetch-Dest: document and so would otherwise be counted as a
98// pageview nobody looked at. Only the first token decides.
99func isPrefetchPurpose(v string) bool {
100 first, _, _ := strings.Cut(v, ";")
101 return strings.EqualFold(strings.TrimSpace(first), "prefetch")
102}
103
104// observeAnalytics files one Obs when the route's app is switched on. It
105// runs inside the observer defer in ServeHTTP, after the response has
106// completed, and reads only: the latched status and Content-Type off the
107// counting writer, the byte count, and the request's own headers. No
108// wrapper is added for it and no path here can wake an instance.
109func (e *Edge) observeAnalytics(rt registry.Route, r *http.Request, ob *countingWriter, bytes int64) {
110 ref := e.analyticsMeter.Load()
111 if ref == nil || !e.analyticsEnabled(rt) {
112 return
113 }
114 // One observation per served response (geoip spec §9, codex R4-1): the
115 // hub that forwarded this over the tunnel already observed it, from its
116 // own cache hit or from this response.
117 if fromTunnel(r.Context()) {
118 return
119 }
120 geoAns, _ := geoAnswerFrom(r.Context())
121 refHost := ""
122 if u, err := url.Parse(r.Header.Get("Referer")); err == nil {
123 refHost = u.Hostname()
124 }
125 prefetch := isPrefetchPurpose(r.Header.Get("Sec-Purpose")) || isPrefetchPurpose(r.Header.Get("Purpose"))
126 ref.Observe(analytics.Obs{
127 Acct: release.RouteAccount(rt), App: rt.App, Host: rt.Host,
128 Method: r.Method, Status: ob.status, ContentType: ob.ctype,
129 Path: r.URL.Path, RefererHost: refHost, UA: r.Header.Get("User-Agent"),
130 ClientIP: clientIP(r), Bytes: bytes,
131 Prefetch: prefetch, FetchDest: strings.ToLower(r.Header.Get("Sec-Fetch-Dest")),
132 When: time.Now(),
133 Country: geoAns.Country, GeoSource: geoAns.Source, GeoState: string(geoAns.State),
134 })
135}
136