@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.
- package/Dockerfile +9 -1
- package/FEATURES.md +17 -0
- package/MIGRATION.md +51 -2
- package/NOTICE.md +2 -0
- package/README.md +53 -13
- package/SECURITY-REVIEW.md +96 -0
- package/VERIFY.md +34 -0
- package/backups/concurrency_test.go +43 -0
- package/backups/register.go +11 -0
- package/bin/launcher.test.mjs +31 -0
- package/bin/pocketbase-extension.mjs +17 -3
- package/cmd/edge/main.go +28 -0
- package/cmd/gateway/main.go +84 -0
- package/cmd/hosting/main.go +27 -0
- package/cmd/pocketbase/main.go +12 -0
- package/deploy/README.md +35 -6
- package/deploy/compose.yaml +5 -0
- package/deploy/edge.json +6 -2
- package/edge/gateway.go +110 -35
- package/edge/openapi.json +226 -1
- package/edge/policy.go +23 -12
- package/edge/telemetry.go +187 -0
- package/edge/telemetry_test.go +181 -0
- package/hosting/README.md +62 -0
- package/hosting/backups.go +381 -0
- package/hosting/backups_test.go +81 -0
- package/hosting/blueprint.example.json +14 -0
- package/hosting/blueprint.go +242 -0
- package/hosting/blueprint_test.go +98 -0
- package/hosting/config.go +126 -0
- package/hosting/deploy/dns.example.json +1 -0
- package/hosting/docs/DEPLOYMENT.md +98 -0
- package/hosting/hosting.example.json +1 -0
- package/hosting/local_target_test.go +29 -0
- package/hosting/scripts/dns.mjs +116 -0
- package/hosting/scripts/routes.mjs +39 -0
- package/hosting/ui/main.js +36 -0
- package/hosting/ui/page.css +86 -0
- package/hosting/ui/page.js +112 -0
- package/multinode/README.md +87 -0
- package/multinode/gateway.docker.json +1 -0
- package/multinode/gateway.example.json +1 -0
- package/multinode/gateway.go +347 -0
- package/multinode/gateway_test.go +217 -0
- package/multinode/security_regression_test.go +106 -0
- package/package.json +11 -6
- package/scripts/check-edge.py +21 -4
- package/scripts/check.sh +5 -2
- package/security/README.md +42 -9
- package/security/config.go +4 -0
- package/security/edge_telemetry.go +123 -0
- package/security/edge_telemetry_test.go +87 -0
- package/security/management.go +3 -1
- package/security/openapi.json +269 -0
- package/security/security.go +37 -13
- package/security/security_test.go +54 -0
- package/security/state.go +12 -7
- package/security/ui/dashboard.css +9 -2
- package/security/ui/dashboard.js +68 -20
- package/security/ui/main.js +16 -4
- package/security/ui/model.js +23 -1
- package/security/ui/model.test.mjs +12 -0
package/edge/openapi.json
CHANGED
|
@@ -1 +1,226 @@
|
|
|
1
|
-
{
|
|
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
|
|
18
|
-
BlockedCIDRs
|
|
19
|
-
BlockedPaths
|
|
20
|
-
TrustedProxies
|
|
21
|
-
Rate
|
|
22
|
-
Burst
|
|
23
|
-
ClientRate
|
|
24
|
-
ClientBurst
|
|
25
|
-
MaxClients
|
|
26
|
-
MaxConcurrent
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
}
|