New page /analytics/compare and API GET /admin/analytics/compare (+ /lists/{group}):
the administrator picks an old (A) and a new (B) run; the report shows the new
addresses (only in B), the ones that left (only in A) and the common ones whose
membership in the seven indicators (pass, partial, fail, egress https any/all,
ingress ssh any/all) differs, with a "what changed" summary per address; the
dynamics of each indicator (delta = new - left + entered - exited) and a verdict
transition matrix. Every number opens a list with CSV. Cancelled addresses are not
part of a run. The list dialog moved to a shared analytics-dialog.js and template;
/analytics got a "compare with another run" button.
Docs, plan and summary in docs/changes/.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
908 lines
28 KiB
Go
908 lines
28 KiB
Go
package dashboard
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeControlAPI is a minimal in-memory stand-in for control-api's
|
|
// /api/v1/admin/* surface, serving the exact JSON shapes the dashboard's
|
|
// client.go expects (see dto.go). It's intentionally simple — enough to
|
|
// exercise the dashboard's rendering logic and mutation flows, not a
|
|
// re-implementation of control-api's own business rules (that's already
|
|
// covered by internal/httpapi's own tests).
|
|
type fakeControlAPI struct {
|
|
mu sync.Mutex
|
|
ips []ipQueueItem
|
|
validators []validatorDTO
|
|
sites map[int]string
|
|
siteState map[int]string
|
|
siteHostname map[int]string
|
|
groups map[string][]string
|
|
checkTypes map[string]checkTypeDTO
|
|
fipSettleSeconds int
|
|
inboundPorts []int
|
|
inboundICMP bool
|
|
|
|
historyRetentionCycles int
|
|
selfCheckMaxAttempts int
|
|
// selfCheckFailedOn is served as self_check_failed_on of the address
|
|
// detail and registry history endpoints.
|
|
selfCheckFailedOn []string
|
|
|
|
// Analytics: the run selector, the report JSON per run, the lists per
|
|
// "run/kind[/class]" and the subnet list of /settings.
|
|
runs []analyticsRun
|
|
reports map[int64]string
|
|
lists map[string]string
|
|
// compares is the comparison JSON per "base-target", compareLists the lists
|
|
// per "base-target/group[/indicator]".
|
|
compares map[string]string
|
|
compareLists map[string]string
|
|
|
|
subnets subnetList
|
|
analyticsReqs []string
|
|
// scanFreeAddresses is what POST /ips/scan "discovers" — tests set it
|
|
// directly rather than this fake reimplementing OpenStack floating-IP
|
|
// filtering (already covered by internal/orchestrator's own tests).
|
|
scanFreeAddresses []string
|
|
registry map[string]registryItem
|
|
registryChecks map[string][]check
|
|
|
|
// Scan job state machine (see the scan handlers): POST starts a job that
|
|
// stays "running" for scanRunPolls GET polls (0 = finishes at once), then
|
|
// ends as done — or as error when scanFinalError is set. scan is the status
|
|
// served by GET; tests may also set it directly (with scanPollsLeft == 0 it
|
|
// stays as is). scanStartStatus != 0 makes POST fail with that HTTP status.
|
|
scan scanStatusDTO
|
|
scanRunPolls int
|
|
scanPollsLeft int
|
|
scanFinalError string
|
|
scanStartStatus int
|
|
|
|
// Requests seen on the list endpoints, for "never loads everything" checks:
|
|
// the raw query of every GET /ips (ipsQueries) and the number of GET
|
|
// /registry calls without `limit` (bare), plus the sizes of the DeleteIPs /
|
|
// SubmitIPs bulk calls.
|
|
ipsQueries []string
|
|
registryQueries []string
|
|
bareIPsCalls int
|
|
deleteChunks []int
|
|
submitChunks []int
|
|
|
|
// autoCycle is the state served by /api/v1/admin/auto-cycle*;
|
|
// autoCycleDown makes all four endpoints answer 500 (unavailable API).
|
|
autoCycle autoCycleDTO
|
|
autoCycleDown bool
|
|
|
|
// authHeaders records the Authorization header of every request served.
|
|
authMu sync.Mutex
|
|
authHeaders []string
|
|
}
|
|
|
|
func newFakeControlAPI(t *testing.T) (*fakeControlAPI, string) {
|
|
t.Helper()
|
|
f := &fakeControlAPI{
|
|
sites: map[int]string{},
|
|
siteState: map[int]string{},
|
|
siteHostname: map[int]string{},
|
|
groups: map[string][]string{},
|
|
checkTypes: map[string]checkTypeDTO{},
|
|
registry: map[string]registryItem{},
|
|
registryChecks: map[string][]check{},
|
|
autoCycle: autoCycleDTO{IntervalSeconds: 3600, Phase: "idle"},
|
|
|
|
selfCheckMaxAttempts: 5,
|
|
}
|
|
ts := httptest.NewServer(f.handler())
|
|
t.Cleanup(ts.Close)
|
|
return f, ts.URL
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeAPIErr(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, errorResponse{Error: msg})
|
|
}
|
|
|
|
func (f *fakeControlAPI) handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("GET /api/v1/admin/status", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
byState := map[string]int{}
|
|
results := map[string]int{"pass": 0, "partial": 0, "fail": 0, "cancelled": 0}
|
|
for _, ip := range f.ips {
|
|
byState[ip.State]++
|
|
if ip.OverallResult != "" {
|
|
results[ip.OverallResult]++
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, statusResponse{TotalIPs: len(f.ips), IPsByState: byState, TotalValidators: len(f.validators), ResultsByOverall: results})
|
|
})
|
|
|
|
mux.HandleFunc("GET /api/v1/admin/ips", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.ipsQueries = append(f.ipsQueries, r.URL.RawQuery)
|
|
qv := r.URL.Query()
|
|
if qv.Get("limit") == "" {
|
|
// Legacy shape: the whole queue as a bare array.
|
|
f.bareIPsCalls++
|
|
writeJSON(w, http.StatusOK, f.ips)
|
|
return
|
|
}
|
|
limit, err := strconv.Atoi(qv.Get("limit"))
|
|
if err != nil || limit < 1 || limit > 1000 {
|
|
writeAPIErr(w, http.StatusBadRequest, "limit must be 1..1000")
|
|
return
|
|
}
|
|
offset, _ := strconv.Atoi(qv.Get("offset"))
|
|
var states map[string]bool
|
|
if st := qv.Get("state"); st != "" {
|
|
states = map[string]bool{}
|
|
for _, x := range strings.Split(st, ",") {
|
|
states[x] = true
|
|
}
|
|
}
|
|
var matched []ipQueueItem
|
|
for _, ip := range f.ips {
|
|
if states != nil && !states[ip.State] {
|
|
continue
|
|
}
|
|
if q := qv.Get("q"); q != "" && !strings.Contains(strings.ToLower(ip.IPAddress), strings.ToLower(q)) {
|
|
continue
|
|
}
|
|
if res := qv.Get("result"); res != "" && ip.OverallResult != res {
|
|
continue
|
|
}
|
|
matched = append(matched, ip)
|
|
}
|
|
if qv.Get("order") == "aggregated_at_desc" {
|
|
sort.SliceStable(matched, func(i, j int) bool {
|
|
a, b := matched[i].AggregatedAt, matched[j].AggregatedAt
|
|
if a == nil || b == nil {
|
|
return a != nil && b == nil
|
|
}
|
|
return a.After(*b)
|
|
})
|
|
} else {
|
|
sort.SliceStable(matched, func(i, j int) bool { return matched[i].Sequence < matched[j].Sequence })
|
|
}
|
|
page := []ipQueueItem{}
|
|
if offset < len(matched) {
|
|
page = matched[offset:]
|
|
if len(page) > limit {
|
|
page = page[:limit]
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, ipsPage{Items: page, Total: len(matched), Limit: limit, Offset: offset})
|
|
})
|
|
|
|
mux.HandleFunc("GET /api/v1/admin/ips/{ip}", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
addr := r.PathValue("ip")
|
|
for _, ip := range f.ips {
|
|
if ip.IPAddress == addr {
|
|
writeJSON(w, http.StatusOK, ipDetailResponse{IP: ip, Checks: []check{}, Events: []event{}, SelfCheckFailedOn: f.selfCheckFailedOn})
|
|
return
|
|
}
|
|
}
|
|
writeAPIErr(w, http.StatusNotFound, "unknown ip: "+addr)
|
|
})
|
|
|
|
mux.HandleFunc("POST /api/v1/admin/ips", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var req struct {
|
|
Addresses []string `json:"addresses"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
if len(req.Addresses) == 0 {
|
|
writeAPIErr(w, http.StatusBadRequest, "addresses must not be empty")
|
|
return
|
|
}
|
|
f.submitChunks = append(f.submitChunks, len(req.Addresses))
|
|
resp := submitIPsResponse{}
|
|
for _, addr := range req.Addresses {
|
|
idx := f.findIP(addr)
|
|
if idx < 0 {
|
|
now := time.Now()
|
|
f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", CreatedAt: now, UpdatedAt: now})
|
|
resp.Added = append(resp.Added, addr)
|
|
continue
|
|
}
|
|
switch f.ips[idx].State {
|
|
case "done", "failed":
|
|
f.ips[idx].State = "queued"
|
|
f.ips[idx].AttemptNumber++
|
|
f.ips[idx].OverallResult = ""
|
|
resp.Requeued = append(resp.Requeued, addr)
|
|
case "queued":
|
|
resp.Reordered = append(resp.Reordered, addr)
|
|
default:
|
|
resp.SkippedInProgress = append(resp.SkippedInProgress, addr)
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
})
|
|
|
|
mux.HandleFunc("POST /api/v1/admin/ips/{ip}/cancel", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
addr := r.PathValue("ip")
|
|
idx := f.findIP(addr)
|
|
if idx < 0 {
|
|
writeAPIErr(w, http.StatusNotFound, "unknown ip: "+addr)
|
|
return
|
|
}
|
|
if f.ips[idx].State == "done" || f.ips[idx].State == "failed" {
|
|
writeAPIErr(w, http.StatusConflict, "already finished")
|
|
return
|
|
}
|
|
f.ips[idx].State = "failed"
|
|
f.ips[idx].OverallResult = "cancelled"
|
|
now := time.Now()
|
|
f.ips[idx].AggregatedAt = &now
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
})
|
|
|
|
mux.HandleFunc("DELETE /api/v1/admin/ips/{ip}", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
addr := r.PathValue("ip")
|
|
idx := f.findIP(addr)
|
|
if idx < 0 {
|
|
writeAPIErr(w, http.StatusNotFound, "unknown ip: "+addr)
|
|
return
|
|
}
|
|
f.ips = append(f.ips[:idx], f.ips[idx+1:]...)
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
})
|
|
|
|
mux.HandleFunc("POST /api/v1/admin/ips/delete", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var req struct {
|
|
Addresses []string `json:"addresses"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
if len(req.Addresses) == 0 {
|
|
writeAPIErr(w, http.StatusBadRequest, "addresses must not be empty")
|
|
return
|
|
}
|
|
f.deleteChunks = append(f.deleteChunks, len(req.Addresses))
|
|
resp := deleteIPsResponse{}
|
|
for _, addr := range req.Addresses {
|
|
idx := f.findIP(addr)
|
|
if idx < 0 {
|
|
resp.NotFound = append(resp.NotFound, addr)
|
|
continue
|
|
}
|
|
f.ips = append(f.ips[:idx], f.ips[idx+1:]...)
|
|
resp.Deleted = append(resp.Deleted, addr)
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
})
|
|
|
|
mux.HandleFunc("POST /api/v1/admin/ips/clear", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
resp := clearQueueResponse{}
|
|
for _, ip := range f.ips {
|
|
resp.Deleted = append(resp.Deleted, ip.IPAddress)
|
|
}
|
|
resp.Count = len(resp.Deleted)
|
|
f.ips = nil
|
|
writeJSON(w, http.StatusOK, resp)
|
|
})
|
|
|
|
mux.HandleFunc("GET /api/v1/admin/config/orchestrator", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
writeJSON(w, http.StatusOK, orchestratorSettingsDTO{
|
|
FIPSettleSeconds: f.fipSettleSeconds,
|
|
HistoryRetentionCycles: f.historyRetentionCycles,
|
|
SelfCheckMaxAttempts: f.selfCheckMaxAttempts,
|
|
})
|
|
})
|
|
mux.HandleFunc("PUT /api/v1/admin/config/orchestrator", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var req orchestratorSettingsDTO
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
if req.FIPSettleSeconds < 0 {
|
|
writeAPIErr(w, http.StatusBadRequest, "fip_settle_seconds must be >= 0")
|
|
return
|
|
}
|
|
if req.HistoryRetentionCycles < 0 {
|
|
writeAPIErr(w, http.StatusBadRequest, "history_retention_cycles must be >= 0")
|
|
return
|
|
}
|
|
if req.SelfCheckMaxAttempts < 1 || req.SelfCheckMaxAttempts > 50 {
|
|
writeAPIErr(w, http.StatusBadRequest, "self_check_max_attempts must be in 1..50")
|
|
return
|
|
}
|
|
f.fipSettleSeconds = req.FIPSettleSeconds
|
|
f.historyRetentionCycles = req.HistoryRetentionCycles
|
|
f.selfCheckMaxAttempts = req.SelfCheckMaxAttempts
|
|
writeJSON(w, http.StatusOK, orchestratorSettingsDTO{
|
|
FIPSettleSeconds: f.fipSettleSeconds,
|
|
HistoryRetentionCycles: f.historyRetentionCycles,
|
|
SelfCheckMaxAttempts: f.selfCheckMaxAttempts,
|
|
})
|
|
})
|
|
|
|
mux.HandleFunc("POST /api/v1/admin/ips/scan", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.scanStartStatus != 0 {
|
|
writeAPIErr(w, f.scanStartStatus, "scan refused")
|
|
return
|
|
}
|
|
if !f.scan.Running {
|
|
now := time.Now()
|
|
f.scan = scanStatusDTO{State: "listing", Running: true, DryRun: r.URL.Query().Get("dry_run") == "true", StartedAt: &now}
|
|
f.scanPollsLeft = f.scanRunPolls
|
|
if f.scanPollsLeft == 0 {
|
|
f.finishScan()
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusAccepted, f.scan)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/admin/ips/scan", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.scan.Running && f.scanPollsLeft > 0 {
|
|
f.scanPollsLeft--
|
|
if f.scanPollsLeft == 0 {
|
|
f.finishScan()
|
|
} else {
|
|
f.scan.Pages++
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, f.scan)
|
|
})
|
|
|
|
mux.HandleFunc("GET /api/v1/admin/auto-cycle", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.autoCycleDown {
|
|
writeAPIErr(w, http.StatusInternalServerError, "auto-cycle unavailable")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, f.autoCycle)
|
|
})
|
|
mux.HandleFunc("PUT /api/v1/admin/auto-cycle", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.autoCycleDown {
|
|
writeAPIErr(w, http.StatusInternalServerError, "auto-cycle unavailable")
|
|
return
|
|
}
|
|
var req struct {
|
|
IntervalSeconds *int `json:"interval_seconds"`
|
|
MaxRunSeconds *int `json:"max_run_seconds"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
if req.IntervalSeconds != nil && *req.IntervalSeconds < 60 {
|
|
writeAPIErr(w, http.StatusBadRequest, "interval_seconds must be >= 60: validation failed")
|
|
return
|
|
}
|
|
if req.MaxRunSeconds != nil && *req.MaxRunSeconds < 0 {
|
|
writeAPIErr(w, http.StatusBadRequest, "max_run_seconds must be >= 0: validation failed")
|
|
return
|
|
}
|
|
if req.IntervalSeconds != nil {
|
|
f.autoCycle.IntervalSeconds = *req.IntervalSeconds
|
|
}
|
|
if req.MaxRunSeconds != nil {
|
|
f.autoCycle.MaxRunSeconds = *req.MaxRunSeconds
|
|
}
|
|
writeJSON(w, http.StatusOK, f.autoCycle)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/admin/auto-cycle/start", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.autoCycleDown {
|
|
writeAPIErr(w, http.StatusInternalServerError, "auto-cycle unavailable")
|
|
return
|
|
}
|
|
if !f.autoCycle.Enabled {
|
|
now := time.Now()
|
|
f.autoCycle.Enabled = true
|
|
f.autoCycle.Phase = "idle"
|
|
f.autoCycle.NextRunAt = &now
|
|
}
|
|
writeJSON(w, http.StatusOK, f.autoCycle)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/admin/auto-cycle/stop", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.autoCycleDown {
|
|
writeAPIErr(w, http.StatusInternalServerError, "auto-cycle unavailable")
|
|
return
|
|
}
|
|
f.autoCycle.Enabled = false
|
|
f.autoCycle.Phase = "idle"
|
|
f.autoCycle.NextRunAt = nil
|
|
f.autoCycle.LastOutcome = "stopped"
|
|
writeJSON(w, http.StatusOK, f.autoCycle)
|
|
})
|
|
|
|
mux.HandleFunc("GET /api/v1/admin/registry", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
qv := r.URL.Query()
|
|
f.registryQueries = append(f.registryQueries, r.URL.RawQuery)
|
|
matched := make([]registryItem, 0, len(f.registry))
|
|
for _, item := range f.registry {
|
|
if q := qv.Get("q"); q != "" && !strings.Contains(strings.ToLower(item.IPAddress), strings.ToLower(q)) {
|
|
continue
|
|
}
|
|
if lr := qv.Get("last_result"); lr != "" && item.LastResult != lr {
|
|
continue
|
|
}
|
|
matched = append(matched, item)
|
|
}
|
|
sort.Slice(matched, func(i, j int) bool { return matched[i].IPAddress < matched[j].IPAddress })
|
|
if qv.Get("limit") == "" {
|
|
writeJSON(w, http.StatusOK, matched)
|
|
return
|
|
}
|
|
limit, err := strconv.Atoi(qv.Get("limit"))
|
|
if err != nil || limit < 1 || limit > 1000 {
|
|
writeAPIErr(w, http.StatusBadRequest, "limit must be 1..1000")
|
|
return
|
|
}
|
|
offset, _ := strconv.Atoi(qv.Get("offset"))
|
|
page := []registryItem{}
|
|
if offset < len(matched) {
|
|
page = matched[offset:]
|
|
if len(page) > limit {
|
|
page = page[:limit]
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, registryPage{Items: page, Total: len(matched), Limit: limit, Offset: offset})
|
|
})
|
|
mux.HandleFunc("GET /api/v1/admin/registry/{ip}", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
addr := r.PathValue("ip")
|
|
item, ok := f.registry[addr]
|
|
if !ok {
|
|
writeAPIErr(w, http.StatusNotFound, "unknown ip: "+addr)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, registryHistoryResponse{Registry: item, Checks: f.registryChecks[addr], SelfCheckFailedOn: f.selfCheckFailedOn})
|
|
})
|
|
|
|
mux.HandleFunc("GET /api/v1/admin/config/subnets", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := f.subnets
|
|
if out.Subnets == nil {
|
|
out.Subnets = []subnetEntry{}
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
})
|
|
mux.HandleFunc("PUT /api/v1/admin/config/subnets", func(w http.ResponseWriter, r *http.Request) {
|
|
var in subnetList
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeAPIErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
for _, s := range in.Subnets {
|
|
if !strings.Contains(s.CIDR, "/") {
|
|
writeAPIErr(w, http.StatusBadRequest, "subnet "+s.CIDR+": not a CIDR")
|
|
return
|
|
}
|
|
}
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.subnets = in
|
|
writeJSON(w, http.StatusOK, in)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/admin/analytics/runs", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := f.runs
|
|
if out == nil {
|
|
out = []analyticsRun{}
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/admin/analytics/runs/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
|
|
rep, ok := f.reports[id]
|
|
if !ok {
|
|
writeAPIErr(w, http.StatusNotFound, "run not found")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(rep))
|
|
})
|
|
mux.HandleFunc("GET /api/v1/admin/analytics/runs/{id}/lists/{kind}", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
|
|
key := r.PathValue("id") + "/" + r.PathValue("kind")
|
|
if c := r.URL.Query().Get("class"); c != "" {
|
|
key += "/" + c
|
|
}
|
|
l, ok := f.lists[key]
|
|
if !ok {
|
|
writeAPIErr(w, http.StatusNotFound, "unknown list")
|
|
return
|
|
}
|
|
if r.URL.Query().Get("format") == "csv" {
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", `attachment; filename="`+r.PathValue("kind")+`_run`+r.PathValue("id")+`.csv"`)
|
|
_, _ = w.Write([]byte("Адрес\r\n1.2.3.4\r\n"))
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(l))
|
|
})
|
|
mux.HandleFunc("GET /api/v1/admin/analytics/compare", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
|
|
rep, ok := f.compares[r.URL.Query().Get("base")+"-"+r.URL.Query().Get("target")]
|
|
if !ok {
|
|
writeAPIErr(w, http.StatusNotFound, "run not found")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(rep))
|
|
})
|
|
mux.HandleFunc("GET /api/v1/admin/analytics/compare/lists/{group}", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
|
|
q := r.URL.Query()
|
|
key := q.Get("base") + "-" + q.Get("target") + "/" + r.PathValue("group")
|
|
if ind := q.Get("indicator"); ind != "" {
|
|
key += "/" + ind
|
|
}
|
|
l, ok := f.compareLists[key]
|
|
if !ok {
|
|
writeAPIErr(w, http.StatusNotFound, "unknown list")
|
|
return
|
|
}
|
|
if q.Get("format") == "csv" {
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", `attachment; filename="compare_`+r.PathValue("group")+`_run`+q.Get("base")+"-"+q.Get("target")+`.csv"`)
|
|
_, _ = w.Write([]byte("Адрес\r\n1.2.3.4\r\n"))
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(l))
|
|
})
|
|
mux.HandleFunc("GET /api/v1/admin/config/inbound-checks", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
writeJSON(w, http.StatusOK, inboundChecksDTO{Ports: f.inboundPorts, ICMP: f.inboundICMP})
|
|
})
|
|
mux.HandleFunc("PUT /api/v1/admin/config/inbound-checks", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var req inboundChecksDTO
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
for _, p := range req.Ports {
|
|
if p < 1 || p > 65535 {
|
|
writeAPIErr(w, http.StatusBadRequest, "port out of range")
|
|
return
|
|
}
|
|
}
|
|
f.inboundPorts = req.Ports
|
|
f.inboundICMP = req.ICMP
|
|
writeJSON(w, http.StatusOK, inboundChecksDTO{Ports: f.inboundPorts, ICMP: f.inboundICMP})
|
|
})
|
|
|
|
mux.HandleFunc("GET /api/v1/admin/config/validators", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
writeJSON(w, http.StatusOK, f.validators)
|
|
})
|
|
mux.HandleFunc("POST /api/v1/admin/config/validators", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var req validatorDTO
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
for _, v := range f.validators {
|
|
if v.ValidatorID == req.ValidatorID {
|
|
writeAPIErr(w, http.StatusConflict, "already exists")
|
|
return
|
|
}
|
|
}
|
|
req.State = "idle"
|
|
f.validators = append(f.validators, req)
|
|
writeJSON(w, http.StatusCreated, req)
|
|
})
|
|
mux.HandleFunc("PUT /api/v1/admin/config/validators/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
id := r.PathValue("id")
|
|
var req struct {
|
|
OSPortID string `json:"os_port_id"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
for i, v := range f.validators {
|
|
if v.ValidatorID == id {
|
|
f.validators[i].OSPortID = req.OSPortID
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
return
|
|
}
|
|
}
|
|
writeAPIErr(w, http.StatusNotFound, "unknown validator")
|
|
})
|
|
mux.HandleFunc("DELETE /api/v1/admin/config/validators/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
id := r.PathValue("id")
|
|
for i, v := range f.validators {
|
|
if v.ValidatorID == id {
|
|
f.validators = append(f.validators[:i], f.validators[i+1:]...)
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
return
|
|
}
|
|
}
|
|
writeAPIErr(w, http.StatusNotFound, "unknown validator")
|
|
})
|
|
|
|
mux.HandleFunc("GET /api/v1/admin/config/sites", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []siteDTO
|
|
for idx, id := range f.sites {
|
|
out = append(out, siteDTO{Index: idx, SiteID: id, Hostname: f.siteHostname[idx], State: f.siteState[idx]})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
})
|
|
mux.HandleFunc("PUT /api/v1/admin/config/sites/{index}", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var idx int
|
|
fmt.Sscanf(r.PathValue("index"), "%d", &idx)
|
|
var req struct {
|
|
SiteID string `json:"site_id"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
for existingIdx, id := range f.sites {
|
|
if id == req.SiteID && existingIdx != idx {
|
|
writeAPIErr(w, http.StatusConflict, "site_id already assigned to another slot")
|
|
return
|
|
}
|
|
}
|
|
f.sites[idx] = req.SiteID
|
|
// UpsertSite always resets connection status on write (see its
|
|
// control-api doc comment) — mirror that here.
|
|
f.siteState[idx] = "unregistered"
|
|
f.siteHostname[idx] = ""
|
|
writeJSON(w, http.StatusOK, siteDTO{Index: idx, SiteID: req.SiteID, State: "unregistered"})
|
|
})
|
|
mux.HandleFunc("DELETE /api/v1/admin/config/sites/{index}", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var idx int
|
|
fmt.Sscanf(r.PathValue("index"), "%d", &idx)
|
|
delete(f.sites, idx)
|
|
delete(f.siteState, idx)
|
|
delete(f.siteHostname, idx)
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
})
|
|
|
|
mux.HandleFunc("GET /api/v1/admin/config/targets", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []targetGroupDTO
|
|
for name, targets := range f.groups {
|
|
out = append(out, targetGroupDTO{Name: name, Targets: targets})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
})
|
|
mux.HandleFunc("PUT /api/v1/admin/config/targets/{group}", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
name := r.PathValue("group")
|
|
var req struct {
|
|
Targets []string `json:"targets"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
if len(req.Targets) == 0 {
|
|
writeAPIErr(w, http.StatusBadRequest, "targets must not be empty")
|
|
return
|
|
}
|
|
f.groups[name] = req.Targets
|
|
writeJSON(w, http.StatusOK, targetGroupDTO{Name: name, Targets: req.Targets})
|
|
})
|
|
mux.HandleFunc("DELETE /api/v1/admin/config/targets/{group}", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
name := r.PathValue("group")
|
|
for _, ct := range f.checkTypes {
|
|
for _, g := range ct.Targets {
|
|
if g == name {
|
|
writeAPIErr(w, http.StatusConflict, "in use by check type "+ct.Name)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
delete(f.groups, name)
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
})
|
|
|
|
mux.HandleFunc("GET /api/v1/admin/config/check-types", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []checkTypeDTO
|
|
for _, ct := range f.checkTypes {
|
|
out = append(out, ct)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
})
|
|
mux.HandleFunc("PUT /api/v1/admin/config/check-types/{name}", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
name := r.PathValue("name")
|
|
var req struct {
|
|
Enabled bool `json:"enabled"`
|
|
Targets []string `json:"targets"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
for _, g := range req.Targets {
|
|
if _, ok := f.groups[g]; !ok {
|
|
writeAPIErr(w, http.StatusBadRequest, "unknown target group "+g)
|
|
return
|
|
}
|
|
}
|
|
ct := checkTypeDTO{Name: name, Enabled: req.Enabled, Targets: req.Targets}
|
|
f.checkTypes[name] = ct
|
|
writeJSON(w, http.StatusOK, ct)
|
|
})
|
|
mux.HandleFunc("DELETE /api/v1/admin/config/check-types/{name}", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
delete(f.checkTypes, r.PathValue("name"))
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
})
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
f.authMu.Lock()
|
|
f.authHeaders = append(f.authHeaders, r.Header.Get("Authorization"))
|
|
f.authMu.Unlock()
|
|
mux.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// finishScan ends the running scan job (caller holds f.mu): the discovered
|
|
// free addresses are queued unless it was a dry run, or the job fails with
|
|
// scanFinalError.
|
|
func (f *fakeControlAPI) finishScan() {
|
|
now := time.Now()
|
|
f.scan.Running = false
|
|
f.scan.FinishedAt = &now
|
|
f.scan.Discovered = len(f.scanFreeAddresses)
|
|
f.scan.Free = len(f.scanFreeAddresses)
|
|
if f.scanFinalError != "" {
|
|
f.scan.State = "error"
|
|
f.scan.Error = f.scanFinalError
|
|
return
|
|
}
|
|
f.scan.State = "done"
|
|
if f.scan.DryRun {
|
|
return
|
|
}
|
|
for _, addr := range f.scanFreeAddresses {
|
|
if f.findIP(addr) < 0 {
|
|
f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", Sequence: len(f.ips) + 1, CreatedAt: now, UpdatedAt: now})
|
|
f.scan.Added++
|
|
} else {
|
|
f.scan.Reordered++
|
|
}
|
|
}
|
|
}
|
|
|
|
// seedIPs appends n queued addresses 10.x.y.z (distinct, in sequence order)
|
|
// and returns them. Use it for tests that need pages' worth of rows.
|
|
func (f *fakeControlAPI) seedIPs(n int) []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
now := time.Now()
|
|
out := make([]string, 0, n)
|
|
base := len(f.ips)
|
|
for i := 0; i < n; i++ {
|
|
k := base + i + 1
|
|
addr := fmt.Sprintf("10.%d.%d.%d", k/65536, (k/256)%256, k%256)
|
|
f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", Sequence: k, CreatedAt: now, UpdatedAt: now})
|
|
out = append(out, addr)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (f *fakeControlAPI) findIP(addr string) int {
|
|
for i, ip := range f.ips {
|
|
if ip.IPAddress == addr {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func newTestServer(t *testing.T, caURL string) *httptest.Server {
|
|
t.Helper()
|
|
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
srv, err := New(Config{
|
|
ControlAPIBaseURL: caURL,
|
|
ControlAPITimeout: 5 * time.Second,
|
|
LastCompletedCount: 20,
|
|
OverviewPollIntervalS: 5,
|
|
}, log)
|
|
if err != nil {
|
|
t.Fatalf("new dashboard server: %v", err)
|
|
}
|
|
ts := httptest.NewServer(srv.Handler())
|
|
t.Cleanup(ts.Close)
|
|
return ts
|
|
}
|
|
|
|
func get(t *testing.T, ts *httptest.Server, path string) string {
|
|
t.Helper()
|
|
resp, err := http.Get(ts.URL + path)
|
|
if err != nil {
|
|
t.Fatalf("GET %s: %v", path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return string(body)
|
|
}
|
|
|
|
func postForm(t *testing.T, ts *httptest.Server, method, path string, form url.Values) string {
|
|
t.Helper()
|
|
req, err := http.NewRequest(method, ts.URL+path, strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
t.Fatalf("build request: %v", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
resp, err := ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", method, path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return string(body)
|
|
}
|
|
|
|
// newSlowAPI serves an empty JSON object for every request after delay.
|
|
func newSlowAPI(t *testing.T, delay time.Duration) string {
|
|
t.Helper()
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(delay)
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{})
|
|
}))
|
|
t.Cleanup(ts.Close)
|
|
return ts.URL
|
|
}
|