Registry and Analytics: run, subnet, direction and protocol filters, successes-by-target chart
Registry (/registry):
- filters by run (slice by the address's cycle in that run), subnet
(drop-down of configured subnets), direction (egress/ingress) and
protocol (icmp, tcp, ssh, https, tls); status in scope is computed over
the narrowed checks
- chart "successful checks per target (egress) / site (ingress)" when both
direction and protocol are chosen; a row opens the list of addresses
(dialog, CSV)
- API: direction/protocol parameters and run in GET /admin/registry,
GET /admin/registry/breakdown and /breakdown/list
- subnet filter passes ids as one JSON parameter (SQLite variable limit)
Analytics (/analytics):
- subnet filter recomputes the whole page over the addresses of the run
inside the subnet; only their checks are read; cache per run and subnet
- direction and protocol focus the page; with both set the registry chart
is shown
- subnet parameter in GET /admin/analytics/runs/{id} and lists (JSON, CSV)
Docs: plans and summaries in docs/changes, README, API, USAGE.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
068c10ea1c
commit
ded196ec8d
40 files changed
+2545
-188
No files matched your search
@@ -97,8 +97,8 @@ func TestRouteTableIsClassified(t *testing.T) {
|
||||
t.Fatalf("admin route %q is %s, want admin", rt.Pattern, rt.Access)
|
||||
}
|
||||
}
|
||||
if counts["admin"] != 41 || counts["agent"] != 5 || counts["open"] != 8 {
|
||||
t.Fatalf("access counts = %v, want admin=41 agent=5 open=8", counts)
|
||||
if counts["admin"] != 43 || counts["agent"] != 5 || counts["open"] != 8 {
|
||||
t.Fatalf("access counts = %v, want admin=43 agent=5 open=8", counts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,28 @@ type registryPageResponse struct {
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Run int64 `json:"run"` // the run the results are read from, 0 = each address's newest cycle
|
||||
}
|
||||
|
||||
// registryBreakdownDTO is GET /admin/registry/breakdown: the checks of one
|
||||
// direction and protocol per target (group "target", egress) or site (group
|
||||
// "site", ingress). Key is what ".../breakdown/list?key=" takes, Label the
|
||||
// name to show; Total counts checks, OK the successful ones. Run is 0 for each
|
||||
// address's newest cycle.
|
||||
type registryBreakdownDTO struct {
|
||||
Group string `json:"group"`
|
||||
Direction string `json:"direction"`
|
||||
Protocol string `json:"protocol"`
|
||||
Run int64 `json:"run"`
|
||||
Addresses int `json:"addresses"`
|
||||
Rows []breakdownRowDTO `json:"rows"`
|
||||
}
|
||||
|
||||
type breakdownRowDTO struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Total int `json:"total"`
|
||||
OK int `json:"ok"`
|
||||
}
|
||||
|
||||
// registryDTO is one row of the durable per-address registry — see
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -42,17 +43,29 @@ type subnetsDTO struct {
|
||||
Subnets []subnetDTO `json:"subnets"`
|
||||
}
|
||||
|
||||
// analyticsCache keeps the computed analysis of finalized runs. An entry is
|
||||
// analyticsCache keeps the computed analysis of finalized runs, per run and
|
||||
// subnet (the subnet narrows the report; "" is the whole run). An entry is
|
||||
// valid while the run's data version (checks written, results) is unchanged;
|
||||
// the subnet list is part of the key because it changes the grouping.
|
||||
// the subnet list is part of the version because it changes the grouping. At
|
||||
// most analyticsCacheMax entries are kept, the least recently used one goes
|
||||
// first, so trying many subnets does not grow the memory without limit.
|
||||
type analyticsCache struct {
|
||||
mu sync.Mutex
|
||||
entries map[int64]analyticsEntry
|
||||
entries map[analyticsKey]analyticsEntry
|
||||
clock uint64
|
||||
}
|
||||
|
||||
const analyticsCacheMax = 16
|
||||
|
||||
type analyticsKey struct {
|
||||
run int64
|
||||
subnet string
|
||||
}
|
||||
|
||||
type analyticsEntry struct {
|
||||
version string
|
||||
an *analytics.Analysis
|
||||
used uint64
|
||||
}
|
||||
|
||||
func (s *Server) handleAnalyticsRuns(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -72,21 +85,30 @@ func (s *Server) handleAnalyticsRuns(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// analysisFor returns the analysis of the run named by the {id} of the path;
|
||||
// see analysisByID.
|
||||
// analysisFor returns the analysis of the run named by the {id} of the path,
|
||||
// narrowed to the ?subnet= (a CIDR) when given; see analysisByID.
|
||||
func (s *Server) analysisFor(w http.ResponseWriter, r *http.Request) *analytics.Analysis {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid run id")
|
||||
return nil
|
||||
}
|
||||
return s.analysisByID(w, r, id)
|
||||
var subnet netip.Prefix
|
||||
if v := strings.TrimSpace(r.URL.Query().Get("subnet")); v != "" {
|
||||
if subnet, err = netip.ParsePrefix(v); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid subnet "+strconv.Quote(v)+" (a CIDR such as 203.0.113.0/24 is expected)")
|
||||
return nil
|
||||
}
|
||||
subnet = subnet.Masked()
|
||||
}
|
||||
return s.analysisByID(w, r, id, subnet)
|
||||
}
|
||||
|
||||
// analysisByID returns the analysis of a finalized run, from the cache when
|
||||
// the run's data has not changed since it was computed. It writes the error
|
||||
// response itself and returns nil when it cannot.
|
||||
func (s *Server) analysisByID(w http.ResponseWriter, r *http.Request, id int64) *analytics.Analysis {
|
||||
// analysisByID returns the analysis of a finalized run (of the addresses
|
||||
// inside subnet, unless it is the zero prefix), from the cache when the run's
|
||||
// data has not changed since it was computed. It writes the error response
|
||||
// itself and returns nil when it cannot.
|
||||
func (s *Server) analysisByID(w http.ResponseWriter, r *http.Request, id int64, subnet netip.Prefix) *analytics.Analysis {
|
||||
ctx := r.Context()
|
||||
run, err := s.DB.GetRun(ctx, id)
|
||||
if err != nil {
|
||||
@@ -113,23 +135,42 @@ func (s *Server) analysisByID(w http.ResponseWriter, r *http.Request, id int64)
|
||||
}
|
||||
version := data + "#" + sb.String()
|
||||
|
||||
key := analyticsKey{run: id}
|
||||
if subnet.IsValid() {
|
||||
key.subnet = subnet.String()
|
||||
}
|
||||
s.analytics.mu.Lock()
|
||||
defer s.analytics.mu.Unlock()
|
||||
if e, ok := s.analytics.entries[id]; ok && e.version == version {
|
||||
s.analytics.clock++
|
||||
if e, ok := s.analytics.entries[key]; ok && e.version == version {
|
||||
e.used = s.analytics.clock
|
||||
s.analytics.entries[key] = e
|
||||
return e.an
|
||||
}
|
||||
an, err := analytics.Load(ctx, s.DB, id)
|
||||
an, err := analytics.Load(ctx, s.DB, id, subnet)
|
||||
if err != nil {
|
||||
writeDBError(w, err)
|
||||
return nil
|
||||
}
|
||||
if s.analytics.entries == nil {
|
||||
s.analytics.entries = map[int64]analyticsEntry{}
|
||||
s.analytics.entries = map[analyticsKey]analyticsEntry{}
|
||||
}
|
||||
s.analytics.entries[id] = analyticsEntry{version: version, an: an}
|
||||
if _, ok := s.analytics.entries[key]; !ok && len(s.analytics.entries) >= analyticsCacheMax {
|
||||
var oldest analyticsKey
|
||||
least := ^uint64(0)
|
||||
for k, e := range s.analytics.entries {
|
||||
if e.used < least {
|
||||
oldest, least = k, e.used
|
||||
}
|
||||
}
|
||||
delete(s.analytics.entries, oldest)
|
||||
}
|
||||
s.analytics.entries[key] = analyticsEntry{version: version, an: an, used: s.analytics.clock}
|
||||
return an
|
||||
}
|
||||
|
||||
// handleAnalyticsRun serves the report of a finished run, narrowed to
|
||||
// ?subnet= (a CIDR) when given.
|
||||
func (s *Server) handleAnalyticsRun(w http.ResponseWriter, r *http.Request) {
|
||||
if an := s.analysisFor(w, r); an != nil {
|
||||
writeJSON(w, http.StatusOK, an.Report)
|
||||
@@ -138,7 +179,7 @@ func (s *Server) handleAnalyticsRun(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var nonSlug = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// handleAnalyticsList serves the address table behind one indicator
|
||||
// handleAnalyticsList serves the address table (of the ?subnet= when given) behind one indicator
|
||||
// (kind = egress_https_any|egress_https_all|ingress_ssh_any|ingress_ssh_all),
|
||||
// the addresses of one verdict (kind = verdict_pass|verdict_partial|verdict_fail)
|
||||
// or one ingress error class (kind = error, ?class=...), as JSON or, with
|
||||
@@ -201,11 +242,11 @@ func (s *Server) compareFor(w http.ResponseWriter, r *http.Request) (c *analytic
|
||||
writeError(w, http.StatusBadRequest, "base and target must be different runs")
|
||||
return nil, 0, 0
|
||||
}
|
||||
a := s.analysisByID(w, r, ids[0])
|
||||
a := s.analysisByID(w, r, ids[0], netip.Prefix{})
|
||||
if a == nil {
|
||||
return nil, 0, 0
|
||||
}
|
||||
b := s.analysisByID(w, r, ids[1])
|
||||
b := s.analysisByID(w, r, ids[1], netip.Prefix{})
|
||||
if b == nil {
|
||||
return nil, 0, 0
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@ package httpapi
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -355,6 +359,82 @@ func TestAnalyticsCacheFollowsData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ?subnet= narrows the report and the lists to the addresses inside it, a
|
||||
// malformed CIDR is a 400, and the cache keeps the subnets apart and bounded.
|
||||
func TestAnalyticsSubnetFilter(t *testing.T) {
|
||||
fc, d, _, _ := newConfigTestHarness(t)
|
||||
id := finishedRun(t, d) // 9.9.9.1 passes, 9.9.9.2 is partial
|
||||
base := "/api/v1/admin/analytics/runs/" + itoa64(id)
|
||||
report := func(subnet string) (addresses, partial int, scope string) {
|
||||
resp, body := fc.do(http.MethodGet, base+"?subnet="+url.QueryEscape(subnet), nil)
|
||||
var rep struct {
|
||||
Summary struct{ Addresses, Partial int } `json:"summary"`
|
||||
Scope *struct {
|
||||
Subnet string `json:"subnet"`
|
||||
RunAddresses int `json:"run_addresses"`
|
||||
} `json:"scope"`
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &rep) != nil {
|
||||
t.Fatalf("report %q: %d %s", subnet, resp.StatusCode, body)
|
||||
}
|
||||
if rep.Scope != nil {
|
||||
scope = fmt.Sprintf("%s/%d", rep.Scope.Subnet, rep.Scope.RunAddresses)
|
||||
}
|
||||
return rep.Summary.Addresses, rep.Summary.Partial, scope
|
||||
}
|
||||
for _, c := range []struct {
|
||||
subnet string
|
||||
addrs, partial int
|
||||
scope string
|
||||
}{
|
||||
{"9.9.9.2/32", 1, 1, "9.9.9.2/32/2"},
|
||||
{"9.9.9.1/32", 1, 0, "9.9.9.1/32/2"},
|
||||
{"9.9.9.0/24", 2, 1, "9.9.9.0/24/2"},
|
||||
{"9.9.9.77/24", 2, 1, "9.9.9.0/24/2"}, // host bits are masked
|
||||
{"10.0.0.0/8", 0, 0, "10.0.0.0/8/2"},
|
||||
{"", 2, 1, ""},
|
||||
{"9.9.9.2/32", 1, 1, "9.9.9.2/32/2"}, // the first subnet again: not mixed up with the others
|
||||
} {
|
||||
if a, p, sc := report(c.subnet); a != c.addrs || p != c.partial || sc != c.scope {
|
||||
t.Errorf("subnet %q: addresses %d, partial %d, scope %q; want %d, %d, %q", c.subnet, a, p, sc, c.addrs, c.partial, c.scope)
|
||||
}
|
||||
}
|
||||
|
||||
var l struct {
|
||||
Rows [][]string `json:"rows"`
|
||||
}
|
||||
resp, body := fc.do(http.MethodGet, base+"/lists/verdict_partial?subnet=9.9.9.2/32", nil)
|
||||
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" {
|
||||
t.Fatalf("list in the subnet: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
resp, body = fc.do(http.MethodGet, base+"/lists/verdict_partial?subnet=9.9.9.1/32", nil)
|
||||
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 0 {
|
||||
t.Fatalf("list outside the subnet: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
resp, body = fc.do(http.MethodGet, base+"/lists/verdict_partial?format=csv&subnet=9.9.9.1/32", nil)
|
||||
if resp.StatusCode != http.StatusOK || strings.Contains(string(body), "9.9.9.2") {
|
||||
t.Fatalf("csv outside the subnet: %d %q", resp.StatusCode, body)
|
||||
}
|
||||
for _, path := range []string{base + "?subnet=nonsense", base + "/lists/verdict_pass?subnet=9.9.9.0", base + "/lists/verdict_pass?subnet=9.9.9.0/33"} {
|
||||
if resp, body := fc.do(http.MethodGet, path, nil); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("%s: %d %s, want 400", path, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// The cache holds a bounded number of entries.
|
||||
s := &Server{DB: d}
|
||||
for i := 0; i < analyticsCacheMax+5; i++ {
|
||||
rec := httptest.NewRecorder()
|
||||
subnet := netip.PrefixFrom(netip.AddrFrom4([4]byte{9, 9, byte(i), 0}), 24)
|
||||
if an := s.analysisByID(rec, httptest.NewRequest(http.MethodGet, "/", nil), id, subnet); an == nil {
|
||||
t.Fatalf("analysis of %s: %d %s", subnet, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
if n := len(s.analytics.entries); n != analyticsCacheMax {
|
||||
t.Errorf("cache entries = %d, want %d", n, analyticsCacheMax)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubnetsConfigEndpoints(t *testing.T) {
|
||||
fc, _, _, _ := newConfigTestHarness(t)
|
||||
resp, body := fc.do(http.MethodPut, "/api/v1/admin/config/subnets", subnetsDTO{Subnets: []subnetDTO{{CIDR: " 10.1.2.3/24 ", Label: "a"}, {CIDR: "10.0.0.0/8"}}})
|
||||
@@ -407,3 +487,63 @@ func TestRegistryRunAndSubnetFilters(t *testing.T) {
|
||||
}
|
||||
|
||||
func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
|
||||
|
||||
// The breakdown endpoints: 400 without direction and protocol, the shape of the
|
||||
// chart rows (site names, run slice), the list behind a row as JSON and CSV, 404
|
||||
// for an unknown key.
|
||||
func TestRegistryBreakdownEndpoints(t *testing.T) {
|
||||
fc, d, _, _ := newConfigTestHarness(t)
|
||||
fc.do(http.MethodPut, "/api/v1/admin/config/sites/1", putSiteRequest{SiteID: "rxmsk"})
|
||||
id := finishedRun(t, d)
|
||||
const base = "/api/v1/admin/registry/breakdown"
|
||||
|
||||
for _, bad := range []string{"", "?direction=egress", "?protocol=ssh", "?direction=up&protocol=ssh", "?direction=egress&protocol=dns"} {
|
||||
if resp, _ := fc.do(http.MethodGet, base+bad, nil); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("%q: %d, want 400", bad, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
if resp, _ := fc.do(http.MethodGet, base+"/list?direction=ingress&protocol=ssh", nil); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("list without key: %d, want 400", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, body := fc.do(http.MethodGet, base+"?direction=ingress&protocol=ssh&run="+itoa64(id), nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("breakdown: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
var b registryBreakdownDTO
|
||||
if err := json.Unmarshal(body, &b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := registryBreakdownDTO{Group: "site", Direction: "ingress", Protocol: "ssh", Run: id, Addresses: 2,
|
||||
Rows: []breakdownRowDTO{{Key: "inbound-site-1", Label: "rxmsk", Total: 2, OK: 1}}}
|
||||
if !reflect.DeepEqual(b, want) {
|
||||
t.Errorf("breakdown = %+v, want %+v", b, want)
|
||||
}
|
||||
|
||||
resp, body = fc.do(http.MethodGet, base+"?direction=egress&protocol=https", nil)
|
||||
if err := json.Unmarshal(body, &b); err != nil || resp.StatusCode != http.StatusOK || b.Group != "target" ||
|
||||
len(b.Rows) != 1 || b.Rows[0].Key != "https://a.test" || b.Rows[0].Label != "a.test" || b.Rows[0].OK != 2 {
|
||||
t.Errorf("egress breakdown: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
listURL := base + "/list?direction=ingress&protocol=ssh&run=" + itoa64(id) + "&key=inbound-site-1"
|
||||
resp, body = fc.do(http.MethodGet, listURL, nil)
|
||||
var l struct {
|
||||
Columns []string `json:"columns"`
|
||||
Rows [][]string `json:"rows"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &l); err != nil || resp.StatusCode != http.StatusOK || len(l.Rows) != 2 || len(l.Rows[0]) != len(l.Columns) {
|
||||
t.Fatalf("list: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
if r := l.Rows[0]; r[0] != "9.9.9.2" || r[1] != "провал" || r[3] != "rxmsk" || r[5] != "dial tcp: i/o timeout" {
|
||||
t.Errorf("failure must come first: %v", r)
|
||||
}
|
||||
resp, body = fc.do(http.MethodGet, listURL+"&format=csv", nil)
|
||||
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") ||
|
||||
!strings.Contains(resp.Header.Get("Content-Disposition"), "registry_ingress_ssh_rxmsk.csv") || !strings.Contains(string(body), "9.9.9.2") {
|
||||
t.Errorf("csv: %d %v %s", resp.StatusCode, resp.Header, body)
|
||||
}
|
||||
if resp, _ := fc.do(http.MethodGet, base+"/list?direction=ingress&protocol=ssh&key=inbound-site-9", nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("unknown key: %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,58 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cloudipvalidator/internal/analytics"
|
||||
"cloudipvalidator/internal/db"
|
||||
)
|
||||
|
||||
// parseRegistryFilter reads the filter parameters of the registry endpoints
|
||||
// (q, last_result, run, subnet, direction, protocol). A non-empty message is
|
||||
// the reason a value is invalid.
|
||||
func parseRegistryFilter(q url.Values) (f db.RegistryFilter, msg string) {
|
||||
f = db.RegistryFilter{Query: strings.TrimSpace(q.Get("q")), LastResult: q.Get("last_result"), Subnet: strings.TrimSpace(q.Get("subnet"))}
|
||||
if f.Subnet != "" {
|
||||
if _, err := netip.ParsePrefix(f.Subnet); err != nil {
|
||||
return f, "invalid subnet " + strconv.Quote(f.Subnet) + " (a CIDR such as 203.0.113.0/24 is expected)"
|
||||
}
|
||||
}
|
||||
if v := q.Get("run"); v != "" {
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return f, "invalid run " + strconv.Quote(v)
|
||||
}
|
||||
f.RunID = id
|
||||
}
|
||||
if f.LastResult != "" && !db.IsValidResult(f.LastResult) {
|
||||
return f, "invalid last_result " + strconv.Quote(f.LastResult) + " (valid: pass, partial, fail, cancelled)"
|
||||
}
|
||||
f.Level, f.Family = q.Get("direction"), q.Get("protocol")
|
||||
if f.Level != "" && !db.IsValidRegistryLevel(f.Level) {
|
||||
return f, "invalid direction " + strconv.Quote(f.Level) + " (valid: egress, ingress)"
|
||||
}
|
||||
if f.Family != "" && !db.IsValidRegistryFamily(f.Family) {
|
||||
return f, "invalid protocol " + strconv.Quote(f.Family) + " (valid: " + strings.Join(db.RegistryFamilies, ", ") + ")"
|
||||
}
|
||||
return f, ""
|
||||
}
|
||||
|
||||
// handleAdminRegistry lists every address ever submitted to the check
|
||||
// queue, each with a summary of its accumulated check history — the
|
||||
// durable record that survives an address being deleted from ip_queue and
|
||||
// later re-added. See migrations/0007_ip_registry.sql. Without `limit` it is
|
||||
// the bare array of every row; with `limit` (1..1000) it returns the envelope
|
||||
// {items,total,limit,offset} (filters: offset, q = substring of the address,
|
||||
// last_result = pass|partial|fail|cancelled).
|
||||
// {items,total,limit,offset,run} (filters: offset, q = substring of the
|
||||
// address, last_result = pass|partial|fail|cancelled, run, subnet, direction =
|
||||
// egress|ingress, protocol = icmp|tcp|ssh|https|tls). With `run` the result
|
||||
// fields are those of the address in that run, narrowed by direction/protocol;
|
||||
// see db.ListRegistryPage.
|
||||
func (s *Server) handleAdminRegistry(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
limit, offset, paged, err := parsePaging(q)
|
||||
@@ -23,23 +60,9 @@ func (s *Server) handleAdminRegistry(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
filter := db.RegistryFilter{Query: strings.TrimSpace(q.Get("q")), LastResult: q.Get("last_result"), Subnet: strings.TrimSpace(q.Get("subnet"))}
|
||||
if filter.Subnet != "" {
|
||||
if _, err := netip.ParsePrefix(filter.Subnet); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid subnet "+strconv.Quote(filter.Subnet)+" (a CIDR such as 203.0.113.0/24 is expected)")
|
||||
return
|
||||
}
|
||||
}
|
||||
if v := q.Get("run"); v != "" {
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid run "+strconv.Quote(v))
|
||||
return
|
||||
}
|
||||
filter.RunID = id
|
||||
}
|
||||
if filter.LastResult != "" && !db.IsValidResult(filter.LastResult) {
|
||||
writeError(w, http.StatusBadRequest, "invalid last_result "+strconv.Quote(filter.LastResult)+" (valid: pass, partial, fail, cancelled)")
|
||||
filter, msg := parseRegistryFilter(q)
|
||||
if msg != "" {
|
||||
writeError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,7 +85,7 @@ func (s *Server) handleAdminRegistry(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, registryPageResponse{Items: out, Total: total, Limit: limit, Offset: offset})
|
||||
writeJSON(w, http.StatusOK, registryPageResponse{Items: out, Total: total, Limit: limit, Offset: offset, Run: filter.RunID})
|
||||
}
|
||||
|
||||
// handleAdminRegistryHistory returns one address's registry record plus its
|
||||
@@ -117,3 +140,130 @@ func levelResultToDTO(l db.LevelResult) levelResultDTO {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// breakdownFor reads the filter of the breakdown endpoints, which need both a
|
||||
// direction and a protocol, and writes the 400 response itself when it cannot.
|
||||
func breakdownFor(w http.ResponseWriter, r *http.Request) (db.RegistryFilter, bool) {
|
||||
f, msg := parseRegistryFilter(r.URL.Query())
|
||||
if msg == "" && (f.Level == "" || f.Family == "") {
|
||||
msg = "direction and protocol are required"
|
||||
}
|
||||
if msg != "" {
|
||||
writeError(w, http.StatusBadRequest, msg)
|
||||
return f, false
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
|
||||
// breakdownLabel is the name of a breakdown group: the target without the
|
||||
// scheme and the trailing "/" (egress), or the site name, "site-N" when the
|
||||
// site is no longer configured (ingress; key is the checks.source).
|
||||
func breakdownLabel(group, key string, sites map[int]string) string {
|
||||
if group == db.BreakdownTarget {
|
||||
if i := strings.Index(key, "://"); i >= 0 {
|
||||
key = key[i+3:]
|
||||
}
|
||||
return strings.TrimRight(key, "/")
|
||||
}
|
||||
idx, _ := strconv.Atoi(strings.TrimPrefix(key, "inbound-site-"))
|
||||
if n := sites[idx]; n != "" {
|
||||
return n
|
||||
}
|
||||
return "site-" + strconv.Itoa(idx)
|
||||
}
|
||||
|
||||
func (s *Server) siteNames(r *http.Request) (map[int]string, error) {
|
||||
sites, err := s.DB.ListSites(r.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make(map[int]string, len(sites))
|
||||
for _, x := range sites {
|
||||
names[x.Index] = x.SiteID
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// handleAdminRegistryBreakdown counts the checks of one direction and protocol
|
||||
// per target (egress) or site (ingress) over the addresses the registry filter
|
||||
// selects (same parameters as GET /admin/registry, direction and protocol
|
||||
// required), most successful first; see db.RegistryBreakdown.
|
||||
func (s *Server) handleAdminRegistryBreakdown(w http.ResponseWriter, r *http.Request) {
|
||||
f, ok := breakdownFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
b, err := s.DB.RegistryBreakdown(r.Context(), f)
|
||||
if err != nil {
|
||||
writeDBError(w, err)
|
||||
return
|
||||
}
|
||||
names, err := s.siteNames(r)
|
||||
if err != nil {
|
||||
writeDBError(w, err)
|
||||
return
|
||||
}
|
||||
out := registryBreakdownDTO{Group: b.Group, Direction: f.Level, Protocol: f.Family, Run: f.RunID, Addresses: b.Addresses,
|
||||
Rows: make([]breakdownRowDTO, len(b.Rows))}
|
||||
for i, x := range b.Rows {
|
||||
out.Rows[i] = breakdownRowDTO{Key: x.Key, Label: breakdownLabel(b.Group, x.Key, names), Total: x.Total, OK: x.OK}
|
||||
}
|
||||
sort.SliceStable(out.Rows, func(i, j int) bool {
|
||||
if out.Rows[i].OK != out.Rows[j].OK {
|
||||
return out.Rows[i].OK > out.Rows[j].OK
|
||||
}
|
||||
return out.Rows[i].Label < out.Rows[j].Label
|
||||
})
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleAdminRegistryBreakdownList serves the checks behind one row of the
|
||||
// breakdown (?key=, as in its rows) as a table, failures first, or with
|
||||
// ?format=csv as a downloadable CSV file. An unknown key is 404.
|
||||
func (s *Server) handleAdminRegistryBreakdownList(w http.ResponseWriter, r *http.Request) {
|
||||
f, ok := breakdownFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key := r.URL.Query().Get("key")
|
||||
if key == "" {
|
||||
writeError(w, http.StatusBadRequest, "key is required")
|
||||
return
|
||||
}
|
||||
checks, err := s.DB.RegistryBreakdownList(r.Context(), f, key)
|
||||
if err != nil {
|
||||
writeDBError(w, err)
|
||||
return
|
||||
}
|
||||
names, err := s.siteNames(r)
|
||||
if err != nil {
|
||||
writeDBError(w, err)
|
||||
return
|
||||
}
|
||||
group := db.BreakdownTarget
|
||||
columns := []string{"Адрес", "Результат", "Тип проверки", "Цель", "Валидатор", "Задержка, мс", "Детали", "Проверено (UTC)"}
|
||||
if f.Level == db.LevelIngress {
|
||||
group = db.BreakdownSite
|
||||
columns = []string{"Адрес", "Результат", "Тип проверки", "Площадка", "Задержка, мс", "Детали", "Проверено (UTC)"}
|
||||
}
|
||||
rows := make([][]string, len(checks))
|
||||
for i, c := range checks {
|
||||
result := "провал"
|
||||
if c.Success {
|
||||
result = "успешно"
|
||||
}
|
||||
row := []string{c.IPAddress, result, c.CheckType, breakdownLabel(group, key, names)}
|
||||
if group == db.BreakdownTarget {
|
||||
row = append(row, analytics.ShortValidator(c.ValidatorID))
|
||||
}
|
||||
rows[i] = append(row, strconv.FormatInt(c.LatencyMS, 10), c.Detail, c.CheckedAt.UTC().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
if r.URL.Query().Get("format") == "csv" {
|
||||
writeCSV(w, columns, rows, fmt.Sprintf("registry_%s_%s_%s.csv", f.Level, f.Family, strings.Trim(nonSlug.ReplaceAllString(strings.ToLower(breakdownLabel(group, key, names)), "-"), "-")))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Columns []string `json:"columns"`
|
||||
Rows [][]string `json:"rows"`
|
||||
}{columns, rows})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -303,7 +304,21 @@ func TestAdminRegistryPaginationFiltersAndCompat(t *testing.T) {
|
||||
if p = page("limit=50&q=0.0.1"); p.Total != 4 { // 10.0.0.1, .10, .11, .12
|
||||
t.Fatalf("q: %+v", p)
|
||||
}
|
||||
for _, bad := range []string{"limit=0", "limit=1001", "offset=1", "limit=5&last_result=bogus", "limit=5&offset=-3"} {
|
||||
// run, subnet, direction and protocol together with the older filters; the
|
||||
// status is then that of the narrowed checks, and the run is echoed.
|
||||
runs, err := d.ListRuns(context.Background())
|
||||
if err != nil || len(runs) != 1 {
|
||||
t.Fatalf("runs: %+v %v", runs, err)
|
||||
}
|
||||
runQ := "limit=50&run=" + strconv.FormatInt(runs[0].ID, 10) + "&direction=egress&protocol="
|
||||
if p = page(runQ + "https&subnet=10.0.0.0/28&last_result=pass&q=10.0.0."); p.Total != 2 || p.Run != runs[0].ID || p.Items[0].Egress.Total != 1 || p.Items[0].Ingress.Total != 0 {
|
||||
t.Fatalf("run+subnet+direction+protocol+status: %+v", p)
|
||||
}
|
||||
if p = page(runQ + "tls"); p.Total != 0 {
|
||||
t.Fatalf("tls on egress: %+v", p)
|
||||
}
|
||||
for _, bad := range []string{"limit=0", "limit=1001", "offset=1", "limit=5&last_result=bogus", "limit=5&offset=-3",
|
||||
"limit=5&direction=sideways", "limit=5&protocol=udp", "limit=5&protocol=TCP"} {
|
||||
if resp, body := fc.do(http.MethodGet, "/api/v1/admin/registry?"+bad, nil); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("%s: expected 400, got %d %s", bad, resp.StatusCode, body)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ func (s *Server) routeTable() []route {
|
||||
{"POST /api/v1/admin/auto-cycle/start", s.handleAdminStartAutoCycle, accessAdmin},
|
||||
{"POST /api/v1/admin/auto-cycle/stop", s.handleAdminStopAutoCycle, accessAdmin},
|
||||
{"GET /api/v1/admin/registry", s.handleAdminRegistry, accessAdmin},
|
||||
{"GET /api/v1/admin/registry/breakdown", s.handleAdminRegistryBreakdown, accessAdmin},
|
||||
{"GET /api/v1/admin/registry/breakdown/list", s.handleAdminRegistryBreakdownList, accessAdmin},
|
||||
{"GET /api/v1/admin/registry/{ip}", s.handleAdminRegistryHistory, accessAdmin},
|
||||
{"GET /api/v1/admin/analytics/runs", s.handleAnalyticsRuns, accessAdmin},
|
||||
{"GET /api/v1/admin/analytics/runs/{id}", s.handleAnalyticsRun, accessAdmin},
|
||||
|
||||
Reference in new issue
Block a user