@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/scripts/check-edge.py
CHANGED
|
@@ -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(
|
|
26
|
+
return response.status, response.read((1 << 20) + 1)
|
|
26
27
|
except urllib.error.HTTPError as error:
|
|
27
|
-
return error.code, error.read(
|
|
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'] >=
|
|
40
|
-
|
|
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
|
package/security/README.md
CHANGED
|
@@ -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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
these
|
|
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
|
-
|
|
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
|
|
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
|
|
package/security/config.go
CHANGED
|
@@ -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
|
+
}
|
package/security/management.go
CHANGED
|
@@ -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{"
|
|
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
|
package/security/openapi.json
CHANGED
|
@@ -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
|
}
|