@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
@@ -3,6 +3,7 @@
3
3
  import argparse
4
4
  import concurrent.futures
5
5
  import json
6
+ import http.client
6
7
  import urllib.error
7
8
  import urllib.parse
8
9
  import urllib.request
@@ -22,9 +23,9 @@ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect
22
23
  def get(url):
23
24
  try:
24
25
  with opener.open(url, timeout=5) as response:
25
- return response.status, response.read(65536)
26
+ return response.status, response.read((1 << 20) + 1)
26
27
  except urllib.error.HTTPError as error:
27
- return error.code, error.read(65536)
28
+ return error.code, error.read((1 << 20) + 1)
28
29
  code, body = get(args.operator + '/edge/status')
29
30
  assert code == 200, 'operator status unavailable'
30
31
  before = json.loads(body)
@@ -32,11 +33,27 @@ assert before['policyReady'] and before['upstreamHealthy'], 'gateway not ready'
32
33
  with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
33
34
  codes = list(pool.map(lambda _: get(args.public + '/_/')[0], range(200)))
34
35
  assert all(code == 403 for code in codes), set(codes)
36
+ # Send only a Content-Length header, never a gigabyte payload.
37
+ public = urllib.parse.urlsplit(args.public)
38
+ for _ in range(3):
39
+ connection = http.client.HTTPConnection(public.hostname, public.port, timeout=3)
40
+ try:
41
+ connection.putrequest('POST', '/api/health')
42
+ connection.putheader('Content-Length', str(1 << 30))
43
+ connection.endheaders()
44
+ response = connection.getresponse()
45
+ assert response.status == 413, 'oversized request was not rejected immediately'
46
+ response.read(1024)
47
+ finally:
48
+ connection.close()
35
49
  assert get(args.operator + '/edge/healthz')[0] == 204
36
50
  assert get(args.operator + '/edge/readyz')[0] == 204
37
51
  code, body = get(args.operator + '/edge/status')
38
52
  after = json.loads(body)
39
- assert after['rejected'] - before['rejected'] >= 200
40
- print(json.dumps({'blockedRequests': len(codes), 'healthCanary': 'passed',
53
+ assert after['rejected'] - before['rejected'] >= 203
54
+ assert after['reasons'].get('private_route', 0) - before['reasons'].get('private_route', 0) >= 200
55
+ assert after['reasons'].get('body_too_large', 0) - before['reasons'].get('body_too_large', 0) >= 3
56
+ assert any(i['reason'] == 'body_too_large' for i in after['incidents']), 'oversize event missing'
57
+ print(json.dumps({'blockedRequests': len(codes) + 3, 'oversizeHeadersRejected': 3, 'healthCanary': 'passed',
41
58
  'publicAcceptedDelta': after['accepted'] - before['accepted'],
42
59
  'note': 'Zero accepted delta expected on an otherwise idle instance.'}))
package/scripts/check.sh CHANGED
@@ -4,8 +4,11 @@ export GOWORK=off
4
4
  module=$(go list -m -f '{{.Version}} {{if .Replace}}REPLACED{{end}}' github.com/pocketbase/pocketbase)
5
5
  case "$module" in *REPLACED*) echo 'Compatibility checks must use unmodified upstream PocketBase' >&2; exit 1;; esac
6
6
  go test ./...
7
- go test -race ./edge ./security ./features ./localization ./loadtest ./backups ./settings ./mail ./admin ./otp ./watcher
7
+ go test -race ./hosting ./multinode ./edge ./security ./features ./localization ./loadtest ./backups ./settings ./mail ./admin ./otp ./watcher
8
8
  go vet ./...
9
- node --test security/ui/*.test.mjs
9
+ node --test security/ui/*.test.mjs bin/*.test.mjs
10
10
  go build -o /tmp/spink-pocketbase-check ./cmd/pocketbase
11
11
  go build -o /tmp/spink-edge-check ./cmd/edge
12
+
13
+ go build -o /tmp/spink-gateway-check ./cmd/gateway
14
+ go build -o /tmp/spink-hosting-check ./cmd/hosting
@@ -23,17 +23,28 @@ Unauthenticated clients share a source budget. Identity storage is capped and us
23
23
  process-local HMAC fingerprints; excess identities share an overflow bucket.
24
24
  Forwarded addresses are considered only for configured immediate proxy peers.
25
25
 
26
- Detection evaluates ten-second windows. Three consecutive high-rejection,
27
- authentication-failure or combined slow/failing windows open a suspected incident.
28
- Six clear windows resolve it. Legitimate load or client errors can trigger these
29
- patterns. The detector never installs blocking actions automatically. History is
30
- capped at 256 incidents. Missing traffic is not independent evidence of recovery;
31
- these are heuristic state transitions, not validated attack classification.
26
+ Detection evaluates application traffic every second. A window with at least 20
27
+ requests and a majority of rejections, over 80% authentication failures, or a
28
+ majority of both slow and failing requests opens a suspected incident immediately.
29
+ Evidence accumulates across suspicious windows; 60 seconds without a suspicious
30
+ window resolves the application incident. Legitimate load or client errors can
31
+ trigger these patterns. The detector never installs blocking actions automatically.
32
+ History is capped at 256 incidents shared across application and firewall sources.
33
+ These are heuristic transitions, not proof of attacker intent.
34
+
35
+ The optional edge telemetry connection records **every public gateway rejection**
36
+ immediately, including a single oversized upload. Events group by rejection reason
37
+ until 60 seconds of silence; counters accumulate throughout the group. Upload size
38
+ and timeout events are warning signals. Other groups become warnings at 20 denials
39
+ in a rolling ten-second window; ordinary denials remain policy notices. The dashboard
40
+ polls every two seconds while visible. Gateway snapshots publish every second and
41
+ PocketBase imports them every second, so display latency is normally up to four
42
+ seconds, subject to scheduler and I/O delays. Detection does not wait 30 seconds.
32
43
 
33
44
  ## Management
34
45
 
35
46
  Open `/_/#/security` through the Security tab after signing into the admin dashboard. It reuses the dashboard SDK session without copying tokens. Extension assets are public; APIs still require a superuser and an immediate peer in `ManagementPeers` (loopback by default). Logout/unmount cancels polling and discards old-session responses.
36
- Four concurrent management requests have a separate admission lane.
47
+ Eight concurrent management requests have a separate admission lane.
37
48
 
38
49
  | Route | Behavior |
39
50
  | --- | --- |
@@ -51,12 +62,34 @@ Action body: `family`, `reason` (1–200 bytes), RFC3339 `expiresAt` in the next
51
62
  in enforce mode. Policy changes, acknowledgements, action changes/cancellation log
52
63
  operator identity. No endpoint exports a raw request body, token or query.
53
64
 
54
- Optional `StatePath` writes atomic private checkpoints every ten seconds, after
65
+ Optional `StatePath` writes atomic private checkpoints every second, after
55
66
  management mutations and on shutdown. Validated policy, revision, incidents and
56
67
  unexpired actions survive restart; startup mode always wins. Corrupt/missing state
57
68
  falls back to startup configuration, with corruption reported as `storageDegraded`.
58
69
  Writes are best effort; an active policy can precede a failed disk write. This is
59
- not a transactional audit journal. Request counters/identities are process-local.
70
+ not a transactional audit journal. Request counters/identities are process-local. Imported edge incident evidence and acknowledgement survive restart; firewall session counters reset with the gateway process.
71
+
72
+ ## Firewall telemetry
73
+
74
+ Set `config.EdgeStatePath` (or `PB_SECURITY_EDGE_STATE_PATH` with the supplied
75
+ binary) to the absolute path of the gateway snapshot. Start the gateway with
76
+ `-telemetry /absolute/path/edge.json`. The Compose template configures a separate
77
+ `telemetry` volume: the gateway can write it, PocketBase mounts it read-only.
78
+ Keep the security policy volume read-only at the gateway. Telemetry is informational
79
+ and never changes admission rules. Missing, invalid, or over-five-second-old
80
+ snapshots show as unavailable/stale, retaining the last known values and incidents.
81
+
82
+ Status includes `edge`, `edgeConfigured`, and `edgeStale`. Edge metrics distinguish
83
+ HTTP arrivals, forwarded requests, rejected requests, payload bytes actually read,
84
+ client-declared rejected bytes, upstream failures, active capacity and reason totals.
85
+ Do not add application counters to edge counters: forwarded traffic appears at both
86
+ stages. Counters exclude operator traffic, TCP/TLS/header-parse failures and traffic
87
+ blocked by upstream infrastructure. Bytes are HTTP body bytes read by this process,
88
+ not NIC bandwidth. A rejected Content-Length of 1 GiB does not mean 1 GiB arrived.
89
+ Declared-size sums saturate at uint64 maximum rather than wrapping. No client IPs,
90
+ URLs, queries, credentials or bodies are exported. Each snapshot is bounded to
91
+ 1 MiB, 60 time buckets and 128 edge events. The checkpoint is best-effort; a crash
92
+ can lose the unpublished tail. Use infrastructure metrics for network-level attacks.
60
93
 
61
94
  ## Boundaries
62
95
 
@@ -9,6 +9,7 @@ import (
9
9
 
10
10
  type Config struct {
11
11
  OperatorPeers []string `json:"-"`
12
+ EdgeStatePath string `json:"-"`
12
13
  StatePath string `json:"-"`
13
14
  Mode string `json:"mode"`
14
15
  RequestsPerSecond float64 `json:"requestsPerSecond"`
@@ -30,6 +31,9 @@ func DefaultConfig() Config {
30
31
  return Config{Mode: "observe", RequestsPerSecond: 100, Burst: 200, IdentityPerSecond: 20, IdentityBurst: 40, MaxConcurrent: 64, MaxWrites: 8, MaxFiles: 8, MaxRealtime: 128, MaxSubscriptions: 100, MaxSubscriptionsTotal: 4096, MaxIdentities: 4096, ManagementPeers: []string{"127.0.0.0/8", "::1/128"}}
31
32
  }
32
33
  func (c Config) Validate() error {
34
+ if c.EdgeStatePath != "" && !filepath.IsAbs(c.EdgeStatePath) {
35
+ return errors.New("edge state path must be absolute")
36
+ }
33
37
  if c.StatePath != "" && !filepath.IsAbs(c.StatePath) {
34
38
  return errors.New("state path must be absolute")
35
39
  }
@@ -0,0 +1,123 @@
1
+ package security
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "io"
7
+ "os"
8
+ "strconv"
9
+ "time"
10
+ )
11
+
12
+ // EdgeTelemetry is an aggregate snapshot. It contains no request bodies, tokens or IPs.
13
+ type EdgeTelemetry struct {
14
+ Version int `json:"version"`
15
+ Session string `json:"session"`
16
+ StartedAt time.Time `json:"startedAt"`
17
+ UpdatedAt time.Time `json:"updatedAt"`
18
+ PolicyReady bool `json:"policyReady"`
19
+ UpstreamHealthy bool `json:"upstreamHealthy"`
20
+ Active int `json:"active"`
21
+ TrackedClients int `json:"trackedClients"`
22
+ Accepted uint64 `json:"accepted"`
23
+ Requests uint64 `json:"requests"`
24
+ Forwarded uint64 `json:"forwarded"`
25
+ Rejected uint64 `json:"rejected"`
26
+ BodyReadBytes uint64 `json:"bodyReadBytes"`
27
+ DeclaredRejectedBytes uint64 `json:"declaredRejectedBytes"`
28
+ UpstreamFailures uint64 `json:"upstreamFailures"`
29
+ Reasons map[string]uint64 `json:"reasons"`
30
+ Traffic []TrafficWindow `json:"traffic"`
31
+ Incidents []Incident `json:"incidents"`
32
+ MaxBodyBytes int64 `json:"maxBodyBytes"`
33
+ BodyReadTimeoutSeconds int `json:"bodyReadTimeoutSeconds"`
34
+ MaxConcurrent int `json:"maxConcurrent"`
35
+ MaxClientConcurrent int `json:"maxClientConcurrent"`
36
+ }
37
+
38
+ func (s *Service) readEdge(now time.Time) {
39
+ s.mu.Lock()
40
+ path := s.config.EdgeStatePath
41
+ s.mu.Unlock()
42
+ if path == "" {
43
+ return
44
+ }
45
+ snapshot, err := ReadEdgeTelemetry(path, now)
46
+ s.mu.Lock()
47
+ defer s.mu.Unlock()
48
+ s.edgeError = err != nil
49
+ if err != nil {
50
+ return
51
+ }
52
+ s.edge = &snapshot
53
+ for i := range s.incidents {
54
+ if s.incidents[i].Source == "edge" && now.Sub(s.incidents[i].LastSeen) >= time.Minute {
55
+ s.incidents[i].Phase = "resolved"
56
+ }
57
+ }
58
+ sequence := uint64(0)
59
+ for _, incoming := range snapshot.Incidents {
60
+ sequence = max(sequence, incoming.ID)
61
+ sourceID := snapshot.Session + ":" + incoming.SourceID
62
+ found := -1
63
+ for i := range s.incidents {
64
+ if s.incidents[i].Source == "edge" && s.incidents[i].SourceID == sourceID {
65
+ found = i
66
+ break
67
+ }
68
+ }
69
+ incoming.SourceID = sourceID
70
+ if found >= 0 {
71
+ incoming.ID = s.incidents[found].ID
72
+ incoming.Acknowledged = s.incidents[found].Acknowledged
73
+ s.incidents[found] = incoming
74
+ } else {
75
+ if snapshot.Session == s.edgeSession && incoming.ID <= s.edgeSequence {
76
+ continue
77
+ }
78
+ s.nextID++
79
+ incoming.ID = s.nextID
80
+ s.incidents = append(s.incidents, incoming)
81
+ if len(s.incidents) > 256 {
82
+ s.incidents = s.incidents[1:]
83
+ }
84
+ }
85
+ }
86
+ s.edgeSequence = max(sequence, s.edgeSequenceIfSession(snapshot.Session))
87
+ s.edgeSession = snapshot.Session
88
+ }
89
+
90
+ func (s *Service) edgeSequenceIfSession(session string) uint64 {
91
+ if s.edgeSession == session {
92
+ return s.edgeSequence
93
+ }
94
+ return 0
95
+ }
96
+
97
+ func ReadEdgeTelemetry(path string, now time.Time) (EdgeTelemetry, error) {
98
+ var v EdgeTelemetry
99
+ f, err := os.Open(path)
100
+ if err != nil {
101
+ return v, err
102
+ }
103
+ defer f.Close()
104
+ raw, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
105
+ if err != nil {
106
+ return v, err
107
+ }
108
+ if len(raw) > 1<<20 {
109
+ return v, errors.New("edge telemetry too large")
110
+ }
111
+ if err = json.Unmarshal(raw, &v); err != nil {
112
+ return v, err
113
+ }
114
+ if v.Version != 1 || len(v.Session) != 32 || v.UpdatedAt.Before(now.Add(-5*time.Second)) || v.UpdatedAt.After(now.Add(5*time.Second)) || len(v.Incidents) > 128 || len(v.Traffic) > 60 || len(v.Reasons) > 32 {
115
+ return v, errors.New("invalid or stale edge telemetry")
116
+ }
117
+ for _, i := range v.Incidents {
118
+ if i.Source != "edge" || len(i.SourceID) > 64 || i.SourceID == "" || i.Family != "other" || len(i.Reason) > 64 || (i.Phase != "active" && i.Phase != "recovering" && i.Phase != "resolved") || i.ID == 0 || i.SourceID != strconv.FormatUint(i.ID, 10) {
119
+ return v, errors.New("invalid edge event")
120
+ }
121
+ }
122
+ return v, nil
123
+ }
@@ -0,0 +1,87 @@
1
+ package security
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "path/filepath"
7
+ "testing"
8
+ "time"
9
+ )
10
+
11
+ func TestEdgeImportPersistsAndDoesNotDoubleCount(t *testing.T) {
12
+ dir := t.TempDir()
13
+ c := DefaultConfig()
14
+ c.StatePath = filepath.Join(dir, "state.json")
15
+ c.EdgeStatePath = filepath.Join(dir, "edge.json")
16
+ s, err := New(c)
17
+ if err != nil {
18
+ t.Fatal(err)
19
+ }
20
+ // Stop the ticker to make persistence and timestamp assertions deterministic.
21
+ close(s.stop)
22
+ <-s.done
23
+ now := time.Now()
24
+ v := EdgeTelemetry{Version: 1, Session: "01234567890123456789012345678901", UpdatedAt: now, Requests: 50, Rejected: 50, Incidents: []Incident{{ID: 1, Source: "edge", SourceID: "1", Family: "other", Reason: "client_rate", Phase: "active", FirstSeen: now, LastSeen: now, Evidence: Observation{Count: 50, Rejected: 50}}}}
25
+ write := func() {
26
+ b, _ := json.Marshal(v)
27
+ if err := os.WriteFile(c.EdgeStatePath, b, 0600); err != nil {
28
+ t.Fatal(err)
29
+ }
30
+ }
31
+ write()
32
+ s.readEdge(now)
33
+ s.readEdge(now)
34
+ if len(s.incidents) != 1 || s.incidents[0].Evidence.Count != 50 || s.total != 0 {
35
+ t.Fatal("lost or duplicated edge evidence")
36
+ }
37
+ s.incidents[0].Acknowledged = true
38
+ v.Incidents[0].Evidence.Count = 70
39
+ v.Incidents[0].Evidence.Rejected = 70
40
+ write()
41
+ s.readEdge(now)
42
+ if len(s.incidents) != 1 || s.incidents[0].Evidence.Count != 70 || !s.incidents[0].Acknowledged {
43
+ t.Fatal("update lost acknowledgement")
44
+ }
45
+ s.persist()
46
+ saved := s.incidents
47
+ s.incidents = nil
48
+ s.readEdge(now)
49
+ if len(s.incidents) != 0 {
50
+ t.Fatal("evicted event reimported")
51
+ }
52
+ s.incidents = saved
53
+ restored, err := New(c)
54
+ if err != nil {
55
+ t.Fatal(err)
56
+ }
57
+ defer restored.Close()
58
+ if restored.config.EdgeStatePath != c.EdgeStatePath || len(restored.incidents) != 1 || restored.incidents[0].Evidence.Count != 70 {
59
+ t.Fatal("edge evidence or runtime path lost on restart")
60
+ }
61
+ s.readEdge(now.Add(6 * time.Second))
62
+ if !s.edgeError || s.edge == nil || s.edge.Incidents[0].Evidence.Count != 70 {
63
+ t.Fatal("staleness lost last known evidence")
64
+ }
65
+ }
66
+ func TestApplicationBurstDetectedInFirstWindowWithCumulativeEvidence(t *testing.T) {
67
+ s, err := New(DefaultConfig())
68
+ if err != nil {
69
+ t.Fatal(err)
70
+ }
71
+ defer s.Close()
72
+ now := time.Now()
73
+ for j := 0; j < 30; j++ {
74
+ s.observe("reads", true, 429, 0)
75
+ }
76
+ s.evaluate(now)
77
+ if len(s.incidents) != 1 || s.incidents[0].Evidence.Count != 30 {
78
+ t.Fatal("short burst was lost")
79
+ }
80
+ for j := 0; j < 25; j++ {
81
+ s.observe("reads", true, 429, 0)
82
+ }
83
+ s.evaluate(now.Add(time.Second))
84
+ if s.incidents[0].Evidence.Count != 55 {
85
+ t.Fatal("evidence overwritten")
86
+ }
87
+ }
@@ -23,7 +23,8 @@ func (s *Service) bindManagement(r *router.Router[*core.RequestEvent]) {
23
23
  })
24
24
  g.GET("/status", func(e *core.RequestEvent) error {
25
25
  s.mu.Lock()
26
- result := map[string]any{"mode": s.config.Mode, "revision": s.revision, "identities": len(s.identities), "active": maps.Clone(s.active), "observed": s.total, "rejected": s.rejected, "subscriptions": s.totalSubscriptions, "storageDegraded": s.storageError, "detector": "heuristic-v1", "actions": currentActions(s.actions, time.Now()), "startedAt": s.startedAt, "lastEvaluated": s.lastEvaluated, "traffic": append([]TrafficWindow{}, s.history...), "checkpointEnabled": s.config.StatePath != ""}
26
+ result := map[string]any{"edge": s.edge, "edgeConfigured": s.config.EdgeStatePath != "", "edgeStale": s.edgeError || s.edge != nil && time.Since(s.edge.UpdatedAt) > 5*time.Second,
27
+ "mode": s.config.Mode, "revision": s.revision, "identities": len(s.identities), "active": maps.Clone(s.active), "observed": s.total, "rejected": s.rejected, "subscriptions": s.totalSubscriptions, "storageDegraded": s.storageError, "detector": "heuristic-v2", "actions": currentActions(s.actions, time.Now()), "startedAt": s.startedAt, "lastEvaluated": s.lastEvaluated, "traffic": append([]TrafficWindow{}, s.history...), "checkpointEnabled": s.config.StatePath != ""}
27
28
  s.mu.Unlock()
28
29
  return e.JSON(http.StatusOK, result)
29
30
  })
@@ -96,6 +97,7 @@ func (s *Service) bindManagement(r *router.Router[*core.RequestEvent]) {
96
97
  }
97
98
  c.OperatorPeers = s.config.OperatorPeers
98
99
  c.StatePath = s.config.StatePath
100
+ c.EdgeStatePath = s.config.EdgeStatePath
99
101
  s.config = cloneConfig(c)
100
102
  s.revision++
101
103
  revision := s.revision
@@ -152,6 +152,254 @@
152
152
  "description": "Must be within the next 15 minutes"
153
153
  }
154
154
  }
155
+ },
156
+ "EdgeTelemetry": {
157
+ "type": "object",
158
+ "properties": {
159
+ "version": {
160
+ "type": "integer"
161
+ },
162
+ "active": {
163
+ "type": "integer"
164
+ },
165
+ "trackedClients": {
166
+ "type": "integer"
167
+ },
168
+ "requests": {
169
+ "type": "integer"
170
+ },
171
+ "forwarded": {
172
+ "type": "integer"
173
+ },
174
+ "accepted": {
175
+ "type": "integer"
176
+ },
177
+ "rejected": {
178
+ "type": "integer"
179
+ },
180
+ "bodyReadBytes": {
181
+ "type": "integer"
182
+ },
183
+ "declaredRejectedBytes": {
184
+ "type": "integer"
185
+ },
186
+ "upstreamFailures": {
187
+ "type": "integer"
188
+ },
189
+ "maxBodyBytes": {
190
+ "type": "integer"
191
+ },
192
+ "bodyReadTimeoutSeconds": {
193
+ "type": "integer"
194
+ },
195
+ "maxConcurrent": {
196
+ "type": "integer"
197
+ },
198
+ "maxClientConcurrent": {
199
+ "type": "integer"
200
+ },
201
+ "session": {
202
+ "type": "string"
203
+ },
204
+ "startedAt": {
205
+ "type": "string",
206
+ "format": "date-time"
207
+ },
208
+ "updatedAt": {
209
+ "type": "string",
210
+ "format": "date-time"
211
+ },
212
+ "policyReady": {
213
+ "type": "boolean"
214
+ },
215
+ "upstreamHealthy": {
216
+ "type": "boolean"
217
+ },
218
+ "reasons": {
219
+ "type": "object",
220
+ "additionalProperties": {
221
+ "type": "integer"
222
+ }
223
+ },
224
+ "traffic": {
225
+ "type": "array",
226
+ "maxItems": 60,
227
+ "items": {
228
+ "type": "object",
229
+ "properties": {
230
+ "at": {
231
+ "type": "string",
232
+ "format": "date-time"
233
+ },
234
+ "seconds": {
235
+ "type": "number"
236
+ },
237
+ "count": {
238
+ "type": "integer"
239
+ },
240
+ "rejected": {
241
+ "type": "integer"
242
+ },
243
+ "failures": {
244
+ "type": "integer"
245
+ },
246
+ "slow": {
247
+ "type": "integer"
248
+ }
249
+ }
250
+ }
251
+ },
252
+ "incidents": {
253
+ "type": "array",
254
+ "maxItems": 128,
255
+ "items": {
256
+ "type": "object",
257
+ "properties": {
258
+ "id": {
259
+ "type": "integer"
260
+ },
261
+ "source": {
262
+ "type": "string",
263
+ "enum": [
264
+ "edge",
265
+ "pocketbase"
266
+ ]
267
+ },
268
+ "sourceId": {
269
+ "type": "string"
270
+ },
271
+ "reason": {
272
+ "type": "string"
273
+ },
274
+ "severity": {
275
+ "type": "string",
276
+ "enum": [
277
+ "notice",
278
+ "warning"
279
+ ]
280
+ },
281
+ "family": {
282
+ "type": "string"
283
+ },
284
+ "phase": {
285
+ "type": "string",
286
+ "enum": [
287
+ "active",
288
+ "recovering",
289
+ "resolved"
290
+ ]
291
+ },
292
+ "firstSeen": {
293
+ "type": "string",
294
+ "format": "date-time"
295
+ },
296
+ "lastSeen": {
297
+ "type": "string",
298
+ "format": "date-time"
299
+ },
300
+ "acknowledged": {
301
+ "type": "boolean"
302
+ },
303
+ "evidence": {
304
+ "type": "object",
305
+ "properties": {
306
+ "count": {
307
+ "type": "integer"
308
+ },
309
+ "rejected": {
310
+ "type": "integer"
311
+ },
312
+ "failures": {
313
+ "type": "integer"
314
+ },
315
+ "slow": {
316
+ "type": "integer"
317
+ },
318
+ "bodyReadBytes": {
319
+ "type": "integer"
320
+ },
321
+ "declaredRejectedBytes": {
322
+ "type": "integer"
323
+ }
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
329
+ }
330
+ },
331
+ "Incident": {
332
+ "type": "object",
333
+ "properties": {
334
+ "id": {
335
+ "type": "integer"
336
+ },
337
+ "source": {
338
+ "type": "string",
339
+ "enum": [
340
+ "edge",
341
+ "pocketbase"
342
+ ]
343
+ },
344
+ "sourceId": {
345
+ "type": "string"
346
+ },
347
+ "reason": {
348
+ "type": "string"
349
+ },
350
+ "severity": {
351
+ "type": "string",
352
+ "enum": [
353
+ "notice",
354
+ "warning"
355
+ ]
356
+ },
357
+ "family": {
358
+ "type": "string"
359
+ },
360
+ "phase": {
361
+ "type": "string",
362
+ "enum": [
363
+ "active",
364
+ "recovering",
365
+ "resolved"
366
+ ]
367
+ },
368
+ "firstSeen": {
369
+ "type": "string",
370
+ "format": "date-time"
371
+ },
372
+ "lastSeen": {
373
+ "type": "string",
374
+ "format": "date-time"
375
+ },
376
+ "acknowledged": {
377
+ "type": "boolean"
378
+ },
379
+ "evidence": {
380
+ "type": "object",
381
+ "properties": {
382
+ "count": {
383
+ "type": "integer"
384
+ },
385
+ "rejected": {
386
+ "type": "integer"
387
+ },
388
+ "failures": {
389
+ "type": "integer"
390
+ },
391
+ "slow": {
392
+ "type": "integer"
393
+ },
394
+ "bodyReadBytes": {
395
+ "type": "integer"
396
+ },
397
+ "declaredRejectedBytes": {
398
+ "type": "integer"
399
+ }
400
+ }
401
+ }
402
+ }
155
403
  }
156
404
  }
157
405
  },
@@ -204,6 +452,13 @@
204
452
  "items": {
205
453
  "type": "object",
206
454
  "properties": {
455
+ "at": {
456
+ "type": "string",
457
+ "format": "date-time"
458
+ },
459
+ "seconds": {
460
+ "type": "number"
461
+ },
207
462
  "count": {
208
463
  "type": "integer"
209
464
  },
@@ -224,6 +479,20 @@
224
479
  "additionalProperties": {
225
480
  "$ref": "#/components/schemas/Action"
226
481
  }
482
+ },
483
+ "edge": {
484
+ "allOf": [
485
+ {
486
+ "$ref": "#/components/schemas/EdgeTelemetry"
487
+ }
488
+ ],
489
+ "nullable": true
490
+ },
491
+ "edgeConfigured": {
492
+ "type": "boolean"
493
+ },
494
+ "edgeStale": {
495
+ "type": "boolean"
227
496
  }
228
497
  }
229
498
  }