Harden unified agent runtime and installer

This commit is contained in:
rcourtman 2026-04-23 23:04:18 +01:00
parent 0622169be8
commit 9bada35337
27 changed files with 996 additions and 254 deletions

View file

@ -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
@ -75,6 +79,7 @@ var (
// For testing
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:

View file

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

View file

@ -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
}

View file

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

View file

@ -73,6 +73,7 @@ type Server struct {
type agentConn struct {
conn *websocket.Conn
agent ConnectedAgent
approvalGrantKey []byte
writeMu sync.Mutex
done chan struct{}
doneOnce sync.Once
@ -450,6 +451,7 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
Tags: reg.Tags,
ConnectedAt: time.Now(),
},
approvalGrantKey: DeriveApprovalGrantKey(reg.Token),
done: make(chan struct{}),
}
@ -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).

View file

@ -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,

View file

@ -93,6 +93,7 @@ type ExecuteCommandPayload struct {
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

View file

@ -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":

View file

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

View file

@ -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 {

View file

@ -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/"))
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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"

View file

@ -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
@ -309,6 +311,7 @@ func New(cfg Config) (*Agent, error) {
stateDir: cfg.StateDir,
interval: cfg.Interval,
trimmedPulseURL: pulseURL,
remoteConfigChanged: make(chan struct{}, 1),
reportBuffer: utils.New[agentshost.Report](bufferCapacity),
collector: collector,
newCommandClient: newCommandClientFn,
@ -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,
)

View file

@ -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")
}

View file

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

View file

@ -186,6 +186,7 @@ type executeCommandPayload struct {
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"`
@ -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")

View file

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

View file

@ -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{
payload := testApprovedCommandPayload(t, c, executeCommandPayload{
RequestID: "r1",
Command: "echo hello",
ApprovalID: "approval-1",
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{
payload := testApprovedCommandPayload(t, c, executeCommandPayload{
RequestID: "r1",
Command: "echo err 1>&2; exit 3",
ApprovalID: "approval-1",
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{
payload := testApprovedCommandPayload(t, c, executeCommandPayload{
RequestID: "r1",
Command: "sleep 2",
ApprovalID: "approval-1",
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{
payload := testApprovedCommandPayload(t, c, executeCommandPayload{
RequestID: "r1",
Command: "head -c 1048580 /dev/zero | tr '\\0' 'a'",
ApprovalID: "approval-1",
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{
payload := testApprovedCommandPayload(t, c, executeCommandPayload{
RequestID: "r1",
Command: "head -c 1048580 /dev/zero | tr '\\0' 'b' 1>&2",
ApprovalID: "approval-1",
Timeout: 5,
})
result := c.executeCommand(context.Background(), payload)
if !result.Success {
t.Fatalf("expected success, got %#v", result)
}

View file

@ -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,8 +640,12 @@ if (-not (Test-PEBinary $TempPath)) {
Exit 1
}
# Verify checksum if server provided one
if (-not [string]::IsNullOrWhiteSpace($serverChecksum)) {
# 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
@ -619,9 +653,6 @@ if (-not [string]::IsNullOrWhiteSpace($serverChecksum)) {
Exit 1
}
Write-Host "Checksum verified: $localChecksum" -ForegroundColor Green
} else {
Write-Host "Warning: Server did not provide checksum header" -ForegroundColor Yellow
}
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" }

View file

@ -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)
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
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
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
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"
fi
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=" <string>${INSTALL_DIR}/${BINARY_NAME}</string>
@ -1807,10 +1871,10 @@ if [[ "$OS" == "darwin" ]]; then
<string>${PULSE_URL}</string>
<string>--interval</string>
<string>${INTERVAL}</string>"
if [[ -n "$PULSE_TOKEN" ]]; then
if [[ -n "$RUNTIME_TOKEN_FILE" ]]; then
PLIST_ARGS="${PLIST_ARGS}
<string>--token</string>
<string>${PULSE_TOKEN}</string>"
<string>--token-file</string>
<string>${RUNTIME_TOKEN_FILE}</string>"
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" <<EOF
# Pulse Agent configuration (for reference)
PULSE_URL=${PULSE_URL}
PULSE_TOKEN=${PULSE_TOKEN}
PULSE_TOKEN_FILE=${RUNTIME_TOKEN_FILE}
PULSE_INTERVAL=${INTERVAL}
PULSE_ENABLE_HOST=${ENABLE_HOST}
PULSE_ENABLE_DOCKER=${ENABLE_DOCKER}
@ -2323,6 +2395,8 @@ if command -v rc-service >/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

View file

@ -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 {

View file

@ -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) {
@ -980,6 +983,7 @@ func TestBuildExecArgsArrayPersistsInsecureForPlainHTTPInstall(t *testing.T) {
ENABLE_PROXMOX=""
PROXMOX_TYPE=""
INSECURE="false"
RUNTIME_TOKEN_FILE="/var/lib/pulse-agent/token"
ENABLE_COMMANDS=""
ENROLL=""
KUBE_INCLUDE_ALL_PODS=""
@ -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`,

View file

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