package dashboard import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) // apiErr is returned by every client method for a non-2xx response from // control-api, or for a transport-level failure (control-api unreachable/ // timeout, Status==0). Handlers use it (via errors.As) to render an error // banner with the right status/severity instead of a raw 500 — see // writeErrorBanner in render.go. type apiErr struct { Status int Message string } func (e *apiErr) Error() string { if e.Status == 0 { return fmt.Sprintf("control-api недоступен: %s", e.Message) } return fmt.Sprintf("control-api: %d %s", e.Status, e.Message) } // client is a thin, dashboard-local JSON client for control-api's // /api/v1/admin/* surface. It intentionally doesn't reuse // internal/apiclient.Client (shared by validator-agent/prober): that // client's Do only returns an opaque error, with no way to recover the // HTTP status code — which the dashboard needs to render 400 vs 404 vs 409 // vs 5xx differently. Duplicating ~30 lines here avoids widening // apiclient's contract (and its blast radius on the other two binaries) // for a need only this package has. type client struct { baseURL string http *http.Client // long is used for clear / bulk operations, which legitimately take far // longer than a plain read (thousands of rows in one transaction): same // transport, but a longer whole-call timeout. long *http.Client // token, when non-empty, is sent to control-api as a Bearer credential. token string } // longCallTimeout is the minimum whole-call timeout for clear and bulk // operations (ClearQueue, DeleteIPs, SubmitIPs). const longCallTimeout = 120 * time.Second func newClient(baseURL string, timeout time.Duration) *client { longT := longCallTimeout if timeout > longT { longT = timeout } return &client{ baseURL: baseURL, http: &http.Client{Timeout: timeout}, long: &http.Client{Timeout: longT}, } } func (c *client) do(ctx context.Context, method, path string, body, out interface{}) error { return c.doWith(ctx, c.http, method, path, body, out) } // doLong is do with the long (clear/bulk) timeout. func (c *client) doLong(ctx context.Context, method, path string, body, out interface{}) error { hc := c.long if hc == nil { hc = c.http } return c.doWith(ctx, hc, method, path, body, out) } func (c *client) doWith(ctx context.Context, hc *http.Client, method, path string, body, out interface{}) error { var reader io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { return fmt.Errorf("marshal request: %w", err) } reader = bytes.NewReader(b) } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) if err != nil { return fmt.Errorf("build request: %w", err) } if body != nil { req.Header.Set("Content-Type", "application/json") } if c.token != "" { req.Header.Set("Authorization", "Bearer "+c.token) } resp, err := hc.Do(req) if err != nil { return &apiErr{Status: 0, Message: err.Error()} } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { msg := string(respBody) var er errorResponse if json.Unmarshal(respBody, &er) == nil && er.Error != "" { msg = er.Error } return &apiErr{Status: resp.StatusCode, Message: msg} } if out != nil && len(respBody) > 0 { if err := json.Unmarshal(respBody, out); err != nil { return fmt.Errorf("decode response from %s %s: %w", method, path, err) } } return nil } func (c *client) Status(ctx context.Context) (statusResponse, error) { var out statusResponse err := c.do(ctx, http.MethodGet, "/api/v1/admin/status", nil, &out) return out, err } // maxPageLimit is control-api's cap on `limit`. const maxPageLimit = 1000 // clampLimit keeps limit within 1..maxPageLimit: a request without `limit` // would make control-api answer with the legacy unbounded bare array. func clampLimit(limit int) int { if limit < 1 { return 1 } if limit > maxPageLimit { return maxPageLimit } return limit } // ipsQuery selects one page of GET /admin/ips: server-side filters plus // limit/offset. Order is "sequence" (default) or "aggregated_at_desc". type ipsQuery struct { States []string Q string Result string Order string Limit int Offset int } func (q ipsQuery) values() url.Values { v := url.Values{} v.Set("limit", strconv.Itoa(clampLimit(q.Limit))) if q.Offset > 0 { v.Set("offset", strconv.Itoa(q.Offset)) } if len(q.States) > 0 { v.Set("state", strings.Join(q.States, ",")) } if q.Q != "" { v.Set("q", q.Q) } if q.Result != "" { v.Set("result", q.Result) } if q.Order != "" { v.Set("order", q.Order) } return v } // ListIPsPage returns one page of the check queue plus the total number of // rows matching the filter. Never loads the whole queue. func (c *client) ListIPsPage(ctx context.Context, q ipsQuery) (ipsPage, error) { var out ipsPage err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips?"+q.values().Encode(), nil, &out) return out, err } func (c *client) GetIP(ctx context.Context, ip string) (ipDetailResponse, error) { var out ipDetailResponse err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips/"+url.PathEscape(ip), nil, &out) return out, err } // SubmitIPs is the single entry point for both adding new addresses and // forcing a recheck of already-finished ones — see docs/API.md. func (c *client) SubmitIPs(ctx context.Context, addresses []string) (submitIPsResponse, error) { var out submitIPsResponse err := c.doLong(ctx, http.MethodPost, "/api/v1/admin/ips", map[string][]string{"addresses": addresses}, &out) return out, err } func (c *client) CancelIP(ctx context.Context, ip string) error { return c.do(ctx, http.MethodPost, "/api/v1/admin/ips/"+url.PathEscape(ip)+"/cancel", nil, nil) } // DeleteIP permanently removes one address and its full history — unlike // CancelIP, there's nothing left to look up afterward. func (c *client) DeleteIP(ctx context.Context, ip string) error { return c.do(ctx, http.MethodDelete, "/api/v1/admin/ips/"+url.PathEscape(ip), nil, nil) } // DeleteIPs permanently removes a specific list of addresses in one call. func (c *client) DeleteIPs(ctx context.Context, addresses []string) (deleteIPsResponse, error) { var out deleteIPsResponse err := c.doLong(ctx, http.MethodPost, "/api/v1/admin/ips/delete", map[string][]string{"addresses": addresses}, &out) return out, err } // ClearQueue permanently removes every address currently in the queue, // including those actively being checked. func (c *client) ClearQueue(ctx context.Context) (clearQueueResponse, error) { var out clearQueueResponse err := c.doLong(ctx, http.MethodPost, "/api/v1/admin/ips/clear", nil, &out) return out, err } // StartScan starts control-api's background floating-IP scan (or joins the // one already running) and returns immediately with its current status. func (c *client) StartScan(ctx context.Context, dryRun bool) (scanStatusDTO, error) { var out scanStatusDTO path := "/api/v1/admin/ips/scan" if dryRun { path += "?dry_run=true" } err := c.do(ctx, http.MethodPost, path, nil, &out) return out, err } // ScanStatus returns the progress of the background scan job. func (c *client) ScanStatus(ctx context.Context) (scanStatusDTO, error) { var out scanStatusDTO err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips/scan", nil, &out) return out, err } // registryQuery selects one page of GET /admin/registry. type registryQuery struct { Q string LastResult string Run int64 // only addresses with a result in this run Subnet string // only addresses inside this CIDR Limit int Offset int } // ListRegistryPage returns one page of the registry (every address ever // submitted to the check queue, each with a summary of its accumulated check // history — survives an address being deleted from the queue and later // re-added) plus the total number of rows matching the filter. func (c *client) ListRegistryPage(ctx context.Context, q registryQuery) (registryPage, error) { v := url.Values{} v.Set("limit", strconv.Itoa(clampLimit(q.Limit))) if q.Offset > 0 { v.Set("offset", strconv.Itoa(q.Offset)) } if q.Q != "" { v.Set("q", q.Q) } if q.LastResult != "" { v.Set("last_result", q.LastResult) } if q.Run > 0 { v.Set("run", strconv.FormatInt(q.Run, 10)) } if q.Subnet != "" { v.Set("subnet", q.Subnet) } var out registryPage err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry?"+v.Encode(), nil, &out) return out, err } // GetRegistryHistory returns one address's registry record plus its full // retained check history across every cycle still kept. func (c *client) GetRegistryHistory(ctx context.Context, ip string) (registryHistoryResponse, error) { var out registryHistoryResponse err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry/"+url.PathEscape(ip), nil, &out) return out, err } func (c *client) ListValidators(ctx context.Context) ([]validatorDTO, error) { var out []validatorDTO err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/validators", nil, &out) return out, err } func (c *client) CreateValidator(ctx context.Context, id, osPortID string) error { body := map[string]string{"validator_id": id, "os_port_id": osPortID} return c.do(ctx, http.MethodPost, "/api/v1/admin/config/validators", body, nil) } func (c *client) UpdateValidator(ctx context.Context, id, osPortID string) error { body := map[string]string{"os_port_id": osPortID} return c.do(ctx, http.MethodPut, "/api/v1/admin/config/validators/"+url.PathEscape(id), body, nil) } func (c *client) DeleteValidator(ctx context.Context, id string) error { return c.do(ctx, http.MethodDelete, "/api/v1/admin/config/validators/"+url.PathEscape(id), nil, nil) } func (c *client) ListSites(ctx context.Context) ([]siteDTO, error) { var out []siteDTO err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/sites", nil, &out) return out, err } func (c *client) PutSite(ctx context.Context, index int, siteID string) error { body := map[string]string{"site_id": siteID} return c.do(ctx, http.MethodPut, fmt.Sprintf("/api/v1/admin/config/sites/%d", index), body, nil) } func (c *client) DeleteSite(ctx context.Context, index int) error { return c.do(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/admin/config/sites/%d", index), nil, nil) } func (c *client) ListTargetGroups(ctx context.Context) ([]targetGroupDTO, error) { var out []targetGroupDTO err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/targets", nil, &out) return out, err } func (c *client) PutTargetGroup(ctx context.Context, name string, targets []string) error { body := map[string][]string{"targets": targets} return c.do(ctx, http.MethodPut, "/api/v1/admin/config/targets/"+url.PathEscape(name), body, nil) } func (c *client) DeleteTargetGroup(ctx context.Context, name string) error { return c.do(ctx, http.MethodDelete, "/api/v1/admin/config/targets/"+url.PathEscape(name), nil, nil) } func (c *client) ListCheckTypes(ctx context.Context) ([]checkTypeDTO, error) { var out []checkTypeDTO err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/check-types", nil, &out) return out, err } func (c *client) PutCheckType(ctx context.Context, name string, enabled bool, targets []string) error { body := map[string]interface{}{"enabled": enabled, "targets": targets} return c.do(ctx, http.MethodPut, "/api/v1/admin/config/check-types/"+url.PathEscape(name), body, nil) } func (c *client) DeleteCheckType(ctx context.Context, name string) error { return c.do(ctx, http.MethodDelete, "/api/v1/admin/config/check-types/"+url.PathEscape(name), nil, nil) } func (c *client) GetOrchestratorSettings(ctx context.Context) (orchestratorSettingsDTO, error) { var out orchestratorSettingsDTO err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/orchestrator", nil, &out) return out, err } func (c *client) PutOrchestratorSettings(ctx context.Context, fipSettleSeconds, historyRetentionCycles, selfCheckMaxAttempts int) (orchestratorSettingsDTO, error) { var out orchestratorSettingsDTO err := c.do(ctx, http.MethodPut, "/api/v1/admin/config/orchestrator", orchestratorSettingsDTO{FIPSettleSeconds: fipSettleSeconds, HistoryRetentionCycles: historyRetentionCycles, SelfCheckMaxAttempts: selfCheckMaxAttempts}, &out) return out, err } func (c *client) GetInboundChecks(ctx context.Context) (inboundChecksDTO, error) { var out inboundChecksDTO err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/inbound-checks", nil, &out) return out, err } func (c *client) PutInboundChecks(ctx context.Context, ports []int, icmp bool) (inboundChecksDTO, error) { var out inboundChecksDTO err := c.do(ctx, http.MethodPut, "/api/v1/admin/config/inbound-checks", inboundChecksDTO{Ports: ports, ICMP: icmp}, &out) return out, err } func (c *client) GetAutoCycle(ctx context.Context) (autoCycleDTO, error) { var out autoCycleDTO err := c.do(ctx, http.MethodGet, "/api/v1/admin/auto-cycle", nil, &out) return out, err } func (c *client) PutAutoCycle(ctx context.Context, intervalSeconds, maxRunSeconds int) (autoCycleDTO, error) { var out autoCycleDTO err := c.do(ctx, http.MethodPut, "/api/v1/admin/auto-cycle", map[string]int{"interval_seconds": intervalSeconds, "max_run_seconds": maxRunSeconds}, &out) return out, err } func (c *client) StartAutoCycle(ctx context.Context) (autoCycleDTO, error) { var out autoCycleDTO err := c.do(ctx, http.MethodPost, "/api/v1/admin/auto-cycle/start", nil, &out) return out, err } func (c *client) StopAutoCycle(ctx context.Context) (autoCycleDTO, error) { var out autoCycleDTO err := c.do(ctx, http.MethodPost, "/api/v1/admin/auto-cycle/stop", nil, &out) return out, err } // analyticsRun is one entry of GET /admin/analytics/runs (the run selector). type analyticsRun struct { ID int64 `json:"id"` Kind string `json:"kind"` State string `json:"state"` StartedAt time.Time `json:"started_at"` FinalizedAt *time.Time `json:"finalized_at"` Addresses int `json:"addresses"` Pass int `json:"pass"` Partial int `json:"partial"` Fail int `json:"fail"` Cancelled int `json:"cancelled"` Total int `json:"total"` Pending int `json:"pending"` } func (c *client) ListAnalyticsRuns(ctx context.Context) ([]analyticsRun, error) { var out []analyticsRun err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/runs", nil, &out) return out, err } // GetAnalyticsReport returns the analytics of one finished run as the raw // JSON control-api computed; the page's script reads it as it is. func (c *client) GetAnalyticsReport(ctx context.Context, runID int64) (json.RawMessage, error) { var out json.RawMessage err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/runs/"+strconv.FormatInt(runID, 10), nil, &out) return out, err } func analyticsListPath(runID int64, kind, class string, csv bool) string { v := url.Values{} if class != "" { v.Set("class", class) } if csv { v.Set("format", "csv") } p := "/api/v1/admin/analytics/runs/" + strconv.FormatInt(runID, 10) + "/lists/" + url.PathEscape(kind) if len(v) > 0 { p += "?" + v.Encode() } return p } // GetAnalyticsList returns one address table (JSON) of a run. func (c *client) GetAnalyticsList(ctx context.Context, runID int64, kind, class string) (json.RawMessage, error) { var out json.RawMessage err := c.do(ctx, http.MethodGet, analyticsListPath(runID, kind, class, false), nil, &out) return out, err } // GetAnalyticsListCSV returns the CSV file of one address table, with the // file name control-api proposed. func (c *client) GetAnalyticsListCSV(ctx context.Context, runID int64, kind, class string) ([]byte, string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+analyticsListPath(runID, kind, class, true), nil) if err != nil { return nil, "", fmt.Errorf("build request: %w", err) } if c.token != "" { req.Header.Set("Authorization", "Bearer "+c.token) } resp, err := c.http.Do(req) if err != nil { return nil, "", &apiErr{Status: 0, Message: err.Error()} } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { msg := string(body) var er errorResponse if json.Unmarshal(body, &er) == nil && er.Error != "" { msg = er.Error } return nil, "", &apiErr{Status: resp.StatusCode, Message: msg} } return body, resp.Header.Get("Content-Disposition"), nil } // subnetEntry is one line of the subnet list (GET/PUT /admin/config/subnets). type subnetEntry struct { CIDR string `json:"cidr"` Label string `json:"label,omitempty"` } type subnetList struct { Subnets []subnetEntry `json:"subnets"` } func (c *client) GetSubnets(ctx context.Context) (subnetList, error) { var out subnetList err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/subnets", nil, &out) return out, err } func (c *client) PutSubnets(ctx context.Context, in subnetList) (subnetList, error) { var out subnetList err := c.do(ctx, http.MethodPut, "/api/v1/admin/config/subnets", in, &out) return out, err }