Files
ayurishchevandClaude Sonnet 5.5 abbee9a08a Add self-check via control-api (self_check.methods)
control-api is hosted outside the cloud and validators reach it directly,
so it sees the floating IP as the connection's source address. New open
route GET /api/v1/agents/{id}/observed-ip returns that address (taken only
from the TCP peer; forwarding headers are ignored so a validator cannot
forge it).

The agent gets self_check.methods, a priority-ordered list of ip_echo
(unchanged) and control_api; the default stays [ip_echo]. The self-check
passes when any method confirms the address; the next method is tried on
no answer and on a mismatch. Each method has its own timeout so a hung
first method cannot starve the fallback, and control_api uses a new TCP
connection per call (a connection opened before the floating IP was
attached would keep reporting the old address).

Also: docker agent template/env, example config, docs, plan in
docs/changes, e2e script switch E2E_SELF_CHECK_METHODS, rebuilt
bin/control-api and bin/validator-agent.

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

184 lines
5.7 KiB
Go

package httpapi
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"cloudipvalidator/internal/config"
"cloudipvalidator/internal/db"
"cloudipvalidator/internal/openstack"
"cloudipvalidator/internal/orchestrator"
)
const (
testAdminToken = "admin-token-for-tests"
testAgentToken = "agent-token-for-tests"
)
func newAuthTestServer(t *testing.T, admin, agent string) (*Server, *httptest.Server) {
t.Helper()
ctx := context.Background()
d, err := db.Open(ctx, filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { d.Close() })
cfg := &config.ControlAPI{
Orchestrator: config.OrchestratorConfig{
PollIntervalSeconds: 1, SelfCheckTimeoutSeconds: 10, MaxSelfCheckRetries: 3,
CheckingWindowSeconds: 120, MaxRetries: 3, LeaseTTLSeconds: 180, HeartbeatTimeoutSeconds: 30,
},
Inbound: config.InboundConfig{Ports: []int{22}, ICMP: true},
}
if err := d.BootstrapFromConfig(ctx, cfg); err != nil {
t.Fatalf("bootstrap: %v", err)
}
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
orch := orchestrator.New(d, openstack.NewMockClient(), cfg, log)
t.Cleanup(func() { orch.CancelScan() })
srv := New(d, orch, log).WithAuth(admin, agent)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return srv, ts
}
// concretePath fills the {wildcards} of a ServeMux pattern with dummy values.
func concretePath(pattern string) (method, path string) {
method, path, _ = strings.Cut(pattern, " ")
var b strings.Builder
for {
i := strings.IndexByte(path, '{')
if i < 0 {
b.WriteString(path)
break
}
j := strings.IndexByte(path, '}')
b.WriteString(path[:i])
b.WriteString("1")
path = path[j+1:]
}
return method, b.String()
}
func callWithToken(t *testing.T, ts *httptest.Server, pattern, token string) *http.Response {
t.Helper()
method, path := concretePath(pattern)
req, err := http.NewRequest(method, ts.URL+path, strings.NewReader("{}"))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("%s: %v", pattern, err)
}
resp.Body.Close()
return resp
}
func TestRouteTableIsClassified(t *testing.T) {
srv, _ := newAuthTestServer(t, "", "")
counts := map[string]int{}
for _, rt := range srv.Routes() {
counts[rt.Access]++
if rt.Access == "invalid" {
t.Fatalf("route %q has no valid access level", rt.Pattern)
}
if strings.Contains(rt.Pattern, "/api/v1/admin/") && rt.Access != "admin" {
t.Fatalf("admin route %q is %s, want admin", rt.Pattern, rt.Access)
}
}
if counts["admin"] != 34 || counts["agent"] != 5 || counts["open"] != 8 {
t.Fatalf("access counts = %v, want admin=34 agent=5 open=8", counts)
}
}
func TestAuthMatrixOverRouteTable(t *testing.T) {
srv, ts := newAuthTestServer(t, testAdminToken, testAgentToken)
for _, rt := range srv.Routes() {
rt := rt
t.Run(rt.Pattern, func(t *testing.T) {
tokens := []struct {
name, token string
allowed bool
}{
{"none", "", rt.Access == "open"},
{"wrong", "definitely-wrong", rt.Access == "open"},
{"admin token", testAdminToken, rt.Access == "open" || rt.Access == "admin"},
{"agent token", testAgentToken, rt.Access == "open" || rt.Access == "agent"},
}
for _, tc := range tokens {
resp := callWithToken(t, ts, rt.Pattern, tc.token)
if tc.allowed && resp.StatusCode == http.StatusUnauthorized {
t.Fatalf("%s: got 401, want request to pass auth", tc.name)
}
if !tc.allowed {
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("%s: status=%d, want 401", tc.name, resp.StatusCode)
}
if resp.Header.Get("WWW-Authenticate") != "Bearer" {
t.Fatalf("%s: missing WWW-Authenticate: Bearer", tc.name)
}
}
}
})
}
}
func TestEmptyTokensLeaveEverythingOpen(t *testing.T) {
srv, ts := newAuthTestServer(t, "", "")
for _, rt := range srv.Routes() {
if resp := callWithToken(t, ts, rt.Pattern, ""); resp.StatusCode == http.StatusUnauthorized {
t.Fatalf("%s: got 401 with no tokens configured", rt.Pattern)
}
}
}
func TestOnlyConfiguredLevelIsEnforced(t *testing.T) {
_, ts := newAuthTestServer(t, testAdminToken, "")
if resp := callWithToken(t, ts, "GET /api/v1/admin/status", ""); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("admin without token: status=%d, want 401", resp.StatusCode)
}
if resp := callWithToken(t, ts, "POST /api/v1/agents/{id}/results", ""); resp.StatusCode == http.StatusUnauthorized {
t.Fatalf("agent route must stay open while agent token is unset")
}
}
func TestHealthzAlwaysOpenAndBearerParsing(t *testing.T) {
_, ts := newAuthTestServer(t, testAdminToken, testAgentToken)
if resp := callWithToken(t, ts, "GET /healthz", ""); resp.StatusCode != http.StatusOK {
t.Fatalf("healthz: status=%d, want 200", resp.StatusCode)
}
// Raw token without the Bearer scheme must not authenticate.
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/admin/status", nil)
req.Header.Set("Authorization", testAdminToken)
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("scheme-less token: status=%d, want 401", resp.StatusCode)
}
// Lowercase scheme is accepted (RFC 7235: case-insensitive).
req, _ = http.NewRequest(http.MethodGet, ts.URL+"/api/v1/admin/status", nil)
req.Header.Set("Authorization", "bearer "+testAdminToken)
resp, err = ts.Client().Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("lowercase bearer: status=%d, want 200", resp.StatusCode)
}
}