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