@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
|
@@ -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
|
+
}
|
package/cmd/pocketbase/main.go
CHANGED
|
@@ -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 /
|
|
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
|
|
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.
|
|
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
|
|
134
|
-
|
|
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
|
|
package/deploy/compose.yaml
CHANGED
|
@@ -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": [
|
|
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
|
-
|
|
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.
|
|
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.
|
|
262
|
+
g.rejectPublic(w, r, code, reason, 0)
|
|
235
263
|
return
|
|
236
264
|
}
|
|
237
|
-
defer func() {
|
|
238
|
-
|
|
239
|
-
g.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|