mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
3c31aa4805
The Docker analog of the #1753 estate was still broken: two live machines reusing one short hostname and one pasted unified install token collapsed into a single flip-flopping DockerHost record, because the hostname+token identity fallback adopts a record whose machine ID disagrees with the report's. That fold is deliberate for recreated containers (whose /etc/machine-id regenerates), so it cannot simply be guarded by machine-ID inequality - the discriminator is a revisit: a recreated container transitions to its new machine ID exactly once, while two live machines alternate. Removing the collapsed record then revoked the shared token unconditionally, rejecting every surviving module - host reports included, since a unified install shares one credential - with 401 "Unauthorized access attempt". Consult the identity flap tracker before the hostname fallbacks adopt a machine-ID-disagreeing record: a report whose machine ID returns to a value already seen behind that identity is a second live machine and is not folded. The machine whose identifiers minted the record reclaims it, so the first site keeps its record and history, and the other site falls through to the token binding check, converging on the documented "Each Docker / Podman module must use a unique API token" rejection instead of silently overwriting the record every cycle. RemoveDockerHost now skips token revocation while any host or Docker record still authenticates with the credential, mirroring the host-agent removal guard. Regression coverage: an end-to-end router test walking the two-site shared-token estate (host + Docker reports, alternating cycles, removal) asserting the first site's identity stays stable, the second site gets the unique-token guidance, and the shared token survives removal; a router test proving removal of a machine's Docker record keeps the unified token its host record still uses; and a state-layer test pinning the reclaim/no-flip-flop convergence. Recreated-container adoption and the existing token-uniqueness rejections keep their tests unchanged.
247 lines
9.8 KiB
Go
247 lines
9.8 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/api/agentbinding"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
|
agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
|
|
)
|
|
|
|
// Docker analog of issue #1753: two standalone sites reuse one short hostname
|
|
// and one pasted unified install token. The host-agent side of this estate was
|
|
// fixed by machine-qualifying the host token bindings; these tests walk the
|
|
// same estate through /api/agents/docker/report and fail if the Docker records
|
|
// collapse into one identity, or if removing one Docker host revokes the
|
|
// shared token while a sibling record still authenticates with it - the same
|
|
// 401 "Unauthorized access attempt" chain the reporter hit on the host path.
|
|
|
|
func issue1753DockerInstallTokenRecord(t *testing.T, raw, site string) config.APITokenRecord {
|
|
t.Helper()
|
|
record, err := config.NewAPITokenRecord(raw, "Unified agent install ("+site+")", agenttokens.HostScopes(false))
|
|
if err != nil {
|
|
t.Fatalf("NewAPITokenRecord: %v", err)
|
|
}
|
|
record.Metadata = map[string]string{
|
|
"install_type": "host",
|
|
"issued_via": agentbinding.IssuedViaConfig,
|
|
agenttokens.RuntimeRoleMetadataKey: agenttokens.CredentialKindMonitoringCollector,
|
|
}
|
|
return *record
|
|
}
|
|
|
|
func issue1753DockerReport(hostname, machineID string) agentsdocker.Report {
|
|
return agentsdocker.Report{
|
|
Agent: agentsdocker.AgentInfo{
|
|
// The unified agent's Docker module reports the machine ID as its
|
|
// agent ID (the hostagent fallback chain, #985/#986).
|
|
ID: machineID,
|
|
Version: "6.4.2",
|
|
Type: "unified",
|
|
IntervalSeconds: 30,
|
|
},
|
|
Host: agentsdocker.HostInfo{
|
|
Hostname: hostname,
|
|
MachineID: machineID,
|
|
Runtime: "docker",
|
|
},
|
|
Containers: []agentsdocker.Container{
|
|
{ID: "container-" + machineID, Name: "app", State: "running"},
|
|
},
|
|
Timestamp: time.Now().UTC(),
|
|
}
|
|
}
|
|
|
|
func issue1753PostDockerReport(t *testing.T, router *Router, token string, report agentsdocker.Report) (int, map[string]any) {
|
|
t.Helper()
|
|
body, err := json.Marshal(report)
|
|
if err != nil {
|
|
t.Fatalf("marshal docker report: %v", err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/api/agents/docker/report", bytes.NewReader(body))
|
|
req.Header.Set("X-API-Token", token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
router.Handler().ServeHTTP(rec, req)
|
|
|
|
var payload map[string]any
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &payload)
|
|
return rec.Code, payload
|
|
}
|
|
|
|
func issue1753TokenNames(cfg *config.Config) map[string]bool {
|
|
config.Mu.RLock()
|
|
defer config.Mu.RUnlock()
|
|
names := make(map[string]bool, len(cfg.APITokens))
|
|
for _, record := range cfg.APITokens {
|
|
names[record.Name] = true
|
|
}
|
|
return names
|
|
}
|
|
|
|
// TestIssue1753DockerSharedTokenTwoSitesSameShortHostname models the reporter's
|
|
// estate on the Docker module: both sites' unified agents report host and
|
|
// Docker inventory with the same short hostname and one shared install token.
|
|
//
|
|
// The Docker path deliberately requires a unique token per module, so the
|
|
// second site's Docker reports must converge to the documented, actionable
|
|
// rejection ("generate a new token") - never to a silent fold that leaves one
|
|
// record flip-flopping between machines. The first site's Docker identity must
|
|
// stay stable throughout, and removing the Docker record must not revoke the
|
|
// shared token the host records still authenticate with (the 401 chain from
|
|
// the issue).
|
|
func TestIssue1753DockerSharedTokenTwoSitesSameShortHostname(t *testing.T) {
|
|
const shared = "issue1753-docker-shared.13571357"
|
|
|
|
cfg := newTestConfigWithTokens(t, issue1753DockerInstallTokenRecord(t, shared, "shared"))
|
|
monitor, err := monitoring.New(cfg)
|
|
if err != nil {
|
|
t.Fatalf("new monitor: %v", err)
|
|
}
|
|
defer monitor.Stop()
|
|
router := NewRouter(cfg, monitor, nil, nil, func() error { return nil }, "6.4.2")
|
|
|
|
// Alternate reports the way two live 30s-interval agents do. Site B's
|
|
// very first Docker report may legitimately fold (a changed machine ID is
|
|
// indistinguishable from a recreated container until the old machine
|
|
// reports again), but from then on it must be rejected with the
|
|
// unique-token guidance rather than adopted.
|
|
var ackIDA string
|
|
var siteBRejected bool
|
|
for round := 0; round < 4; round++ {
|
|
code, payload := issue1753PostReport(t, router, shared,
|
|
issue1753Report("", "docker01", "machine-id-site-a", "10.1.0.10"))
|
|
if code != http.StatusOK {
|
|
t.Fatalf("round %d: site A host report rejected with %d (%v)", round, code, payload)
|
|
}
|
|
code, payload = issue1753PostReport(t, router, shared,
|
|
issue1753Report("", "docker01", "machine-id-site-b", "10.2.0.10"))
|
|
if code != http.StatusOK {
|
|
t.Fatalf("round %d: site B host report rejected with %d (%v)", round, code, payload)
|
|
}
|
|
|
|
code, payload = issue1753PostDockerReport(t, router, shared,
|
|
issue1753DockerReport("docker01", "machine-id-site-a"))
|
|
if code != http.StatusOK {
|
|
t.Fatalf("round %d: site A docker report rejected with %d (%v)", round, code, payload)
|
|
}
|
|
gotID, _ := payload["agentId"].(string)
|
|
if gotID == "" {
|
|
t.Fatalf("round %d: site A docker report acknowledged without an identity", round)
|
|
}
|
|
if ackIDA == "" {
|
|
ackIDA = gotID
|
|
} else if gotID != ackIDA {
|
|
t.Fatalf("round %d: site A docker identity flapped from %q to %q", round, ackIDA, gotID)
|
|
}
|
|
|
|
code, payload = issue1753PostDockerReport(t, router, shared,
|
|
issue1753DockerReport("docker01", "machine-id-site-b"))
|
|
switch {
|
|
case code == http.StatusOK && round == 0:
|
|
// The one ambiguous cycle: nothing distinguishes site B's first
|
|
// report from a recreated container yet.
|
|
case code == http.StatusOK:
|
|
t.Fatalf("round %d: site B's Docker module silently adopted an identity (%v) instead of the unique-token rejection; the record would flip-flop between machines", round, payload)
|
|
default:
|
|
message, _ := payload["error"].(string)
|
|
if !strings.Contains(message, "unique API token") {
|
|
t.Fatalf("round %d: site B docker report rejected with %d but without the unique-token guidance: %v", round, code, payload)
|
|
}
|
|
siteBRejected = true
|
|
}
|
|
}
|
|
if !siteBRejected {
|
|
t.Fatal("site B's Docker module was never steered to a unique token")
|
|
}
|
|
|
|
// Site A's record must have settled on site A's machine, not the last
|
|
// reporter's: the flip-flop is what let a later removal act on the wrong
|
|
// site in the original estate.
|
|
dockerHost, ok := monitor.GetDockerHost(ackIDA)
|
|
if !ok {
|
|
t.Fatalf("site A docker host %q missing from state", ackIDA)
|
|
}
|
|
if dockerHost.MachineID != "machine-id-site-a" {
|
|
t.Fatalf("site A docker record carries machine ID %q, want %q", dockerHost.MachineID, "machine-id-site-a")
|
|
}
|
|
|
|
// The reporter's removal step: the Docker record is removed while both
|
|
// sites' host records still authenticate with the shared token. The token
|
|
// must survive, and host reporting must keep working - its revocation is
|
|
// the 401 "Unauthorized access attempt" chain from the issue.
|
|
if _, err := monitor.RemoveDockerHost(ackIDA); err != nil {
|
|
t.Fatalf("RemoveDockerHost(%q): %v", ackIDA, err)
|
|
}
|
|
|
|
code, payload := issue1753PostReport(t, router, shared,
|
|
issue1753Report("", "docker01", "machine-id-site-a", "10.1.0.10"))
|
|
if code != http.StatusOK {
|
|
t.Fatalf("site A host report rejected with %d after Docker host removal (%v)", code, payload)
|
|
}
|
|
code, payload = issue1753PostReport(t, router, shared,
|
|
issue1753Report("", "docker01", "machine-id-site-b", "10.2.0.10"))
|
|
if code != http.StatusOK {
|
|
t.Fatalf("site B host report rejected with %d after Docker host removal (%v)", code, payload)
|
|
}
|
|
|
|
if names := issue1753TokenNames(cfg); !names["Unified agent install (shared)"] {
|
|
t.Fatalf("shared install token was revoked while both host records still use it (have %v)", names)
|
|
}
|
|
}
|
|
|
|
// TestIssue1753DockerHostRemovalKeepsUnifiedSiblingToken covers the same
|
|
// revocation chain without any hostname collision: a single unified install
|
|
// shares one token between the machine's host record and its Docker record.
|
|
// Removing only the Docker host (stop monitoring Docker, keep the host) must
|
|
// not revoke the token the host module still reports with.
|
|
func TestIssue1753DockerHostRemovalKeepsUnifiedSiblingToken(t *testing.T) {
|
|
const token = "issue1753-unified-single.24682468"
|
|
|
|
cfg := newTestConfigWithTokens(t, issue1753DockerInstallTokenRecord(t, token, "single"))
|
|
monitor, err := monitoring.New(cfg)
|
|
if err != nil {
|
|
t.Fatalf("new monitor: %v", err)
|
|
}
|
|
defer monitor.Stop()
|
|
router := NewRouter(cfg, monitor, nil, nil, func() error { return nil }, "6.4.2")
|
|
|
|
code, payload := issue1753PostReport(t, router, token,
|
|
issue1753Report("", "nas01", "machine-id-nas01", "10.3.0.10"))
|
|
if code != http.StatusOK {
|
|
t.Fatalf("host report rejected with %d (%v)", code, payload)
|
|
}
|
|
|
|
code, payload = issue1753PostDockerReport(t, router, token,
|
|
issue1753DockerReport("nas01", "machine-id-nas01"))
|
|
if code != http.StatusOK {
|
|
t.Fatalf("docker report rejected with %d (%v)", code, payload)
|
|
}
|
|
dockerID, _ := payload["agentId"].(string)
|
|
if dockerID == "" {
|
|
t.Fatal("expected an acknowledged Docker host identity")
|
|
}
|
|
|
|
if _, err := monitor.RemoveDockerHost(dockerID); err != nil {
|
|
t.Fatalf("RemoveDockerHost(%q): %v", dockerID, err)
|
|
}
|
|
|
|
code, payload = issue1753PostReport(t, router, token,
|
|
issue1753Report("", "nas01", "machine-id-nas01", "10.3.0.10"))
|
|
if code != http.StatusOK {
|
|
t.Fatalf("host report rejected with %d after Docker host removal (%v); the shared unified token must survive", code, payload)
|
|
}
|
|
|
|
if names := issue1753TokenNames(cfg); !names["Unified agent install (single)"] {
|
|
t.Fatalf("unified install token was revoked while the host record still uses it (have %v)", names)
|
|
}
|
|
}
|