diff --git a/Makefile b/Makefile index b3f1e997f..446fa26a7 100644 --- a/Makefile +++ b/Makefile @@ -88,6 +88,12 @@ build-agents: CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-linux-armv7 ./cmd/pulse-host-agent && \ CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-darwin-amd64 ./cmd/pulse-host-agent && \ CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-darwin-arm64 ./cmd/pulse-host-agent && \ - CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-windows-amd64.exe ./cmd/pulse-host-agent + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-windows-amd64.exe ./cmd/pulse-host-agent && \ + echo "Building unified agent binaries..." && \ + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-linux-amd64 ./cmd/pulse-agent && \ + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-linux-arm64 ./cmd/pulse-agent && \ + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-macos-amd64 ./cmd/pulse-agent && \ + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-macos-arm64 ./cmd/pulse-agent && \ + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-windows-amd64.exe ./cmd/pulse-agent @ln -sf pulse-host-agent-windows-amd64.exe bin/pulse-host-agent-windows-amd64 @echo "✓ All agent binaries built in bin/" diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go new file mode 100644 index 000000000..5a4403f0e --- /dev/null +++ b/cmd/pulse-agent/main.go @@ -0,0 +1,257 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/dockeragent" + "github.com/rcourtman/pulse-go-rewrite/internal/hostagent" + "github.com/rcourtman/pulse-go-rewrite/internal/utils" + "github.com/rs/zerolog" + "golang.org/x/sync/errgroup" +) + +var ( + Version = "dev" +) + +type multiValue []string + +func (m *multiValue) String() string { + return strings.Join(*m, ",") +} + +func (m *multiValue) Set(value string) error { + *m = append(*m, value) + return nil +} + +func main() { + // 1. Parse Configuration + cfg := loadConfig() + + // 2. Setup Logging + zerolog.SetGlobalLevel(cfg.LogLevel) + logger := zerolog.New(os.Stdout).Level(cfg.LogLevel).With().Timestamp().Logger() + cfg.Logger = &logger + + // 3. Setup Context & Signal Handling + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + g, ctx := errgroup.WithContext(ctx) + + logger.Info(). + Str("version", Version). + Str("pulse_url", cfg.PulseURL). + Bool("host_agent", cfg.EnableHost). + Bool("docker_agent", cfg.EnableDocker). + Msg("Starting Pulse Unified Agent") + + // 4. Start Host Agent (if enabled) + if cfg.EnableHost { + hostCfg := hostagent.Config{ + PulseURL: cfg.PulseURL, + APIToken: cfg.APIToken, + Interval: cfg.Interval, + HostnameOverride: cfg.HostnameOverride, + AgentID: cfg.AgentID, // Shared ID? Or separate? Usually separate for now. + Tags: cfg.Tags, + InsecureSkipVerify: cfg.InsecureSkipVerify, + LogLevel: cfg.LogLevel, + Logger: &logger, + } + + // If AgentID is set globally, we might want to suffix it or let the agents derive their own. + // For now, let's pass it through. If it's empty, agents derive their own. + + agent, err := hostagent.New(hostCfg) + if err != nil { + logger.Fatal().Err(err).Msg("Failed to initialize host agent") + } + + g.Go(func() error { + logger.Info().Msg("Host agent module started") + return agent.Run(ctx) + }) + } + + // 5. Start Docker Agent (if enabled) + if cfg.EnableDocker { + dockerCfg := dockeragent.Config{ + PulseURL: cfg.PulseURL, + APIToken: cfg.APIToken, + Interval: cfg.Interval, + HostnameOverride: cfg.HostnameOverride, + AgentID: cfg.AgentID, + InsecureSkipVerify: cfg.InsecureSkipVerify, + DisableAutoUpdate: true, // Unified agent handles updates (future) + LogLevel: cfg.LogLevel, + Logger: &logger, + // Docker specific defaults + SwarmScope: "node", + IncludeContainers: true, + IncludeServices: true, + IncludeTasks: true, + CollectDiskMetrics: true, + } + + agent, err := dockeragent.New(dockerCfg) + if err != nil { + logger.Fatal().Err(err).Msg("Failed to initialize docker agent") + } + // Docker agent has a Close method we should call, but errgroup doesn't make defer easy here. + // Ideally we wrap this. For now, we rely on OS cleanup or context cancellation. + + g.Go(func() error { + logger.Info().Msg("Docker agent module started") + return agent.Run(ctx) + }) + } + + // 6. Wait for all agents to exit + if err := g.Wait(); err != nil && err != context.Canceled { + logger.Error().Err(err).Msg("Agent terminated with error") + os.Exit(1) + } + + logger.Info().Msg("Pulse Unified Agent stopped") +} + +type Config struct { + PulseURL string + APIToken string + Interval time.Duration + HostnameOverride string + AgentID string + Tags []string + InsecureSkipVerify bool + LogLevel zerolog.Level + Logger *zerolog.Logger + + // Module flags + EnableHost bool + EnableDocker bool +} + +func loadConfig() Config { + // Environment Variables + envURL := utils.GetenvTrim("PULSE_URL") + envToken := utils.GetenvTrim("PULSE_TOKEN") + envInterval := utils.GetenvTrim("PULSE_INTERVAL") + envHostname := utils.GetenvTrim("PULSE_HOSTNAME") + envAgentID := utils.GetenvTrim("PULSE_AGENT_ID") + envInsecure := utils.GetenvTrim("PULSE_INSECURE_SKIP_VERIFY") + envTags := utils.GetenvTrim("PULSE_TAGS") + envLogLevel := utils.GetenvTrim("LOG_LEVEL") + envEnableHost := utils.GetenvTrim("PULSE_ENABLE_HOST") + envEnableDocker := utils.GetenvTrim("PULSE_ENABLE_DOCKER") + + // Defaults + defaultInterval := 30 * time.Second + if envInterval != "" { + if parsed, err := time.ParseDuration(envInterval); err == nil { + defaultInterval = parsed + } + } + + defaultEnableHost := true + if envEnableHost != "" { + defaultEnableHost = utils.ParseBool(envEnableHost) + } + + defaultEnableDocker := false + if envEnableDocker != "" { + defaultEnableDocker = utils.ParseBool(envEnableDocker) + } + + // Flags + urlFlag := flag.String("url", envURL, "Pulse server URL") + tokenFlag := flag.String("token", envToken, "Pulse API token") + intervalFlag := flag.Duration("interval", defaultInterval, "Reporting interval") + hostnameFlag := flag.String("hostname", envHostname, "Override hostname") + agentIDFlag := flag.String("agent-id", envAgentID, "Override agent identifier") + insecureFlag := flag.Bool("insecure", utils.ParseBool(envInsecure), "Skip TLS verification") + logLevelFlag := flag.String("log-level", defaultLogLevel(envLogLevel), "Log level") + + enableHostFlag := flag.Bool("enable-host", defaultEnableHost, "Enable Host Agent module") + enableDockerFlag := flag.Bool("enable-docker", defaultEnableDocker, "Enable Docker Agent module") + + var tagFlags multiValue + flag.Var(&tagFlags, "tag", "Tag to apply (repeatable)") + + flag.Parse() + + // Validation + pulseURL := strings.TrimSpace(*urlFlag) + if pulseURL == "" { + pulseURL = "http://localhost:7655" + } + + token := strings.TrimSpace(*tokenFlag) + if token == "" { + fmt.Fprintln(os.Stderr, "error: Pulse API token is required") + os.Exit(1) + } + + logLevel, err := parseLogLevel(*logLevelFlag) + if err != nil { + logLevel = zerolog.InfoLevel + } + + tags := gatherTags(envTags, tagFlags) + + return Config{ + PulseURL: pulseURL, + APIToken: token, + Interval: *intervalFlag, + HostnameOverride: strings.TrimSpace(*hostnameFlag), + AgentID: strings.TrimSpace(*agentIDFlag), + Tags: tags, + InsecureSkipVerify: *insecureFlag, + LogLevel: logLevel, + EnableHost: *enableHostFlag, + EnableDocker: *enableDockerFlag, + } +} + +// Helpers (duplicated from existing agents for now, to be moved to shared pkg later) +func gatherTags(env string, flags []string) []string { + tags := make([]string, 0) + if env != "" { + for _, tag := range strings.Split(env, ",") { + tag = strings.TrimSpace(tag) + if tag != "" { + tags = append(tags, tag) + } + } + } + for _, tag := range flags { + tag = strings.TrimSpace(tag) + if tag != "" { + tags = append(tags, tag) + } + } + return tags +} + +func parseLogLevel(value string) (zerolog.Level, error) { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized == "" { + return zerolog.InfoLevel, nil + } + return zerolog.ParseLevel(normalized) +} + +func defaultLogLevel(envValue string) string { + if strings.TrimSpace(envValue) == "" { + return "info" + } + return envValue +} diff --git a/frontend-modern/src/components/Settings/DockerAgents.tsx b/frontend-modern/src/components/Settings/DockerAgents.tsx index acd2a0c3f..e92d784ab 100644 --- a/frontend-modern/src/components/Settings/DockerAgents.tsx +++ b/frontend-modern/src/components/Settings/DockerAgents.tsx @@ -113,115 +113,115 @@ export const DockerAgents: Component = () => { }; const modalHostIsOnline = () => modalHostStatus().toLowerCase() === 'online'; -const modalHostHidden = () => Boolean(hostToRemove()?.hidden); -const modalCommand = createMemo(() => hostToRemove()?.command ?? null); -const modalCommandStatus = createMemo(() => modalCommand()?.status ?? null); -const modalCommandInProgress = createMemo(() => { - const status = modalCommandStatus(); - return status === 'queued' || status === 'dispatched' || status === 'acknowledged'; -}); -const modalCommandFailed = createMemo(() => modalCommandStatus() === 'failed'); -const modalCommandCompleted = createMemo(() => modalCommandStatus() === 'completed'); -const modalCommandProgress = createMemo(() => { - const cmd = modalCommand(); - if (!cmd) return []; - - const statusOrder: Record = { - queued: 0, - dispatched: 1, - acknowledged: 2, - completed: 3, - failed: 4, - expired: 5, - }; - const currentIndex = statusOrder[cmd.status] ?? 0; - const steps = [ - { key: 'queued', label: 'Stop command queued' }, - { key: 'dispatched', label: 'Instruction delivered to the agent' }, - { key: 'acknowledged', label: 'Agent acknowledged the stop request' }, - { key: 'completed', label: 'Agent disabled the service and removed autostart' }, - ]; - - return steps.map((step) => { - const stepIndex = statusOrder[step.key] ?? 0; - return { - label: step.label, - done: currentIndex > stepIndex, - active: currentIndex === stepIndex, - }; + const modalHostHidden = () => Boolean(hostToRemove()?.hidden); + const modalCommand = createMemo(() => hostToRemove()?.command ?? null); + const modalCommandStatus = createMemo(() => modalCommand()?.status ?? null); + const modalCommandInProgress = createMemo(() => { + const status = modalCommandStatus(); + return status === 'queued' || status === 'dispatched' || status === 'acknowledged'; }); -}); + const modalCommandFailed = createMemo(() => modalCommandStatus() === 'failed'); + const modalCommandCompleted = createMemo(() => modalCommandStatus() === 'completed'); + const modalCommandProgress = createMemo(() => { + const cmd = modalCommand(); + if (!cmd) return []; -const modalCommandTimedOut = createMemo(() => { - return modalCommandInProgress() && elapsedSeconds() > 120; // 2 minutes -}); + const statusOrder: Record = { + queued: 0, + dispatched: 1, + acknowledged: 2, + completed: 3, + failed: 4, + expired: 5, + }; + const currentIndex = statusOrder[cmd.status] ?? 0; + const steps = [ + { key: 'queued', label: 'Stop command queued' }, + { key: 'dispatched', label: 'Instruction delivered to the agent' }, + { key: 'acknowledged', label: 'Agent acknowledged the stop request' }, + { key: 'completed', label: 'Agent disabled the service and removed autostart' }, + ]; -const modalLastHeartbeat = createMemo(() => { - const host = hostToRemove(); - return host?.lastSeen ? formatRelativeTime(host.lastSeen) : null; -}); - -const modalHostPendingUninstall = createMemo(() => Boolean(hostToRemove()?.pendingUninstall)); -const modalHasCommand = createMemo(() => Boolean(modalCommand())); -const [hasShownCommandCompletion, setHasShownCommandCompletion] = createSignal(false); - -const formatElapsedTime = (seconds: number) => { - if (seconds < 60) { - return `${seconds}s`; - } - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins}m ${secs}s`; -}; - -type RemovalStatusTone = 'info' | 'success' | 'danger'; - -const removalBadgeClassMap: Record = { - info: 'inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-blue-700 dark:bg-blue-900/40 dark:text-blue-200', - success: - 'inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200', - danger: - 'inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-red-600 dark:bg-red-900/40 dark:text-red-200', -}; - -const removalTextClassMap: Record = { - info: 'text-blue-700 dark:text-blue-300', - success: 'text-emerald-700 dark:text-emerald-300', - danger: 'text-red-600 dark:text-red-300', -}; - -const getRemovalStatusInfo = (host: DockerHost): { label: string; tone: RemovalStatusTone } | null => { - const status = host.command?.status ?? null; - - switch (status) { - case 'failed': + return steps.map((step) => { + const stepIndex = statusOrder[step.key] ?? 0; return { - label: host.command?.failureReason || 'Pulse could not stop the agent automatically.', - tone: 'danger', + label: step.label, + done: currentIndex > stepIndex, + active: currentIndex === stepIndex, }; - case 'expired': - return { - label: 'Stop command expired before the agent responded.', - tone: 'danger', - }; - case 'completed': - return { - label: 'Agent stopped. Pulse will hide this host after the next missed heartbeat.', - tone: 'success', - }; - case 'acknowledged': - return { label: 'Agent acknowledged the stop command—waiting for shutdown.', tone: 'info' }; - case 'dispatched': - return { label: 'Instruction delivered to the agent.', tone: 'info' }; - case 'queued': - return { label: 'Stop command queued; waiting to reach the agent.', tone: 'info' }; - default: - if (host.pendingUninstall) { - return { label: 'Marked for uninstall; waiting for agent confirmation.', tone: 'info' }; - } - return null; - } -}; + }); + }); + + const modalCommandTimedOut = createMemo(() => { + return modalCommandInProgress() && elapsedSeconds() > 120; // 2 minutes + }); + + const modalLastHeartbeat = createMemo(() => { + const host = hostToRemove(); + return host?.lastSeen ? formatRelativeTime(host.lastSeen) : null; + }); + + const modalHostPendingUninstall = createMemo(() => Boolean(hostToRemove()?.pendingUninstall)); + const modalHasCommand = createMemo(() => Boolean(modalCommand())); + const [hasShownCommandCompletion, setHasShownCommandCompletion] = createSignal(false); + + const formatElapsedTime = (seconds: number) => { + if (seconds < 60) { + return `${seconds}s`; + } + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}m ${secs}s`; + }; + + type RemovalStatusTone = 'info' | 'success' | 'danger'; + + const removalBadgeClassMap: Record = { + info: 'inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-blue-700 dark:bg-blue-900/40 dark:text-blue-200', + success: + 'inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200', + danger: + 'inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-red-600 dark:bg-red-900/40 dark:text-red-200', + }; + + const removalTextClassMap: Record = { + info: 'text-blue-700 dark:text-blue-300', + success: 'text-emerald-700 dark:text-emerald-300', + danger: 'text-red-600 dark:text-red-300', + }; + + const getRemovalStatusInfo = (host: DockerHost): { label: string; tone: RemovalStatusTone } | null => { + const status = host.command?.status ?? null; + + switch (status) { + case 'failed': + return { + label: host.command?.failureReason || 'Pulse could not stop the agent automatically.', + tone: 'danger', + }; + case 'expired': + return { + label: 'Stop command expired before the agent responded.', + tone: 'danger', + }; + case 'completed': + return { + label: 'Agent stopped. Pulse will hide this host after the next missed heartbeat.', + tone: 'success', + }; + case 'acknowledged': + return { label: 'Agent acknowledged the stop command—waiting for shutdown.', tone: 'info' }; + case 'dispatched': + return { label: 'Instruction delivered to the agent.', tone: 'info' }; + case 'queued': + return { label: 'Stop command queued; waiting to reach the agent.', tone: 'info' }; + default: + if (host.pendingUninstall) { + return { label: 'Marked for uninstall; waiting for agent confirmation.', tone: 'info' }; + } + return null; + } + }; createEffect(() => { if (!showRemoveModal()) return; @@ -346,24 +346,24 @@ const getRemovalStatusInfo = (host: DockerHost): { label: string; tone: RemovalS const url = pulseUrl(); const tokenValue = requiresToken() ? TOKEN_PLACEHOLDER : 'disabled'; const tokenSegment = `--token '${tokenValue}'`; - return `curl -fSL '${url}/install-docker-agent.sh' -o /tmp/pulse-install-docker-agent.sh && sudo bash /tmp/pulse-install-docker-agent.sh --url '${url}' ${tokenSegment} && rm -f /tmp/pulse-install-docker-agent.sh`; + return `curl -fsSL '${url}/install.sh' | bash -s -- --url '${url}' ${tokenSegment} --enable-docker`; }; const getUninstallCommand = () => { const url = pulseUrl(); - return `curl -fSL '${url}/install-docker-agent.sh' -o /tmp/pulse-install-docker-agent.sh && sudo bash /tmp/pulse-install-docker-agent.sh --uninstall && rm -f /tmp/pulse-install-docker-agent.sh`; + return `curl -fsSL '${url}/install.sh' | bash -s -- --uninstall`; }; const getSystemdService = () => { const token = requiresToken() ? TOKEN_PLACEHOLDER : 'disabled'; return `[Unit] -Description=Pulse Docker Agent +Description=Pulse Unified Agent After=network-online.target docker.service Wants=network-online.target [Service] Type=simple -ExecStart=/usr/local/bin/pulse-docker-agent --url ${pulseUrl()} --token ${token} --interval 30s +ExecStart=/usr/local/bin/pulse-agent --url ${pulseUrl()} --token ${token} --interval 30s --enable-docker Restart=always RestartSec=5s User=root @@ -610,176 +610,176 @@ WantedBy=multi-user.target`; - -
-

Enroll a container runtime

-

- Run the command below on any host running Docker or Podman. The installer will automatically detect your container runtime. -

-
+ +
+

Enroll a container runtime

+

+ Run the command below on any host running Docker or Podman. The installer will automatically detect your container runtime. +

+
-
- -
-
-

Generate API token

-

- Create a fresh token scoped to {DOCKER_REPORT_SCOPE} -

-
- -
- setTokenName(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && !isGeneratingToken()) { - handleGenerateToken(); - } - }} - placeholder="Token name (optional)" - class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-900/60" - /> - -
- - -
- - - - - Token {latestRecord()?.name} created and inserted into the command below. - -
-
-
-
- - -
-
- - -
-
-                  {getInstallCommandTemplate().replace(TOKEN_PLACEHOLDER, currentToken() || TOKEN_PLACEHOLDER)}
-                
-

- The installer downloads the agent, detects your container runtime, configures a systemd service, and starts reporting automatically. +

+ +
+
+

Generate API token

+

+ Create a fresh token scoped to {DOCKER_REPORT_SCOPE}

- - +
+ setTokenName(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !isGeneratingToken()) { + handleGenerateToken(); + } + }} + placeholder="Token name (optional)" + class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-900/60" + /> + +
+ + +
+ + + + + Token {latestRecord()?.name} created and inserted into the command below. + +
+
+
+
+ + +
+
+ + +
+
+                {getInstallCommandTemplate().replace(TOKEN_PLACEHOLDER, currentToken() || TOKEN_PLACEHOLDER)}
+              

- Generate a token to see the install command. + The unified installer downloads the agent, detects your container runtime, configures a systemd service, and starts reporting automatically.

- -
+
+ -
- - Advanced options (uninstall & manual install) - -
-
-

Uninstall

-
- - {getUninstallCommand()} + +

+ Generate a token to see the install command. +

+
+
+ +
+ + Advanced options (uninstall & manual install) + +
+
+

Uninstall

+
+ + {getUninstallCommand()} + + +
+

+ Stops the agent, removes the binary, the systemd unit, and related files. +

+
+ +
+

Manual installation

+
+

1. Build the binary

+
+ + cd /opt/pulse +
+ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o pulse-agent ./cmd/pulse-agent
+
+

+ Building with CGO_ENABLED=0 keeps the binary fully static so it runs on hosts with older glibc (e.g. Debian 11). +

+

2. Copy to host

+
+ + scp pulse-agent user@docker-host:/usr/local/bin/ +
+ ssh user@docker-host chmod +x /usr/local/bin/pulse-agent +
+
+

3. Systemd template

+
+
+
{getSystemdService()}
+
-

- Stops the agent, removes the binary, the systemd unit, and related files. -

-
- -
-

Manual installation

-
-

1. Build the binary

-
- - cd /opt/pulse -
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o pulse-docker-agent ./cmd/pulse-docker-agent -
-
-

- Building with CGO_ENABLED=0 keeps the binary fully static so it runs on hosts with older glibc (e.g. Debian 11). -

-

2. Copy to host

-
- - scp pulse-docker-agent user@docker-host:/usr/local/bin/ -
- ssh user@docker-host chmod +x /usr/local/bin/pulse-docker-agent -
-
-

3. Systemd template

-
- -
-
{getSystemdService()}
-
-
-

4. Enable & start

-
- - systemctl daemon-reload -
- systemctl enable --now pulse-docker-agent -
-
+

4. Enable & start

+
+ + systemctl daemon-reload +
+ systemctl enable --now pulse-agent +
-
- +
+
+ {/* Remove Container Host Modal */} @@ -832,11 +832,10 @@ WantedBy=multi-user.target`; modalCommandStatus() === 'completed' || (modalHostPendingUninstall() && !modalHasCommand()) } - class={`inline-flex items-center justify-center rounded px-4 py-2 text-sm font-medium text-white transition-colors ${ - modalCommandStatus() === 'completed' - ? 'bg-emerald-600 dark:bg-emerald-500' - : 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400' - } disabled:cursor-not-allowed disabled:opacity-60`} + class={`inline-flex items-center justify-center rounded px-4 py-2 text-sm font-medium text-white transition-colors ${modalCommandStatus() === 'completed' + ? 'bg-emerald-600 dark:bg-emerald-500' + : 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400' + } disabled:cursor-not-allowed disabled:opacity-60`} > {(() => { if (removeActionLoading() === 'queue') return 'Sending…'; @@ -895,13 +894,12 @@ WantedBy=multi-user.target`; class={`${step.done || step.active ? 'text-blue-700 dark:text-blue-200' : 'text-gray-500 dark:text-gray-400'} flex items-center gap-2 text-xs`} > {step.label} @@ -966,12 +964,12 @@ WantedBy=multi-user.target`; Agent confirmed the stop. Pulse has already cleaned up everything it controls:

    -
  • • Terminated the running pulse-docker-agent process
  • +
  • • Terminated the running pulse-agent process
  • • Disabled future auto-start (stops the systemd unit or removes the Unraid autostart script if one exists)
  • • Cleared the host from the dashboard so new reports won’t appear unexpectedly

- The binary remains at /usr/local/bin/pulse-docker-agent for quick reinstalls. Use the uninstall command below if you prefer to remove it too. + The binary remains at /usr/local/bin/pulse-agent for quick reinstalls. Use the uninstall command below if you prefer to remove it too.

@@ -996,13 +994,12 @@ WantedBy=multi-user.target`; class={`${step.done || step.active ? 'text-blue-700 dark:text-blue-200' : 'text-gray-500 dark:text-gray-400'} flex items-center gap-2`} > {step.label} @@ -1011,7 +1008,7 @@ WantedBy=multi-user.target`;

Pulse responds to the agent's /api/agents/docker/report call with a stop command. The agent disables its service, removes - /boot/config/go.d/pulse-docker-agent.sh, and posts back to + /boot/config/go.d/pulse-agent.sh, and posts back to /api/agents/docker/commands/<id>/ack so Pulse knows it can remove the row.

@@ -1113,7 +1110,7 @@ WantedBy=multi-user.target`;

Command copied to clipboard.

- This command stops the agent, removes the systemd service (or Unraid autostart hook), deletes /var/log/pulse-docker-agent.log, and uninstalls the binary. Pulse will notice the host is gone after the next heartbeat (≈2 minutes) and clean up the row automatically. + This command stops the agent, removes the systemd service (or Unraid autostart hook), deletes /var/log/pulse-agent.log, and uninstalls the binary. Pulse will notice the host is gone after the next heartbeat (≈2 minutes) and clean up the row automatically.

@@ -1443,11 +1440,10 @@ WantedBy=multi-user.target`;
{host.status || 'unknown'} @@ -1486,7 +1482,7 @@ WantedBy=multi-user.target`; ); }} - +
{describeRuntime(host)} diff --git a/frontend-modern/src/components/Settings/HostAgents.tsx b/frontend-modern/src/components/Settings/HostAgents.tsx index ef749a4da..ce0ea222f 100644 --- a/frontend-modern/src/components/Settings/HostAgents.tsx +++ b/frontend-modern/src/components/Settings/HostAgents.tsx @@ -37,14 +37,14 @@ const commandsByPlatform: Record< linux: { title: 'Install on Linux', description: - 'The installer downloads the agent binary and configures it as a systemd service.', + 'The unified installer downloads the agent binary and configures it as a systemd service.', snippets: [ { label: 'Install with systemd', - command: `curl -fsSL ${pulseUrl()}/install-host-agent.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`, + command: `curl -fsSL ${pulseUrl()}/install.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`, note: ( - Automatically installs to /usr/local/bin/pulse-host-agent and creates /etc/systemd/system/pulse-host-agent.service. + Automatically installs to /usr/local/bin/pulse-agent and creates /etc/systemd/system/pulse-agent.service. ), }, @@ -53,14 +53,14 @@ const commandsByPlatform: Record< macos: { title: 'Install on macOS', description: - 'The installer downloads the universal binary and sets up a launchd service for background monitoring.', + 'The unified installer downloads the universal binary and sets up a launchd service for background monitoring.', snippets: [ { label: 'Install with launchd', - command: `curl -fsSL ${pulseUrl()}/install-host-agent.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`, + command: `curl -fsSL ${pulseUrl()}/install.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`, note: ( - Creates ~/Library/LaunchAgents/com.pulse.host-agent.plist and starts the agent automatically. + Creates /Library/LaunchDaemons/com.pulse.agent.plist and starts the agent automatically. ), }, @@ -69,20 +69,20 @@ const commandsByPlatform: Record< windows: { title: 'Install on Windows', description: - 'Run the PowerShell script to install and configure the host agent as a Windows service with automatic startup.', + 'Run the PowerShell script to install and configure the unified agent as a Windows service with automatic startup.', snippets: [ { label: 'Install as Windows Service (PowerShell)', - command: `irm ${pulseUrl()}/install-host-agent.ps1 | iex`, + command: `irm ${pulseUrl()}/install.ps1 | iex`, note: ( - Run in PowerShell as Administrator. The script will prompt for the Pulse URL and API token, download the agent binary, and install it as a Windows service with automatic startup. The agent runs natively and can access all Windows performance counters. + Run in PowerShell as Administrator. The script will prompt for the Pulse URL and API token, download the agent binary, and install it as a Windows service with automatic startup. ), }, { label: 'Install with parameters (PowerShell)', - command: `$env:PULSE_URL="${pulseUrl()}"; $env:PULSE_TOKEN="${TOKEN_PLACEHOLDER}"; irm ${pulseUrl()}/install-host-agent.ps1 | iex`, + command: `$env:PULSE_URL="${pulseUrl()}"; $env:PULSE_TOKEN="${TOKEN_PLACEHOLDER}"; irm ${pulseUrl()}/install.ps1 | iex`, note: ( Non-interactive installation. Set environment variables before running to skip prompts. @@ -436,20 +436,20 @@ export const HostAgents: Component = () => { }); - const requiresToken = () => { - const status = securityStatus(); - if (status) { - return status.requiresAuth || status.apiTokenConfigured; - } - return true; - }; + const requiresToken = () => { + const status = securityStatus(); + if (status) { + return status.requiresAuth || status.apiTokenConfigured; + } + return true; + }; - onCleanup(() => { - if (highlightTimer) { - clearTimeout(highlightTimer); - highlightTimer = null; - } - }); + onCleanup(() => { + if (highlightTimer) { + clearTimeout(highlightTimer); + highlightTimer = null; + } + }); const hasToken = () => Boolean(currentToken()); const commandsUnlocked = () => (requiresToken() ? hasToken() : hasToken() || confirmedNoToken()); @@ -538,26 +538,26 @@ User=root WantedBy=multi-user.target`; function getManualUninstallCommand(): string { - return `sudo systemctl stop pulse-host-agent && \\ -sudo systemctl disable pulse-host-agent && \\ -sudo rm -f /etc/systemd/system/pulse-host-agent.service && \\ -sudo rm -f /usr/local/bin/pulse-host-agent && \\ + return `sudo systemctl stop pulse-agent && \\ +sudo systemctl disable pulse-agent && \\ +sudo rm -f /etc/systemd/system/pulse-agent.service && \\ +sudo rm -f /usr/local/bin/pulse-agent && \\ sudo systemctl daemon-reload`; } function getHostUninstallCommand(host: Host | null): string { const platform = host?.platform?.toLowerCase(); if (platform === 'macos' || platform === 'darwin' || platform === 'mac') { - return `launchctl unload ~/Library/LaunchAgents/com.pulse.host-agent.plist >/dev/null 2>&1 || true && \\ -rm -f ~/Library/LaunchAgents/com.pulse.host-agent.plist && \\ -sudo rm -f /usr/local/bin/pulse-host-agent && \\ -rm -f ~/Library/Logs/pulse-host-agent.log`; + return `launchctl unload /Library/LaunchDaemons/com.pulse.agent.plist >/dev/null 2>&1 || true && \\ +rm -f /Library/LaunchDaemons/com.pulse.agent.plist && \\ +sudo rm -f /usr/local/bin/pulse-agent && \\ +rm -f /var/log/pulse-agent.log`; } if (platform === 'windows' || platform === 'win32' || platform === 'windows_nt') { - return `Stop-Service -Name PulseHostAgent -ErrorAction SilentlyContinue; \\ -sc.exe delete PulseHostAgent; \\ -Remove-Item 'C:\\\\Program Files\\\\Pulse\\\\pulse-host-agent.exe' -Force -ErrorAction SilentlyContinue; \\ -Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAction SilentlyContinue`; + return `Stop-Service -Name PulseAgent -ErrorAction SilentlyContinue; \\ +sc.exe delete PulseAgent; \\ +Remove-Item 'C:\\\\Program Files\\\\Pulse\\\\pulse-agent.exe' -Force -ErrorAction SilentlyContinue; \\ +Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-agent.log' -Force -ErrorAction SilentlyContinue`; } return getManualUninstallCommand(); } @@ -619,241 +619,240 @@ Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAct
- -
-
- Tokens are optional on this Pulse instance. Confirm to generate commands without embedding a token. -
- -
-
+ > + {confirmedNoToken() ? 'No token confirmed' : 'Confirm without token'} + +
+ - -
-

Installation commands

-

- Copy the command for the platform you are deploying. -

-
- - {(section) => ( -
-
-
{section.title}
-

{section.description}

-
-
- - {(snippet) => { - const copyCommand = () => - snippet.command.replace(TOKEN_PLACEHOLDER, resolvedToken()); - - return ( -
-
-
- {snippet.label} -
- -
-
-                                    {copyCommand()}
-                                  
- -

{snippet.note}

-
-
- ); - }} -
-
-
- )} -
-
-
-
-
Check installation status
- -
-

- Enter the hostname (or host ID) from the machine you just installed. Pulse returns the latest status instantly. -

-
- { - setLookupValue(event.currentTarget.value); - setLookupError(null); - setLookupResult(null); - }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - void handleLookup(); - } - }} - placeholder="Hostname or host ID" - class="flex-1 rounded-lg border border-blue-200 bg-white px-3 py-2 text-sm text-blue-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-100 dark:focus:border-blue-300 dark:focus:ring-blue-800/60" - /> -
- -

{lookupError()}

-
- - {(result) => { - const host = () => result().host; - const statusBadgeClasses = () => - host().connected - ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' - : 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'; - return ( -
-
-
- {host().displayName || host().hostname} -
-
- - {host().connected ? 'Connected' : 'Not reporting yet'} - - - {host().status || 'unknown'} - -
-
-
- Last seen {formatRelativeTime(host().lastSeen)} ({formatAbsoluteTime(host().lastSeen)}) -
- -
- Agent version {host().agentVersion} -
-
-
- ); - }} -
-
-
- - Advanced options (manual install & uninstall) - -
-
-

Manual Linux install

-

- Build the agent from source and manage the service yourself instead of using the helper script. -

-

1. Build the binary

-
- - cd /opt/pulse -
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o pulse-host-agent ./cmd/pulse-host-agent -
-
-

2. Copy to host

-
- - scp pulse-host-agent user@host:/usr/local/bin/ -
- ssh user@host sudo chmod +x /usr/local/bin/pulse-host-agent -
-
-

3. Systemd service template

-
- -
-
{getSystemdServiceUnit()}
-
-
-

4. Enable & start

-
- - sudo systemctl daemon-reload -
- sudo systemctl enable --now pulse-host-agent -
-
-
-
-

Manual uninstall

-
- - {getManualUninstallCommand()} - - -
-

- Stops the agent, removes the systemd unit, and deletes the binary. -

-
-
-
-
-
- - + +
+

Installation commands

- Generate a new token to unlock the install commands. + Copy the command for the platform you are deploying.

- - -

Confirm the no-token setup to continue.

-
+
+ + {(section) => ( +
+
+
{section.title}
+

{section.description}

+
+
+ + {(snippet) => { + const copyCommand = () => + snippet.command.replace(TOKEN_PLACEHOLDER, resolvedToken()); + + return ( +
+
+
+ {snippet.label} +
+ +
+
+                                  {copyCommand()}
+                                
+ +

{snippet.note}

+
+
+ ); + }} +
+
+
+ )} +
+
+
+
+
Check installation status
+ +
+

+ Enter the hostname (or host ID) from the machine you just installed. Pulse returns the latest status instantly. +

+
+ { + setLookupValue(event.currentTarget.value); + setLookupError(null); + setLookupResult(null); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void handleLookup(); + } + }} + placeholder="Hostname or host ID" + class="flex-1 rounded-lg border border-blue-200 bg-white px-3 py-2 text-sm text-blue-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-100 dark:focus:border-blue-300 dark:focus:ring-blue-800/60" + /> +
+ +

{lookupError()}

+
+ + {(result) => { + const host = () => result().host; + const statusBadgeClasses = () => + host().connected + ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' + : 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'; + return ( +
+
+
+ {host().displayName || host().hostname} +
+
+ + {host().connected ? 'Connected' : 'Not reporting yet'} + + + {host().status || 'unknown'} + +
+
+
+ Last seen {formatRelativeTime(host().lastSeen)} ({formatAbsoluteTime(host().lastSeen)}) +
+ +
+ Agent version {host().agentVersion} +
+
+
+ ); + }} +
+
+
+ + Advanced options (manual install & uninstall) + +
+
+

Manual Linux install

+

+ Build the agent from source and manage the service yourself instead of using the helper script. +

+

1. Build the binary

+
+ + cd /opt/pulse +
+ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o pulse-agent ./cmd/pulse-agent +
+
+

2. Copy to host

+
+ + scp pulse-agent user@host:/usr/local/bin/ +
+ ssh user@host sudo chmod +x /usr/local/bin/pulse-agent +
+
+

3. Systemd service template

+
+ +
+
{getSystemdServiceUnit()}
+
+
+

4. Enable & start

+
+ + sudo systemctl daemon-reload +
+ sudo systemctl enable --now pulse-host-agent +
+
+
+
+

Manual uninstall

+
+ + {getManualUninstallCommand()} + + +
+

+ Stops the agent, removes the systemd unit, and deletes the binary. +

+
+
+
+
+
+ + +

+ Generate a new token to unlock the install commands. +

+
+ +

Confirm the no-token setup to continue.

+
@@ -1010,11 +1009,10 @@ Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAct
{host.status || 'unknown'} @@ -1098,10 +1096,10 @@ Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAct ); }} - - -
- + + + +
@@ -1191,11 +1189,10 @@ Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAct
Host status {hostRemovalStatusLabel()} diff --git a/frontend-modern/vite.config.ts b/frontend-modern/vite.config.ts index 8adef6c97..72306c76a 100644 --- a/frontend-modern/vite.config.ts +++ b/frontend-modern/vite.config.ts @@ -12,9 +12,9 @@ const backendProtocol = process.env.PULSE_DEV_API_PROTOCOL ?? 'http'; const backendHost = process.env.PULSE_DEV_API_HOST ?? '127.0.0.1'; const backendPort = Number( process.env.PULSE_DEV_API_PORT ?? - process.env.FRONTEND_PORT ?? - process.env.PORT ?? - 7655, + process.env.FRONTEND_PORT ?? + process.env.PORT ?? + 7655, ); const backendUrl = @@ -72,6 +72,14 @@ export default defineConfig({ target: backendUrl, changeOrigin: true, }, + '/install.sh': { + target: backendUrl, + changeOrigin: true, + }, + '/install.ps1': { + target: backendUrl, + changeOrigin: true, + }, '/download': { target: backendUrl, changeOrigin: true, diff --git a/internal/api/unified_agent.go b/internal/api/unified_agent.go new file mode 100644 index 000000000..468cc82fd --- /dev/null +++ b/internal/api/unified_agent.go @@ -0,0 +1,110 @@ +package api + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/rs/zerolog/log" +) + +// handleDownloadUnifiedInstallScript serves the universal install.sh script +func (r *Router) handleDownloadUnifiedInstallScript(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet && req.Method != http.MethodHead { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + scriptPath := filepath.Join(r.config.AppRoot, "scripts", "install.sh") + + // Check if file exists + if _, err := os.Stat(scriptPath); os.IsNotExist(err) { + log.Error().Str("path", scriptPath).Msg("Unified install script not found") + http.Error(w, "Install script not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "text/x-shellscript") + w.Header().Set("Content-Disposition", "inline; filename=\"install.sh\"") + http.ServeFile(w, req, scriptPath) +} + +// handleDownloadUnifiedInstallScriptPS serves the universal install.ps1 script +func (r *Router) handleDownloadUnifiedInstallScriptPS(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet && req.Method != http.MethodHead { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + scriptPath := filepath.Join(r.config.AppRoot, "scripts", "install.ps1") + + // Check if file exists + if _, err := os.Stat(scriptPath); os.IsNotExist(err) { + log.Error().Str("path", scriptPath).Msg("Unified PowerShell install script not found") + http.Error(w, "Install script not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("Content-Disposition", "inline; filename=\"install.ps1\"") + http.ServeFile(w, req, scriptPath) +} + +// handleDownloadUnifiedAgent serves the pulse-agent binary +func (r *Router) handleDownloadUnifiedAgent(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet && req.Method != http.MethodHead { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // For now, we only have the locally built binary. + // In production, this would look up the correct binary for the requested OS/Arch. + // Query params: ?os=linux&arch=amd64 + + osName := req.URL.Query().Get("os") + arch := req.URL.Query().Get("arch") + + if osName == "" { + osName = "linux" // Default + } + if arch == "" { + arch = "amd64" // Default + } + + // Normalize OS + osName = strings.ToLower(osName) + if osName == "darwin" { + osName = "macos" + } + + // In dev mode, we just serve the binary we built in the root + // In prod, we'd look in a dist folder + binaryName := "pulse-agent" + if osName == "windows" { + binaryName = "pulse-agent.exe" + } + + // Try to find the binary + // 1. Check root (dev) + binaryPath := filepath.Join(r.config.AppRoot, binaryName) + if _, err := os.Stat(binaryPath); os.IsNotExist(err) { + // 2. Check dist folder (prod/build) + binaryPath = filepath.Join(r.config.AppRoot, "dist", fmt.Sprintf("%s-%s", osName, arch), binaryName) + if _, err := os.Stat(binaryPath); os.IsNotExist(err) { + // Fallback for dev: just serve the root binary regardless of requested OS/Arch + // This allows testing the flow even if cross-compilation hasn't happened + binaryPath = filepath.Join(r.config.AppRoot, "pulse-agent") + if _, err := os.Stat(binaryPath); os.IsNotExist(err) { + log.Error().Str("path", binaryPath).Msg("Unified agent binary not found") + http.Error(w, "Agent binary not found", http.StatusNotFound) + return + } + } + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", binaryName)) + http.ServeFile(w, req, binaryPath) +} diff --git a/internal/config/config.go b/internal/config/config.go index 68f8a3403..a0b6ea0d8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -77,6 +77,7 @@ type Config struct { FrontendPort int `envconfig:"FRONTEND_PORT" default:"7655"` ConfigPath string `envconfig:"CONFIG_PATH" default:"/etc/pulse"` DataPath string `envconfig:"DATA_PATH" default:"/var/lib/pulse"` + AppRoot string `json:"-"` // Root directory of the application (where binary lives) PublicURL string `envconfig:"PULSE_PUBLIC_URL" default:""` // Full URL to access Pulse (e.g., http://192.168.1.100:7655) // Proxmox VE connections @@ -530,6 +531,7 @@ func Load() (*Config, error) { FrontendPort: 7655, ConfigPath: dataDir, DataPath: dataDir, + AppRoot: detectAppRoot(), ConcurrentPolling: true, ConnectionTimeout: 60 * time.Second, MetricsRetentionDays: 7, diff --git a/internal/config/detect_root.go b/internal/config/detect_root.go new file mode 100644 index 000000000..9054ebe6f --- /dev/null +++ b/internal/config/detect_root.go @@ -0,0 +1,37 @@ +package config + +import ( + "os" + "path/filepath" + "strings" +) + +// detectAppRoot attempts to find the application root directory +func detectAppRoot() string { + // 1. Check environment variable + if root := os.Getenv("PULSE_APP_ROOT"); root != "" { + return root + } + + // 2. Get executable path + exe, err := os.Executable() + if err == nil { + // If running via "go run", executable is in /tmp, which isn't helpful for finding source files + // But in production, it's correct. + // Check if we are in a temp dir (go run) + if strings.Contains(exe, os.TempDir()) || strings.Contains(exe, "/var/folders/") { + // Fallback to current working directory + if cwd, err := os.Getwd(); err == nil { + return cwd + } + } + return filepath.Dir(exe) + } + + // 3. Fallback to current working directory + if cwd, err := os.Getwd(); err == nil { + return cwd + } + + return "." +} diff --git a/pulse-agent b/pulse-agent new file mode 100755 index 000000000..fc9486e7a Binary files /dev/null and b/pulse-agent differ diff --git a/scripts/sync-production-config.sh b/scripts/sync-production-config.sh index 3d416b2f9..8e7dc488c 100755 --- a/scripts/sync-production-config.sh +++ b/scripts/sync-production-config.sh @@ -47,13 +47,14 @@ else openssl rand -base64 32 > "$DEV_DIR/.encryption.key" chmod 600 "$DEV_DIR/.encryption.key" echo "✓ Generated dev encryption key at $DEV_DIR/.encryption.key" + + # Remove encrypted artifacts that rely on the missing/old key + find "$DEV_DIR" -maxdepth 1 -type f -name 'nodes.enc*' -exec rm -f {} \; + rm -f "$DEV_DIR/email.enc" "$DEV_DIR/webhooks.enc" + echo "✓ Cleared encrypted artifacts (new key generated)" else echo "✓ Reusing existing dev encryption key" fi - # Remove encrypted artifacts that rely on the missing production key - find "$DEV_DIR" -maxdepth 1 -type f -name 'nodes.enc*' -exec rm -f {} \; - rm -f "$DEV_DIR/email.enc" "$DEV_DIR/webhooks.enc" - echo "✓ Cleared encrypted production artifacts from dev config" fi # Copy nodes configuration - WITH VALIDATION