diff --git a/frontend-modern/src/components/Settings/UnifiedAgents.tsx b/frontend-modern/src/components/Settings/UnifiedAgents.tsx index 37d53ed..a7ddf88 100644 --- a/frontend-modern/src/components/Settings/UnifiedAgents.tsx +++ b/frontend-modern/src/components/Settings/UnifiedAgents.tsx @@ -36,14 +36,14 @@ const commandsByPlatform: Record< linux: { title: 'Install on Linux', description: - 'The unified installer downloads the agent binary and configures it as a systemd service.', + 'The unified installer downloads the agent binary and configures the appropriate service for your system.', snippets: [ { - label: 'Install with systemd', + label: 'Install', command: `curl -fsSL ${pulseUrl()}/install.sh | sudo bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`, note: ( - Automatically installs to /usr/local/bin/pulse-agent and creates /etc/systemd/system/pulse-agent.service. + Automatically detects your init system (systemd, OpenRC, Unraid, Synology) and configures the appropriate service. Works on Debian, Ubuntu, Fedora, Alpine, Gentoo, Unraid, Synology, and more. ), }, diff --git a/internal/agentupdate/update.go b/internal/agentupdate/update.go index b1fc101..2cea345 100644 --- a/internal/agentupdate/update.go +++ b/internal/agentupdate/update.go @@ -23,6 +23,14 @@ import ( "github.com/rs/zerolog" ) +const ( + // maxBinarySize is the maximum allowed size for downloaded binaries (100 MB) + maxBinarySize = 100 * 1024 * 1024 + + // downloadTimeout is the maximum time allowed for downloading a binary + downloadTimeout = 5 * time.Minute +) + // Config holds the configuration for the updater. type Config struct { // PulseURL is the base URL of the Pulse server (e.g., "https://pulse.example.com:7655") @@ -70,7 +78,8 @@ func New(cfg Config) *Updater { transport := &http.Transport{ TLSClientConfig: &tls.Config{ - InsecureSkipVerify: cfg.InsecureSkipVerify, + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: cfg.InsecureSkipVerify, //nolint:gosec }, } @@ -78,7 +87,7 @@ func New(cfg Config) *Updater { cfg: cfg, client: &http.Client{ Transport: transport, - Timeout: 30 * time.Second, + Timeout: downloadTimeout, }, logger: logger, } @@ -199,6 +208,64 @@ func (u *Updater) getServerVersion(ctx context.Context) (string, error) { return versionResp.Version, nil } +// isUnraid checks if we're running on Unraid by looking for /etc/unraid-version +func isUnraid() bool { + _, err := os.Stat("/etc/unraid-version") + return err == nil +} + +// verifyBinaryMagic checks that the file is a valid executable for the current platform +func verifyBinaryMagic(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + magic := make([]byte, 4) + if _, err := io.ReadFull(f, magic); err != nil { + return fmt.Errorf("failed to read magic bytes: %w", err) + } + + switch runtime.GOOS { + case "linux": + // ELF magic: 0x7f 'E' 'L' 'F' + if magic[0] == 0x7f && magic[1] == 'E' && magic[2] == 'L' && magic[3] == 'F' { + return nil + } + return errors.New("not a valid ELF binary") + + case "darwin": + // Mach-O magic bytes (little-endian): + // - 0xfeedface (32-bit) + // - 0xfeedfacf (64-bit) + // - 0xcafebabe (universal/fat binary) + // Note: bytes are reversed due to little-endian + if (magic[0] == 0xcf && magic[1] == 0xfa && magic[2] == 0xed && magic[3] == 0xfe) || // 64-bit + (magic[0] == 0xce && magic[1] == 0xfa && magic[2] == 0xed && magic[3] == 0xfe) || // 32-bit + (magic[0] == 0xca && magic[1] == 0xfe && magic[2] == 0xba && magic[3] == 0xbe) { // universal + return nil + } + return errors.New("not a valid Mach-O binary") + + case "windows": + // PE magic: 'M' 'Z' + if magic[0] == 'M' && magic[1] == 'Z' { + return nil + } + return errors.New("not a valid PE binary") + + default: + // Unknown platform, skip verification + return nil + } +} + +// unraidPersistentPath returns the path where the binary should be persisted on Unraid +func unraidPersistentPath(agentName string) string { + return fmt.Sprintf("/boot/config/plugins/%s/%s", agentName, agentName) +} + // performUpdate downloads and installs the new agent binary. func (u *Updater) performUpdate(ctx context.Context) error { execPath, err := os.Executable() @@ -268,29 +335,40 @@ func (u *Updater) performUpdate(ctx context.Context) error { tmpPath := tmpFile.Name() defer os.Remove(tmpPath) // Clean up on failure - // Write downloaded binary with checksum calculation + // Write downloaded binary with checksum calculation and size limit hasher := sha256.New() - if _, err := io.Copy(tmpFile, io.TeeReader(resp.Body, hasher)); err != nil { + limitedReader := io.LimitReader(resp.Body, maxBinarySize+1) // +1 to detect overflow + written, err := io.Copy(tmpFile, io.TeeReader(limitedReader, hasher)) + if err != nil { tmpFile.Close() return fmt.Errorf("failed to write binary: %w", err) } + if written > maxBinarySize { + tmpFile.Close() + return fmt.Errorf("downloaded binary exceeds maximum size (%d bytes)", maxBinarySize) + } if err := tmpFile.Close(); err != nil { return fmt.Errorf("failed to close temp file: %w", err) } - // Verify checksum - downloadChecksum := hex.EncodeToString(hasher.Sum(nil)) - if checksumHeader != "" { - expected := strings.ToLower(strings.TrimSpace(checksumHeader)) - actual := strings.ToLower(downloadChecksum) - if expected != actual { - return fmt.Errorf("checksum mismatch: expected %s, got %s", expected, actual) - } - u.logger.Debug().Str("checksum", downloadChecksum).Msg("Checksum verified") - } else { - u.logger.Warn().Msg("No checksum header; skipping verification") + // Verify it's a valid executable (basic sanity check) + if err := verifyBinaryMagic(tmpPath); err != nil { + return fmt.Errorf("downloaded file is not a valid executable: %w", err) } + // Verify checksum (mandatory for security) + downloadChecksum := hex.EncodeToString(hasher.Sum(nil)) + if checksumHeader == "" { + return fmt.Errorf("server did not provide checksum header (X-Checksum-Sha256); refusing update for security") + } + + expected := strings.ToLower(strings.TrimSpace(checksumHeader)) + actual := strings.ToLower(downloadChecksum) + if expected != actual { + return fmt.Errorf("checksum mismatch: expected %s, got %s", expected, actual) + } + u.logger.Debug().Str("checksum", downloadChecksum).Msg("Checksum verified") + // Make executable if err := os.Chmod(tmpPath, 0755); err != nil { return fmt.Errorf("failed to chmod: %w", err) @@ -311,6 +389,33 @@ func (u *Updater) performUpdate(ctx context.Context) error { // Remove backup on success os.Remove(backupPath) + // On Unraid, also update the persistent copy on the flash drive + // This ensures the update survives reboots + if isUnraid() { + persistPath := unraidPersistentPath(u.cfg.AgentName) + if _, err := os.Stat(persistPath); err == nil { + // Persistent path exists, update it + u.logger.Debug().Str("path", persistPath).Msg("Updating Unraid persistent binary") + + // Read the newly installed binary + newBinary, err := os.ReadFile(execPath) + if err != nil { + u.logger.Warn().Err(err).Msg("Failed to read new binary for Unraid persistence") + } else { + // Write to persistent storage (atomic via temp file) + tmpPersist := persistPath + ".tmp" + if err := os.WriteFile(tmpPersist, newBinary, 0644); err != nil { + u.logger.Warn().Err(err).Msg("Failed to write Unraid persistent binary") + } else if err := os.Rename(tmpPersist, persistPath); err != nil { + u.logger.Warn().Err(err).Msg("Failed to rename Unraid persistent binary") + os.Remove(tmpPersist) + } else { + u.logger.Info().Str("path", persistPath).Msg("Updated Unraid persistent binary") + } + } + } + } + // Restart with same arguments args := os.Args env := os.Environ() diff --git a/internal/dockeragent/agent.go b/internal/dockeragent/agent.go index 64ae03e..9135db7 100644 --- a/internal/dockeragent/agent.go +++ b/internal/dockeragent/agent.go @@ -1272,14 +1272,18 @@ func (a *Agent) httpClientFor(target TargetConfig) *http.Client { } func newHTTPClient(insecure bool) *http.Client { - transport := &http.Transport{} + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + } if insecure { - transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec + tlsConfig.InsecureSkipVerify = true //nolint:gosec } return &http.Client{ - Timeout: 15 * time.Second, - Transport: transport, + Timeout: 15 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, } } @@ -1631,6 +1635,32 @@ func (a *Agent) checkForUpdates(ctx context.Context) { a.logger.Info().Msg("Agent updated successfully, restarting...") } +// isUnraid checks if we're running on Unraid by looking for /etc/unraid-version +func isUnraid() bool { + _, err := os.Stat("/etc/unraid-version") + return err == nil +} + +// verifyELFMagic checks that the file is a valid ELF binary +func verifyELFMagic(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + magic := make([]byte, 4) + if _, err := io.ReadFull(f, magic); err != nil { + return fmt.Errorf("failed to read magic bytes: %w", err) + } + + // ELF magic: 0x7f 'E' 'L' 'F' + if magic[0] == 0x7f && magic[1] == 'E' && magic[2] == 'L' && magic[3] == 'F' { + return nil + } + return errors.New("not a valid ELF binary") +} + func determineSelfUpdateArch() string { switch runtime.GOARCH { case "amd64": @@ -1746,28 +1776,41 @@ func (a *Agent) selfUpdate(ctx context.Context) error { tmpPath := tmpFile.Name() defer os.Remove(tmpPath) // Clean up if something goes wrong - // Write downloaded binary to temp file + // Write downloaded binary to temp file with size limit (100 MB max) + const maxBinarySize = 100 * 1024 * 1024 hasher := sha256.New() - if _, err := io.Copy(tmpFile, io.TeeReader(resp.Body, hasher)); err != nil { + limitedReader := io.LimitReader(resp.Body, maxBinarySize+1) + written, err := io.Copy(tmpFile, io.TeeReader(limitedReader, hasher)) + if err != nil { tmpFile.Close() return fmt.Errorf("failed to write downloaded binary: %w", err) } + if written > maxBinarySize { + tmpFile.Close() + return fmt.Errorf("downloaded binary exceeds maximum size (%d bytes)", maxBinarySize) + } if err := tmpFile.Close(); err != nil { return fmt.Errorf("failed to close temp file: %w", err) } - downloadChecksum := hex.EncodeToString(hasher.Sum(nil)) - if checksumHeader != "" { - expected := strings.ToLower(strings.TrimSpace(checksumHeader)) - actual := strings.ToLower(downloadChecksum) - if expected != actual { - return fmt.Errorf("checksum verification failed: expected %s, got %s", expected, actual) - } - a.logger.Debug().Str("checksum", downloadChecksum).Msg("Self-update: checksum verified") - } else { - a.logger.Warn().Msg("Self-update: checksum header missing; skipping verification") + // Verify it's a valid ELF binary (basic sanity check for Linux) + if err := verifyELFMagic(tmpPath); err != nil { + return fmt.Errorf("downloaded file is not a valid executable: %w", err) } + // Verify checksum (mandatory for security) + downloadChecksum := hex.EncodeToString(hasher.Sum(nil)) + if checksumHeader == "" { + return fmt.Errorf("server did not provide checksum header (X-Checksum-Sha256); refusing update for security") + } + + expected := strings.ToLower(strings.TrimSpace(checksumHeader)) + actual := strings.ToLower(downloadChecksum) + if expected != actual { + return fmt.Errorf("checksum verification failed: expected %s, got %s", expected, actual) + } + a.logger.Debug().Str("checksum", downloadChecksum).Msg("Self-update: checksum verified") + // Make temp file executable if err := os.Chmod(tmpPath, 0755); err != nil { return fmt.Errorf("failed to make temp file executable: %w", err) @@ -1789,6 +1832,25 @@ func (a *Agent) selfUpdate(ctx context.Context) error { // Remove backup on success os.Remove(backupPath) + // On Unraid, also update the persistent copy on the flash drive + if isUnraid() { + persistPath := "/boot/config/plugins/pulse-docker-agent/pulse-docker-agent" + if _, err := os.Stat(persistPath); err == nil { + a.logger.Debug().Str("path", persistPath).Msg("Updating Unraid persistent binary") + if newBinary, err := os.ReadFile(execPath); err == nil { + tmpPersist := persistPath + ".tmp" + if err := os.WriteFile(tmpPersist, newBinary, 0644); err != nil { + a.logger.Warn().Err(err).Msg("Failed to write Unraid persistent binary") + } else if err := os.Rename(tmpPersist, persistPath); err != nil { + a.logger.Warn().Err(err).Msg("Failed to rename Unraid persistent binary") + os.Remove(tmpPersist) + } else { + a.logger.Info().Str("path", persistPath).Msg("Updated Unraid persistent binary") + } + } + } + } + // Restart agent with same arguments args := os.Args env := os.Environ() diff --git a/scripts/install.sh b/scripts/install.sh index fdb2564..f02f1cc 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # # Pulse Unified Agent Installer -# Supports: Linux (systemd), macOS (launchd), Synology (upstart) +# Supports: Linux (systemd, OpenRC), macOS (launchd), Synology DSM (6.x/7+), Unraid # # Usage: # curl -fsSL http://pulse/install.sh | bash -s -- --url http://pulse --token [options] @@ -14,6 +14,20 @@ set -euo pipefail +# Wrap entire script in a function to protect against partial download +# See: https://www.kicksecure.com/wiki/Dev/curl_bash_pipe +main() { + +# --- Cleanup trap --- +TMP_FILES=() +# shellcheck disable=SC2317 # Invoked by trap, not directly +cleanup() { + for f in "${TMP_FILES[@]}"; do + rm -f "$f" 2>/dev/null || true + done +} +trap cleanup EXIT + # --- Check Root --- if [[ $EUID -ne 0 ]]; then echo "This script must be run as root. Please use sudo." @@ -39,16 +53,34 @@ INSECURE="false" log_info() { printf "[INFO] %s\n" "$1"; } log_warn() { printf "[WARN] %s\n" "$1"; } log_error() { printf "[ERROR] %s\n" "$1"; } -fail() { +fail() { log_error "$1" if [[ -t 0 ]]; then - read -p "Press Enter to exit..." + read -r -p "Press Enter to exit..." elif [[ -e /dev/tty ]]; then - read -p "Press Enter to exit..." < /dev/tty + read -r -p "Press Enter to exit..." < /dev/tty fi exit 1 } +# Build exec args string for use in service files +# Returns via EXEC_ARGS variable +build_exec_args() { + EXEC_ARGS="--url ${PULSE_URL} --token ${PULSE_TOKEN} --interval ${INTERVAL}" + if [[ "$ENABLE_HOST" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-host"; fi + if [[ "$ENABLE_DOCKER" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --enable-docker"; fi + if [[ "$INSECURE" == "true" ]]; then EXEC_ARGS="$EXEC_ARGS --insecure"; fi +} + +# Build exec args as array for direct execution (proper quoting) +# Returns via EXEC_ARGS_ARRAY variable +build_exec_args_array() { + EXEC_ARGS_ARRAY=(--url "$PULSE_URL" --token "$PULSE_TOKEN" --interval "$INTERVAL") + if [[ "$ENABLE_HOST" == "true" ]]; then EXEC_ARGS_ARRAY+=(--enable-host); fi + if [[ "$ENABLE_DOCKER" == "true" ]]; then EXEC_ARGS_ARRAY+=(--enable-docker); fi + if [[ "$INSECURE" == "true" ]]; then EXEC_ARGS_ARRAY+=(--insecure); fi +} + # --- Parse Arguments --- while [[ $# -gt 0 ]]; do case $1 in @@ -68,7 +100,7 @@ done # --- Uninstall Logic --- if [[ "$UNINSTALL" == "true" ]]; then log_info "Uninstalling ${AGENT_NAME}..." - + # Systemd if command -v systemctl >/dev/null 2>&1; then systemctl stop "${AGENT_NAME}" 2>/dev/null || true @@ -84,10 +116,44 @@ if [[ "$UNINSTALL" == "true" ]]; then rm -f "$PLIST" fi - # Upstart (Synology) - if [[ -f "/etc/init/${AGENT_NAME}.conf" ]]; then - initctl stop "${AGENT_NAME}" 2>/dev/null || true - rm -f "/etc/init/${AGENT_NAME}.conf" + # Synology DSM (handles both DSM 7+ systemd and DSM 6.x upstart) + if [[ -d /usr/syno ]]; then + # DSM 7+ uses systemd + if [[ -f "/etc/systemd/system/${AGENT_NAME}.service" ]]; then + systemctl stop "${AGENT_NAME}" 2>/dev/null || true + systemctl disable "${AGENT_NAME}" 2>/dev/null || true + rm -f "/etc/systemd/system/${AGENT_NAME}.service" + systemctl daemon-reload 2>/dev/null || true + fi + # DSM 6.x uses upstart + if [[ -f "/etc/init/${AGENT_NAME}.conf" ]]; then + initctl stop "${AGENT_NAME}" 2>/dev/null || true + rm -f "/etc/init/${AGENT_NAME}.conf" + fi + fi + + # Unraid + if [[ -f /etc/unraid-version ]] || [[ -d /boot/config/plugins/pulse-agent ]]; then + log_info "Removing Unraid installation..." + # Stop running agent + pkill -f "pulse-agent" 2>/dev/null || true + # Remove from /boot/config/go + GO_SCRIPT="/boot/config/go" + if [[ -f "$GO_SCRIPT" ]]; then + sed -i '/# Pulse Agent/,/^$/d' "$GO_SCRIPT" 2>/dev/null || true + sed -i '/pulse-agent/d' "$GO_SCRIPT" 2>/dev/null || true + fi + # Remove installation directory + rm -rf /boot/config/plugins/pulse-agent + # Remove symlink + rm -f "${INSTALL_DIR}/${BINARY_NAME}" + fi + + # OpenRC (Alpine, Gentoo, Artix, etc.) + if command -v rc-service >/dev/null 2>&1; then + rc-service "${AGENT_NAME}" stop 2>/dev/null || true + rc-update del "${AGENT_NAME}" default 2>/dev/null || true + rm -f "/etc/init.d/${AGENT_NAME}" fi rm -f "${INSTALL_DIR}/${BINARY_NAME}" @@ -100,6 +166,21 @@ if [[ -z "$PULSE_URL" || -z "$PULSE_TOKEN" ]]; then fail "Missing required arguments: --url and --token" fi +# Validate URL format (basic check) +if [[ ! "$PULSE_URL" =~ ^https?:// ]]; then + fail "Invalid URL format. Must start with http:// or https://" +fi + +# Validate token format (should be hex string, typically 64 chars) +if [[ ! "$PULSE_TOKEN" =~ ^[a-fA-F0-9]+$ ]]; then + fail "Invalid token format. Token should be a hexadecimal string." +fi + +# Validate interval format +if [[ ! "$INTERVAL" =~ ^[0-9]+[smh]?$ ]]; then + fail "Invalid interval format. Use format like '30s', '5m', or '1h'." +fi + # --- Download --- OS=$(uname -s | tr '[:upper:]' '[:lower:]') ARCH=$(uname -m) @@ -119,21 +200,43 @@ ARCH_PARAM="${OS}-${ARCH}" DOWNLOAD_URL="${PULSE_URL}/download/${BINARY_NAME}?arch=${ARCH_PARAM}" log_info "Downloading agent from ${DOWNLOAD_URL}..." -# Create temp file +# Create temp file and register for cleanup TMP_BIN=$(mktemp) -chmod +x "$TMP_BIN" +TMP_FILES+=("$TMP_BIN") -CURL_ARGS="-fsSL" -if [[ "$INSECURE" == "true" ]]; then CURL_ARGS="-k $CURL_ARGS"; fi +# Build curl arguments as array for proper quoting +CURL_ARGS=(-fsSL --connect-timeout 30 --max-time 300) +if [[ "$INSECURE" == "true" ]]; then CURL_ARGS+=(-k); fi -if ! curl $CURL_ARGS -o "$TMP_BIN" "$DOWNLOAD_URL"; then +if ! curl "${CURL_ARGS[@]}" -o "$TMP_BIN" "$DOWNLOAD_URL"; then fail "Download failed. Check URL and connectivity." fi +# Verify downloaded binary +if [[ ! -s "$TMP_BIN" ]]; then + fail "Downloaded file is empty." +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 + fail "Downloaded file is not a valid Linux executable." + fi +elif [[ "$OS" == "darwin" ]]; then + # Mach-O magic: feedface (32-bit) or feedfacf (64-bit) or cafebabe (universal) + MAGIC=$(xxd -p -l 4 "$TMP_BIN" 2>/dev/null || head -c 4 "$TMP_BIN" | od -A n -t x1 | tr -d ' ') + if [[ ! "$MAGIC" =~ ^(cffaedfe|cefaedfe|cafebabe|feedface|feedfacf) ]]; then + fail "Downloaded file is not a valid macOS executable." + fi +fi + +chmod +x "$TMP_BIN" + # Install Binary log_info "Installing binary to ${INSTALL_DIR}/${BINARY_NAME}..." mkdir -p "$INSTALL_DIR" mv "$TMP_BIN" "${INSTALL_DIR}/${BINARY_NAME}" +chmod +x "${INSTALL_DIR}/${BINARY_NAME}" # --- Legacy Cleanup --- # Remove old agents if they exist to prevent conflicts @@ -232,18 +335,45 @@ EOF exit 0 fi -# 2. Synology (Upstart) -if [[ -d /usr/syno/etc/rc.sysv ]]; then - CONF="/etc/init/${AGENT_NAME}.conf" - log_info "Configuring Upstart service at $CONF..." +# 2. Synology DSM +# DSM 7+ uses systemd, DSM 6.x uses upstart +if [[ -d /usr/syno ]] && [[ -f /etc/VERSION ]]; then + # Extract major version from /etc/VERSION + DSM_MAJOR=$(grep 'majorversion=' /etc/VERSION | cut -d'"' -f2) + log_info "Detected Synology DSM ${DSM_MAJOR}..." # Build command line args - EXEC_ARGS="--url \"${PULSE_URL}\" --token \"${PULSE_TOKEN}\" --interval \"${INTERVAL}\"" - [[ "$ENABLE_HOST" == "true" ]] && EXEC_ARGS="$EXEC_ARGS --enable-host" - [[ "$ENABLE_DOCKER" == "true" ]] && EXEC_ARGS="$EXEC_ARGS --enable-docker" - [[ "$INSECURE" == "true" ]] && EXEC_ARGS="$EXEC_ARGS --insecure" + build_exec_args - cat > "$CONF" < "$UNIT" < "$CONF" <> ${LOG_FILE} 2>&1 EOF - initctl stop "${AGENT_NAME}" 2>/dev/null || true - initctl start "${AGENT_NAME}" + initctl stop "${AGENT_NAME}" 2>/dev/null || true + initctl start "${AGENT_NAME}" + fi + log_info "Service started." exit 0 fi -# 3. Linux (Systemd) +# 3. Unraid (no init system - use /boot/config/go script) +# Detect Unraid by /etc/unraid-version (preferred) or /boot/config/go with unraid markers +if [[ -f /etc/unraid-version ]]; then + log_info "Detected Unraid system..." + + # Unraid's /boot is FAT32 (no execute permission), so we store the binary there + # for persistence but copy it to RAM disk (/usr/local/bin) for execution + UNRAID_STORAGE_DIR="/boot/config/plugins/pulse-agent" + UNRAID_STORED_BINARY="${UNRAID_STORAGE_DIR}/${BINARY_NAME}" + RUNTIME_BINARY="${INSTALL_DIR}/${BINARY_NAME}" + GO_SCRIPT="/boot/config/go" + + mkdir -p "$UNRAID_STORAGE_DIR" + + # Copy binary to persistent storage (for survival across reboots) + cp "${RUNTIME_BINARY}" "$UNRAID_STORED_BINARY" + # Keep binary in /usr/local/bin (RAM disk) with execute permission for runtime + chmod +x "${RUNTIME_BINARY}" + + 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) + build_exec_args + build_exec_args_array + + # Kill any existing pulse agents (legacy or current) + log_info "Stopping any existing pulse agents..." + # Use process name matching to avoid killing unrelated processes + pkill -f "^${RUNTIME_BINARY}" 2>/dev/null || true + pkill -f "^/usr/local/bin/pulse-host-agent" 2>/dev/null || true + pkill -f "^/usr/local/bin/pulse-docker-agent" 2>/dev/null || true + sleep 2 + + # Clean up legacy binaries from RAM disk + rm -f /usr/local/bin/pulse-host-agent 2>/dev/null || true + rm -f /usr/local/bin/pulse-docker-agent 2>/dev/null || true + + # Create a wrapper script that will be called from /boot/config/go + # This script copies from persistent storage to RAM disk on boot, then starts the agent + WRAPPER_SCRIPT="${UNRAID_STORAGE_DIR}/start-pulse-agent.sh" + cat > "$WRAPPER_SCRIPT" </dev/null || true +pkill -f "^/usr/local/bin/pulse-host-agent" 2>/dev/null || true +pkill -f "^/usr/local/bin/pulse-docker-agent" 2>/dev/null || true +sleep 2 + +# Copy binary from persistent storage to RAM disk (needed after reboot) +cp "${UNRAID_STORED_BINARY}" "${RUNTIME_BINARY}" +chmod +x "${RUNTIME_BINARY}" + +# Start the agent in the background +nohup ${RUNTIME_BINARY} ${EXEC_ARGS} >> /var/log/${AGENT_NAME}.log 2>&1 & +EOF + + # Add to /boot/config/go if not already present + GO_MARKER="# Pulse Agent" + if [[ -f "$GO_SCRIPT" ]]; then + # Remove any existing Pulse agent entries + sed -i "/${GO_MARKER}/,/^$/d" "$GO_SCRIPT" 2>/dev/null || true + sed -i '/pulse-agent/d' "$GO_SCRIPT" 2>/dev/null || true + else + # Create go script if it doesn't exist + echo "#!/bin/bash" > "$GO_SCRIPT" + chmod +x "$GO_SCRIPT" + fi + + # Append startup entry (use bash explicitly since /boot is FAT32 and doesn't support execute bits) + cat >> "$GO_SCRIPT" <> "/var/log/${AGENT_NAME}.log" 2>&1 & + + log_info "Installation complete!" + log_info "The agent will start automatically on boot." + log_info "To check status: pgrep -a pulse-agent" + log_info "To view logs: tail -f /var/log/${AGENT_NAME}.log" + exit 0 +fi + +# 4. OpenRC (Alpine, Gentoo, Artix, etc.) +# Check for rc-service but make sure we're not on a systemd system that happens to have it +if command -v rc-service >/dev/null 2>&1 && [[ -d /etc/init.d ]] && ! command -v systemctl >/dev/null 2>&1; then + INITSCRIPT="/etc/init.d/${AGENT_NAME}" + log_info "Configuring OpenRC service at $INITSCRIPT..." + + # Build command line args + build_exec_args + + # Create OpenRC init script following Alpine best practices + # Using command_background=yes with pidfile for proper daemon management + cat > "$INITSCRIPT" <<'INITEOF' +#!/sbin/openrc-run +# Pulse Unified Agent OpenRC init script + +name="pulse-agent" +description="Pulse Unified Agent" + +command="INSTALL_DIR_PLACEHOLDER/BINARY_NAME_PLACEHOLDER" +command_args="EXEC_ARGS_PLACEHOLDER" +command_background="yes" +command_user="root" + +pidfile="/run/${RC_SVCNAME}.pid" +output_log="/var/log/pulse-agent.log" +error_log="/var/log/pulse-agent.log" + +# Ensure log file exists +start_pre() { + touch "$output_log" +} + +depend() { + need net + use docker +} +INITEOF + + # Replace placeholders with actual values + sed -i "s|INSTALL_DIR_PLACEHOLDER|${INSTALL_DIR}|g" "$INITSCRIPT" + sed -i "s|BINARY_NAME_PLACEHOLDER|${BINARY_NAME}|g" "$INITSCRIPT" + sed -i "s|EXEC_ARGS_PLACEHOLDER|${EXEC_ARGS}|g" "$INITSCRIPT" + + chmod +x "$INITSCRIPT" + rc-service "${AGENT_NAME}" stop 2>/dev/null || true + rc-update add "${AGENT_NAME}" default 2>/dev/null || true + rc-service "${AGENT_NAME}" start + log_info "Service started." + exit 0 +fi + +# 5. Linux (Systemd) if command -v systemctl >/dev/null 2>&1; then UNIT="/etc/systemd/system/${AGENT_NAME}.service" log_info "Configuring Systemd service at $UNIT..." # Build command line args - EXEC_ARGS="--url ${PULSE_URL} --token ${PULSE_TOKEN} --interval ${INTERVAL}" - [[ "$ENABLE_HOST" == "true" ]] && EXEC_ARGS="$EXEC_ARGS --enable-host" - [[ "$ENABLE_DOCKER" == "true" ]] && EXEC_ARGS="$EXEC_ARGS --enable-docker" - [[ "$INSECURE" == "true" ]] && EXEC_ARGS="$EXEC_ARGS --insecure" + build_exec_args cat > "$UNIT" <