@x47base/pocketbase-addon 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/Dockerfile +9 -1
  2. package/FEATURES.md +17 -0
  3. package/MIGRATION.md +51 -2
  4. package/NOTICE.md +2 -0
  5. package/README.md +53 -13
  6. package/SECURITY-REVIEW.md +96 -0
  7. package/VERIFY.md +34 -0
  8. package/backups/concurrency_test.go +43 -0
  9. package/backups/register.go +11 -0
  10. package/bin/launcher.test.mjs +31 -0
  11. package/bin/pocketbase-extension.mjs +17 -3
  12. package/cmd/edge/main.go +28 -0
  13. package/cmd/gateway/main.go +84 -0
  14. package/cmd/hosting/main.go +27 -0
  15. package/cmd/pocketbase/main.go +12 -0
  16. package/deploy/README.md +35 -6
  17. package/deploy/compose.yaml +5 -0
  18. package/deploy/edge.json +6 -2
  19. package/edge/gateway.go +110 -35
  20. package/edge/openapi.json +226 -1
  21. package/edge/policy.go +23 -12
  22. package/edge/telemetry.go +187 -0
  23. package/edge/telemetry_test.go +181 -0
  24. package/hosting/README.md +62 -0
  25. package/hosting/backups.go +381 -0
  26. package/hosting/backups_test.go +81 -0
  27. package/hosting/blueprint.example.json +14 -0
  28. package/hosting/blueprint.go +242 -0
  29. package/hosting/blueprint_test.go +98 -0
  30. package/hosting/config.go +126 -0
  31. package/hosting/deploy/dns.example.json +1 -0
  32. package/hosting/docs/DEPLOYMENT.md +98 -0
  33. package/hosting/hosting.example.json +1 -0
  34. package/hosting/local_target_test.go +29 -0
  35. package/hosting/scripts/dns.mjs +116 -0
  36. package/hosting/scripts/routes.mjs +39 -0
  37. package/hosting/ui/main.js +36 -0
  38. package/hosting/ui/page.css +86 -0
  39. package/hosting/ui/page.js +112 -0
  40. package/multinode/README.md +87 -0
  41. package/multinode/gateway.docker.json +1 -0
  42. package/multinode/gateway.example.json +1 -0
  43. package/multinode/gateway.go +347 -0
  44. package/multinode/gateway_test.go +217 -0
  45. package/multinode/security_regression_test.go +106 -0
  46. package/package.json +11 -6
  47. package/scripts/check-edge.py +21 -4
  48. package/scripts/check.sh +5 -2
  49. package/security/README.md +42 -9
  50. package/security/config.go +4 -0
  51. package/security/edge_telemetry.go +123 -0
  52. package/security/edge_telemetry_test.go +87 -0
  53. package/security/management.go +3 -1
  54. package/security/openapi.json +269 -0
  55. package/security/security.go +37 -13
  56. package/security/security_test.go +54 -0
  57. package/security/state.go +12 -7
  58. package/security/ui/dashboard.css +9 -2
  59. package/security/ui/dashboard.js +68 -20
  60. package/security/ui/main.js +16 -4
  61. package/security/ui/model.js +23 -1
  62. package/security/ui/model.test.mjs +12 -0
@@ -0,0 +1,27 @@
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "flag"
6
+ hosting "github.com/spink-dev/pocketbase-extension/hosting"
7
+ "log"
8
+ "os"
9
+ )
10
+
11
+ func main() {
12
+ config := flag.String("blueprint", "blueprint.json", "Deployment blueprint")
13
+ out := flag.String("out", "generated", "Output directory")
14
+ flag.Parse()
15
+ raw, err := os.ReadFile(*config)
16
+ if err != nil {
17
+ log.Fatal(err)
18
+ }
19
+ var b hosting.Blueprint
20
+ if err = json.Unmarshal(raw, &b); err != nil {
21
+ log.Fatal(err)
22
+ }
23
+ if err = b.Render(*out); err != nil {
24
+ log.Fatal(err)
25
+ }
26
+ log.Print("Rendered Docker, Kubernetes, routing and DNS plans; no infrastructure was changed")
27
+ }
@@ -6,6 +6,7 @@ import (
6
6
  "github.com/pocketbase/pocketbase/plugins/migratecmd"
7
7
  adapter "github.com/spink-dev/pocketbase-extension"
8
8
  _ "github.com/spink-dev/pocketbase-extension/features"
9
+ "github.com/spink-dev/pocketbase-extension/hosting"
9
10
  "github.com/spink-dev/pocketbase-extension/jsvm"
10
11
  "github.com/spink-dev/pocketbase-extension/web"
11
12
  "log"
@@ -28,11 +29,13 @@ func main() {
28
29
  log.Fatal(err)
29
30
  }
30
31
  config := adapter.DefaultConfig()
32
+ config.Mode = "enforce"
31
33
  statePath, err := filepath.Abs(filepath.Join(app.DataDir(), "security", "state.json"))
32
34
  if err != nil {
33
35
  log.Fatal(err)
34
36
  }
35
37
  config.StatePath = statePath
38
+ config.EdgeStatePath = os.Getenv("PB_SECURITY_EDGE_STATE_PATH")
36
39
  if override := os.Getenv("PB_SECURITY_STATE_PATH"); override != "" {
37
40
  config.StatePath = override
38
41
  }
@@ -64,6 +67,15 @@ func main() {
64
67
  if err := adapter.Register(app, config); err != nil {
65
68
  log.Fatal(err)
66
69
  }
70
+ if path := os.Getenv("HOSTING_ADDON_CONFIG"); path != "" {
71
+ c, err := hosting.Load(path)
72
+ if err != nil {
73
+ log.Fatal(err)
74
+ }
75
+ if _, err := hosting.Register(app, c); err != nil {
76
+ log.Fatal(err)
77
+ }
78
+ }
67
79
  jsvm.MustRegister(app, jsvm.Config{HooksDir: hooksDir, MigrationsDir: migrationsDir, HooksWatch: watchHooks, HooksPoolSize: 15})
68
80
  migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{Dir: migrationsDir, Automigrate: automigrate, TemplateLang: migratecmd.TemplateLangJS})
69
81
  app.OnServe().BindFunc(func(e *core.ServeEvent) error {
package/deploy/README.md CHANGED
@@ -5,7 +5,7 @@ has no published port. Use this as a single-host deployment template, with upstr
5
5
  TLS/DDoS protection appropriate to your hosting provider.
6
6
 
7
7
  ```sh
8
- cd /Users/x47base/git/spink-dev_pocketbase-extension
8
+ cd /path/to/pocketbase-addon
9
9
  docker compose -f deploy/compose.yaml up --build -d --wait
10
10
  docker compose -f deploy/compose.yaml logs pocketbase
11
11
  ```
@@ -19,7 +19,7 @@ your existing development instance on 8090 and its admin account are untouched.
19
19
  |---|---|
20
20
  | `http://127.0.0.1:8080` | Public API: explicitly allowed collections only |
21
21
  | `http://127.0.0.1:8081/_/` | Native admin, Security, Backups, x47base extensions |
22
- | `http://127.0.0.1:8081/edge/status` | Gateway accepted/rejected counts, active work, readiness |
22
+ | `http://127.0.0.1:8081/edge/status` | Gateway traffic, rejection reasons, upload bytes, active work and readiness |
23
23
  | `http://127.0.0.1:8081/edge/healthz` | Gateway process liveness; no database call |
24
24
  | `http://127.0.0.1:8081/edge/readyz` | Policy validity plus cached upstream health |
25
25
 
@@ -35,6 +35,8 @@ flowchart LR
35
35
  Lane --> DB
36
36
  DB --> State[Security checkpoint volume]
37
37
  State -. read only, one second poll .-> Edge
38
+ Edge --> Telemetry[Separate aggregate telemetry volume]
39
+ Telemetry -. read only, one second poll .-> DB
38
40
  App[Versioned migrations and hooks] --> DB
39
41
  ```
40
42
 
@@ -74,10 +76,20 @@ remains available to diagnose and recover. Preserve the separate security volume
74
76
  when replacing the database; it is not included in database backup archives.
75
77
 
76
78
  Defaults: 50 public requests/second (burst 100), 20 per client (burst 40), 32 active
77
- requests, 1MiB bodies, 4096 tracked clients. No waiting queue is added. New identities
79
+ requests, at most 4 concurrent requests per client, 1MiB bodies, 4096 tracked clients. No waiting queue is added. New identities
78
80
  are denied when the bounded table is full; idle entries expire after a minute.
79
81
  Public bodies, including chunked bodies, are read and size-checked **before** any
80
- upstream side effect. Aggregate configured body buffering is capped at 128MiB.
82
+ upstream side effect. Declared oversize bodies are rejected before reading. Unknown
83
+ length bodies read at most the configured maximum plus one detection byte. Buffers
84
+ have fixed capacity, avoiding geometric growth beyond the configured body budget
85
+ (128MiB total plus at most one byte per active request). `bodyReadTimeoutSeconds`
86
+ defaults to 5 (range 1–10); `maxClientConcurrent` defaults to min(4, maxConcurrent).
87
+ Timed-out uploads return 408 and size violations return 413; failed reads return
88
+ 400. Rejections close HTTP/1 connections and expire the read deadline so unread
89
+ uploads are not drained before responding. The timeout uses Go's
90
+ [ResponseController](https://pkg.go.dev/net/http#ResponseController.SetReadDeadline).
91
+ The per-client budget shares capacity behind a NAT unless a trusted proxy supplies
92
+ a sanitized client address.
81
93
  Public connections cap at 256, headers at 16KiB, header reads at 3 seconds, request
82
94
  reads at 10 seconds, writes at 30 seconds. Realtime streams must reconnect after
83
95
  the public write deadline; they share the public concurrency budget.
@@ -130,8 +142,25 @@ Health checks report health; Docker restart policy restarts exited processes,
130
142
  not merely unhealthy ones. Rejection reduces database work; it cannot guarantee
131
143
  availability during link saturation, host exhaustion, disk failure or costly
132
144
  allowed queries. Use provider/CDN DDoS protection, backups and external monitoring.
133
- Gateway rejections appear in `/edge/status`; PocketBase incidents only describe
134
- traffic that reaches PocketBase, so the two counts intentionally differ.
145
+ Gateway rejections appear in `/edge/status` and **Security Overview / Incidents**
146
+ through the dedicated telemetry volume. Both sources are labeled; do not sum their
147
+ request counts. The UI reports stale/missing telemetry instead of implying safety.
148
+ See [metric semantics and retention](../security/README.md#firewall-telemetry).
149
+
150
+ ## Upgrade an existing deployment
151
+
152
+ Rebuild **both** images and recreate both services using the updated Compose file:
153
+
154
+ ```sh
155
+ docker compose -f deploy/compose.yaml up --build -d --wait
156
+ ```
157
+
158
+ Keep the existing Compose project name and data/security volumes. Do not use
159
+ `down -v`. The new telemetry volume is automatically created with non-root
160
+ ownership. Custom Compose configurations must add `PB_SECURITY_EDGE_STATE_PATH`,
161
+ the edge `-telemetry` argument and the separate volume mounts from this template.
162
+ Reload `http://127.0.0.1:8081/_/#/security` after deployment. A source update alone
163
+ does not change an already-running image or a published npm installation.
135
164
 
136
165
  ## Verification and stopping
137
166
 
@@ -16,6 +16,7 @@ services:
16
16
  environment:
17
17
  PB_SECURITY_MODE: enforce
18
18
  PB_SECURITY_STATE_PATH: /state/state.json
19
+ PB_SECURITY_EDGE_STATE_PATH: /telemetry/edge.json
19
20
  PB_SECURITY_OPERATOR_PEERS: 172.30.80.2/32
20
21
  PB_SECURITY_TRUSTED_PEERS: 172.30.80.2/32
21
22
  PB_SECURITY_MANAGEMENT_PEERS: 172.30.80.2/32,127.0.0.0/8,::1/128
@@ -23,6 +24,7 @@ services:
23
24
  volumes:
24
25
  - data:/pb_data
25
26
  - security:/state
27
+ - telemetry:/telemetry:ro
26
28
  networks:
27
29
  backend:
28
30
  ipv4_address: 172.30.80.3
@@ -34,6 +36,7 @@ services:
34
36
  context: ..
35
37
  target: edge
36
38
  image: spink-pocketbase-edge:local
39
+ command: ["-telemetry", "/telemetry/edge.json"]
37
40
  restart: unless-stopped
38
41
  read_only: true
39
42
  cap_drop: [ALL]
@@ -48,6 +51,7 @@ services:
48
51
  volumes:
49
52
  - ./edge.json:/etc/spink/edge.json:ro
50
53
  - security:/state:ro
54
+ - telemetry:/telemetry
51
55
  depends_on:
52
56
  pocketbase:
53
57
  condition: service_healthy
@@ -66,3 +70,4 @@ networks:
66
70
  volumes:
67
71
  data: {}
68
72
  security: {}
73
+ telemetry: {}
package/deploy/edge.json CHANGED
@@ -1,5 +1,7 @@
1
1
  {
2
- "collections": ["notes"],
2
+ "collections": [
3
+ "notes"
4
+ ],
3
5
  "blockedCIDRs": [],
4
6
  "blockedPaths": [],
5
7
  "trustedProxies": [],
@@ -9,5 +11,7 @@
9
11
  "clientBurst": 40,
10
12
  "maxClients": 4096,
11
13
  "maxConcurrent": 32,
12
- "maxBodyBytes": 1048576
14
+ "maxBodyBytes": 1048576,
15
+ "maxClientConcurrent": 4,
16
+ "bodyReadTimeoutSeconds": 5
13
17
  }
package/edge/gateway.go CHANGED
@@ -5,6 +5,7 @@ import (
5
5
  "context"
6
6
  _ "embed"
7
7
  "encoding/json"
8
+ "errors"
8
9
  "io"
9
10
  "net"
10
11
  "net/http"
@@ -52,18 +53,22 @@ type Gateway struct {
52
53
  global bucket
53
54
  clients map[netip.Addr]*bucket
54
55
  active int
56
+ clientActive map[netip.Addr]int
57
+ metrics *telemetry
55
58
  admin chan struct{}
56
59
  proxy *httputil.ReverseProxy
57
60
  upstreamHealthy atomic.Bool
58
- accepted atomic.Uint64
59
- rejected atomic.Uint64
60
61
  }
61
62
 
62
63
  func New(upstream *url.URL, p Policy) (*Gateway, error) {
63
64
  if err := p.Validate(); err != nil {
64
65
  return nil, err
65
66
  }
66
- g := &Gateway{policy: p, clients: map[netip.Addr]*bucket{}, admin: make(chan struct{}, 8)}
67
+ metrics, err := newTelemetry()
68
+ if err != nil {
69
+ return nil, err
70
+ }
71
+ g := &Gateway{metrics: metrics, clientActive: map[netip.Addr]int{}, policy: p, clients: map[netip.Addr]*bucket{}, admin: make(chan struct{}, security.OperatorConcurrency)}
67
72
  g.proxy = &httputil.ReverseProxy{
68
73
  Rewrite: func(r *httputil.ProxyRequest) {
69
74
  r.SetURL(upstream)
@@ -82,8 +87,17 @@ func New(upstream *url.URL, p Policy) (*Gateway, error) {
82
87
  },
83
88
  Transport: &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second}).DialContext, MaxIdleConns: 128, MaxIdleConnsPerHost: 128, IdleConnTimeout: 60 * time.Second, ResponseHeaderTimeout: 10 * time.Second, MaxResponseHeaderBytes: 64 << 10},
84
89
  ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
90
+ if r.Context().Value(operatorKey{}) != true {
91
+ g.metrics.upstreamFailure()
92
+ }
85
93
  http.Error(w, "upstream unavailable", http.StatusBadGateway)
86
94
  },
95
+ ModifyResponse: func(r *http.Response) error {
96
+ if r.StatusCode >= 500 && r.Request.Context().Value(operatorKey{}) != true {
97
+ g.metrics.upstreamFailure()
98
+ }
99
+ return nil
100
+ },
87
101
  FlushInterval: -1,
88
102
  }
89
103
  return g, nil
@@ -101,13 +115,21 @@ func (g *Gateway) Apply(p Policy, s Snapshot, valid bool) {
101
115
  }
102
116
  func (g *Gateway) SetHealthy(v bool) { g.upstreamHealthy.Store(v) }
103
117
  func (g *Gateway) reject(w http.ResponseWriter, code int) {
104
- g.rejected.Add(1)
105
118
  w.Header().Set("Cache-Control", "no-store")
106
119
  if code == 429 || code == 503 {
107
120
  w.Header().Set("Retry-After", "1")
108
121
  }
109
122
  http.Error(w, http.StatusText(code), code)
110
123
  }
124
+ func (g *Gateway) rejectPublic(w http.ResponseWriter, r *http.Request, code int, reason string, read uint64) {
125
+ g.metrics.outcome(reason, read, r.ContentLength)
126
+ // Do not let net/http drain an unread upload before sending the rejection.
127
+ if r.ProtoMajor == 1 {
128
+ w.Header().Set("Connection", "close")
129
+ }
130
+ _ = http.NewResponseController(w).SetReadDeadline(time.Now())
131
+ g.reject(w, code)
132
+ }
111
133
  func canonical(r *http.Request) bool {
112
134
  // Reject ambiguous decoding instead of normalizing differently from the backend.
113
135
  return r.URL.RawPath == "" && !strings.ContainsAny(r.URL.Path, "\\%\x00") && path.Clean(r.URL.Path) == strings.TrimSuffix(r.URL.Path, "/") && !strings.Contains(r.URL.Path, "//")
@@ -166,92 +188,125 @@ func publicPath(r *http.Request, p Policy) bool {
166
188
  return len(parts) >= 4 && (parts[3] == "records" || strings.HasPrefix(parts[3], "auth-") || strings.HasPrefix(parts[3], "request-") || strings.HasPrefix(parts[3], "confirm-"))
167
189
  }
168
190
  func (g *Gateway) Public(w http.ResponseWriter, r *http.Request) {
191
+ g.metrics.begin()
169
192
  if !canonical(r) {
170
- g.reject(w, 400)
193
+ g.rejectPublic(w, r, 400, "invalid_path", 0)
171
194
  return
172
195
  }
173
196
  g.mu.Lock()
174
197
  p := g.policy
175
198
  ip, ok := client(r, p)
176
- code := 0
199
+ code, reason := 0, ""
177
200
  now := time.Now()
178
201
  switch {
179
202
  case !g.valid:
180
- code = 503
203
+ code, reason = 503, "policy_unavailable"
181
204
  case !ok:
182
- code = 400
205
+ code, reason = 400, "invalid_client"
206
+ case r.ContentLength > p.MaxBodyBytes:
207
+ code, reason = 413, "body_too_large"
183
208
  case contains(p.blocked, ip):
184
- code = 403
209
+ code, reason = 403, "blocked_network"
185
210
  case !publicPath(r, p):
186
- code = 403
211
+ code, reason = 403, "private_route"
187
212
  }
188
213
  if code == 0 {
189
214
  for _, prefix := range p.BlockedPaths {
190
215
  if strings.HasPrefix(r.URL.Path, prefix) {
191
- code = 403
216
+ code, reason = 403, "blocked_path"
192
217
  break
193
218
  }
194
219
  }
195
220
  }
196
221
  if code == 0 && g.snapshot.Policy.Mode == "enforce" {
197
222
  if a, exists := g.snapshot.Actions[security.RequestFamily(r)]; exists && a.ExpiresAt.After(now) {
198
- code = 403
223
+ code, reason = 403, "family_block"
199
224
  }
200
225
  }
201
226
  if code == 0 && !g.global.take(now, p.Rate, p.Burst) {
202
- code = 429
227
+ code, reason = 429, "global_rate"
203
228
  }
204
229
  if code == 0 {
205
230
  b := g.clients[ip]
206
231
  if b == nil {
207
232
  if len(g.clients) >= p.MaxClients {
208
233
  for key, old := range g.clients {
209
- if now.Sub(old.updated) > time.Minute {
234
+ if now.Sub(old.updated) > time.Minute && g.clientActive[key] == 0 {
210
235
  delete(g.clients, key)
211
236
  }
212
237
  }
213
238
  }
214
239
  if len(g.clients) >= p.MaxClients {
215
- code = 429
240
+ code, reason = 429, "client_capacity"
216
241
  } else {
217
242
  b = &bucket{}
218
243
  g.clients[ip] = b
219
244
  }
220
245
  }
221
246
  if b != nil && !b.take(now, p.ClientRate, p.ClientBurst) {
222
- code = 429
247
+ code, reason = 429, "client_rate"
223
248
  }
224
249
  }
225
250
  if code == 0 {
226
251
  if g.active >= p.MaxConcurrent {
227
- code = 503
252
+ code, reason = 503, "global_concurrency"
253
+ } else if g.clientActive[ip] >= p.MaxClientConcurrent {
254
+ code, reason = 429, "client_concurrency"
228
255
  } else {
229
256
  g.active++
257
+ g.clientActive[ip]++
230
258
  }
231
259
  }
232
260
  g.mu.Unlock()
233
261
  if code != 0 {
234
- g.reject(w, code)
262
+ g.rejectPublic(w, r, code, reason, 0)
235
263
  return
236
264
  }
237
- defer func() { g.mu.Lock(); g.active--; g.mu.Unlock() }()
238
- if r.ContentLength > p.MaxBodyBytes {
239
- g.reject(w, 413)
240
- return
241
- }
242
- // Fully bound and validate chunked bodies before any upstream side effect.
243
- if r.Body != nil {
244
- b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, p.MaxBodyBytes))
245
- r.Body.Close()
246
- if err != nil {
247
- g.reject(w, 413)
265
+ defer func() {
266
+ g.mu.Lock()
267
+ g.active--
268
+ g.clientActive[ip]--
269
+ if g.clientActive[ip] == 0 {
270
+ delete(g.clientActive, ip)
271
+ }
272
+ g.mu.Unlock()
273
+ }()
274
+ var read uint64
275
+ if r.Body != nil && r.Body != http.NoBody {
276
+ controller := http.NewResponseController(w)
277
+ _ = controller.SetReadDeadline(time.Now().Add(time.Duration(p.BodyReadTimeoutSeconds) * time.Second))
278
+ // Allocate a fixed upper bound; ReadAll's growing buffers can exceed the body budget.
279
+ size := p.MaxBodyBytes + 1
280
+ if r.ContentLength >= 0 {
281
+ size = min(size, r.ContentLength+1)
282
+ }
283
+ body := make([]byte, int(size))
284
+ n, err := readBoundedBody(r.Body, body)
285
+ read = uint64(n)
286
+ if int64(n) > p.MaxBodyBytes {
287
+ g.rejectPublic(w, r, 413, "body_too_large", read)
248
288
  return
249
289
  }
250
- r.Body = io.NopCloser(bytes.NewReader(b))
251
- r.ContentLength = int64(len(b))
290
+ var timeout net.Error
291
+ if errors.As(err, &timeout) && timeout.Timeout() {
292
+ g.rejectPublic(w, r, 408, "body_timeout", read)
293
+ return
294
+ }
295
+ if err != nil && err != io.EOF {
296
+ g.rejectPublic(w, r, 400, "body_read_error", read)
297
+ return
298
+ }
299
+ if r.ContentLength >= 0 && int64(n) != r.ContentLength {
300
+ g.rejectPublic(w, r, 400, "body_read_error", read)
301
+ return
302
+ }
303
+ r.Body.Close()
304
+ _ = controller.SetReadDeadline(time.Time{})
305
+ r.Body = io.NopCloser(bytes.NewReader(body[:n]))
306
+ r.ContentLength = int64(n)
252
307
  }
253
308
  r.RemoteAddr = net.JoinHostPort(ip.String(), "0")
254
- g.accepted.Add(1)
309
+ g.metrics.outcome("", read, 0)
255
310
  g.proxy.ServeHTTP(w, r)
256
311
  }
257
312
  func (g *Gateway) Operator(w http.ResponseWriter, r *http.Request) {
@@ -274,9 +329,7 @@ func (g *Gateway) Operator(w http.ResponseWriter, r *http.Request) {
274
329
  w.WriteHeader(204)
275
330
  return
276
331
  case "/edge/status":
277
- g.mu.Lock()
278
- state := map[string]any{"policyReady": g.valid, "upstreamHealthy": g.upstreamHealthy.Load(), "active": g.active, "trackedClients": len(g.clients), "accepted": g.accepted.Load(), "rejected": g.rejected.Load()}
279
- g.mu.Unlock()
332
+ state := g.Telemetry()
280
333
  w.Header().Set("Content-Type", "application/json")
281
334
  w.Header().Set("Cache-Control", "no-store")
282
335
  json.NewEncoder(w).Encode(state)
@@ -293,3 +346,25 @@ func (g *Gateway) Operator(w http.ResponseWriter, r *http.Request) {
293
346
  r = r.WithContext(context.WithValue(r.Context(), operatorKey{}, true))
294
347
  g.proxy.ServeHTTP(w, r)
295
348
  }
349
+
350
+ // Preserve malformed/chopped chunked-body errors instead of converting EOF to
351
+ // ErrUnexpectedEOF, which would make a valid short body indistinguishable.
352
+ func readBoundedBody(reader io.Reader, buffer []byte) (int, error) {
353
+ total, empty := 0, 0
354
+ for total < len(buffer) {
355
+ n, err := reader.Read(buffer[total:])
356
+ total += n
357
+ if err != nil {
358
+ return total, err
359
+ }
360
+ if n == 0 {
361
+ empty++
362
+ if empty >= 100 {
363
+ return total, io.ErrNoProgress
364
+ }
365
+ } else {
366
+ empty = 0
367
+ }
368
+ }
369
+ return total, nil
370
+ }