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))