diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go
index 686a32e9be..6d54c1ccf7 100644
--- a/cmd/pulse-agent/main.go
+++ b/cmd/pulse-agent/main.go
@@ -51,6 +51,10 @@ type Runnable interface {
Run(ctx context.Context) error
}
+type RemoteConfigApplier interface {
+ ApplyRemoteConfig(settings map[string]interface{}, commandsEnabled *bool)
+}
+
// Runnable closer for Docker agent which needs cleanup
type RunnableCloser interface {
Runnable
@@ -73,8 +77,9 @@ var (
runAsWindowsServiceFunc = runAsWindowsService
// For testing
- retryInitialDelay = 5 * time.Second
- retryMaxDelay = 5 * time.Minute
+ retryInitialDelay = 5 * time.Second
+ retryMaxDelay = 5 * time.Minute
+ remoteConfigRefreshInterval = 1 * time.Minute
)
type multiValue []string
@@ -170,11 +175,14 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
}
}
+ var remoteConfigClient *remoteconfig.Client
+ var remoteConfigAppliers []RemoteConfigApplier
+
// 2c. Fetch Remote Config
// Only if we have enough info to contact server
if cfg.PulseURL != "" && cfg.APIToken != "" && cfg.AgentID != "" {
logger.Debug().Msg("Fetching remote configuration...")
- rc := remoteconfig.New(remoteconfig.Config{
+ remoteConfigClient = remoteconfig.New(remoteconfig.Config{
PulseURL: cfg.PulseURL,
APIToken: cfg.APIToken,
AgentID: cfg.AgentID,
@@ -184,11 +192,11 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
ServerFingerprint: cfg.ServerFingerprint,
Logger: logger,
})
- defer rc.Close()
+ defer remoteConfigClient.Close()
// Use a short timeout for config fetch so we don't block startup too long
rcCtx, rcCancel := context.WithTimeout(ctx, 10*time.Second)
- settings, commandsEnabled, err := rc.Fetch(rcCtx)
+ settings, commandsEnabled, err := remoteConfigClient.Fetch(rcCtx)
rcCancel()
if err != nil {
@@ -304,6 +312,9 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
if err != nil {
return fmt.Errorf("failed to initialize host agent: %w", err)
}
+ if applier, ok := agent.(RemoteConfigApplier); ok {
+ remoteConfigAppliers = append(remoteConfigAppliers, applier)
+ }
g.Go(func() error {
logger.Info().Msg("Host agent module started")
@@ -422,6 +433,13 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
}
}
+ if remoteConfigClient != nil && len(remoteConfigAppliers) > 0 {
+ g.Go(func() error {
+ runRemoteConfigLoop(ctx, remoteConfigClient, remoteConfigAppliers, &logger)
+ return nil
+ })
+ }
+
// Mark as ready after all agents started
ready.Store(true)
@@ -441,6 +459,43 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
return nil
}
+func runRemoteConfigLoop(ctx context.Context, client *remoteconfig.Client, appliers []RemoteConfigApplier, logger *zerolog.Logger) {
+ if client == nil || len(appliers) == 0 {
+ return
+ }
+ ticker := time.NewTicker(remoteConfigRefreshInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ }
+
+ fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ settings, commandsEnabled, err := client.Fetch(fetchCtx)
+ cancel()
+ if err != nil {
+ logger.Warn().
+ Err(err).
+ Str("component", "remote_config").
+ Str("action", "refresh_failed").
+ Msg("Failed to refresh remote config")
+ continue
+ }
+ for _, applier := range appliers {
+ applier.ApplyRemoteConfig(settings, commandsEnabled)
+ }
+ logger.Debug().
+ Str("component", "remote_config").
+ Str("action", "refresh_applied").
+ Int("settings_count", len(settings)).
+ Bool("commands_enabled_present", commandsEnabled != nil).
+ Msg("Applied refreshed remote config")
+ }
+}
+
func cleanupDockerAgent(agent RunnableCloser, logger *zerolog.Logger) {
if agent == nil || reflect.ValueOf(agent).IsNil() {
return
@@ -1215,34 +1270,6 @@ func applyRemoteSettings(cfg *Config, settings map[string]interface{}, logger *z
}
}
}
- if d, ok := remoteDurationSetting(settings, "interval"); ok && d > 0 {
- cfg.Interval = d
- logger.Info().Dur("val", d).Msg("Remote config: interval")
- }
- if b, ok := remoteBoolSetting(settings, "disable_auto_update"); ok {
- cfg.DisableAutoUpdate = b
- logger.Info().Bool("val", b).Msg("Remote config: disable_auto_update")
- }
- if b, ok := remoteBoolSetting(settings, "disable_docker_update_checks"); ok {
- cfg.DisableDockerUpdateChecks = b
- logger.Info().Bool("val", b).Msg("Remote config: disable_docker_update_checks")
- }
- if b, ok := remoteBoolSetting(settings, "kube_include_all_pods"); ok {
- cfg.KubeIncludeAllPods = b
- logger.Info().Bool("val", b).Msg("Remote config: kube_include_all_pods")
- }
- if b, ok := remoteBoolSetting(settings, "kube_include_all_deployments"); ok {
- cfg.KubeIncludeAllDeployments = b
- logger.Info().Bool("val", b).Msg("Remote config: kube_include_all_deployments")
- }
- if s, ok := remoteStringSetting(settings, "report_ip"); ok {
- cfg.ReportIP = s
- logger.Info().Str("val", s).Msg("Remote config: report_ip")
- }
- if b, ok := remoteBoolSetting(settings, "disable_ceph"); ok {
- cfg.DisableCeph = b
- logger.Info().Bool("val", b).Msg("Remote config: disable_ceph")
- }
}
func remoteBoolSetting(settings map[string]interface{}, key string) (bool, bool) {
@@ -1277,7 +1304,7 @@ func remoteDurationSetting(settings map[string]interface{}, key string) (time.Du
}
return parsed, true
case float64:
- return time.Duration(typed) * time.Second, true
+ return time.Duration(typed * float64(time.Second)), true
case int:
return time.Duration(typed) * time.Second, true
case int64:
diff --git a/cmd/pulse-agent/main_test.go b/cmd/pulse-agent/main_test.go
index f4e2028ffa..1f608085a7 100644
--- a/cmd/pulse-agent/main_test.go
+++ b/cmd/pulse-agent/main_test.go
@@ -1807,3 +1807,13 @@ func TestRetryLogEvent_LevelThrottling(t *testing.T) {
}
}
}
+
+func TestRemoteDurationSettingPreservesFractionalSeconds(t *testing.T) {
+ got, ok := remoteDurationSetting(map[string]interface{}{"interval": 0.25}, "interval")
+ if !ok {
+ t.Fatal("remoteDurationSetting() ok = false, want true")
+ }
+ if got != 250*time.Millisecond {
+ t.Fatalf("remoteDurationSetting() = %s, want 250ms", got)
+ }
+}
diff --git a/internal/agentexec/approval_grant.go b/internal/agentexec/approval_grant.go
new file mode 100644
index 0000000000..fa1770302a
--- /dev/null
+++ b/internal/agentexec/approval_grant.go
@@ -0,0 +1,170 @@
+package agentexec
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+const (
+ approvalGrantVersion = 1
+ approvalGrantKeyPrefix = "pulse-agent-exec-approval-grant-v1"
+ approvalGrantSigPrefix = "hmac-sha256:"
+ DefaultApprovalGrantTTL = 2 * time.Minute
+)
+
+// CommandApprovalGrant is a server-issued, token-bound grant that lets an agent
+// verify an approval-gated command before executing it.
+type CommandApprovalGrant struct {
+ Version int `json:"version"`
+ ApprovalID string `json:"approval_id"`
+ RequestID string `json:"request_id"`
+ AgentID string `json:"agent_id"`
+ CommandHash string `json:"command_hash"`
+ TargetType string `json:"target_type"`
+ TargetID string `json:"target_id,omitempty"`
+ IssuedAt time.Time `json:"issued_at"`
+ ExpiresAt time.Time `json:"expires_at"`
+ Nonce string `json:"nonce"`
+ Signature string `json:"signature"`
+}
+
+// DeriveApprovalGrantKey returns the in-memory signing key derived from the
+// runtime agent token. Callers should keep the raw token out of long-lived state.
+func DeriveApprovalGrantKey(token string) []byte {
+ token = strings.TrimSpace(token)
+ if token == "" {
+ return nil
+ }
+ sum := sha256.Sum256([]byte(approvalGrantKeyPrefix + "\x00" + token))
+ return sum[:]
+}
+
+// ComputeCommandApprovalHash computes the canonical command+target hash used by
+// approval grants and the approval store.
+func ComputeCommandApprovalHash(command, targetType, targetID string) string {
+ h := sha256.New()
+ h.Write([]byte(command))
+ h.Write([]byte("|"))
+ h.Write([]byte(strings.ToLower(strings.TrimSpace(targetType))))
+ h.Write([]byte("|"))
+ h.Write([]byte(strings.TrimSpace(targetID)))
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+func NewCommandApprovalGrant(key []byte, agentID string, cmd ExecuteCommandPayload, now time.Time, ttl time.Duration) (*CommandApprovalGrant, error) {
+ if len(key) == 0 {
+ return nil, errors.New("approval grant key is unavailable")
+ }
+ if strings.TrimSpace(cmd.ApprovalID) == "" {
+ return nil, errors.New("approval id is required")
+ }
+ if ttl <= 0 {
+ ttl = DefaultApprovalGrantTTL
+ }
+ nonce, err := newApprovalGrantNonce()
+ if err != nil {
+ return nil, err
+ }
+ grant := &CommandApprovalGrant{
+ Version: approvalGrantVersion,
+ ApprovalID: strings.TrimSpace(cmd.ApprovalID),
+ RequestID: strings.TrimSpace(cmd.RequestID),
+ AgentID: strings.TrimSpace(agentID),
+ CommandHash: ComputeCommandApprovalHash(cmd.Command, cmd.TargetType, cmd.TargetID),
+ TargetType: strings.ToLower(strings.TrimSpace(cmd.TargetType)),
+ TargetID: strings.TrimSpace(cmd.TargetID),
+ IssuedAt: now.UTC(),
+ ExpiresAt: now.Add(ttl).UTC(),
+ Nonce: nonce,
+ }
+ grant.Signature = signApprovalGrant(key, grant)
+ return grant, nil
+}
+
+func VerifyCommandApprovalGrant(token string, agentID string, cmd ExecuteCommandPayload, now time.Time) error {
+ if cmd.ApprovalGrant == nil {
+ return errors.New("approval grant is required")
+ }
+ key := DeriveApprovalGrantKey(token)
+ return VerifyCommandApprovalGrantWithKey(key, agentID, cmd, now)
+}
+
+func VerifyCommandApprovalGrantWithKey(key []byte, agentID string, cmd ExecuteCommandPayload, now time.Time) error {
+ grant := cmd.ApprovalGrant
+ if grant == nil {
+ return errors.New("approval grant is required")
+ }
+ if len(key) == 0 {
+ return errors.New("approval grant key is unavailable")
+ }
+ if grant.Version != approvalGrantVersion {
+ return fmt.Errorf("unsupported approval grant version %d", grant.Version)
+ }
+ if strings.TrimSpace(grant.ApprovalID) == "" || strings.TrimSpace(grant.ApprovalID) != strings.TrimSpace(cmd.ApprovalID) {
+ return errors.New("approval grant id does not match command")
+ }
+ if strings.TrimSpace(grant.RequestID) == "" || strings.TrimSpace(grant.RequestID) != strings.TrimSpace(cmd.RequestID) {
+ return errors.New("approval grant request does not match command")
+ }
+ if strings.TrimSpace(grant.AgentID) == "" || strings.TrimSpace(grant.AgentID) != strings.TrimSpace(agentID) {
+ return errors.New("approval grant agent does not match command")
+ }
+ if strings.TrimSpace(grant.CommandHash) != ComputeCommandApprovalHash(cmd.Command, cmd.TargetType, cmd.TargetID) {
+ return errors.New("approval grant command hash does not match command")
+ }
+ if strings.ToLower(strings.TrimSpace(grant.TargetType)) != strings.ToLower(strings.TrimSpace(cmd.TargetType)) {
+ return errors.New("approval grant target type does not match command")
+ }
+ if strings.TrimSpace(grant.TargetID) != strings.TrimSpace(cmd.TargetID) {
+ return errors.New("approval grant target id does not match command")
+ }
+ if grant.ExpiresAt.IsZero() || now.UTC().After(grant.ExpiresAt.UTC()) {
+ return errors.New("approval grant has expired")
+ }
+ if grant.IssuedAt.IsZero() || grant.IssuedAt.UTC().After(now.UTC().Add(30*time.Second)) {
+ return errors.New("approval grant issued_at is invalid")
+ }
+ if strings.TrimSpace(grant.Nonce) == "" {
+ return errors.New("approval grant nonce is required")
+ }
+ if !hmac.Equal([]byte(strings.TrimSpace(grant.Signature)), []byte(signApprovalGrant(key, grant))) {
+ return errors.New("approval grant signature is invalid")
+ }
+ return nil
+}
+
+func signApprovalGrant(key []byte, grant *CommandApprovalGrant) string {
+ mac := hmac.New(sha256.New, key)
+ mac.Write([]byte(approvalGrantCanonicalString(grant)))
+ return approvalGrantSigPrefix + base64.StdEncoding.EncodeToString(mac.Sum(nil))
+}
+
+func approvalGrantCanonicalString(grant *CommandApprovalGrant) string {
+ return strings.Join([]string{
+ fmt.Sprintf("%d", grant.Version),
+ strings.TrimSpace(grant.ApprovalID),
+ strings.TrimSpace(grant.RequestID),
+ strings.TrimSpace(grant.AgentID),
+ strings.TrimSpace(grant.CommandHash),
+ strings.ToLower(strings.TrimSpace(grant.TargetType)),
+ strings.TrimSpace(grant.TargetID),
+ grant.IssuedAt.UTC().Format(time.RFC3339Nano),
+ grant.ExpiresAt.UTC().Format(time.RFC3339Nano),
+ strings.TrimSpace(grant.Nonce),
+ }, "\n")
+}
+
+func newApprovalGrantNonce() (string, error) {
+ var raw [16]byte
+ if _, err := rand.Read(raw[:]); err != nil {
+ return "", fmt.Errorf("generate approval grant nonce: %w", err)
+ }
+ return base64.RawURLEncoding.EncodeToString(raw[:]), nil
+}
diff --git a/internal/agentexec/approval_grant_test.go b/internal/agentexec/approval_grant_test.go
new file mode 100644
index 0000000000..a89666157d
--- /dev/null
+++ b/internal/agentexec/approval_grant_test.go
@@ -0,0 +1,53 @@
+package agentexec
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestCommandApprovalGrantVerifiesExactCommandAndTarget(t *testing.T) {
+ key := DeriveApprovalGrantKey("runtime-token")
+ cmd := ExecuteCommandPayload{
+ RequestID: "req-1",
+ Command: "systemctl restart nginx",
+ ApprovalID: "approval-1",
+ TargetType: "agent",
+ }
+ now := time.Now()
+ grant, err := NewCommandApprovalGrant(key, "agent-1", cmd, now, time.Minute)
+ if err != nil {
+ t.Fatalf("NewCommandApprovalGrant() error = %v", err)
+ }
+ cmd.ApprovalGrant = grant
+
+ if err := VerifyCommandApprovalGrant("runtime-token", "agent-1", cmd, now.Add(10*time.Second)); err != nil {
+ t.Fatalf("VerifyCommandApprovalGrant() error = %v", err)
+ }
+
+ tampered := cmd
+ tampered.Command = "systemctl restart postgres"
+ if err := VerifyCommandApprovalGrant("runtime-token", "agent-1", tampered, now.Add(10*time.Second)); err == nil || !strings.Contains(err.Error(), "command hash") {
+ t.Fatalf("expected command hash rejection, got %v", err)
+ }
+}
+
+func TestCommandApprovalGrantExpires(t *testing.T) {
+ key := DeriveApprovalGrantKey("runtime-token")
+ cmd := ExecuteCommandPayload{
+ RequestID: "req-1",
+ Command: "systemctl restart nginx",
+ ApprovalID: "approval-1",
+ TargetType: "agent",
+ }
+ now := time.Now()
+ grant, err := NewCommandApprovalGrant(key, "agent-1", cmd, now, time.Second)
+ if err != nil {
+ t.Fatalf("NewCommandApprovalGrant() error = %v", err)
+ }
+ cmd.ApprovalGrant = grant
+
+ if err := VerifyCommandApprovalGrant("runtime-token", "agent-1", cmd, now.Add(2*time.Second)); err == nil || !strings.Contains(err.Error(), "expired") {
+ t.Fatalf("expected expiry rejection, got %v", err)
+ }
+}
diff --git a/internal/agentexec/server.go b/internal/agentexec/server.go
index 3a6cf56096..ff9fbede58 100644
--- a/internal/agentexec/server.go
+++ b/internal/agentexec/server.go
@@ -71,11 +71,12 @@ type Server struct {
}
type agentConn struct {
- conn *websocket.Conn
- agent ConnectedAgent
- writeMu sync.Mutex
- done chan struct{}
- doneOnce sync.Once
+ conn *websocket.Conn
+ agent ConnectedAgent
+ approvalGrantKey []byte
+ writeMu sync.Mutex
+ done chan struct{}
+ doneOnce sync.Once
}
func (ac *agentConn) signalDone() {
@@ -450,7 +451,8 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
Tags: reg.Tags,
ConnectedAt: time.Now(),
},
- done: make(chan struct{}),
+ approvalGrantKey: DeriveApprovalGrantKey(reg.Token),
+ done: make(chan struct{}),
}
// Clear deadline for normal operation - both on the WebSocket and underlying connection
@@ -851,6 +853,13 @@ func (s *Server) ExecuteCommand(ctx context.Context, agentID string, cmd Execute
if err := s.authorizeCommandPayload(cmd); err != nil {
return nil, err
}
+ if s.commandPolicy != nil && s.commandPolicy.Evaluate(cmd.Command) == PolicyRequireApproval && cmd.ApprovalGrant == nil && len(ac.approvalGrantKey) > 0 {
+ grant, grantErr := NewCommandApprovalGrant(ac.approvalGrantKey, agentID, cmd, time.Now(), DefaultApprovalGrantTTL)
+ if grantErr != nil {
+ return nil, fmt.Errorf("failed to issue approval grant: %w", grantErr)
+ }
+ cmd.ApprovalGrant = grant
+ }
execLog := log.With().
Str("agent_id", agentID).
diff --git a/internal/agentexec/server_websocket_test.go b/internal/agentexec/server_websocket_test.go
index 5833a8a584..f1d6e7d5b2 100644
--- a/internal/agentexec/server_websocket_test.go
+++ b/internal/agentexec/server_websocket_test.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -403,6 +404,14 @@ func TestExecuteCommand_RoundTripViaWebSocket(t *testing.T) {
agentErr <- err
return
}
+ if payload.ApprovalGrant == nil {
+ agentErr <- fmt.Errorf("missing approval grant")
+ return
+ }
+ if err := VerifyCommandApprovalGrant("any", "a1", payload, time.Now()); err != nil {
+ agentErr <- err
+ return
+ }
_ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
if err := conn.WriteJSON(mustNewMessage(t, MsgTypeCommandResult, "", CommandResultPayload{
RequestID: payload.RequestID,
diff --git a/internal/agentexec/types.go b/internal/agentexec/types.go
index 73f680ff2c..bcca780530 100644
--- a/internal/agentexec/types.go
+++ b/internal/agentexec/types.go
@@ -90,12 +90,13 @@ type RegisteredPayload struct {
// ExecuteCommandPayload is sent by server to request command execution
type ExecuteCommandPayload struct {
- RequestID string `json:"request_id"`
- Command string `json:"command"`
- ApprovalID string `json:"approval_id,omitempty"`
- TargetType string `json:"target_type"` // "agent", "container", "vm"
- TargetID string `json:"target_id,omitempty"` // VMID for container/VM
- Timeout int `json:"timeout,omitempty"` // seconds, 0 = default
+ RequestID string `json:"request_id"`
+ Command string `json:"command"`
+ ApprovalID string `json:"approval_id,omitempty"`
+ ApprovalGrant *CommandApprovalGrant `json:"approval_grant,omitempty"`
+ TargetType string `json:"target_type"` // "agent", "container", "vm"
+ TargetID string `json:"target_id,omitempty"` // VMID for container/VM
+ Timeout int `json:"timeout,omitempty"` // seconds, 0 = default
}
// ReadFilePayload is sent by server to request file content
diff --git a/internal/agentupdate/coverage_test.go b/internal/agentupdate/coverage_test.go
index 2764a3ae53..f21c064cfb 100644
--- a/internal/agentupdate/coverage_test.go
+++ b/internal/agentupdate/coverage_test.go
@@ -27,6 +27,15 @@ func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
+func TestUpdateRetryBackoffUsesOperationalDelays(t *testing.T) {
+ if updateRetryBaseDelay < time.Second {
+ t.Fatalf("updateRetryBaseDelay = %s, want at least 1s", updateRetryBaseDelay)
+ }
+ if updateRetryMaxDelay < 30*time.Second {
+ t.Fatalf("updateRetryMaxDelay = %s, want at least 30s", updateRetryMaxDelay)
+ }
+}
+
func testBinary() []byte {
switch runtime.GOOS {
case "darwin":
diff --git a/internal/agentupdate/update.go b/internal/agentupdate/update.go
index 4d0ba5b6a3..c2baf2aebc 100644
--- a/internal/agentupdate/update.go
+++ b/internal/agentupdate/update.go
@@ -49,10 +49,10 @@ const (
updateRequestMaxAttempts = 3
// updateRetryBaseDelay is the initial retry backoff delay for update HTTP failures.
- updateRetryBaseDelay = 25 * time.Millisecond
+ updateRetryBaseDelay = 1 * time.Second
// updateRetryMaxDelay caps exponential backoff for update HTTP retries.
- updateRetryMaxDelay = 250 * time.Millisecond
+ updateRetryMaxDelay = 30 * time.Second
)
type goOS string
diff --git a/internal/api/agent_install_command_shared.go b/internal/api/agent_install_command_shared.go
index dd62b84ea8..f477dec152 100644
--- a/internal/api/agent_install_command_shared.go
+++ b/internal/api/agent_install_command_shared.go
@@ -196,15 +196,21 @@ func resolveLoopbackAwarePublicBaseURL(req *http.Request, cfg *config.Config) st
func buildProxmoxAgentInstallCommand(opts agentInstallCommandOptions) string {
baseURL := normalizeAgentInstallBaseURL(opts.BaseURL)
installScriptURL := baseURL + "/install.sh"
- command := fmt.Sprintf(`curl -fsSL %s | bash -s -- \
+ token := strings.TrimSpace(opts.Token)
+ tokenSetup := ""
+ tokenArg := ""
+ tokenCleanup := ""
+ if token != "" {
+ tokenSetup = fmt.Sprintf(`token_file=$(mktemp) && chmod 600 "$token_file" && printf %%s %s > "$token_file" && `, posixShellQuote(token))
+ tokenArg = ` \
+ --token-file "$token_file"`
+ tokenCleanup = `; rc=$?; rm -f "$token_file"; exit $rc`
+ }
+ command := fmt.Sprintf(`%scurl -fsSL %s | bash -s -- \
--url %s \
--enable-proxmox`,
- posixShellQuote(installScriptURL), posixShellQuote(baseURL))
-
- if token := strings.TrimSpace(opts.Token); token != "" {
- command += fmt.Sprintf(` \
- --token %s`, posixShellQuote(token))
- }
+ tokenSetup, posixShellQuote(installScriptURL), posixShellQuote(baseURL))
+ command += tokenArg
if installBaseURLRequiresInsecure(baseURL) {
command += ` \
@@ -216,7 +222,7 @@ func buildProxmoxAgentInstallCommand(opts agentInstallCommandOptions) string {
--proxmox-type %s`, posixShellQuote(opts.InstallType))
}
- return withPrivilegeEscalation(command)
+ return withPrivilegeEscalation(command) + tokenCleanup
}
func buildContainerRuntimeAgentInstallCommand(baseURL string, token string) string {
diff --git a/internal/api/agent_install_command_shared_test.go b/internal/api/agent_install_command_shared_test.go
index 1f2d988477..a206ede1a8 100644
--- a/internal/api/agent_install_command_shared_test.go
+++ b/internal/api/agent_install_command_shared_test.go
@@ -28,7 +28,9 @@ func TestBuildProxmoxAgentInstallCommand(t *testing.T) {
IncludeInstallType: true,
})
require.Contains(t, command, posixShellQuote("https://pulse.example.com/install.sh"))
- require.Contains(t, command, "--token "+posixShellQuote("token-123"))
+ require.Contains(t, command, "printf %s "+posixShellQuote("token-123")+` > "$token_file"`)
+ require.Contains(t, command, `--token-file "$token_file"`)
+ require.Contains(t, command, `rm -f "$token_file"`)
require.Contains(t, command, "--proxmox-type "+posixShellQuote("pbs"))
}
@@ -84,7 +86,8 @@ func TestBuildProxmoxAgentInstallCommand_ShellEscapesArguments(t *testing.T) {
require.Contains(t, command, posixShellQuote(baseURL+"/install.sh"))
require.Contains(t, command, "--url "+posixShellQuote(baseURL))
- require.Contains(t, command, "--token "+posixShellQuote(token))
+ require.Contains(t, command, "printf %s "+posixShellQuote(token)+` > "$token_file"`)
+ require.Contains(t, command, `--token-file "$token_file"`)
require.Contains(t, command, "--proxmox-type "+posixShellQuote("pve"))
}
@@ -98,6 +101,7 @@ func TestBuildProxmoxAgentInstallCommand_NormalizesTrailingSlashes(t *testing.T)
require.Contains(t, command, posixShellQuote("https://pulse.example.com/base/install.sh"))
require.Contains(t, command, "--url "+posixShellQuote("https://pulse.example.com/base"))
+ require.Contains(t, command, `--token-file "$token_file"`)
require.NotContains(t, command, "//install.sh")
require.NotContains(t, command, "--url "+posixShellQuote("https://pulse.example.com/base/"))
}
diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go
index 4e4b0533bd..9bf21cf961 100644
--- a/internal/api/ai_handlers.go
+++ b/internal/api/ai_handlers.go
@@ -3640,12 +3640,6 @@ func (h *AISettingsHandler) HandleRunCommand(w http.ResponseWriter, r *http.Requ
return
}
- // Gated for AI Auto-Fix (Pro feature)
- if !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) {
- WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Pulse Patrol Auto-Fix requires Pulse Pro")
- return
- }
-
// Parse request
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
bodyBytes, readErr := io.ReadAll(r.Body)
@@ -3658,7 +3652,7 @@ func (h *AISettingsHandler) HandleRunCommand(w http.ResponseWriter, r *http.Requ
var req AIRunCommandRequest
if err := json.Unmarshal(bodyBytes, &req); err != nil {
- log.Error().Err(err).Str("body", string(bodyBytes)).Msg("Failed to decode JSON body")
+ log.Error().Err(err).Msg("Failed to decode JSON body")
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
@@ -3678,6 +3672,13 @@ func (h *AISettingsHandler) HandleRunCommand(w http.ResponseWriter, r *http.Requ
return
}
+ // Gated for AI Auto-Fix (Pro feature). Request shape is validated before the
+ // entitlement check so clients get deterministic 400s for malformed calls.
+ if !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) {
+ WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Pulse Patrol Auto-Fix requires Pulse Pro")
+ return
+ }
+
store := approval.GetStore()
if store == nil {
http.Error(w, "Approval store not initialized", http.StatusServiceUnavailable)
@@ -3697,7 +3698,7 @@ func (h *AISettingsHandler) HandleRunCommand(w http.ResponseWriter, r *http.Requ
}
log.Info().
- Str("command", req.Command).
+ Str("command_hash", agentexec.ComputeCommandApprovalHash(req.Command, approvalTargetType, approvalTargetID)).
Str("approval_id", req.ApprovalID).
Str("target_type", approvalTargetType).
Str("target_id", approvalTargetID).
diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go
index a07006ca15..73ee0b50ac 100644
--- a/internal/api/ai_handlers_test.go
+++ b/internal/api/ai_handlers_test.go
@@ -1729,6 +1729,7 @@ func TestHandleRunCommand_InvalidBody(t *testing.T) {
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/run-command", bytes.NewReader([]byte(`{invalid json}`)))
+ req.RemoteAddr = "127.0.0.1:12345"
rec := httptest.NewRecorder()
handler.HandleRunCommand(rec, req)
diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go
index 1e78f5cb5c..088fdce036 100644
--- a/internal/api/contract_test.go
+++ b/internal/api/contract_test.go
@@ -7900,6 +7900,9 @@ func TestContract_ProxmoxInstallCommandIncludesInsecureForPlainHTTP(t *testing.T
if !strings.Contains(got, "--insecure") {
t.Fatalf("install command missing insecure flag for plain HTTP Pulse URL: %s", got)
}
+ if !strings.Contains(got, `--token-file "$token_file"`) {
+ t.Fatalf("install command missing token-file transport: %s", got)
+ }
}
func TestContract_ProxmoxInstallCommandUsesPrivilegeEscalationWrapper(t *testing.T) {
@@ -7919,6 +7922,9 @@ func TestContract_ProxmoxInstallCommandUsesPrivilegeEscalationWrapper(t *testing
if strings.Contains(got, "| bash -s -- --url") {
t.Fatalf("install command preserved raw bash pipe instead of governed wrapper: %s", got)
}
+ if !strings.Contains(got, `rm -f "$token_file"`) {
+ t.Fatalf("install command missing ephemeral token cleanup: %s", got)
+ }
}
func TestContract_OptionalAuthProxmoxInstallCommandOmitsToken(t *testing.T) {
@@ -7951,6 +7957,9 @@ func TestContract_ProxmoxInstallCommandNormalizesTrailingSlashBaseURL(t *testing
if !strings.Contains(got, "--url "+posixShellQuote("https://pulse.example.com/base")) {
t.Fatalf("install command missing normalized base URL: %s", got)
}
+ if !strings.Contains(got, `--token-file "$token_file"`) {
+ t.Fatalf("install command missing token-file transport: %s", got)
+ }
if strings.Contains(got, "//install.sh") {
t.Fatalf("install command preserved double-slash install path: %s", got)
}
diff --git a/internal/api/hosted_agent_install_command_test.go b/internal/api/hosted_agent_install_command_test.go
index d266a53024..5a862f8caf 100644
--- a/internal/api/hosted_agent_install_command_test.go
+++ b/internal/api/hosted_agent_install_command_test.go
@@ -57,7 +57,8 @@ func TestHostedTenantAgentInstallCommand_GeneratesOrgBoundTokenAndCommand(t *tes
require.Equal(t, orgID, resp.OrgID)
require.Contains(t, resp.Command, "https://cloud.example.com/install.sh")
- require.Contains(t, resp.Command, "--token "+posixShellQuote(resp.Token))
+ require.Contains(t, resp.Command, "printf %s "+posixShellQuote(resp.Token)+` > "$token_file"`)
+ require.Contains(t, resp.Command, `--token-file "$token_file"`)
require.Contains(t, resp.Command, "--proxmox-type "+posixShellQuote("pbs"))
require.Regexp(t, regexp.MustCompile(`^[a-f0-9]{64}$`), resp.Token)
diff --git a/internal/api/unified_agent.go b/internal/api/unified_agent.go
index 1b8f7d527e..fe4d31f171 100644
--- a/internal/api/unified_agent.go
+++ b/internal/api/unified_agent.go
@@ -253,19 +253,66 @@ func (r *Router) handleDownloadUnifiedAgent(w http.ResponseWriter, req *http.Req
}
func validateUnifiedAgentBinary(path string) error {
- content, err := os.ReadFile(path)
+ file, err := os.Open(path)
if err != nil {
- return fmt.Errorf("read binary: %w", err)
+ return fmt.Errorf("open binary: %w", err)
}
- if bytes.Contains(content, []byte(legacyUnifiedAgentReportPath)) {
+ defer file.Close()
+
+ hasCanonical, hasLegacy, err := scanUnifiedAgentBinaryContract(file)
+ if err != nil {
+ return fmt.Errorf("scan binary contract: %w", err)
+ }
+ if hasLegacy {
return fmt.Errorf("references deprecated host endpoint %s", legacyUnifiedAgentReportPath)
}
- if !bytes.Contains(content, []byte(canonicalUnifiedAgentReportPath)) {
+ if !hasCanonical {
return fmt.Errorf("missing canonical host endpoint %s", canonicalUnifiedAgentReportPath)
}
return nil
}
+func scanUnifiedAgentBinaryContract(r io.Reader) (hasCanonical bool, hasLegacy bool, err error) {
+ canonicalNeedle := []byte(canonicalUnifiedAgentReportPath)
+ legacyNeedle := []byte(legacyUnifiedAgentReportPath)
+ maxNeedleLen := len(canonicalNeedle)
+ if len(legacyNeedle) > maxNeedleLen {
+ maxNeedleLen = len(legacyNeedle)
+ }
+ overlap := maxNeedleLen - 1
+ if overlap < 0 {
+ overlap = 0
+ }
+
+ buf := make([]byte, 64*1024)
+ window := make([]byte, 0, len(buf)+overlap)
+ for {
+ n, readErr := r.Read(buf)
+ if n > 0 {
+ window = append(window, buf[:n]...)
+ if bytes.Contains(window, canonicalNeedle) {
+ hasCanonical = true
+ }
+ if bytes.Contains(window, legacyNeedle) {
+ hasLegacy = true
+ }
+ if hasCanonical && hasLegacy {
+ return true, true, nil
+ }
+ if len(window) > overlap {
+ window = append(window[:0], window[len(window)-overlap:]...)
+ }
+ }
+ if readErr == io.EOF {
+ break
+ }
+ if readErr != nil {
+ return false, false, readErr
+ }
+ }
+ return hasCanonical, hasLegacy, nil
+}
+
// proxyAgentBinaryFromGitHub downloads an agent binary from GitHub releases and serves
// it to the requesting agent with the X-Checksum-Sha256 header. This is used when the
// binary isn't available locally (e.g., LXC/bare-metal installations updated via web UI).
@@ -349,6 +396,7 @@ func (r *Router) proxyAgentBinaryFromGitHub(w http.ResponseWriter, req *http.Req
}
const maxAgentBinarySize = 100 * 1024 * 1024
+const maxInstallScriptSize = 512 * 1024
func readBinaryWithChecksum(body io.Reader) ([]byte, string, error) {
limitedReader := io.LimitReader(body, maxAgentBinarySize+1)
@@ -643,13 +691,19 @@ func (r *Router) proxyInstallScriptFromGitHub(w http.ResponseWriter, req *http.R
return
}
- // Read the script content
- content, err := io.ReadAll(resp.Body)
+ // Read the script content with a hard cap so a bad upstream response cannot
+ // exhaust memory while the server is proxying release assets.
+ content, err := io.ReadAll(io.LimitReader(resp.Body, maxInstallScriptSize+1))
if err != nil {
log.Error().Err(err).Msg("Failed to read install script from GitHub")
http.Error(w, "Failed to read install script", http.StatusInternalServerError)
return
}
+ if len(content) > maxInstallScriptSize {
+ log.Error().Int("size", len(content)).Str("url", githubURL).Msg("Install script from GitHub exceeded size limit")
+ http.Error(w, "Install script exceeds size limit", http.StatusServiceUnavailable)
+ return
+ }
// Determine content type based on script extension
contentType := "text/x-shellscript"
diff --git a/internal/hostagent/agent.go b/internal/hostagent/agent.go
index ff369f16a0..d1c1f8ee6c 100644
--- a/internal/hostagent/agent.go
+++ b/internal/hostagent/agent.go
@@ -98,6 +98,8 @@ type Agent struct {
interval time.Duration
stateDir string
trimmedPulseURL string
+ configMu sync.RWMutex
+ remoteConfigChanged chan struct{}
reportBuffer *utils.Queue[agentshost.Report]
commandClient *CommandClient
commandClientMu sync.Mutex
@@ -290,29 +292,30 @@ func New(cfg Config) (*Agent, error) {
}
agent := &Agent{
- cfg: cfg,
- logger: logger,
- httpClient: client,
- hostInfo: info,
- hostname: hostname,
- displayName: displayName,
- platform: platform,
- osName: osName,
- osVersion: osVersion,
- kernelVersion: kernelVersion,
- architecture: arch,
- machineID: machineID,
- agentID: agentID,
- agentVersion: agentVersion,
- updatedFrom: updatedFrom,
- reportIP: cfg.ReportIP,
- stateDir: cfg.StateDir,
- interval: cfg.Interval,
- trimmedPulseURL: pulseURL,
- reportBuffer: utils.New[agentshost.Report](bufferCapacity),
- collector: collector,
- newCommandClient: newCommandClientFn,
- runCommandClient: runCommandClientFn,
+ cfg: cfg,
+ logger: logger,
+ httpClient: client,
+ hostInfo: info,
+ hostname: hostname,
+ displayName: displayName,
+ platform: platform,
+ osName: osName,
+ osVersion: osVersion,
+ kernelVersion: kernelVersion,
+ architecture: arch,
+ machineID: machineID,
+ agentID: agentID,
+ agentVersion: agentVersion,
+ updatedFrom: updatedFrom,
+ reportIP: cfg.ReportIP,
+ stateDir: cfg.StateDir,
+ interval: cfg.Interval,
+ trimmedPulseURL: pulseURL,
+ remoteConfigChanged: make(chan struct{}, 1),
+ reportBuffer: utils.New[agentshost.Report](bufferCapacity),
+ collector: collector,
+ newCommandClient: newCommandClientFn,
+ runCommandClient: runCommandClientFn,
}
// Create command client for AI command execution (only if enabled)
@@ -402,7 +405,7 @@ func (a *Agent) Run(ctx context.Context) error {
// Load any reports buffered from a previous shutdown
a.loadPersistedBuffer()
- ticker := time.NewTicker(a.interval)
+ ticker := time.NewTicker(a.currentInterval())
defer ticker.Stop()
defer a.persistBuffer()
@@ -417,6 +420,8 @@ func (a *Agent) Run(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
+ case <-a.remoteConfigChanged:
+ ticker.Reset(a.currentInterval())
case <-ticker.C:
if err := a.process(ctx); err != nil {
if errors.Is(err, context.Canceled) {
@@ -431,6 +436,52 @@ func (a *Agent) Run(ctx context.Context) error {
}
}
+type runtimeConfigSnapshot struct {
+ agentType string
+ interval time.Duration
+ commandsEnabled bool
+ diskExclude []string
+ tags []string
+ reportIP string
+ disableCeph bool
+}
+
+func (a *Agent) currentInterval() time.Duration {
+ a.configMu.RLock()
+ defer a.configMu.RUnlock()
+ if a.interval <= 0 {
+ return time.Minute
+ }
+ return a.interval
+}
+
+func (a *Agent) currentReportIP() string {
+ a.configMu.RLock()
+ defer a.configMu.RUnlock()
+ return a.reportIP
+}
+
+func (a *Agent) runtimeConfigSnapshot() runtimeConfigSnapshot {
+ a.configMu.RLock()
+ defer a.configMu.RUnlock()
+ return runtimeConfigSnapshot{
+ agentType: a.cfg.AgentType,
+ interval: a.interval,
+ commandsEnabled: a.cfg.EnableCommands,
+ diskExclude: append([]string(nil), a.cfg.DiskExclude...),
+ tags: append([]string(nil), a.cfg.Tags...),
+ reportIP: a.reportIP,
+ disableCeph: a.cfg.DisableCeph,
+ }
+}
+
+func (a *Agent) signalRemoteConfigChanged() {
+ select {
+ case a.remoteConfigChanged <- struct{}{}:
+ default:
+ }
+}
+
func (a *Agent) startCommandClient(client *CommandClient) {
if client == nil {
return
@@ -636,11 +687,12 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
collectCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
+ runtimeConfig := a.runtimeConfigSnapshot()
uptime, err := a.collector.HostUptime(collectCtx)
if err != nil {
a.logger.Debug().Err(err).Msg("Failed to collect host uptime; defaulting to 0")
}
- snapshot, err := a.collector.Metrics(collectCtx, a.cfg.DiskExclude)
+ snapshot, err := a.collector.Metrics(collectCtx, runtimeConfig.diskExclude)
if err != nil {
return agentshost.Report{}, fmt.Errorf("collect metrics: %w", err)
}
@@ -649,7 +701,7 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
sensorData := a.collectTemperatures(collectCtx)
// Collect S.M.A.R.T. disk data (best effort - don't fail if unavailable)
- smartData := a.collectSMARTData(collectCtx)
+ smartData := a.collectSMARTData(collectCtx, runtimeConfig.diskExclude)
if len(smartData) > 0 {
sensorData.SMART = smartData
}
@@ -661,7 +713,7 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
unraidData := a.collectUnraidStorage(collectCtx)
// Collect Ceph cluster data (best effort - only on Ceph nodes)
- cephData := a.collectCephStatus(collectCtx)
+ cephData := a.collectCephStatus(collectCtx, runtimeConfig.disableCeph)
// Collect temperature data from Proxmox cluster peers via SSH (best effort).
// Uses parent ctx, not collectCtx — cluster SSH has its own 15s budget that
@@ -679,12 +731,12 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
Agent: agentshost.AgentInfo{
ID: a.agentID,
Version: a.agentVersion,
- Type: a.cfg.AgentType,
- IntervalSeconds: int(a.interval / time.Second),
+ Type: runtimeConfig.agentType,
+ IntervalSeconds: int(runtimeConfig.interval / time.Second),
Hostname: a.hostname,
UpdatedFrom: updatedFrom,
- CommandsEnabled: a.cfg.EnableCommands,
- DiskExclude: append([]string(nil), a.cfg.DiskExclude...),
+ CommandsEnabled: runtimeConfig.commandsEnabled,
+ DiskExclude: append([]string(nil), runtimeConfig.diskExclude...),
},
Host: agentshost.HostInfo{
ID: a.machineID,
@@ -700,7 +752,7 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
CPUCount: snapshot.CPUCount,
UptimeSeconds: int64(uptime),
LoadAverage: append([]float64(nil), snapshot.LoadAverage...),
- ReportIP: a.reportIP,
+ ReportIP: runtimeConfig.reportIP,
},
Metrics: agentshost.Metrics{
CPUUsagePercent: snapshot.CPUUsagePercent,
@@ -714,7 +766,7 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
Unraid: unraidData,
Ceph: cephData,
ClusterSensors: clusterSensors,
- Tags: append([]string(nil), a.cfg.Tags...),
+ Tags: append([]string(nil), runtimeConfig.tags...),
Timestamp: a.collector.Now(),
}
@@ -814,19 +866,60 @@ func (a *Agent) persistAgentID(agentID string) {
}
}
-// applyRemoteConfig applies server-side configuration overrides.
-// Currently handles enabling/disabling the command execution feature dynamically.
+// ApplyRemoteConfig applies server-side configuration overrides that can be
+// reconciled safely without restarting the agent process.
+func (a *Agent) ApplyRemoteConfig(settings map[string]interface{}, commandsEnabled *bool) {
+ if commandsEnabled != nil {
+ a.applyRemoteConfig(*commandsEnabled)
+ }
+ if interval, ok := remoteDurationSetting(settings, "interval"); ok && interval > 0 {
+ intervalChanged := false
+ a.configMu.Lock()
+ if a.interval != interval {
+ intervalChanged = true
+ }
+ a.interval = interval
+ a.cfg.Interval = interval
+ a.configMu.Unlock()
+ if intervalChanged {
+ a.signalRemoteConfigChanged()
+ }
+ a.logger.Info().Dur("interval", interval).Msg("Applied remote host report interval")
+ }
+ if reportIP, ok := remoteStringSetting(settings, "report_ip"); ok {
+ trimmedReportIP := strings.TrimSpace(reportIP)
+ a.configMu.Lock()
+ a.reportIP = trimmedReportIP
+ a.cfg.ReportIP = trimmedReportIP
+ a.configMu.Unlock()
+ a.logger.Info().Str("report_ip", trimmedReportIP).Msg("Applied remote host report IP override")
+ }
+ if disableCeph, ok := remoteBoolSetting(settings, "disable_ceph"); ok {
+ a.configMu.Lock()
+ a.cfg.DisableCeph = disableCeph
+ a.configMu.Unlock()
+ a.logger.Info().Bool("disable_ceph", disableCeph).Msg("Applied remote Ceph collection setting")
+ }
+}
+
+// applyRemoteConfig applies server-side command execution overrides.
func (a *Agent) applyRemoteConfig(commandsEnabled bool) {
var (
clientToStart *CommandClient
+ commandCfg Config
shouldStop bool
)
+ a.configMu.Lock()
+ a.cfg.EnableCommands = commandsEnabled
+ commandCfg = a.cfg
+ a.configMu.Unlock()
+
a.commandClientMu.Lock()
currentlyEnabled := a.commandClient != nil
switch {
case commandsEnabled && !currentlyEnabled:
- clientToStart = a.newCommandClient(a.cfg, a.agentID, a.hostname, a.platform, a.agentVersion)
+ clientToStart = a.newCommandClient(commandCfg, a.agentID, a.hostname, a.platform, a.agentVersion)
a.commandClient = clientToStart
case !commandsEnabled && currentlyEnabled:
shouldStop = true
@@ -842,6 +935,56 @@ func (a *Agent) applyRemoteConfig(commandsEnabled bool) {
}
}
+func remoteBoolSetting(settings map[string]interface{}, key string) (bool, bool) {
+ if settings == nil {
+ return false, false
+ }
+ value, ok := settings[key]
+ if !ok {
+ return false, false
+ }
+ parsed, ok := value.(bool)
+ return parsed, ok
+}
+
+func remoteStringSetting(settings map[string]interface{}, key string) (string, bool) {
+ if settings == nil {
+ return "", false
+ }
+ value, ok := settings[key]
+ if !ok {
+ return "", false
+ }
+ parsed, ok := value.(string)
+ return parsed, ok
+}
+
+func remoteDurationSetting(settings map[string]interface{}, key string) (time.Duration, bool) {
+ if settings == nil {
+ return 0, false
+ }
+ value, ok := settings[key]
+ if !ok {
+ return 0, false
+ }
+ switch typed := value.(type) {
+ case string:
+ parsed, err := time.ParseDuration(typed)
+ if err != nil {
+ return 0, false
+ }
+ return parsed, true
+ case float64:
+ return time.Duration(typed * float64(time.Second)), true
+ case int:
+ return time.Duration(typed) * time.Second, true
+ case int64:
+ return time.Duration(typed) * time.Second, true
+ default:
+ return 0, false
+ }
+}
+
func normalisePlatform(platform string) string {
platform = strings.ToLower(strings.TrimSpace(platform))
switch platform {
@@ -1085,8 +1228,8 @@ func (a *Agent) collectUnraidStorage(ctx context.Context) *agentshost.UnraidStor
// collectCephStatus attempts to collect Ceph cluster status.
// Returns nil if Ceph is not available or not configured on this host.
-func (a *Agent) collectCephStatus(ctx context.Context) *agentshost.CephCluster {
- if a.cfg.DisableCeph {
+func (a *Agent) collectCephStatus(ctx context.Context, disableCeph bool) *agentshost.CephCluster {
+ if disableCeph {
return nil
}
// Only collect on Linux
@@ -1206,13 +1349,13 @@ func (a *Agent) collectCephStatus(ctx context.Context) *agentshost.CephCluster {
// collectSMARTData collects S.M.A.R.T. data from local disks.
// Returns nil if smartctl is not available or no disks are found.
-func (a *Agent) collectSMARTData(ctx context.Context) []agentshost.DiskSMART {
+func (a *Agent) collectSMARTData(ctx context.Context, diskExclude []string) []agentshost.DiskSMART {
goos := a.collector.GOOS()
if goos != "linux" && goos != "freebsd" {
return nil
}
- smartData, err := a.collector.SMARTLocal(ctx, a.cfg.DiskExclude)
+ smartData, err := a.collector.SMARTLocal(ctx, diskExclude)
if err != nil {
a.logger.Debug().Err(err).Msg("Failed to collect S.M.A.R.T. data (smartctl may not be installed)")
return nil
@@ -1278,7 +1421,7 @@ func (a *Agent) runProxmoxSetup(ctx context.Context) {
a.cfg.APIToken,
a.cfg.ProxmoxType,
a.hostname,
- a.reportIP,
+ a.currentReportIP(),
a.stateDir,
a.cfg.InsecureSkipVerify,
)
@@ -1347,7 +1490,7 @@ func (a *Agent) runProxmoxHealthCheckLoop(ctx context.Context) {
a.cfg.APIToken,
a.cfg.ProxmoxType,
a.hostname,
- a.reportIP,
+ a.currentReportIP(),
a.stateDir,
a.cfg.InsecureSkipVerify,
)
diff --git a/internal/hostagent/agent_collection_test.go b/internal/hostagent/agent_collection_test.go
index 65ca21e560..4cb83b577d 100644
--- a/internal/hostagent/agent_collection_test.go
+++ b/internal/hostagent/agent_collection_test.go
@@ -130,7 +130,7 @@ func TestAgent_collectCephStatus_GuardsAndConversion(t *testing.T) {
collector: mc,
}
- if got := a.collectCephStatus(context.Background()); got != nil {
+ if got := a.collectCephStatus(context.Background(), a.cfg.DisableCeph); got != nil {
t.Fatalf("collectCephStatus() = %#v, want nil", got)
}
if called {
@@ -149,7 +149,7 @@ func TestAgent_collectCephStatus_GuardsAndConversion(t *testing.T) {
}
a := &Agent{logger: zerolog.Nop(), collector: mc}
- if got := a.collectCephStatus(context.Background()); got != nil {
+ if got := a.collectCephStatus(context.Background(), false); got != nil {
t.Fatalf("collectCephStatus() = %#v, want nil", got)
}
if called {
@@ -164,7 +164,7 @@ func TestAgent_collectCephStatus_GuardsAndConversion(t *testing.T) {
}
a := &Agent{logger: zerolog.Nop(), collector: mc}
- if got := a.collectCephStatus(context.Background()); got != nil {
+ if got := a.collectCephStatus(context.Background(), false); got != nil {
t.Fatalf("collectCephStatus() = %#v, want nil", got)
}
})
@@ -176,7 +176,7 @@ func TestAgent_collectCephStatus_GuardsAndConversion(t *testing.T) {
}
a := &Agent{logger: zerolog.Nop(), collector: mc}
- if got := a.collectCephStatus(context.Background()); got != nil {
+ if got := a.collectCephStatus(context.Background(), false); got != nil {
t.Fatalf("collectCephStatus() = %#v, want nil", got)
}
})
@@ -242,7 +242,7 @@ func TestAgent_collectCephStatus_GuardsAndConversion(t *testing.T) {
}
a := &Agent{logger: zerolog.Nop(), collector: mc}
- got := a.collectCephStatus(context.Background())
+ got := a.collectCephStatus(context.Background(), false)
if got == nil {
t.Fatal("collectCephStatus() returned nil")
}
diff --git a/internal/hostagent/agent_new_test.go b/internal/hostagent/agent_new_test.go
index 8bc0c4882e..5eb38322d5 100644
--- a/internal/hostagent/agent_new_test.go
+++ b/internal/hostagent/agent_new_test.go
@@ -198,6 +198,7 @@ func TestNewCommandClient_SetsSecureCommandDefaults(t *testing.T) {
logger := zerolog.Nop()
client := NewCommandClient(Config{
PulseURL: "https://pulse.example",
+ APIToken: "runtime-token",
ServerFingerprint: "aabbccdd",
DeploySSHUser: "pulse-deploy",
Logger: &logger,
@@ -219,7 +220,8 @@ func TestNewCommandClient_SetsSecureCommandDefaults(t *testing.T) {
if err := client.authorizeCommand(executeCommandPayload{Command: "systemctl restart nginx"}); err == nil || !strings.Contains(err.Error(), "requires approval") {
t.Fatalf("expected approval-required command to fail closed, got %v", err)
}
- if err := client.authorizeCommand(executeCommandPayload{Command: "systemctl restart nginx", ApprovalID: "approval-1"}); err != nil {
+ approvedPayload := testApprovedCommandPayload(t, client, executeCommandPayload{RequestID: "r1", Command: "systemctl restart nginx"})
+ if err := client.authorizeCommand(approvedPayload); err != nil {
t.Fatalf("expected approved command to pass, got %v", err)
}
if err := client.authorizeCommand(executeCommandPayload{Command: "rm -rf /"}); err == nil || !strings.Contains(err.Error(), "blocked by policy") {
@@ -227,6 +229,49 @@ func TestNewCommandClient_SetsSecureCommandDefaults(t *testing.T) {
}
}
+func TestAgentApplyRemoteConfigUpdatesRuntimeSnapshot(t *testing.T) {
+ logger := zerolog.Nop()
+ commandsEnabled := false
+ agent := &Agent{
+ cfg: Config{
+ AgentType: "unified",
+ EnableCommands: true,
+ Interval: 30 * time.Second,
+ DiskExclude: []string{"/old"},
+ Tags: []string{"edge"},
+ },
+ interval: 30 * time.Second,
+ reportIP: "192.0.2.10",
+ remoteConfigChanged: make(chan struct{}, 1),
+ logger: logger,
+ }
+
+ agent.ApplyRemoteConfig(map[string]interface{}{
+ "interval": "45s",
+ "report_ip": "192.0.2.20",
+ "disable_ceph": true,
+ }, &commandsEnabled)
+
+ snapshot := agent.runtimeConfigSnapshot()
+ if snapshot.interval != 45*time.Second {
+ t.Fatalf("interval = %s, want 45s", snapshot.interval)
+ }
+ if snapshot.reportIP != "192.0.2.20" {
+ t.Fatalf("reportIP = %q, want %q", snapshot.reportIP, "192.0.2.20")
+ }
+ if snapshot.commandsEnabled {
+ t.Fatal("commandsEnabled = true, want false")
+ }
+ if !snapshot.disableCeph {
+ t.Fatal("disableCeph = false, want true")
+ }
+ select {
+ case <-agent.remoteConfigChanged:
+ default:
+ t.Fatal("expected interval change signal")
+ }
+}
+
func TestNewRejectsInvalidDeploySSHUser(t *testing.T) {
mc := &mockCollector{
hostInfoFn: func(context.Context) (*gohost.InfoStat, error) {
diff --git a/internal/hostagent/commands.go b/internal/hostagent/commands.go
index 1427ed1857..4a445c4967 100644
--- a/internal/hostagent/commands.go
+++ b/internal/hostagent/commands.go
@@ -183,12 +183,13 @@ type registeredPayload struct {
}
type executeCommandPayload struct {
- RequestID string `json:"request_id"`
- Command string `json:"command"`
- ApprovalID string `json:"approval_id,omitempty"`
- TargetType string `json:"target_type"`
- TargetID string `json:"target_id,omitempty"`
- Timeout int `json:"timeout,omitempty"`
+ RequestID string `json:"request_id"`
+ Command string `json:"command"`
+ ApprovalID string `json:"approval_id,omitempty"`
+ ApprovalGrant *agentexec.CommandApprovalGrant `json:"approval_grant,omitempty"`
+ TargetType string `json:"target_type"`
+ TargetID string `json:"target_id,omitempty"`
+ Timeout int `json:"timeout,omitempty"`
}
type commandResultPayload struct {
@@ -541,7 +542,7 @@ func (c *CommandClient) handleExecuteCommand(ctx context.Context, conn *websocke
c.logger.Info().
Str("request_id", payload.RequestID).
- Str("command", payload.Command).
+ Str("command_hash", agentexec.ComputeCommandApprovalHash(payload.Command, normalizedCommandTargetType(payload.TargetType), payload.TargetID)).
Str("approval_id", payload.ApprovalID).
Str("target_type", payload.TargetType).
Str("target_id", payload.TargetID).
@@ -636,10 +637,33 @@ func (c *CommandClient) authorizeCommand(payload executeCommandPayload) error {
if strings.TrimSpace(payload.ApprovalID) == "" {
return fmt.Errorf("command requires approval")
}
+ if err := agentexec.VerifyCommandApprovalGrant(c.apiToken, c.agentID, payload.toAgentExecPayload(), time.Now()); err != nil {
+ return fmt.Errorf("command approval grant invalid: %w", err)
+ }
}
return nil
}
+func (p executeCommandPayload) toAgentExecPayload() agentexec.ExecuteCommandPayload {
+ return agentexec.ExecuteCommandPayload{
+ RequestID: strings.TrimSpace(p.RequestID),
+ Command: p.Command,
+ ApprovalID: strings.TrimSpace(p.ApprovalID),
+ ApprovalGrant: p.ApprovalGrant,
+ TargetType: normalizedCommandTargetType(p.TargetType),
+ TargetID: strings.TrimSpace(p.TargetID),
+ Timeout: p.Timeout,
+ }
+}
+
+func normalizedCommandTargetType(targetType string) string {
+ normalized := strings.ToLower(strings.TrimSpace(targetType))
+ if normalized == "" || normalized == "host" {
+ return "agent"
+ }
+ return normalized
+}
+
func (c *CommandClient) ensureSSHKnownHostsManager() (sshknownhosts.Manager, error) {
if c == nil {
return nil, fmt.Errorf("command client is nil")
diff --git a/internal/hostagent/commands_connect_test.go b/internal/hostagent/commands_connect_test.go
index e93c6e5974..e2a4d04aeb 100644
--- a/internal/hostagent/commands_connect_test.go
+++ b/internal/hostagent/commands_connect_test.go
@@ -10,6 +10,7 @@ import (
"time"
"github.com/gorilla/websocket"
+ "github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rs/zerolog"
)
@@ -50,13 +51,26 @@ func TestCommandClient_connectAndHandle_ExecutesCommandAndReturnsResult(t *testi
return
}
- execPayloadBytes, _ := json.Marshal(executeCommandPayload{
+ execPayload := executeCommandPayload{
RequestID: "req-1",
Command: "echo hello",
ApprovalID: "approval-1",
TargetType: "host",
Timeout: 5,
- })
+ }
+ grant, err := agentexec.NewCommandApprovalGrant(
+ agentexec.DeriveApprovalGrantKey("token"),
+ "agent-1",
+ execPayload.toAgentExecPayload(),
+ time.Now(),
+ time.Minute,
+ )
+ if err != nil {
+ t.Errorf("approval grant: %v", err)
+ return
+ }
+ execPayload.ApprovalGrant = grant
+ execPayloadBytes, _ := json.Marshal(execPayload)
if err := conn.WriteJSON(wsMessage{Type: msgTypeExecuteCmd, ID: "req-1", Timestamp: time.Now(), Payload: execPayloadBytes}); err != nil {
t.Errorf("write execute_command: %v", err)
return
diff --git a/internal/hostagent/commands_execute_test.go b/internal/hostagent/commands_execute_test.go
index 7790bf4328..6ba02fb3d6 100644
--- a/internal/hostagent/commands_execute_test.go
+++ b/internal/hostagent/commands_execute_test.go
@@ -6,8 +6,41 @@ import (
"strings"
"testing"
"time"
+
+ "github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
)
+func testApprovedCommandPayload(t *testing.T, client *CommandClient, payload executeCommandPayload) executeCommandPayload {
+ t.Helper()
+ if client.apiToken == "" {
+ client.apiToken = "runtime-token"
+ }
+ if client.agentID == "" {
+ client.agentID = "agent-1"
+ }
+ if payload.RequestID == "" {
+ payload.RequestID = "req-1"
+ }
+ if payload.ApprovalID == "" {
+ payload.ApprovalID = "approval-1"
+ }
+ if payload.TargetType == "" {
+ payload.TargetType = "agent"
+ }
+ grant, err := agentexec.NewCommandApprovalGrant(
+ agentexec.DeriveApprovalGrantKey(client.apiToken),
+ client.agentID,
+ payload.toAgentExecPayload(),
+ time.Now(),
+ time.Minute,
+ )
+ if err != nil {
+ t.Fatalf("NewCommandApprovalGrant() error = %v", err)
+ }
+ payload.ApprovalGrant = grant
+ return payload
+}
+
func TestWrapCommand_TargetWrapping(t *testing.T) {
tests := []struct {
name string
@@ -68,12 +101,12 @@ func TestCommandClient_executeCommand_Success(t *testing.T) {
}
c := &CommandClient{}
- result := c.executeCommand(context.Background(), executeCommandPayload{
- RequestID: "r1",
- Command: "echo hello",
- ApprovalID: "approval-1",
- Timeout: 5,
+ payload := testApprovedCommandPayload(t, c, executeCommandPayload{
+ RequestID: "r1",
+ Command: "echo hello",
+ Timeout: 5,
})
+ result := c.executeCommand(context.Background(), payload)
if !result.Success || result.ExitCode != 0 {
t.Fatalf("expected success, got %#v", result)
}
@@ -88,12 +121,12 @@ func TestCommandClient_executeCommand_NonZeroExitCode(t *testing.T) {
}
c := &CommandClient{}
- result := c.executeCommand(context.Background(), executeCommandPayload{
- RequestID: "r1",
- Command: "echo err 1>&2; exit 3",
- ApprovalID: "approval-1",
- Timeout: 5,
+ payload := testApprovedCommandPayload(t, c, executeCommandPayload{
+ RequestID: "r1",
+ Command: "echo err 1>&2; exit 3",
+ Timeout: 5,
})
+ result := c.executeCommand(context.Background(), payload)
if result.Success {
t.Fatalf("expected failure, got %#v", result)
}
@@ -112,12 +145,12 @@ func TestCommandClient_executeCommand_TimeoutSetsError(t *testing.T) {
c := &CommandClient{}
start := time.Now()
- result := c.executeCommand(context.Background(), executeCommandPayload{
- RequestID: "r1",
- Command: "sleep 2",
- ApprovalID: "approval-1",
- Timeout: 1,
+ payload := testApprovedCommandPayload(t, c, executeCommandPayload{
+ RequestID: "r1",
+ Command: "sleep 2",
+ Timeout: 1,
})
+ result := c.executeCommand(context.Background(), payload)
if time.Since(start) > 3*time.Second {
t.Fatalf("timeout path took too long: %v", time.Since(start))
}
@@ -138,12 +171,12 @@ func TestCommandClient_executeCommand_TruncatesLargeOutput(t *testing.T) {
}
c := &CommandClient{}
- result := c.executeCommand(context.Background(), executeCommandPayload{
- RequestID: "r1",
- Command: "head -c 1048580 /dev/zero | tr '\\0' 'a'",
- ApprovalID: "approval-1",
- Timeout: 5,
+ payload := testApprovedCommandPayload(t, c, executeCommandPayload{
+ RequestID: "r1",
+ Command: "head -c 1048580 /dev/zero | tr '\\0' 'a'",
+ Timeout: 5,
})
+ result := c.executeCommand(context.Background(), payload)
if !result.Success {
t.Fatalf("expected success, got %#v", result)
}
@@ -161,12 +194,12 @@ func TestCommandClient_executeCommand_TruncatesLargeStderr(t *testing.T) {
}
c := &CommandClient{}
- result := c.executeCommand(context.Background(), executeCommandPayload{
- RequestID: "r1",
- Command: "head -c 1048580 /dev/zero | tr '\\0' 'b' 1>&2",
- ApprovalID: "approval-1",
- Timeout: 5,
+ payload := testApprovedCommandPayload(t, c, executeCommandPayload{
+ RequestID: "r1",
+ Command: "head -c 1048580 /dev/zero | tr '\\0' 'b' 1>&2",
+ Timeout: 5,
})
+ result := c.executeCommand(context.Background(), payload)
if !result.Success {
t.Fatalf("expected success, got %#v", result)
}
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index 4752b2a78a..1b24396f94 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -29,6 +29,7 @@ $BinaryName = "pulse-agent.exe"
$InstallDir = "C:\Program Files\Pulse"
$StateDir = "$env:ProgramData\Pulse"
$ConnectionStatePath = "$StateDir\connection.env"
+$TokenFilePath = "$StateDir\token"
$LogFile = "$env:ProgramData\Pulse\pulse-agent.log"
$DownloadTimeoutSec = 300
$PinnedInstallerSshPublicKey = "__PULSE_INSTALLER_SSH_PUBLIC_KEY__"
@@ -337,8 +338,8 @@ function Invoke-WithOptionalInsecureTls {
function Save-ConnectionState {
$lines = @("PULSE_URL='$Url'")
- if (-not [string]::IsNullOrWhiteSpace($Token)) {
- $lines += "PULSE_TOKEN='$Token'"
+ if (Test-Path $TokenFilePath) {
+ $lines += "PULSE_TOKEN_FILE='$TokenFilePath'"
}
if (-not [string]::IsNullOrWhiteSpace($AgentId)) {
$lines += "PULSE_AGENT_ID='$AgentId'"
@@ -357,6 +358,21 @@ function Save-ConnectionState {
Set-Content -Path $ConnectionStatePath -Value ($lines -join "`n") -Encoding UTF8
}
+function Write-RuntimeTokenFile {
+ if ([string]::IsNullOrWhiteSpace($Token)) {
+ Remove-Item $TokenFilePath -Force -ErrorAction SilentlyContinue
+ return
+ }
+
+ New-Item -ItemType Directory -Path $StateDir -Force | Out-Null
+ Set-Content -Path $TokenFilePath -Value $Token -NoNewline -Encoding ASCII
+ try {
+ icacls.exe $TokenFilePath /inheritance:r /grant:r "SYSTEM:F" "Administrators:F" | Out-Null
+ } catch {
+ Write-Host "Warning: Failed to tighten token file ACLs: $_" -ForegroundColor Yellow
+ }
+}
+
function Get-ConnectionStateValue {
param(
[string]$Key
@@ -386,6 +402,12 @@ if ($Uninstall) {
if ([string]::IsNullOrWhiteSpace($Token)) {
$Token = Get-ConnectionStateValue "PULSE_TOKEN"
}
+ if ([string]::IsNullOrWhiteSpace($Token)) {
+ $savedTokenFile = Get-ConnectionStateValue "PULSE_TOKEN_FILE"
+ if (-not [string]::IsNullOrWhiteSpace($savedTokenFile) -and (Test-Path $savedTokenFile)) {
+ $Token = (Get-Content -Path $savedTokenFile -Raw).Trim()
+ }
+ }
if ([string]::IsNullOrWhiteSpace($AgentId)) {
$AgentId = Get-ConnectionStateValue "PULSE_AGENT_ID"
}
@@ -438,8 +460,8 @@ if ($Uninstall) {
Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $customCaCertificate -Action {
$lookupResult = Invoke-RestMethod @lookupArgs
}
- if ($lookupResult -and -not [string]::IsNullOrWhiteSpace($lookupResult.id)) {
- $detectedAgentId = $lookupResult.id.Trim()
+ if ($lookupResult -and $lookupResult.agent -and -not [string]::IsNullOrWhiteSpace($lookupResult.agent.id)) {
+ $detectedAgentId = $lookupResult.agent.id.Trim()
}
} catch {
# Ignore lookup errors during uninstall
@@ -540,7 +562,15 @@ $Url = $Url.TrimEnd('/')
# --- Download ---
# Determine architecture
-$Arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "386" }
+$processorArch = "$env:PROCESSOR_ARCHITECTURE"
+$processorArch64 = "$env:PROCESSOR_ARCHITEW6432"
+if ($processorArch -eq "ARM64" -or $processorArch64 -eq "ARM64") {
+ $Arch = "arm64"
+} elseif ([Environment]::Is64BitOperatingSystem) {
+ $Arch = "amd64"
+} else {
+ $Arch = "386"
+}
$ArchParam = "windows-$Arch"
$DownloadUrl = "$Url/download/pulse-agent?arch=$ArchParam"
Write-Host "Downloading agent from $DownloadUrl..." -ForegroundColor Cyan
@@ -610,18 +640,19 @@ if (-not (Test-PEBinary $TempPath)) {
Exit 1
}
-# Verify checksum if server provided one
-if (-not [string]::IsNullOrWhiteSpace($serverChecksum)) {
- $localChecksum = Get-FileChecksum $TempPath
- if ($localChecksum -ne $serverChecksum.ToLower()) {
- Cleanup
- Show-Error "Checksum verification failed!`nExpected: $serverChecksum`nGot: $localChecksum"
- Exit 1
- }
- Write-Host "Checksum verified: $localChecksum" -ForegroundColor Green
-} else {
- Write-Host "Warning: Server did not provide checksum header" -ForegroundColor Yellow
+# Verify checksum from the same download response.
+if ([string]::IsNullOrWhiteSpace($serverChecksum)) {
+ Cleanup
+ Show-Error "Server did not provide checksum header; refusing install."
+ Exit 1
}
+$localChecksum = Get-FileChecksum $TempPath
+if ($localChecksum -ne $serverChecksum.ToLower()) {
+ Cleanup
+ Show-Error "Checksum verification failed!`nExpected: $serverChecksum`nGot: $localChecksum"
+ Exit 1
+}
+Write-Host "Checksum verified: $localChecksum" -ForegroundColor Green
if (Test-HasPinnedInstallerSignatureKey) {
if ([string]::IsNullOrWhiteSpace($serverChecksum)) {
@@ -681,6 +712,7 @@ Remove-Item "$DestPath.backup" -Force -ErrorAction SilentlyContinue
# --- Service Installation ---
Write-Host "Configuring Windows Service..." -ForegroundColor Cyan
+Write-RuntimeTokenFile
Save-ConnectionState
# Build command line args (properly escaped)
@@ -688,7 +720,7 @@ $ServiceArgs = @(
"--url", "`"$Url`"",
"--interval", "`"$Interval`""
)
-if (-not [string]::IsNullOrWhiteSpace($Token)) { $ServiceArgs += @("--token", "`"$Token`"") }
+if (-not [string]::IsNullOrWhiteSpace($Token)) { $ServiceArgs += @("--token-file", "`"$TokenFilePath`"") }
if ($EnableHost) { $ServiceArgs += "--enable-host" } else { $ServiceArgs += "--enable-host=false" }
if ($EnableDocker) { $ServiceArgs += "--enable-docker" }
if ($EnableKubernetes) { $ServiceArgs += "--enable-kubernetes" }
diff --git a/scripts/install.sh b/scripts/install.sh
index c2c3a2c364..d5a8c8cf78 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -85,6 +85,7 @@ STATE_DIR="/var/lib/pulse-agent" # Persistent state directory (overridden per p
CURL_CA_BUNDLE="" # Path to CA bundle for curl and agent TLS (sets SSL_CERT_FILE)
NON_INTERACTIVE="false"
TOKEN_FILE_PATH="" # Path to file containing the token
+RUNTIME_TOKEN_FILE="" # Secure token file passed to the installed service
OUTPUT_FORMAT="text" # "text" (default) or "json"
PREFLIGHT_ONLY="false"
INSTALL_SIGNATURE_NAMESPACE="pulse-install"
@@ -319,6 +320,32 @@ restore_selinux_contexts() {
# --- Post-Start Health Verification ---
# After starting the agent service, poll its readiness endpoint to verify it
# actually started. The agent exposes /readyz on :9191 once modules are initialized.
+verify_agent_server_registration() {
+ local lookup_hostname="${HOSTNAME_OVERRIDE}"
+ local lookup_resp=""
+ local lookup_args=(-fsSL --connect-timeout 5 --max-time 10)
+
+ if [[ -z "$PULSE_URL" ]]; then
+ return 1
+ fi
+ if [[ -z "$lookup_hostname" ]]; then
+ lookup_hostname=$(hostname 2>/dev/null || true)
+ fi
+ if [[ -z "$lookup_hostname" ]]; then
+ return 1
+ fi
+
+ if [[ -n "$PULSE_TOKEN" ]]; then lookup_args+=(-H "X-API-Token: ${PULSE_TOKEN}"); fi
+ if [[ "$INSECURE" == "true" ]]; then lookup_args+=(-k); fi
+ if [[ -n "$CURL_CA_BUNDLE" ]]; then lookup_args+=(--cacert "$CURL_CA_BUNDLE"); fi
+
+ lookup_resp=$(curl "${lookup_args[@]}" "${PULSE_URL}/api/agents/agent/lookup?hostname=$(url_encode "$lookup_hostname")" 2>/dev/null || true)
+ if echo "$lookup_resp" | grep -q '"agent"[[:space:]]*:' && echo "$lookup_resp" | grep -q '"id"[[:space:]]*:'; then
+ return 0
+ fi
+ return 1
+}
+
verify_agent_started() {
local health_url="http://127.0.0.1:9191/readyz"
local max_iterations=8
@@ -334,7 +361,11 @@ verify_agent_started() {
while [ $iteration -lt $max_iterations ]; do
# Check the readiness endpoint first — this is the definitive signal
if curl -sf --max-time 2 "$health_url" >/dev/null 2>&1; then
- log_info "Agent is running and healthy."
+ if verify_agent_server_registration; then
+ log_info "Agent is running, healthy, and registered with Pulse."
+ else
+ log_warn "Agent local health is ready, but server registration was not confirmed yet."
+ fi
return 0
fi
@@ -1001,7 +1032,13 @@ build_exec_arg_items() {
local include_token="${1:-true}"
EXEC_ARG_ITEMS=(--url "$PULSE_URL" --interval "$INTERVAL")
- if [[ "$include_token" == "true" && -n "$PULSE_TOKEN" ]]; then EXEC_ARG_ITEMS+=(--token "$PULSE_TOKEN"); fi
+ if [[ "$include_token" == "true" && -n "$PULSE_TOKEN" ]]; then
+ if [[ -n "$RUNTIME_TOKEN_FILE" ]]; then
+ EXEC_ARG_ITEMS+=(--token-file "$RUNTIME_TOKEN_FILE")
+ else
+ fail "Internal installer error: runtime token file was not prepared before service rendering." "$EXIT_GENERAL"
+ fi
+ fi
# Always pass enable-host flag since agent defaults to true
if [[ "$ENABLE_HOST" == "true" ]]; then
EXEC_ARG_ITEMS+=(--enable-host)
@@ -1064,6 +1101,44 @@ build_exec_args_array() {
EXEC_ARGS_ARRAY=("${EXEC_ARG_ITEMS[@]}")
}
+ensure_runtime_token_file() {
+ local state_dir="${1:-$STATE_DIR}"
+ local token_file="${state_dir}/token"
+
+ RUNTIME_TOKEN_FILE=""
+ if [[ -z "$PULSE_TOKEN" ]]; then
+ rm -f "$token_file" 2>/dev/null || true
+ log_info "No API token provided; installer will configure token-optional agent runtime."
+ return 0
+ fi
+
+ mkdir -p "$state_dir"
+ local old_umask=""
+ old_umask=$(umask)
+ umask 077
+ if ! printf '%s' "$PULSE_TOKEN" > "$token_file"; then
+ umask "$old_umask"
+ fail "Failed to write runtime token file: $token_file" "$EXIT_GENERAL"
+ fi
+ umask "$old_umask"
+ chmod 600 "$token_file"
+ if [[ "$(id -u 2>/dev/null || echo 1)" == "0" ]]; then
+ chown root:root "$token_file" 2>/dev/null || true
+ fi
+ RUNTIME_TOKEN_FILE="$token_file"
+ log_info "Token stored securely at $token_file (mode 600)"
+}
+
+clear_proxmox_state_if_needed() {
+ if [[ "$ENABLE_PROXMOX" != "true" ]]; then
+ return 0
+ fi
+ log_info "Clearing Proxmox state for fresh registration..."
+ rm -f "${STATE_DIR}/proxmox-registered" 2>/dev/null || true
+ rm -f "${STATE_DIR}/proxmox-pve-registered" 2>/dev/null || true
+ rm -f "${STATE_DIR}/proxmox-pbs-registered" 2>/dev/null || true
+}
+
write_connection_state_value() {
local file="$1"
local key="$2"
@@ -1104,6 +1179,13 @@ recover_connection_state() {
if [[ -z "$PULSE_TOKEN" ]]; then
PULSE_TOKEN=$(read_connection_state_value "$file" "PULSE_TOKEN")
fi
+ if [[ -z "$PULSE_TOKEN" ]]; then
+ local saved_token_file=""
+ saved_token_file=$(read_connection_state_value "$file" "PULSE_TOKEN_FILE")
+ if [[ -n "$saved_token_file" && -f "$saved_token_file" ]]; then
+ PULSE_TOKEN=$(cat "$saved_token_file")
+ fi
+ fi
if [[ -z "$AGENT_ID" ]]; then
AGENT_ID=$(read_connection_state_value "$file" "PULSE_AGENT_ID")
fi
@@ -1149,11 +1231,11 @@ save_connection_info() {
mkdir -p "$state_dir"
# Save connection details so uninstall can deregister without --url/--token.
# Single-quote values to prevent shell interpretation on read-back.
- # PULSE_URL is validated as ^https?:// and PULSE_TOKEN, when present, as
- # ^[a-fA-F0-9]+$, so neither can contain single quotes.
+ # Legacy connection files may contain PULSE_TOKEN, but new installs persist
+ # only the protected token file path.
: > "$conn_env"
write_connection_state_value "$conn_env" "PULSE_URL" "$PULSE_URL"
- write_connection_state_value "$conn_env" "PULSE_TOKEN" "$PULSE_TOKEN"
+ write_connection_state_value "$conn_env" "PULSE_TOKEN_FILE" "$RUNTIME_TOKEN_FILE"
write_connection_state_value "$conn_env" "PULSE_AGENT_ID" "$AGENT_ID"
write_connection_state_value "$conn_env" "PULSE_HOSTNAME" "$HOSTNAME_OVERRIDE"
if [[ "$INSECURE" == "true" ]]; then
@@ -1678,13 +1760,15 @@ log_info "Downloading agent from ${DOWNLOAD_URL}..."
# Create temp file and register for cleanup
TMP_BIN=$(mktemp)
TMP_FILES+=("$TMP_BIN")
+TMP_HEADERS=$(mktemp)
+TMP_FILES+=("$TMP_HEADERS")
# Build curl arguments as array for proper quoting
-CURL_ARGS=(-fsSL --connect-timeout 30 --max-time 300)
+CURL_ARGS=(-fsSL --connect-timeout 30 --max-time 300 -D "$TMP_HEADERS" -o "$TMP_BIN")
if [[ "$INSECURE" == "true" ]]; then CURL_ARGS+=(-k); fi
if [[ -n "$CURL_CA_BUNDLE" ]]; then CURL_ARGS+=(--cacert "$CURL_CA_BUNDLE"); fi
-if ! curl "${CURL_ARGS[@]}" -o "$TMP_BIN" "$DOWNLOAD_URL"; then
+if ! curl "${CURL_ARGS[@]}" "$DOWNLOAD_URL"; then
fail "Download failed. Check URL and connectivity." "$EXIT_DOWNLOAD_FAILED"
fi
@@ -1695,7 +1779,8 @@ fi
# Check if it's a valid executable (ELF for Linux, Mach-O for macOS)
if [[ "$OS" == "linux" ]]; then
- if ! head -c 4 "$TMP_BIN" | grep -q "ELF"; then
+ MAGIC=$(od -An -tx1 -N4 "$TMP_BIN" 2>/dev/null | tr -d ' \n' || true)
+ if [[ "$MAGIC" != "7f454c46" ]]; then
fail "Downloaded file is not a valid Linux executable." "$EXIT_DOWNLOAD_FAILED"
fi
elif [[ "$OS" == "darwin" ]]; then
@@ -1707,42 +1792,28 @@ elif [[ "$OS" == "darwin" ]]; then
fi
# Release metadata verification
-CHECKSUM_URL="${PULSE_URL}/download/${BINARY_NAME}?arch=${ARCH_PARAM}"
-TMP_HEADERS=$(mktemp)
-TMP_FILES+=("$TMP_HEADERS")
-HEADER_CURL_ARGS=(-fsSL --connect-timeout 10 --max-time 30 -D "$TMP_HEADERS" -o /dev/null)
-if [[ "$INSECURE" == "true" ]]; then HEADER_CURL_ARGS+=(-k); fi
-if [[ -n "$CURL_CA_BUNDLE" ]]; then HEADER_CURL_ARGS+=(--cacert "$CURL_CA_BUNDLE"); fi
-
EXPECTED_SHA=""
SSH_SIGNATURE_HEADER=""
-METADATA_FETCHED=false
-if curl "${HEADER_CURL_ARGS[@]}" "$CHECKSUM_URL" 2>/dev/null; then
- METADATA_FETCHED=true
- EXPECTED_SHA=$(grep -i '^X-Checksum-Sha256:' "$TMP_HEADERS" 2>/dev/null | tr -d '\r' | awk '{print $2}' || true)
- SSH_SIGNATURE_HEADER=$(grep -i '^X-Signature-SSHSIG:' "$TMP_HEADERS" 2>/dev/null | tr -d '\r' | sed 's/^[^:]*:[[:space:]]*//' || true)
+EXPECTED_SHA=$(grep -i '^X-Checksum-Sha256:' "$TMP_HEADERS" 2>/dev/null | tr -d '\r' | awk '{print $2}' || true)
+SSH_SIGNATURE_HEADER=$(grep -i '^X-Signature-SSHSIG:' "$TMP_HEADERS" 2>/dev/null | tr -d '\r' | sed 's/^[^:]*:[[:space:]]*//' || true)
+
+if [[ -z "$EXPECTED_SHA" ]]; then
+ fail "Server did not provide checksum header; refusing install." "$EXIT_CHECKSUM_FAILED"
fi
-if has_pinned_installer_signature_key; then
- if [[ "$METADATA_FETCHED" != "true" ]]; then
- fail "Failed to fetch signed download metadata from the Pulse server." "$EXIT_SIGNATURE_FAILED"
- fi
- if [[ -z "$EXPECTED_SHA" ]]; then
- fail "Server did not provide checksum header; refusing signed install." "$EXIT_CHECKSUM_FAILED"
- fi
- if [[ -z "$SSH_SIGNATURE_HEADER" ]]; then
- fail "Server did not provide SSH signature header; refusing signed install." "$EXIT_SIGNATURE_FAILED"
- fi
+if has_pinned_installer_signature_key && [[ -z "$SSH_SIGNATURE_HEADER" ]]; then
+ fail "Server did not provide SSH signature header; refusing signed install." "$EXIT_SIGNATURE_FAILED"
fi
-if [[ -n "$EXPECTED_SHA" ]]; then
- ACTUAL_SHA=$(sha256sum "$TMP_BIN" 2>/dev/null | awk '{print $1}' || shasum -a 256 "$TMP_BIN" 2>/dev/null | awk '{print $1}')
- if [[ -n "$ACTUAL_SHA" && "$ACTUAL_SHA" != "$EXPECTED_SHA" ]]; then
- fail "Checksum verification failed (expected: ${EXPECTED_SHA:0:16}..., got: ${ACTUAL_SHA:0:16}...)" "$EXIT_CHECKSUM_FAILED"
- fi
- json_event "download" "checksum_ok" "Binary checksum verified"
- log_info "Binary checksum verified"
+ACTUAL_SHA=$(sha256sum "$TMP_BIN" 2>/dev/null | awk '{print $1}' || shasum -a 256 "$TMP_BIN" 2>/dev/null | awk '{print $1}')
+if [[ -z "$ACTUAL_SHA" ]]; then
+ fail "Could not compute binary checksum." "$EXIT_CHECKSUM_FAILED"
fi
+if [[ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]]; then
+ fail "Checksum verification failed (expected: ${EXPECTED_SHA:0:16}..., got: ${ACTUAL_SHA:0:16}...)" "$EXIT_CHECKSUM_FAILED"
+fi
+json_event "download" "checksum_ok" "Binary checksum verified"
+log_info "Binary checksum verified"
verify_download_signature "$TMP_BIN" "$SSH_SIGNATURE_HEADER"
@@ -1787,19 +1858,12 @@ fi
# --- Service Installation ---
-# If Proxmox mode is enabled, clear the state files to ensure fresh registration
-# This allows re-installation to re-create the Proxmox API tokens
-if [[ "$ENABLE_PROXMOX" == "true" ]]; then
- log_info "Clearing Proxmox state for fresh registration..."
- rm -f /var/lib/pulse-agent/proxmox-registered 2>/dev/null || true
- rm -f /var/lib/pulse-agent/proxmox-pve-registered 2>/dev/null || true
- rm -f /var/lib/pulse-agent/proxmox-pbs-registered 2>/dev/null || true
-fi
-
# 1. macOS (Launchd)
if [[ "$OS" == "darwin" ]]; then
PLIST="/Library/LaunchDaemons/com.pulse.agent.plist"
log_info "Configuring Launchd service at $PLIST..."
+ ensure_runtime_token_file "$STATE_DIR"
+ clear_proxmox_state_if_needed
# Build program arguments array
PLIST_ARGS=" ${INSTALL_DIR}/${BINARY_NAME}
@@ -1807,10 +1871,10 @@ if [[ "$OS" == "darwin" ]]; then
${PULSE_URL}
--interval
${INTERVAL}"
- if [[ -n "$PULSE_TOKEN" ]]; then
+ if [[ -n "$RUNTIME_TOKEN_FILE" ]]; then
PLIST_ARGS="${PLIST_ARGS}
- --token
- ${PULSE_TOKEN}"
+ --token-file
+ ${RUNTIME_TOKEN_FILE}"
fi
# Always pass enable-host flag since agent defaults to true
@@ -1906,7 +1970,7 @@ EOF
chmod 644 "$PLIST"
launchctl unload "$PLIST" 2>/dev/null || true
launchctl load -w "$PLIST"
- complete_installation_flow "/var/lib/pulse-agent" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f $LOG_FILE"
+ complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f $LOG_FILE"
exit 0
fi
@@ -1918,6 +1982,8 @@ if [[ -d /usr/syno ]] && [[ -f /etc/VERSION ]]; then
log_info "Detected Synology DSM ${DSM_MAJOR}..."
# Build command line args
+ ensure_runtime_token_file "$STATE_DIR"
+ clear_proxmox_state_if_needed
build_exec_args
if [[ "$DSM_MAJOR" -ge 7 ]]; then
@@ -1953,7 +2019,7 @@ EOF
initctl start "${AGENT_NAME}"
fi
- complete_installation_flow "/var/lib/pulse-agent" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f $LOG_FILE"
+ complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f $LOG_FILE"
exit 0
fi
@@ -1980,6 +2046,8 @@ if [[ -f /etc/unraid-version ]]; then
log_info "Installed binary to ${UNRAID_STORED_BINARY} (persistent) and ${RUNTIME_BINARY} (runtime)..."
# Build command line args (string for wrapper script, array for direct execution)
+ ensure_runtime_token_file "$STATE_DIR"
+ clear_proxmox_state_if_needed
build_exec_args
build_exec_args_array
@@ -2093,6 +2161,8 @@ if [[ -f /sbin/getcfg ]] || [[ -f /etc/config/qpkg.conf ]]; then
log_info "Installed binary to ${QNAP_STORED_BINARY} (persistent) and ${RUNTIME_BINARY} (runtime)..."
+ ensure_runtime_token_file "$STATE_DIR"
+ clear_proxmox_state_if_needed
build_exec_args
log_info "Stopping any existing pulse agents..."
@@ -2225,6 +2295,8 @@ if [[ "$TRUENAS" == true ]]; then
fi
# Build command line args
+ ensure_runtime_token_file "$STATE_DIR"
+ clear_proxmox_state_if_needed
build_exec_args
# Store service file in /data (persists across upgrades)
@@ -2250,7 +2322,7 @@ if [[ "$TRUENAS" == true ]]; then
cat > "$TRUENAS_ENV_FILE" </dev/null 2>&1 && [[ -d /etc/init.d ]] && ! command -v
log_info "Configuring OpenRC service at $INITSCRIPT..."
# Build command line args
+ ensure_runtime_token_file "$STATE_DIR"
+ clear_proxmox_state_if_needed
build_exec_args
# Create OpenRC init script following Alpine best practices
@@ -2367,7 +2441,7 @@ INITEOF
chmod +x "$INITSCRIPT"
restart_openrc_agent_service
- complete_installation_flow "/var/lib/pulse-agent" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f $LOG_FILE"
+ complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f $LOG_FILE"
exit 0
fi
@@ -2377,6 +2451,8 @@ if [[ "$OS" == "freebsd" ]] || [[ -f /etc/rc.subr ]]; then
log_info "Configuring FreeBSD rc.d service at $RCSCRIPT..."
# Build command line args
+ ensure_runtime_token_file "$STATE_DIR"
+ clear_proxmox_state_if_needed
build_exec_args
render_freebsd_rc_agent_script "$RCSCRIPT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}"
@@ -2401,7 +2477,7 @@ BOOTEOF
# Stop existing agent if running
restart_sysv_agent_service "$RCSCRIPT"
- complete_installation_flow "/var/lib/pulse-agent" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f /var/log/messages"
+ complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f /var/log/messages"
log_info "To check status: $RCSCRIPT status"
log_info "To view logs: tail -f /var/log/messages"
exit 0
@@ -2410,24 +2486,15 @@ fi
# 5. Linux (Systemd)
if command -v systemctl >/dev/null 2>&1; then
UNIT="/etc/systemd/system/${AGENT_NAME}.service"
- TOKEN_DIR="/var/lib/pulse-agent"
+ TOKEN_DIR="$STATE_DIR"
TOKEN_FILE="${TOKEN_DIR}/token"
log_info "Configuring Systemd service at $UNIT..."
- if [[ -n "$PULSE_TOKEN" ]]; then
- # Write token to secure file (not visible in ps or service file)
- mkdir -p "$TOKEN_DIR"
- echo -n "$PULSE_TOKEN" > "$TOKEN_FILE"
- chmod 600 "$TOKEN_FILE"
- chown root:root "$TOKEN_FILE"
- log_info "Token stored securely at $TOKEN_FILE (mode 600)"
- else
- rm -f "$TOKEN_FILE"
- log_info "No API token provided; installer will configure token-optional agent runtime."
- fi
+ ensure_runtime_token_file "$STATE_DIR"
+ clear_proxmox_state_if_needed
- # Build command line args WITHOUT the token (token is read from file)
- build_exec_args_without_token
+ # Build command line args with --token-file instead of the raw token.
+ build_exec_args
render_systemd_agent_unit "$UNIT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}" "network-online.target docker.service" "network-online.target" "root" ""
# Restrict service file permissions (contains no secrets now, but good practice)
@@ -2437,7 +2504,7 @@ if command -v systemctl >/dev/null 2>&1; then
restore_selinux_contexts
restart_systemd_agent_service
- complete_installation_flow "/var/lib/pulse-agent" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "journalctl -u ${AGENT_NAME} --no-pager -n 20"
+ complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "journalctl -u ${AGENT_NAME} --no-pager -n 20"
if [[ "$UPGRADE_MODE" != "true" && -n "$PULSE_TOKEN" ]]; then
log_info "Token file: $TOKEN_FILE (mode 600, root only)"
fi
@@ -2451,6 +2518,8 @@ if [[ -d /etc/init.d ]] && [[ -w /etc/init.d ]]; then
log_info "Configuring SysV init script at $INITSCRIPT..."
# Build command line args
+ ensure_runtime_token_file "$STATE_DIR"
+ clear_proxmox_state_if_needed
build_exec_args
# Create SysV init script following LSB conventions
@@ -2589,7 +2658,7 @@ INITEOF
# Start the agent
"$INITSCRIPT" start
- complete_installation_flow "/var/lib/pulse-agent" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f /var/log/${AGENT_NAME}.log"
+ complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f /var/log/${AGENT_NAME}.log"
log_info "To check status: $INITSCRIPT status"
log_info "To view logs: tail -f /var/log/${AGENT_NAME}.log"
exit 0
diff --git a/scripts/installtests/install_ps1_test.go b/scripts/installtests/install_ps1_test.go
index 9ee00f4285..523a16e1bf 100644
--- a/scripts/installtests/install_ps1_test.go
+++ b/scripts/installtests/install_ps1_test.go
@@ -34,7 +34,8 @@ func TestInstallPS1AllowsMissingTokenForOptionalAuth(t *testing.T) {
script := string(content)
required := []string{
`if (-not [string]::IsNullOrWhiteSpace($Token) -and -not (Test-ValidToken $Token)) {`,
- `if (-not [string]::IsNullOrWhiteSpace($Token)) { $ServiceArgs += @("--token", "` + "`" + `"$Token` + "`" + `"") }`,
+ `function Write-RuntimeTokenFile {`,
+ `if (-not [string]::IsNullOrWhiteSpace($Token)) { $ServiceArgs += @("--token-file", "` + "`" + `"$TokenFilePath` + "`" + `"") }`,
}
for _, needle := range required {
if !strings.Contains(script, needle) {
@@ -178,15 +179,19 @@ func TestInstallPS1PersistsAndRecoversConnectionIdentity(t *testing.T) {
script := string(content)
required := []string{
`$ConnectionStatePath = "$StateDir\connection.env"`,
+ `$TokenFilePath = "$StateDir\token"`,
`Set-Content -Path $ConnectionStatePath -Value ($lines -join "` + "`n" + `") -Encoding UTF8`,
+ `$lines += "PULSE_TOKEN_FILE='$TokenFilePath'"`,
`$Url = Get-ConnectionStateValue "PULSE_URL"`,
`$Token = Get-ConnectionStateValue "PULSE_TOKEN"`,
+ `$savedTokenFile = Get-ConnectionStateValue "PULSE_TOKEN_FILE"`,
`$AgentId = Get-ConnectionStateValue "PULSE_AGENT_ID"`,
`$Hostname = Get-ConnectionStateValue "PULSE_HOSTNAME"`,
`$Insecure = Parse-Bool (Get-ConnectionStateValue "PULSE_INSECURE_SKIP_VERIFY") $Insecure`,
`$CACertPath = Get-ConnectionStateValue "PULSE_CACERT"`,
`$lines += "PULSE_INSECURE_SKIP_VERIFY='true'"`,
`$lines += "PULSE_CACERT='$CACertPath'"`,
+ `Write-RuntimeTokenFile`,
`Save-ConnectionState`,
}
for _, needle := range required {
diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go
index daa4170670..e3cfd177b1 100644
--- a/scripts/installtests/install_sh_test.go
+++ b/scripts/installtests/install_sh_test.go
@@ -23,7 +23,9 @@ func TestInstallSHAllowsMissingTokenForOptionalAuth(t *testing.T) {
script := string(content)
required := []string{
`build_exec_arg_items() {`,
- `if [[ "$include_token" == "true" && -n "$PULSE_TOKEN" ]]; then EXEC_ARG_ITEMS+=(--token "$PULSE_TOKEN"); fi`,
+ `RUNTIME_TOKEN_FILE=""`,
+ `EXEC_ARG_ITEMS+=(--token-file "$RUNTIME_TOKEN_FILE")`,
+ `fail "Internal installer error: runtime token file was not prepared before service rendering." "$EXIT_GENERAL"`,
`build_exec_args_without_token() {`,
`build_exec_arg_items "false"`,
`build_exec_arg_items "true"`,
@@ -337,6 +339,7 @@ func TestInstallSHPersistsIdentityInConnectionEnv(t *testing.T) {
`read_connection_state_value() {`,
`recover_connection_state() {`,
`find_connection_state_file() {`,
+ `write_connection_state_value "$conn_env" "PULSE_TOKEN_FILE" "$RUNTIME_TOKEN_FILE"`,
`write_connection_state_value "$conn_env" "PULSE_AGENT_ID" "$AGENT_ID"`,
`write_connection_state_value "$conn_env" "PULSE_HOSTNAME" "$HOSTNAME_OVERRIDE"`,
`write_connection_state_value "$conn_env" "PULSE_INSECURE_SKIP_VERIFY" "true"`,
@@ -530,13 +533,13 @@ func TestInstallSHUsesCanonicalCompletionHelper(t *testing.T) {
`json_event "complete" "installed" "Installation installed"`,
`json_event "complete" "updated_unhealthy" "Agent updated but not responding"`,
`json_event "complete" "installed_unhealthy" "Agent installed but not responding"`,
- `complete_installation_flow "/var/lib/pulse-agent" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f $LOG_FILE"`,
+ `complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f $LOG_FILE"`,
`complete_installation_flow "$UNRAID_STORAGE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent is running." "tail -f /var/log/${AGENT_NAME}.log"`,
`complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent is running." "tail -f /var/log/${AGENT_NAME}.log"`,
`complete_installation_flow "$TRUENAS_STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent is running." ""`,
- `complete_installation_flow "/var/lib/pulse-agent" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f /var/log/messages"`,
- `complete_installation_flow "/var/lib/pulse-agent" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "journalctl -u ${AGENT_NAME} --no-pager -n 20"`,
- `complete_installation_flow "/var/lib/pulse-agent" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f /var/log/${AGENT_NAME}.log"`,
+ `complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f /var/log/messages"`,
+ `complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "journalctl -u ${AGENT_NAME} --no-pager -n 20"`,
+ `complete_installation_flow "$STATE_DIR" "Installation complete! Agent is running." "Upgrade complete! Agent restarted with new configuration." "tail -f /var/log/${AGENT_NAME}.log"`,
}
for _, needle := range required {
if !strings.Contains(script, needle) {
@@ -978,10 +981,11 @@ func TestBuildExecArgsArrayPersistsInsecureForPlainHTTPInstall(t *testing.T) {
ENABLE_KUBERNETES=""
KUBECONFIG_PATH=""
ENABLE_PROXMOX=""
- PROXMOX_TYPE=""
- INSECURE="false"
- ENABLE_COMMANDS=""
- ENROLL=""
+ PROXMOX_TYPE=""
+ INSECURE="false"
+ RUNTIME_TOKEN_FILE="/var/lib/pulse-agent/token"
+ ENABLE_COMMANDS=""
+ ENROLL=""
KUBE_INCLUDE_ALL_PODS=""
KUBE_INCLUDE_ALL_DEPLOYMENTS=""
AGENT_ID=""
@@ -1001,6 +1005,12 @@ func TestBuildExecArgsArrayPersistsInsecureForPlainHTTPInstall(t *testing.T) {
if !strings.Contains(got, "--insecure") {
t.Fatalf("EXEC_ARGS_ARRAY missing --insecure: %s", got)
}
+ if !strings.Contains(got, "--token-file /var/lib/pulse-agent/token") {
+ t.Fatalf("EXEC_ARGS_ARRAY missing runtime token file: %s", got)
+ }
+ if strings.Contains(got, "--token deadbeef") {
+ t.Fatalf("EXEC_ARGS_ARRAY preserved raw token: %s", got)
+ }
}
func TestBuildExecArgsWithoutTokenOmitsPersistedToken(t *testing.T) {
@@ -1333,8 +1343,7 @@ func TestInstallSHRequiresPinnedSignatureVerificationForReleaseDownloads(t *test
`PINNED_INSTALLER_SSH_PUBLIC_KEY="__PULSE_INSTALLER_SSH_PUBLIC_KEY__"`,
`has_pinned_installer_signature_key() {`,
`grep -i '^X-Signature-SSHSIG:' "$TMP_HEADERS"`,
- `if [[ "$METADATA_FETCHED" != "true" ]]; then`,
- `Server did not provide checksum header; refusing signed install.`,
+ `Server did not provide checksum header; refusing install.`,
`Server did not provide SSH signature header; refusing signed install.`,
`ssh-keygen -Y verify`,
`Binary signature verified`,
diff --git a/scripts/render_installers.go b/scripts/render_installers.go
index 2663684388..0d13456287 100644
--- a/scripts/render_installers.go
+++ b/scripts/render_installers.go
@@ -14,11 +14,15 @@ func main() {
sourceDir := flag.String("source-dir", "", "directory containing install.sh and install.ps1")
outputDir := flag.String("output-dir", "", "directory to write rendered installers into")
installerSSHPublicKey := flag.String("installer-ssh-public-key", "", "OpenSSH public key to pin into rendered installers")
+ allowEmptyInstallerSSHPublicKey := flag.Bool("allow-empty-installer-ssh-public-key", false, "allow unsigned/dev installer rendering without a pinned installer SSH public key")
flag.Parse()
if strings.TrimSpace(*sourceDir) == "" || strings.TrimSpace(*outputDir) == "" {
fail("source-dir and output-dir are required")
}
+ if strings.TrimSpace(*installerSSHPublicKey) == "" && !*allowEmptyInstallerSSHPublicKey {
+ fail("installer-ssh-public-key is required for rendered installers")
+ }
if err := os.MkdirAll(*outputDir, 0o755); err != nil {
fail(fmt.Sprintf("create output dir: %v", err))