mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
Respect the selected agent credential file
Keep PULSE_TOKEN out of the direct-token flag default. Refuse an unreadable or empty selected file instead of silently borrowing other credentials, while preserving enrolled runtime-token restarts and implicit token-optional startup. Cover actual flag loading and a synthetic host issuer, private installer-writer, compiled agent and trusted HTTPS lookup/report journey. This repairs an independent source-selection defect; it does not establish the cause or recovery of Discussion #2769. Change-source: pulse-maintainer
This commit is contained in:
+33
-19
@@ -1336,7 +1336,9 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
|
||||
// Flags
|
||||
fs := flag.NewFlagSet("pulse-agent", flag.ContinueOnError)
|
||||
urlFlag := fs.String("url", envURL, "Pulse server URL")
|
||||
tokenFlag := fs.String("token", envToken, "Pulse API token (prefer --token-file for security)")
|
||||
// Keep the environment fallback separate from an explicit flag, otherwise
|
||||
// PULSE_TOKEN would masquerade as --token and shadow --token-file.
|
||||
tokenFlag := fs.String("token", "", "Pulse API token (prefer --token-file for security)")
|
||||
tokenFileFlag := fs.String("token-file", "", "Path to file containing Pulse API token (more secure than --token)")
|
||||
intervalFlag := fs.Duration("interval", defaultInterval, "Reporting interval")
|
||||
hostnameFlag := fs.String("hostname", envHostname, "Override hostname")
|
||||
@@ -1419,16 +1421,11 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
|
||||
agentIDFile = filepath.Join(stateDir, "agent-id")
|
||||
}
|
||||
|
||||
// Resolve token with priority: --token > --token-file > env > state-dir file.
|
||||
token := resolveToken(*tokenFlag, *tokenFileFlag, envToken, stateDir)
|
||||
observers, err := agenttarget.LoadObservers(strings.TrimSpace(*observersFileFlag), pulseURL)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load observer destinations: %w", err)
|
||||
}
|
||||
|
||||
// When --enroll is set and a runtime token already exists from a previous
|
||||
// enrollment, use it instead of the bootstrap token embedded in the service
|
||||
// config. This ensures the agent survives process and server restarts.
|
||||
// config. Resolve it first so a removed bootstrap file does not prevent a
|
||||
// previously enrolled agent from restarting.
|
||||
var token string
|
||||
if *enrollFlag {
|
||||
runtimeTokenPath := filepath.Join(stateDir, "runtime.token")
|
||||
if content, err := os.ReadFile(runtimeTokenPath); err == nil {
|
||||
@@ -1437,6 +1434,18 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
// Resolve token with priority: --token > --token-file > env > state-dir file.
|
||||
var err error
|
||||
token, err = resolveToken(*tokenFlag, *tokenFileFlag, envToken, stateDir)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
observers, err := agenttarget.LoadObservers(strings.TrimSpace(*observersFileFlag), pulseURL)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load observer destinations: %w", err)
|
||||
}
|
||||
|
||||
if token == "" && *enrollFlag && !*selfTest {
|
||||
return Config{}, fmt.Errorf("Pulse API token is required for enrollment (use --token, --token-file, PULSE_TOKEN env, or %s)", defaultTokenFilePath())
|
||||
@@ -1668,7 +1677,7 @@ func resolveEnableCommands(enableFlag, disableFlag bool, envEnable, envDisable s
|
||||
// 4. Token file under the resolved state directory
|
||||
//
|
||||
// Reading from a file is more secure than CLI args as tokens won't appear in `ps` output.
|
||||
func resolveToken(tokenFlag, tokenFileFlag, envToken, stateDir string) string {
|
||||
func resolveToken(tokenFlag, tokenFileFlag, envToken, stateDir string) (string, error) {
|
||||
return resolveTokenInternal(tokenFlag, tokenFileFlag, envToken, stateDir, os.ReadFile)
|
||||
}
|
||||
|
||||
@@ -1689,24 +1698,29 @@ func defaultTokenFilePath() string {
|
||||
return filepath.Join(defaultAgentStateDir(), "token")
|
||||
}
|
||||
|
||||
func resolveTokenInternal(tokenFlag, tokenFileFlag, envToken, stateDir string, readFile func(string) ([]byte, error)) string {
|
||||
func resolveTokenInternal(tokenFlag, tokenFileFlag, envToken, stateDir string, readFile func(string) ([]byte, error)) (string, error) {
|
||||
// 1. Direct token from --token flag
|
||||
if t := strings.TrimSpace(tokenFlag); t != "" {
|
||||
return t
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// 2. Token from --token-file flag
|
||||
if tokenFileFlag != "" {
|
||||
if content, err := readFile(tokenFileFlag); err == nil {
|
||||
if t := strings.TrimSpace(string(content)); t != "" {
|
||||
return t
|
||||
}
|
||||
content, err := readFile(tokenFileFlag)
|
||||
if err != nil {
|
||||
// A selected source must not silently become some other credential
|
||||
// (or an unauthenticated agent). Do not include path or file contents.
|
||||
return "", fmt.Errorf("cannot read --token-file; check that the configured file exists and is readable by the agent service")
|
||||
}
|
||||
if t := strings.TrimSpace(string(content)); t != "" {
|
||||
return t, nil
|
||||
}
|
||||
return "", fmt.Errorf("--token-file is empty; provide a file containing only the API token")
|
||||
}
|
||||
|
||||
// 3. PULSE_TOKEN environment variable
|
||||
if t := strings.TrimSpace(envToken); t != "" {
|
||||
return t
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// 4. Token file in the resolved state directory. When stateDir is custom,
|
||||
@@ -1717,11 +1731,11 @@ func resolveTokenInternal(tokenFlag, tokenFileFlag, envToken, stateDir string, r
|
||||
}
|
||||
if content, err := readFile(tokenFile); err == nil {
|
||||
if t := strings.TrimSpace(string(content)); t != "" {
|
||||
return t
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// initModuleWithRetry attempts to initialize an agent module with exponential
|
||||
|
||||
@@ -903,7 +903,10 @@ func TestResolveToken(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := resolveTokenInternal(tc.tokenFlag, tc.tokenFileFlag, tc.envToken, tc.stateDir, fakeReadFile)
|
||||
got, err := resolveTokenInternal(tc.tokenFlag, tc.tokenFileFlag, tc.envToken, tc.stateDir, fakeReadFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tc.expected {
|
||||
t.Fatalf("%s: expected %q, got %q", tc.name, tc.expected, got)
|
||||
}
|
||||
@@ -911,20 +914,126 @@ func TestResolveToken(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("truly empty", func(t *testing.T) {
|
||||
got := resolveTokenInternal("", "", "", customStateDir, fakeReadFileNoDefault)
|
||||
got, err := resolveTokenInternal("", "", "", customStateDir, fakeReadFileNoDefault)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("custom state never borrows default token", func(t *testing.T) {
|
||||
got := resolveTokenInternal("", "", "", filepath.Join(string(filepath.Separator), "missing-custom"), fakeReadFile)
|
||||
got, err := resolveTokenInternal("", "", "", filepath.Join(string(filepath.Separator), "missing-custom"), fakeReadFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("custom state unexpectedly borrowed default token %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Exercise flag parsing as well as the resolver: a resolver-only test cannot
|
||||
// detect an environment value accidentally installed as the --token default.
|
||||
func TestLoadConfigTokenSources(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
envToken string
|
||||
fileToken string
|
||||
runtimeToken string
|
||||
want string
|
||||
wantError string
|
||||
}{
|
||||
{name: "selected file beats environment", args: []string{"--token-file", "FILE"}, envToken: "environment-token", fileToken: "file-token", want: "file-token"},
|
||||
{name: "selected file without environment", args: []string{"--token-file=FILE"}, fileToken: "file-token", want: "file-token"},
|
||||
{name: "single dash file beats environment", args: []string{"-token-file", "FILE"}, envToken: "environment-token", fileToken: "file-token", want: "file-token"},
|
||||
{name: "explicit token beats file and environment", args: []string{"--token-file", "FILE", "--token", "argument-token"}, envToken: "environment-token", fileToken: "file-token", want: "argument-token"},
|
||||
{name: "explicit token does not read lower priority file", args: []string{"--token", "argument-token", "--token-file", "MISSING"}, envToken: "environment-token", want: "argument-token"},
|
||||
{name: "empty token flag does not mask selected file", args: []string{"--token=", "--token-file", "FILE"}, envToken: "environment-token", fileToken: "file-token", want: "file-token"},
|
||||
{name: "environment beats implicit state file", envToken: "environment-token", want: "environment-token"},
|
||||
{name: "implicit custom state file", want: "state-token"},
|
||||
{name: "outer whitespace remains accepted", args: []string{"--token-file", "FILE"}, fileToken: " \tfile-token\r\n", want: "file-token"},
|
||||
{name: "quotes are not part of the file format", args: []string{"--token-file", "FILE"}, fileToken: `"file-token"`, want: `"file-token"`},
|
||||
{name: "assignment is not decoded as a credential", args: []string{"--token-file", "FILE"}, fileToken: "PULSE_TOKEN=file-token", want: "PULSE_TOKEN=file-token"},
|
||||
{name: "missing selected file refuses environment and state fallback", args: []string{"--token-file", "MISSING"}, envToken: "environment-token", wantError: "cannot read --token-file"},
|
||||
{name: "directory selected as a file refuses fallback", args: []string{"--token-file", "DIRECTORY"}, envToken: "environment-token", wantError: "cannot read --token-file"},
|
||||
{name: "empty selected file refuses fallback", args: []string{"--token-file", "FILE"}, envToken: "environment-token", wantError: "--token-file is empty"},
|
||||
{name: "whitespace selected file refuses fallback", args: []string{"--token-file", "FILE"}, fileToken: " \t\r\n", envToken: "environment-token", wantError: "--token-file is empty"},
|
||||
{name: "enrollment restart keeps runtime credential", args: []string{"--enroll", "--token-file", "FILE", "--token", "argument-token"}, envToken: "environment-token", fileToken: "bootstrap-token", runtimeToken: "runtime-token\n", want: "runtime-token"},
|
||||
{name: "enrollment restart survives removed bootstrap file", args: []string{"--enroll", "--token-file", "MISSING"}, runtimeToken: "runtime-token", want: "runtime-token"},
|
||||
{name: "first enrollment still uses selected bootstrap", args: []string{"--enroll", "--token-file", "FILE"}, envToken: "environment-token", fileToken: "bootstrap-token", want: "bootstrap-token"},
|
||||
{name: "empty runtime credential does not mask file failure", args: []string{"--enroll", "--token-file", "MISSING"}, runtimeToken: " \n", wantError: "cannot read --token-file"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stateDir := t.TempDir()
|
||||
file := filepath.Join(stateDir, "selected-token")
|
||||
for name, content := range map[string]string{"token": "state-token", "selected-token": tc.fileToken} {
|
||||
if err := os.WriteFile(filepath.Join(stateDir, name), []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if tc.runtimeToken != "" {
|
||||
if err := os.WriteFile(filepath.Join(stateDir, "runtime.token"), []byte(tc.runtimeToken), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
args := []string{"--url", "https://pulse.example.test", "--state-dir", stateDir, "--agent-id", "retained-agent-id", "--command-authority", "monitoring-only"}
|
||||
for _, arg := range tc.args {
|
||||
arg = strings.ReplaceAll(arg, "FILE", file)
|
||||
arg = strings.ReplaceAll(arg, "MISSING", filepath.Join(stateDir, "missing-token"))
|
||||
arg = strings.ReplaceAll(arg, "DIRECTORY", stateDir)
|
||||
args = append(args, arg)
|
||||
}
|
||||
cfg, err := loadConfig(args, func(key string) string {
|
||||
if key == "PULSE_TOKEN" {
|
||||
return tc.envToken
|
||||
}
|
||||
return ""
|
||||
})
|
||||
if tc.wantError != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
|
||||
t.Fatalf("expected actionable selected-file error, got %v", err)
|
||||
}
|
||||
for _, private := range []string{stateDir, "environment-token", "state-token", tc.fileToken} {
|
||||
if private != "" && strings.Contains(err.Error(), private) {
|
||||
t.Fatal("selected-file error disclosed private configuration")
|
||||
}
|
||||
}
|
||||
if cfg.APIToken != "" {
|
||||
t.Fatal("failed configuration retained a fallback credential")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.APIToken != tc.want {
|
||||
t.Fatal("wrong credential source selected")
|
||||
}
|
||||
if cfg.EnableCommands || cfg.InsecureSkipVerify || !cfg.EnableHost || cfg.AgentID != "retained-agent-id" || cfg.StateDir != stateDir || cfg.CommandAuthorityProfile != hostagent.CommandAuthorityMonitoringOnly {
|
||||
t.Fatal("credential selection changed identity, trust or monitoring-only authority")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTokenSelectedFileReadErrorIsPrivate(t *testing.T) {
|
||||
calls := 0
|
||||
readFile := func(path string) ([]byte, error) {
|
||||
calls++
|
||||
return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission}
|
||||
}
|
||||
got, err := resolveTokenInternal("", "private-path-with-token-value", "environment-token", "/implicit-state", readFile)
|
||||
if err == nil || got != "" || calls != 1 {
|
||||
t.Fatal("selected-file failure fell through to another credential source")
|
||||
}
|
||||
if strings.Contains(err.Error(), "private-path") || strings.Contains(err.Error(), "token-value") {
|
||||
t.Fatal("selected-file error disclosed the configured path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupDockerAgent(t *testing.T) {
|
||||
t.Run("nil agent does nothing", func(t *testing.T) {
|
||||
cleanupDockerAgent(nil, &zerolog.Logger{})
|
||||
|
||||
@@ -15,6 +15,30 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Selected agent credential files retain their own authority
|
||||
|
||||
The real agent flag-loading path resolves a non-empty explicit `--token` before
|
||||
`--token-file`, then `PULSE_TOKEN`, then the resolved instance's implicit token
|
||||
file. An environment fallback must not become the direct flag's default and
|
||||
shadow the operator's selected file. A selected unreadable or empty file stops
|
||||
startup with a credential-safe error rather than borrowing another credential
|
||||
or starting unauthenticated. Implicit token-optional startup remains supported.
|
||||
With enrolment enabled, an existing non-empty `runtime.token` still takes
|
||||
precedence and permits restart even when the bootstrap file has been removed.
|
||||
Token contents remain opaque: surrounding whitespace is trimmed, but quotes
|
||||
and shell assignments are not interpreted or repaired into credentials.
|
||||
|
||||
`TestLoadConfigTokenSources` covers actual flag parsing, source priority,
|
||||
selected-file failure, custom state, enrolment, identity, TLS verification and
|
||||
monitoring-only command ceilings. `TestResolveTokenSelectedFileReadErrorIsPrivate`
|
||||
checks that file errors expose neither the configured path nor its contents.
|
||||
`TestHostAgentCredentialFileHandoff` joins the authenticated host issuer, actual
|
||||
installer token-writer function, private file modes, compiled agent and trusted
|
||||
HTTPS lookup/report routes. It checks normal process restart, credential
|
||||
last-use, identity continuity, environment non-use and adverse representations.
|
||||
That synthetic process journey does not exercise the terminal prompt, complete
|
||||
privileged installer, a reporter's configuration or installed recovery.
|
||||
|
||||
A legacy client echoing the projected `controlled` mode into an unrelated AI
|
||||
settings PUT must retain a stored `autonomous` preference and its existing
|
||||
legacy command-approval selection. `TestAssistantProjectedModeEchoPreservesLegacyPolicy`
|
||||
|
||||
@@ -18,6 +18,24 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Explicit agent credential-file failure does not borrow authority
|
||||
|
||||
The `cmd/pulse-agent/main.go` flag loader keeps `PULSE_TOKEN` separate from an
|
||||
explicit `--token` so an environment value cannot shadow `--token-file`. A
|
||||
selected unreadable or empty credential file stops startup without disclosing
|
||||
the file path or contents and without substituting environment/state authority.
|
||||
Non-empty direct tokens retain first priority; implicit token-optional startup
|
||||
and enrolled runtime-token restart remain supported. Opaque credential contents
|
||||
are not interpreted as shell assignments or unquoted on the operator's behalf.
|
||||
|
||||
Actual flag-loading/error controls are `TestLoadConfigTokenSources` and
|
||||
`TestResolveTokenSelectedFileReadErrorIsPrivate`. The synthetic trusted-HTTPS
|
||||
`TestHostAgentCredentialFileHandoff` also checks no request on selected-file
|
||||
failure, no environment credential use, no token in agent logs, preserved
|
||||
monitoring-only scopes/commands and ordinary process identity continuity.
|
||||
Neither this local journey nor a credential's inventory entry establishes
|
||||
authentication or recovery of an unobserved installed agent.
|
||||
|
||||
### Infrastructure credentials remain within the configured HTTP origin
|
||||
|
||||
The shared `tlsutil.CreateHTTPClientWithTimeout` transport permits redirects
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
//go:build unix
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
|
||||
)
|
||||
|
||||
// This is an offline, synthetic process journey, not an installed-service or
|
||||
// reporter recovery test. It joins the actual host issuer, installer token
|
||||
// writer, agent CLI/transport and authenticated server routes. The terminal
|
||||
// prompt and the rest of the privileged installer are outside this seam.
|
||||
func TestHostAgentCredentialFileHandoff(t *testing.T) {
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binary := filepath.Join(t.TempDir(), "pulse-agent")
|
||||
buildCtx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
build := exec.CommandContext(buildCtx, "go", "build", "-o", binary, "./cmd/pulse-agent")
|
||||
build.Dir = root
|
||||
if output, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build agent from this source: %v\n%s", err, output)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
environment bool
|
||||
format string
|
||||
wantStatus int
|
||||
startupErr bool
|
||||
}{
|
||||
{name: "file only", wantStatus: http.StatusOK},
|
||||
{name: "file over environment", environment: true, wantStatus: http.StatusOK},
|
||||
{name: "outer whitespace", format: "whitespace", wantStatus: http.StatusOK},
|
||||
{name: "quoted credential", format: "quoted", wantStatus: http.StatusUnauthorized},
|
||||
{name: "assignment credential", format: "assignment", wantStatus: http.StatusUnauthorized},
|
||||
{name: "different bare credential", format: "different", wantStatus: http.StatusUnauthorized},
|
||||
{name: "empty file refuses fallback", environment: true, format: "empty", startupErr: true},
|
||||
{name: "missing file refuses fallback", environment: true, format: "missing", startupErr: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
adminRaw := "synthetic-handoff-admin.12345678"
|
||||
envRaw := "synthetic-handoff-environment.12345678"
|
||||
admin := newTokenRecord(t, adminRaw, []string{config.ScopeWildcard}, nil)
|
||||
envRecord := newTokenRecord(t, envRaw, []string{config.ScopeAgentReport, config.ScopeAgentConfigRead}, nil)
|
||||
runtime := newHostRemovalLifecycleHTTPRuntime(t, t.TempDir(), []config.APITokenRecord{admin, envRecord})
|
||||
t.Cleanup(runtime.stop)
|
||||
|
||||
issued := serveHostRemovalLifecycleRequest(t, runtime, http.MethodPost, "/api/agent-install-command", adminRaw,
|
||||
[]byte(`{"type":"host","enableCommands":false,"name":"credential-handoff"}`))
|
||||
install := decodeHostAgentInstallResponse(t, issued)
|
||||
if install.Token == "" || install.Record == nil {
|
||||
t.Fatal("host issuer did not return a scoped credential")
|
||||
}
|
||||
assertHandoffCredentialUse(t, runtime.config, install.Record.ID, false)
|
||||
config.Mu.RLock()
|
||||
for _, record := range runtime.config.APITokens {
|
||||
if record.ID == install.Record.ID && (record.HasScope(config.ScopeAgentExec) || !record.HasScope(config.ScopeAgentReport) || !record.HasScope(config.ScopeAgentConfigRead)) {
|
||||
t.Error("host issuer changed monitoring-only scopes")
|
||||
}
|
||||
}
|
||||
config.Mu.RUnlock()
|
||||
|
||||
events := make(chan handoffHTTPEvent, 64)
|
||||
var requests atomic.Int64
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
event := handoffHTTPEvent{path: r.URL.Path}
|
||||
if strings.HasSuffix(r.URL.Path, "/report") {
|
||||
body, readErr := io.ReadAll(io.LimitReader(r.Body, 4<<20))
|
||||
if readErr != nil {
|
||||
t.Error("could not observe synthetic report")
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
if err := json.Unmarshal(body, &event.report); err != nil {
|
||||
t.Error("could not decode synthetic report")
|
||||
}
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
runtime.router.Handler().ServeHTTP(rec, r)
|
||||
for key, values := range rec.Header() {
|
||||
w.Header()[key] = values
|
||||
}
|
||||
w.WriteHeader(rec.Code)
|
||||
_, _ = w.Write(rec.Body.Bytes())
|
||||
event.status = rec.Code
|
||||
if strings.HasSuffix(event.path, "/lookup") || strings.HasSuffix(event.path, "/report") {
|
||||
select {
|
||||
case events <- event:
|
||||
default:
|
||||
t.Error("synthetic HTTP event buffer exceeded")
|
||||
}
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
state := t.TempDir()
|
||||
tokenFile := persistHandoffToken(t, root, state, install.Token)
|
||||
if tc.format != "" {
|
||||
content := install.Token
|
||||
switch tc.format {
|
||||
case "whitespace":
|
||||
content = " \t" + content + "\r\n"
|
||||
case "quoted":
|
||||
content = `"` + content + `"`
|
||||
case "assignment":
|
||||
content = "PULSE_TOKEN=" + content
|
||||
case "different":
|
||||
content = "different-synthetic-token"
|
||||
case "empty", "missing":
|
||||
content = ""
|
||||
}
|
||||
// Adverse file representations are deliberate fixture edits, not
|
||||
// claims that the generator or installer writes these formats.
|
||||
if err := os.WriteFile(tokenFile, []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.format == "missing" {
|
||||
if err := os.Remove(tokenFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
caFile := filepath.Join(t.TempDir(), "synthetic-server-ca.pem")
|
||||
if err := os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
environment := ""
|
||||
if tc.environment {
|
||||
environment = envRaw
|
||||
}
|
||||
runs := 1
|
||||
if tc.wantStatus == http.StatusOK {
|
||||
runs = 2 // Same state, file and identity after an ordinary restart.
|
||||
}
|
||||
for run := 0; run < runs; run++ {
|
||||
stop, exit, logs := startHandoffAgent(t, binary, server.URL, caFile, tokenFile, state, environment)
|
||||
if tc.startupErr {
|
||||
select {
|
||||
case err := <-exit:
|
||||
if err == nil || !strings.Contains(logs.String(), "--token-file") {
|
||||
t.Fatal("invalid selected file did not produce a startup error")
|
||||
}
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("invalid selected file did not stop startup")
|
||||
}
|
||||
if requests.Load() != 0 {
|
||||
t.Fatal("invalid selected file sent an HTTP request using fallback authority")
|
||||
}
|
||||
} else {
|
||||
awaitHandoffHTTP(t, events, tc.wantStatus, run > 0)
|
||||
}
|
||||
stop()
|
||||
if !tc.startupErr {
|
||||
if err := <-exit; err != nil {
|
||||
t.Fatal("synthetic agent did not stop cleanly")
|
||||
}
|
||||
}
|
||||
if strings.Contains(logs.String(), install.Token) || strings.Contains(logs.String(), envRaw) {
|
||||
t.Fatal("agent logs disclosed a credential")
|
||||
}
|
||||
}
|
||||
assertHandoffCredentialUse(t, runtime.config, install.Record.ID, tc.wantStatus == http.StatusOK)
|
||||
assertHandoffCredentialUse(t, runtime.config, envRecord.ID, false)
|
||||
if tc.wantStatus == http.StatusOK && len(runtime.monitor.GetState().Hosts) != 1 {
|
||||
t.Fatal("process restart duplicated the host identity")
|
||||
}
|
||||
t.Logf("runs=%d report_status=%d startup_refused=%v selected_record_used=%v environment_record_used=false", runs, tc.wantStatus, tc.startupErr, tc.wantStatus == http.StatusOK)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type handoffHTTPEvent struct {
|
||||
path string
|
||||
status int
|
||||
report agentshost.Report
|
||||
}
|
||||
|
||||
func awaitHandoffHTTP(t *testing.T, events <-chan handoffHTTPEvent, wantStatus int, restarted bool) {
|
||||
t.Helper()
|
||||
lookup, report := false, false
|
||||
deadline := time.NewTimer(30 * time.Second)
|
||||
defer deadline.Stop()
|
||||
for !lookup || !report {
|
||||
select {
|
||||
case event := <-events:
|
||||
if strings.HasSuffix(event.path, "/lookup") {
|
||||
lookup = true
|
||||
// A new valid identity is not registered until its first report.
|
||||
if event.status != wantStatus && !(wantStatus == http.StatusOK && !restarted && event.status == http.StatusNotFound) {
|
||||
t.Fatalf("lookup status = %d, want authenticated lookup or new-identity absence", event.status)
|
||||
}
|
||||
} else {
|
||||
report = true
|
||||
if event.status != wantStatus {
|
||||
t.Fatalf("report status = %d, want %d", event.status, wantStatus)
|
||||
}
|
||||
if event.report.Agent.ID != "credential-handoff-agent" || event.report.Host.Hostname != "credential-handoff.local" || event.report.Agent.CommandsEnabled {
|
||||
t.Fatal("report changed selected identity or command authority")
|
||||
}
|
||||
}
|
||||
case <-deadline.C:
|
||||
t.Fatal("agent did not complete both lookup and report")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertHandoffCredentialUse(t *testing.T, cfg *config.Config, id string, used bool) {
|
||||
t.Helper()
|
||||
config.Mu.RLock()
|
||||
defer config.Mu.RUnlock()
|
||||
for _, record := range cfg.APITokens {
|
||||
if record.ID == id {
|
||||
if (record.LastUsedAt != nil) != used {
|
||||
t.Fatal("credential last-used evidence does not match authentication")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("credential record missing")
|
||||
}
|
||||
|
||||
func persistHandoffToken(t *testing.T, root, state, token string) string {
|
||||
t.Helper()
|
||||
script, err := os.ReadFile(filepath.Join(root, "scripts", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(script)
|
||||
start := strings.Index(text, "ensure_runtime_token_file() {")
|
||||
end := strings.Index(text, "\nclear_proxmox_state_if_needed() {")
|
||||
if start < 0 || end <= start {
|
||||
t.Fatal("canonical installer token writer not found")
|
||||
}
|
||||
cmd := exec.Command("bash", "-c", `set -euo pipefail
|
||||
TMP_FILES=(); ENROLL=false; EXIT_GENERAL=1
|
||||
log_info() { :; }; fail() { exit 1; }
|
||||
PULSE_TOKEN=$(cat)
|
||||
`+text[start:end]+`ensure_runtime_token_file "$STATE_DIR"`)
|
||||
cmd.Env = append(os.Environ(), "STATE_DIR="+state)
|
||||
cmd.Stdin = strings.NewReader(token)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("private installer token writer: %v (output bytes %d)", err, len(output))
|
||||
}
|
||||
tokenFile := filepath.Join(state, "token")
|
||||
for path, mode := range map[string]os.FileMode{state: 0700, tokenFile: 0600} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Mode().Perm() != mode {
|
||||
t.Fatal("installer token writer did not retain private file/parent modes")
|
||||
}
|
||||
}
|
||||
return tokenFile
|
||||
}
|
||||
|
||||
func startHandoffAgent(t *testing.T, binary, url, caFile, tokenFile, state, envToken string) (func(), <-chan error, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(binary, "--url", url, "--cacert", caFile, "--token-file", tokenFile,
|
||||
"--state-dir", state, "--agent-id", "credential-handoff-agent", "--hostname", "credential-handoff.local",
|
||||
"--enable-host", "--enable-docker=false", "--enable-kubernetes=false", "--enable-proxmox=false",
|
||||
"--enable-commands=false", "--command-authority", "monitoring-only", "--disable-auto-update", "--interval", "1s", "--health-addr", "127.0.0.1:0")
|
||||
for _, value := range os.Environ() {
|
||||
if !strings.HasPrefix(value, "PULSE_") {
|
||||
cmd.Env = append(cmd.Env, value)
|
||||
}
|
||||
}
|
||||
cmd.Env = append(cmd.Env, "PULSE_TOKEN="+envToken)
|
||||
var logs bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &logs, &logs
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal("start synthetic agent")
|
||||
}
|
||||
exit := make(chan error, 1)
|
||||
done := make(chan struct{})
|
||||
go func() { exit <- cmd.Wait(); close(done) }()
|
||||
var once sync.Once
|
||||
stop := func() {
|
||||
once.Do(func() {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
<-done
|
||||
t.Error("synthetic agent required forced cleanup")
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Cleanup(stop)
|
||||
return stop, exit, &logs
|
||||
}
|
||||
Reference in New Issue
Block a user