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>
72 lines
2.0 KiB
Go
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
|
|
}
|