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>
270 lines
9.2 KiB
Go
270 lines
9.2 KiB
Go
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,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)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
filter, msg := parseRegistryFilter(q)
|
|
if msg != "" {
|
|
writeError(w, http.StatusBadRequest, msg)
|
|
return
|
|
}
|
|
|
|
var items []db.RegistrySummary
|
|
var total int
|
|
if len(q) == 0 {
|
|
items, err = s.DB.ListRegistry(r.Context())
|
|
} else {
|
|
items, total, err = s.DB.ListRegistryPage(r.Context(), filter, limit, offset)
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
out := make([]registryDTO, len(items))
|
|
for i, it := range items {
|
|
out[i] = registrySummaryToDTO(it)
|
|
}
|
|
if !paged {
|
|
writeJSON(w, http.StatusOK, out)
|
|
return
|
|
}
|
|
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
|
|
// full retained check history (across every cycle still kept — see
|
|
// db.PruneRegistryHistory / settings.history_retention_cycles), newest
|
|
// cycle first.
|
|
func (s *Server) handleAdminRegistryHistory(w http.ResponseWriter, r *http.Request) {
|
|
address := r.PathValue("ip")
|
|
summary, err := s.DB.GetRegistryByAddress(r.Context(), address)
|
|
if err != nil {
|
|
writeDBError(w, err)
|
|
return
|
|
}
|
|
checks, err := s.DB.ListChecksForRegistry(r.Context(), summary.ID, nil)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
// Self-check failures over every run of the address.
|
|
failedOn, err := s.DB.ListSelfCheckFailedOn(r.Context(), summary.ID, 0)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, struct {
|
|
Registry registryDTO `json:"registry"`
|
|
Checks []db.Check `json:"checks"`
|
|
SelfCheckFailedOn []string `json:"self_check_failed_on"`
|
|
}{registrySummaryToDTO(*summary), checks, failedOn})
|
|
}
|
|
|
|
func registrySummaryToDTO(s db.RegistrySummary) registryDTO {
|
|
return registryDTO{
|
|
IPAddress: s.IPAddress,
|
|
FirstSeenAt: s.FirstSeenAt,
|
|
LastSeenAt: s.LastSeenAt,
|
|
TotalCycles: s.TotalCycles,
|
|
LastResult: s.LastResult,
|
|
LastCheckedAt: s.LastCheckedAt,
|
|
InQueue: s.InQueue,
|
|
CurrentState: s.CurrentState,
|
|
LastCycleID: s.LastCycleID,
|
|
Egress: levelResultToDTO(s.Egress),
|
|
Ingress: levelResultToDTO(s.Ingress),
|
|
}
|
|
}
|
|
|
|
func levelResultToDTO(l db.LevelResult) levelResultDTO {
|
|
out := levelResultDTO{Total: l.Total, OK: l.OK, ByType: make([]typeStatDTO, len(l.ByType))}
|
|
for i, t := range l.ByType {
|
|
out.ByType[i] = typeStatDTO{Type: t.Type, Total: t.Total, OK: t.OK}
|
|
}
|
|
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})
|
|
}
|