Files
cloud-ip-validator/internal/apiclient/apiclient.go
T
ayurishchevandClaude Sonnet 5.5 debf2afed2 Add authentication: admin/agent bearer tokens for the API, login for the dashboard
control-api: every route now carries a mandatory access level (admin / agent /
open) in a route table. All /api/v1/admin/* require the admin token; the
write calls of validator-agent and prober (self-check, events, results,
complete) require a separate static agent token; register, heartbeat and
fetching the assignment stay open. Tokens come from env vars, are compared in
constant time and never logged. An empty token leaves that level open with a
startup warning (backward compatible).

validator-agent / prober: apiclient sends the agent token only to control-api.

admin-dashboard: login/password (from env) with a stateless HMAC session
cookie, Origin-based CSRF check, per-IP brute-force throttle, HX-Redirect for
htmx polls, logout in the sidebar; the dashboard calls control-api with the
admin token. Login page layout fixed after review.

Also: env plumbing in docker-compose/rxprod-compose/systemd/config examples,
e2e script with token assertions, tests, docs (API, SETUP, USAGE, DASHBOARD,
README), plan and review under docs/changes/, bin/ rebuilt with new
SHA256SUMS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 11:35:24 +03:00

72 lines
2.0 KiB
Go

// Package apiclient is a thin HTTP client for the Control API's /api/v1
// surface, shared by the validator-agent and prober binaries. Neither
// binary talks to the database directly — this is their only channel to
// shared state, keeping both genuinely stateless.
package apiclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type Client struct {
BaseURL string
HTTPClient *http.Client
// Token, when non-empty, is sent as "Authorization: Bearer <Token>" on
// every request to the Control API.
Token string
}
func New(baseURL string, timeout time.Duration) *Client {
return &Client{BaseURL: baseURL, HTTPClient: &http.Client{Timeout: timeout}}
}
// Do issues a JSON request and decodes a JSON response into out (if
// non-nil). A 204 response is treated as "no content" and out is left
// untouched, with ok=false — used for the assignment poll's empty case.
func (c *Client) Do(ctx context.Context, method, path string, body, out interface{}) (ok bool, err error) {
var reader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return false, fmt.Errorf("marshal request: %w", err)
}
reader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reader)
if err != nil {
return false, 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 := c.HTTPClient.Do(req)
if err != nil {
return false, fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent {
return false, nil
}
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
return false, fmt.Errorf("%s %s: status %d: %s", method, path, resp.StatusCode, string(respBody))
}
if out != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, out); err != nil {
return false, fmt.Errorf("%s %s: decode response: %w", method, path, err)
}
}
return true, nil
}