@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
package/edge/openapi.json CHANGED
@@ -1 +1,226 @@
1
- {"openapi":"3.1.0","info":{"title":"Spink edge local diagnostics","version":"0.1.0"},"servers":[{"url":"http://127.0.0.1:8081"}],"paths":{"/edge/healthz":{"get":{"summary":"Gateway process liveness","responses":{"204":{"description":"Alive"}}}},"/edge/readyz":{"get":{"summary":"Validated policy and cached upstream health","responses":{"204":{"description":"Ready"},"503":{"description":"Not ready"}}}},"/edge/status":{"get":{"summary":"Local-only aggregate counters; no database credentials","responses":{"200":{"description":"Status","content":{"application/json":{"schema":{"type":"object","properties":{"policyReady":{"type":"boolean"},"upstreamHealthy":{"type":"boolean"},"active":{"type":"integer"},"trackedClients":{"type":"integer"},"accepted":{"type":"integer"},"rejected":{"type":"integer"}}}}}}}}}}}
1
+ {
2
+ "openapi": "3.1.0",
3
+ "info": {
4
+ "title": "Spink edge local diagnostics",
5
+ "version": "0.1.0"
6
+ },
7
+ "servers": [
8
+ {
9
+ "url": "http://127.0.0.1:8081"
10
+ }
11
+ ],
12
+ "paths": {
13
+ "/edge/healthz": {
14
+ "get": {
15
+ "summary": "Gateway process liveness",
16
+ "responses": {
17
+ "204": {
18
+ "description": "Alive"
19
+ }
20
+ }
21
+ }
22
+ },
23
+ "/edge/readyz": {
24
+ "get": {
25
+ "summary": "Validated policy and cached upstream health",
26
+ "responses": {
27
+ "204": {
28
+ "description": "Ready"
29
+ },
30
+ "503": {
31
+ "description": "Not ready"
32
+ }
33
+ }
34
+ }
35
+ },
36
+ "/edge/status": {
37
+ "get": {
38
+ "summary": "Local-only aggregate counters; no database credentials",
39
+ "responses": {
40
+ "200": {
41
+ "description": "Status",
42
+ "content": {
43
+ "application/json": {
44
+ "schema": {
45
+ "type": "object",
46
+ "properties": {
47
+ "version": {
48
+ "type": "integer"
49
+ },
50
+ "active": {
51
+ "type": "integer"
52
+ },
53
+ "trackedClients": {
54
+ "type": "integer"
55
+ },
56
+ "requests": {
57
+ "type": "integer"
58
+ },
59
+ "forwarded": {
60
+ "type": "integer"
61
+ },
62
+ "accepted": {
63
+ "type": "integer"
64
+ },
65
+ "rejected": {
66
+ "type": "integer"
67
+ },
68
+ "bodyReadBytes": {
69
+ "type": "integer"
70
+ },
71
+ "declaredRejectedBytes": {
72
+ "type": "integer"
73
+ },
74
+ "upstreamFailures": {
75
+ "type": "integer"
76
+ },
77
+ "maxBodyBytes": {
78
+ "type": "integer"
79
+ },
80
+ "bodyReadTimeoutSeconds": {
81
+ "type": "integer"
82
+ },
83
+ "maxConcurrent": {
84
+ "type": "integer"
85
+ },
86
+ "maxClientConcurrent": {
87
+ "type": "integer"
88
+ },
89
+ "session": {
90
+ "type": "string"
91
+ },
92
+ "startedAt": {
93
+ "type": "string",
94
+ "format": "date-time"
95
+ },
96
+ "updatedAt": {
97
+ "type": "string",
98
+ "format": "date-time"
99
+ },
100
+ "policyReady": {
101
+ "type": "boolean"
102
+ },
103
+ "upstreamHealthy": {
104
+ "type": "boolean"
105
+ },
106
+ "reasons": {
107
+ "type": "object",
108
+ "additionalProperties": {
109
+ "type": "integer"
110
+ }
111
+ },
112
+ "traffic": {
113
+ "type": "array",
114
+ "maxItems": 60,
115
+ "items": {
116
+ "type": "object",
117
+ "properties": {
118
+ "at": {
119
+ "type": "string",
120
+ "format": "date-time"
121
+ },
122
+ "seconds": {
123
+ "type": "number"
124
+ },
125
+ "count": {
126
+ "type": "integer"
127
+ },
128
+ "rejected": {
129
+ "type": "integer"
130
+ },
131
+ "failures": {
132
+ "type": "integer"
133
+ },
134
+ "slow": {
135
+ "type": "integer"
136
+ }
137
+ }
138
+ }
139
+ },
140
+ "incidents": {
141
+ "type": "array",
142
+ "maxItems": 128,
143
+ "items": {
144
+ "type": "object",
145
+ "properties": {
146
+ "id": {
147
+ "type": "integer"
148
+ },
149
+ "source": {
150
+ "type": "string",
151
+ "enum": [
152
+ "edge",
153
+ "pocketbase"
154
+ ]
155
+ },
156
+ "sourceId": {
157
+ "type": "string"
158
+ },
159
+ "reason": {
160
+ "type": "string"
161
+ },
162
+ "severity": {
163
+ "type": "string",
164
+ "enum": [
165
+ "notice",
166
+ "warning"
167
+ ]
168
+ },
169
+ "family": {
170
+ "type": "string"
171
+ },
172
+ "phase": {
173
+ "type": "string",
174
+ "enum": [
175
+ "active",
176
+ "recovering",
177
+ "resolved"
178
+ ]
179
+ },
180
+ "firstSeen": {
181
+ "type": "string",
182
+ "format": "date-time"
183
+ },
184
+ "lastSeen": {
185
+ "type": "string",
186
+ "format": "date-time"
187
+ },
188
+ "acknowledged": {
189
+ "type": "boolean"
190
+ },
191
+ "evidence": {
192
+ "type": "object",
193
+ "properties": {
194
+ "count": {
195
+ "type": "integer"
196
+ },
197
+ "rejected": {
198
+ "type": "integer"
199
+ },
200
+ "failures": {
201
+ "type": "integer"
202
+ },
203
+ "slow": {
204
+ "type": "integer"
205
+ },
206
+ "bodyReadBytes": {
207
+ "type": "integer"
208
+ },
209
+ "declaredRejectedBytes": {
210
+ "type": "integer"
211
+ }
212
+ }
213
+ }
214
+ }
215
+ }
216
+ }
217
+ }
218
+ }
219
+ }
220
+ }
221
+ }
222
+ }
223
+ }
224
+ }
225
+ }
226
+ }
package/edge/policy.go CHANGED
@@ -14,21 +14,32 @@ import (
14
14
  )
15
15
 
16
16
  type Policy struct {
17
- Collections []string `json:"collections"`
18
- BlockedCIDRs []string `json:"blockedCIDRs"`
19
- BlockedPaths []string `json:"blockedPaths"`
20
- TrustedProxies []string `json:"trustedProxies"`
21
- Rate int `json:"rate"`
22
- Burst int `json:"burst"`
23
- ClientRate int `json:"clientRate"`
24
- ClientBurst int `json:"clientBurst"`
25
- MaxClients int `json:"maxClients"`
26
- MaxConcurrent int `json:"maxConcurrent"`
27
- MaxBodyBytes int64 `json:"maxBodyBytes"`
28
- blocked, trusted []netip.Prefix
17
+ Collections []string `json:"collections"`
18
+ BlockedCIDRs []string `json:"blockedCIDRs"`
19
+ BlockedPaths []string `json:"blockedPaths"`
20
+ TrustedProxies []string `json:"trustedProxies"`
21
+ Rate int `json:"rate"`
22
+ Burst int `json:"burst"`
23
+ ClientRate int `json:"clientRate"`
24
+ ClientBurst int `json:"clientBurst"`
25
+ MaxClients int `json:"maxClients"`
26
+ MaxConcurrent int `json:"maxConcurrent"`
27
+ MaxClientConcurrent int `json:"maxClientConcurrent"`
28
+ BodyReadTimeoutSeconds int `json:"bodyReadTimeoutSeconds"`
29
+ MaxBodyBytes int64 `json:"maxBodyBytes"`
30
+ blocked, trusted []netip.Prefix
29
31
  }
30
32
 
31
33
  func (p *Policy) Validate() error {
34
+ if p.MaxClientConcurrent == 0 {
35
+ p.MaxClientConcurrent = min(4, p.MaxConcurrent)
36
+ }
37
+ if p.BodyReadTimeoutSeconds == 0 {
38
+ p.BodyReadTimeoutSeconds = 5
39
+ }
40
+ if p.MaxClientConcurrent < 1 || p.MaxClientConcurrent > p.MaxConcurrent || p.BodyReadTimeoutSeconds < 1 || p.BodyReadTimeoutSeconds > 10 {
41
+ return errors.New("invalid per-client concurrency or body timeout")
42
+ }
32
43
  if p.Rate < 1 || p.Rate > 100000 || p.Burst < 1 || p.Burst > 100000 || p.ClientRate < 1 || p.ClientRate > 10000 || p.ClientBurst < 1 || p.ClientBurst > 10000 || p.MaxClients < 1 || p.MaxClients > 100000 || p.MaxConcurrent < 1 || p.MaxConcurrent > 1024 || p.MaxBodyBytes < 1 || p.MaxBodyBytes > 32<<20 {
33
44
  return errors.New("invalid edge limits")
34
45
  }
@@ -0,0 +1,187 @@
1
+ package edge
2
+
3
+ import (
4
+ "crypto/rand"
5
+ "encoding/hex"
6
+ "encoding/json"
7
+ "maps"
8
+ "os"
9
+ "path/filepath"
10
+ "strconv"
11
+ "sync"
12
+ "time"
13
+
14
+ "github.com/spink-dev/pocketbase-extension/security"
15
+ )
16
+
17
+ type rejectionBucket struct {
18
+ second int64
19
+ count int
20
+ }
21
+
22
+ type telemetry struct {
23
+ recent map[string]*[10]rejectionBucket
24
+ mu sync.Mutex
25
+ state security.EdgeTelemetry
26
+ sequence uint64
27
+ }
28
+
29
+ func newTelemetry() (*telemetry, error) {
30
+ var id [16]byte
31
+ if _, err := rand.Read(id[:]); err != nil {
32
+ return nil, err
33
+ }
34
+ return &telemetry{recent: map[string]*[10]rejectionBucket{}, state: security.EdgeTelemetry{Version: 1, Session: hex.EncodeToString(id[:]), StartedAt: time.Now(), Reasons: map[string]uint64{}}}, nil
35
+ }
36
+ func (t *telemetry) window(now time.Time) *security.TrafficWindow {
37
+ at := now.Truncate(time.Second)
38
+ if len(t.state.Traffic) == 0 || !t.state.Traffic[len(t.state.Traffic)-1].At.Equal(at) {
39
+ t.state.Traffic = append(t.state.Traffic, security.TrafficWindow{At: at, Seconds: 1})
40
+ }
41
+ cut := 0
42
+ for cut < len(t.state.Traffic) && t.state.Traffic[cut].At.Before(at.Add(-59*time.Second)) {
43
+ cut++
44
+ }
45
+ if cut > 0 {
46
+ t.state.Traffic = append([]security.TrafficWindow(nil), t.state.Traffic[cut:]...)
47
+ }
48
+ return &t.state.Traffic[len(t.state.Traffic)-1]
49
+ }
50
+ func (t *telemetry) begin() {
51
+ t.mu.Lock()
52
+ defer t.mu.Unlock()
53
+ t.state.Requests++
54
+ t.window(time.Now()).Count++
55
+ }
56
+ func (t *telemetry) outcome(reason string, read uint64, declared int64) {
57
+ t.mu.Lock()
58
+ defer t.mu.Unlock()
59
+ now := time.Now()
60
+ t.state.BodyReadBytes += read
61
+ if reason == "" {
62
+ t.state.Forwarded++
63
+ return
64
+ }
65
+ t.state.Rejected++
66
+ t.state.Reasons[reason]++
67
+ t.window(now).Rejected++
68
+ claimed := uint64(max(0, declared))
69
+ t.state.DeclaredRejectedBytes = saturatedAdd(t.state.DeclaredRejectedBytes, claimed)
70
+ idx := -1
71
+ for i := len(t.state.Incidents) - 1; i >= 0; i-- {
72
+ event := t.state.Incidents[i]
73
+ if event.Reason == reason && now.Sub(event.LastSeen) < time.Minute {
74
+ idx = i
75
+ break
76
+ }
77
+ }
78
+ if idx < 0 {
79
+ t.sequence++
80
+ t.state.Incidents = append(t.state.Incidents, security.Incident{ID: t.sequence, Source: "edge", SourceID: strconv.FormatUint(t.sequence, 10), Family: "other", Reason: reason, Severity: "notice", FirstSeen: now})
81
+ if len(t.state.Incidents) > 128 {
82
+ t.state.Incidents = t.state.Incidents[1:]
83
+ }
84
+ idx = len(t.state.Incidents) - 1
85
+ }
86
+ event := &t.state.Incidents[idx]
87
+ event.LastSeen = now
88
+ event.Phase = "active"
89
+ event.Evidence.Family = "other"
90
+ event.Evidence.Count++
91
+ event.Evidence.Rejected++
92
+ event.Evidence.BodyReadBytes += read
93
+ event.Evidence.DeclaredRejectedBytes = saturatedAdd(event.Evidence.DeclaredRejectedBytes, claimed)
94
+ // Every denial is retained immediately. Severity describes evidence, not attacker intent.
95
+ if reason == "body_too_large" || reason == "body_timeout" {
96
+ event.Severity = "warning"
97
+ }
98
+ window := t.recent[reason]
99
+ if window == nil {
100
+ window = new([10]rejectionBucket)
101
+ t.recent[reason] = window
102
+ }
103
+ second := now.Unix()
104
+ slot := &window[second%10]
105
+ if slot.second != second {
106
+ *slot = rejectionBucket{second: second}
107
+ }
108
+ slot.count++
109
+ recent := 0
110
+ for _, bucket := range window {
111
+ if second-bucket.second < 10 {
112
+ recent += bucket.count
113
+ }
114
+ }
115
+ if recent >= 20 {
116
+ event.Severity = "warning"
117
+ }
118
+ }
119
+ func (t *telemetry) upstreamFailure() {
120
+ t.mu.Lock()
121
+ defer t.mu.Unlock()
122
+ t.state.UpstreamFailures++
123
+ t.window(time.Now()).Failures++
124
+ }
125
+ func (g *Gateway) Telemetry() security.EdgeTelemetry {
126
+ g.metrics.mu.Lock()
127
+ now := time.Now()
128
+ g.metrics.window(now)
129
+ v := g.metrics.state
130
+ v.UpdatedAt = now
131
+ v.Accepted = v.Forwarded
132
+ v.Reasons = maps.Clone(v.Reasons)
133
+ v.Traffic = append([]security.TrafficWindow{}, v.Traffic...)
134
+ v.Incidents = append([]security.Incident{}, v.Incidents...)
135
+ for i := range v.Incidents {
136
+ quiet := now.Sub(v.Incidents[i].LastSeen)
137
+ if quiet >= time.Minute {
138
+ v.Incidents[i].Phase = "resolved"
139
+ } else if quiet >= 5*time.Second {
140
+ v.Incidents[i].Phase = "recovering"
141
+ }
142
+ }
143
+ g.metrics.mu.Unlock()
144
+ g.mu.Lock()
145
+ v.PolicyReady = g.valid
146
+ v.Active = g.active
147
+ v.TrackedClients = len(g.clients)
148
+ v.MaxBodyBytes = g.policy.MaxBodyBytes
149
+ v.BodyReadTimeoutSeconds = g.policy.BodyReadTimeoutSeconds
150
+ v.MaxConcurrent = g.policy.MaxConcurrent
151
+ v.MaxClientConcurrent = g.policy.MaxClientConcurrent
152
+ g.mu.Unlock()
153
+ v.UpstreamHealthy = g.upstreamHealthy.Load()
154
+ return v
155
+ }
156
+
157
+ // WriteTelemetry publishes once per second, never once per rejected request.
158
+ // The telemetry volume is writable only by the edge; policy remains read-only.
159
+ func (g *Gateway) WriteTelemetry(path string) error {
160
+ raw, err := json.Marshal(g.Telemetry())
161
+ if err != nil {
162
+ return err
163
+ }
164
+ if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil {
165
+ return err
166
+ }
167
+ f, err := os.CreateTemp(filepath.Dir(path), ".edge-*")
168
+ if err != nil {
169
+ return err
170
+ }
171
+ defer os.Remove(f.Name())
172
+ if _, err = f.Write(raw); err != nil {
173
+ f.Close()
174
+ return err
175
+ }
176
+ if err = f.Close(); err != nil {
177
+ return err
178
+ }
179
+ return os.Rename(f.Name(), path)
180
+ }
181
+
182
+ func saturatedAdd(a, b uint64) uint64 {
183
+ if ^uint64(0)-a < b {
184
+ return ^uint64(0)
185
+ }
186
+ return a + b
187
+ }
@@ -0,0 +1,181 @@
1
+ package edge
2
+
3
+ import (
4
+ "bufio"
5
+ "fmt"
6
+ "io"
7
+ "net"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "path/filepath"
11
+ "strings"
12
+ "testing"
13
+ "time"
14
+
15
+ "github.com/spink-dev/pocketbase-extension/security"
16
+ )
17
+
18
+ type unreadBody struct{}
19
+
20
+ func (unreadBody) Read([]byte) (int, error) { panic("oversized body must not be read") }
21
+ func (unreadBody) Close() error { return nil }
22
+
23
+ func TestOversizeBurstIsVisibleWithoutReadingOrForwarding(t *testing.T) {
24
+ g, n := fixture(t, policy(), nil)
25
+ for i := 0; i < 25; i++ {
26
+ r := req("/api/collections/notes/records")
27
+ r.Method = "POST"
28
+ r.ContentLength = 1 << 30
29
+ r.Body = unreadBody{}
30
+ w := httptest.NewRecorder()
31
+ g.Public(w, r)
32
+ if w.Code != 413 {
33
+ t.Fatal(w.Code)
34
+ }
35
+ }
36
+ v := g.Telemetry()
37
+ if n.Load() != 0 || v.Requests != 25 || v.Rejected != 25 || v.Forwarded != 0 || v.BodyReadBytes != 0 || v.DeclaredRejectedBytes != 25<<30 || v.Reasons["body_too_large"] != 25 {
38
+ t.Fatalf("wrong counters: %+v", v)
39
+ }
40
+ if len(v.Incidents) != 1 || v.Incidents[0].Evidence.Count != 25 || v.Incidents[0].Severity != "warning" {
41
+ t.Fatalf("missing burst: %+v", v.Incidents)
42
+ }
43
+ file := filepath.Join(t.TempDir(), "edge.json")
44
+ if err := g.WriteTelemetry(file); err != nil {
45
+ t.Fatal(err)
46
+ }
47
+ imported, err := security.ReadEdgeTelemetry(file, time.Now())
48
+ if err != nil || imported.Rejected != 25 {
49
+ t.Fatal(imported, err)
50
+ }
51
+ if _, err = security.ReadEdgeTelemetry(file, time.Now().Add(6*time.Second)); err == nil {
52
+ t.Fatal("stale data accepted")
53
+ }
54
+ }
55
+ func TestChunkedBodyBoundAndCounters(t *testing.T) {
56
+ g, n := fixture(t, policy(), nil)
57
+ r := req("/api/collections/notes/records")
58
+ r.Method = "POST"
59
+ r.ContentLength = -1
60
+ r.Body = io.NopCloser(strings.NewReader(strings.Repeat("x", 2048)))
61
+ w := httptest.NewRecorder()
62
+ g.Public(w, r)
63
+ v := g.Telemetry()
64
+ if w.Code != 413 || n.Load() != 0 || v.BodyReadBytes != 1025 || v.DeclaredRejectedBytes != 0 {
65
+ t.Fatalf("code %d telemetry %+v", w.Code, v)
66
+ }
67
+ }
68
+ func TestSlowBodyAndImmediateLargeHeaderRejection(t *testing.T) {
69
+ p := policy()
70
+ p.BodyReadTimeoutSeconds = 1
71
+ g, n := fixture(t, p, nil)
72
+ server := httptest.NewServer(http.HandlerFunc(g.Public))
73
+ defer server.Close()
74
+ for _, size := range []int64{1 << 30, 100} {
75
+ conn, err := net.Dial("tcp", strings.TrimPrefix(server.URL, "http://"))
76
+ if err != nil {
77
+ t.Fatal(err)
78
+ }
79
+ conn.SetDeadline(time.Now().Add(3 * time.Second))
80
+ start := time.Now()
81
+ fmt.Fprintf(conn, "POST /api/collections/notes/records HTTP/1.1\r\nHost: test\r\nContent-Length: %d\r\n\r\n", size)
82
+ response, err := http.ReadResponse(bufio.NewReader(conn), nil)
83
+ if err != nil {
84
+ conn.Close()
85
+ t.Fatal(err)
86
+ }
87
+ want := 408
88
+ if size > 1024 {
89
+ want = 413
90
+ if time.Since(start) > 500*time.Millisecond {
91
+ t.Error("large request waited for body")
92
+ }
93
+ }
94
+ if response.StatusCode != want {
95
+ t.Errorf("got %d want %d", response.StatusCode, want)
96
+ }
97
+ response.Body.Close()
98
+ conn.Close()
99
+ }
100
+ v := g.Telemetry()
101
+ if n.Load() != 0 || v.Reasons["body_timeout"] != 1 || v.Reasons["body_too_large"] != 1 {
102
+ t.Fatal(v)
103
+ }
104
+ }
105
+ func TestClientConcurrencyPreservesOtherClientCapacity(t *testing.T) {
106
+ entered := make(chan struct{}, 1)
107
+ release := make(chan struct{})
108
+ p := policy()
109
+ p.MaxClientConcurrent = 1
110
+ g, _ := fixture(t, p, func(w http.ResponseWriter, r *http.Request) {
111
+ if r.Header.Get("Hold") == "1" {
112
+ entered <- struct{}{}
113
+ <-release
114
+ }
115
+ w.WriteHeader(204)
116
+ })
117
+ done := make(chan struct{})
118
+ go func() {
119
+ defer close(done)
120
+ r := req("/api/health")
121
+ r.Header.Set("Hold", "1")
122
+ g.Public(httptest.NewRecorder(), r)
123
+ }()
124
+ <-entered
125
+ w := httptest.NewRecorder()
126
+ g.Public(w, req("/api/health"))
127
+ if w.Code != 429 {
128
+ t.Error(w.Code)
129
+ }
130
+ other := req("/api/health")
131
+ other.RemoteAddr = "198.51.100.2:1234"
132
+ w = httptest.NewRecorder()
133
+ g.Public(w, other)
134
+ if w.Code != 204 {
135
+ t.Error(w.Code)
136
+ }
137
+ close(release)
138
+ <-done
139
+ if g.Telemetry().Reasons["client_concurrency"] != 1 {
140
+ t.Fatal(g.Telemetry())
141
+ }
142
+ }
143
+ func TestEveryDenialRetainedAndOperatorExcluded(t *testing.T) {
144
+ g, _ := fixture(t, policy(), nil)
145
+ for i := 0; i < 40; i++ {
146
+ g.Public(httptest.NewRecorder(), req("/_/"))
147
+ }
148
+ g.Operator(httptest.NewRecorder(), req("/edge/status"))
149
+ v := g.Telemetry()
150
+ if v.Requests != 40 || v.Reasons["private_route"] != 40 || v.Incidents[0].Evidence.Count != 40 {
151
+ t.Fatal(v)
152
+ }
153
+ g.metrics.mu.Lock()
154
+ g.metrics.state.Incidents[0].LastSeen = time.Now().Add(-time.Minute)
155
+ g.metrics.mu.Unlock()
156
+ if g.Telemetry().Incidents[0].Phase != "resolved" {
157
+ t.Fatal("event not resolved after quiet period")
158
+ }
159
+ }
160
+
161
+ func TestTruncatedChunkedUploadNeverReachesUpstream(t *testing.T) {
162
+ g, n := fixture(t, policy(), nil)
163
+ server := httptest.NewServer(http.HandlerFunc(g.Public))
164
+ defer server.Close()
165
+ conn, err := net.Dial("tcp", strings.TrimPrefix(server.URL, "http://"))
166
+ if err != nil {
167
+ t.Fatal(err)
168
+ }
169
+ defer conn.Close()
170
+ conn.SetDeadline(time.Now().Add(3 * time.Second))
171
+ fmt.Fprint(conn, "POST /api/collections/notes/records HTTP/1.1\r\nHost: test\r\nTransfer-Encoding: chunked\r\n\r\n64\r\nshort")
172
+ conn.(*net.TCPConn).CloseWrite()
173
+ response, err := http.ReadResponse(bufio.NewReader(conn), nil)
174
+ if err != nil {
175
+ t.Fatal(err)
176
+ }
177
+ defer response.Body.Close()
178
+ if response.StatusCode != 400 || n.Load() != 0 || g.Telemetry().Reasons["body_read_error"] != 1 {
179
+ t.Fatal(response.StatusCode, n.Load(), g.Telemetry())
180
+ }
181
+ }