From eb969bfbd54f08ffa5ba4dacbdd536dd375902d2 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 19 Oct 2025 15:03:24 +0000 Subject: [PATCH] feat: add turnkey Docker installer with automatic proxy setup Adds a one-command Docker deployment flow that: - Detects if running in LXC and installs Docker if needed - Automatically installs pulse-sensor-proxy on the Proxmox host - Configures bind mount for proxy socket into LXC - Generates optimized docker-compose.yml with proxy socket - Enables temperature monitoring via host-side proxy The install-docker.sh script handles the complete setup including: - Docker installation (if needed) - ACL configuration for container UIDs - Bind mount setup - Automatic apparmor=unconfined for socket access Accessible via: curl -sSL http://pulse:7655/api/install/install-docker.sh | bash --- internal/api/router.go | 169 ++++++++++++++----- internal/api/types.go | 1 + scripts/install-docker.sh | 290 ++++++++++++++++++++++++++++++++ scripts/install-sensor-proxy.sh | 139 ++++++++++++--- 4 files changed, 537 insertions(+), 62 deletions(-) create mode 100644 scripts/install-docker.sh diff --git a/internal/api/router.go b/internal/api/router.go index 1a244cb7e..dcf392bd4 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -6,7 +6,6 @@ import ( "encoding/base64" "encoding/json" "fmt" - "io" "net/http" "os" "os/exec" @@ -133,6 +132,7 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/diagnostics/docker/prepare-token", RequireAdmin(r.config, r.handleDiagnosticsDockerPrepareToken)) r.mux.HandleFunc("/api/install/pulse-sensor-proxy", r.handleDownloadPulseSensorProxy) r.mux.HandleFunc("/api/install/install-sensor-proxy.sh", r.handleDownloadInstallerScript) + r.mux.HandleFunc("/api/install/install-docker.sh", r.handleDownloadDockerInstallerScript) r.mux.HandleFunc("/api/config", r.handleConfig) r.mux.HandleFunc("/api/backups", r.handleBackups) r.mux.HandleFunc("/api/backups/", r.handleBackups) @@ -1167,6 +1167,8 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { "/api/server/info", // Server info for installer script "/api/install/install-sensor-proxy.sh", // Temperature proxy installer fallback "/api/install/pulse-sensor-proxy", // Temperature proxy binary fallback + "/api/install/install-docker.sh", // Docker turnkey installer + "/api/system/proxy-public-key", // Temperature proxy public key for setup script } // Also allow static assets without auth (JS, CSS, etc) @@ -1429,6 +1431,9 @@ func (r *Router) handleHealth(w http.ResponseWriter, req *http.Request) { Msg("Legacy SSH configuration detected - user should migrate to proxy architecture") } + // Check for dev mode SSH override (FOR TESTING ONLY - NEVER in production) + devModeSSH := os.Getenv("PULSE_DEV_ALLOW_CONTAINER_SSH") == "true" + response := HealthResponse{ Status: "healthy", Timestamp: time.Now().Unix(), @@ -1436,6 +1441,7 @@ func (r *Router) handleHealth(w http.ResponseWriter, req *http.Request) { LegacySSHDetected: legacySSH, RecommendProxyUpgrade: recommendProxy, ProxyInstallScriptAvailable: true, // Install script is always available + DevModeSSH: devModeSSH, } if err := utils.WriteJSONResponse(w, response); err != nil { @@ -3081,59 +3087,106 @@ func (r *Router) handleDownloadPulseSensorProxy(w http.ResponseWriter, req *http return } + // Get requested architecture from query param arch := strings.TrimSpace(req.URL.Query().Get("arch")) if arch == "" { - arch = runtime.GOARCH + arch = "linux-amd64" // Default to amd64 } - if runtime.GOOS != "linux" && arch != "linux-amd64" { - writeErrorResponse(w, http.StatusBadRequest, "unsupported_arch", "Only linux-amd64 builds are supported in this environment", nil) + var binaryPath string + var filename string + + // Map architecture to binary filename + switch arch { + case "linux-amd64", "amd64": + filename = "pulse-sensor-proxy-linux-amd64" + case "linux-arm64", "arm64": + filename = "pulse-sensor-proxy-linux-arm64" + case "linux-armv7", "armv7", "armhf": + filename = "pulse-sensor-proxy-linux-armv7" + default: + writeErrorResponse(w, http.StatusBadRequest, "unsupported_arch", fmt.Sprintf("Unsupported architecture: %s", arch), nil) return } - tmpFile, err := os.CreateTemp("", "pulse-sensor-proxy-*.bin") + // Try pre-built architecture-specific binary first (in container) + binaryPath = filepath.Join("/opt/pulse/bin", filename) + content, err := os.ReadFile(binaryPath) if err != nil { - writeErrorResponse(w, http.StatusInternalServerError, "tempfile_error", "Failed to create temporary file", nil) - return - } - tmpFileName := tmpFile.Name() - tmpFile.Close() - defer os.Remove(tmpFileName) - - cmd := exec.Command("go", "build", "-o", tmpFileName, "./cmd/pulse-sensor-proxy") - cmd.Dir = r.projectRoot - cmd.Env = append(os.Environ(), - "GOOS=linux", - "GOARCH=amd64", - "CGO_ENABLED=0", - ) - - buildOutput, err := cmd.CombinedOutput() - if err != nil { - log.Error().Err(err).Bytes("output", buildOutput).Msg("Failed to build pulse-sensor-proxy binary") - writeErrorResponse(w, http.StatusInternalServerError, "build_failed", "Failed to build proxy binary on the server", nil) - return + // Try generic pulse-sensor-proxy binary (built for host arch) + genericPath := "/opt/pulse/bin/pulse-sensor-proxy" + content, err = os.ReadFile(genericPath) + if err == nil { + log.Info(). + Str("arch", arch). + Str("path", genericPath). + Int("size", len(content)). + Msg("Serving generic pulse-sensor-proxy binary (built for host arch)") + binaryPath = genericPath + } } - builtFile, err := os.Open(tmpFileName) if err != nil { - writeErrorResponse(w, http.StatusInternalServerError, "open_error", "Failed to open built proxy binary", nil) - return - } - defer builtFile.Close() + // Fallback: Try to build on-the-fly for dev environments + log.Info(). + Str("arch", arch). + Str("tried_path", binaryPath). + Msg("Pre-built binary not found, attempting to build on-the-fly (dev mode)") - stat, err := builtFile.Stat() - if err != nil { - writeErrorResponse(w, http.StatusInternalServerError, "stat_error", "Failed to stat built binary", nil) - return + if !strings.HasPrefix(arch, "linux-amd64") && runtime.GOARCH != strings.TrimPrefix(arch, "linux-") { + writeErrorResponse(w, http.StatusBadRequest, "cross_compile_unsupported", "Cross-compilation not supported in dev mode", nil) + return + } + + tmpFile, err := os.CreateTemp("", "pulse-sensor-proxy-*.bin") + if err != nil { + log.Error().Err(err).Msg("Failed to create temp file for on-the-fly build") + writeErrorResponse(w, http.StatusInternalServerError, "tempfile_error", "Binary not available and build failed", nil) + return + } + tmpFileName := tmpFile.Name() + tmpFile.Close() + defer os.Remove(tmpFileName) + + cmd := exec.Command("go", "build", "-o", tmpFileName, "./cmd/pulse-sensor-proxy") + cmd.Dir = r.projectRoot + cmd.Env = append(os.Environ(), + "CGO_ENABLED=0", + ) + + buildOutput, err := cmd.CombinedOutput() + if err != nil { + log.Error().Err(err).Bytes("output", buildOutput).Msg("Failed to build pulse-sensor-proxy binary on-the-fly") + writeErrorResponse(w, http.StatusInternalServerError, "build_failed", "Binary not available and on-the-fly build failed", nil) + return + } + + // Read the built binary + content, err = os.ReadFile(tmpFileName) + if err != nil { + log.Error().Err(err).Msg("Failed to read built binary") + writeErrorResponse(w, http.StatusInternalServerError, "read_error", "Failed to read built binary", nil) + return + } + + log.Info(). + Str("arch", arch). + Int("size", len(content)). + Msg("Successfully built pulse-sensor-proxy binary on-the-fly") + } else { + log.Info(). + Str("path", binaryPath). + Str("arch", arch). + Int("size", len(content)). + Msg("Serving pre-built pulse-sensor-proxy binary") } w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"pulse-sensor-proxy-linux-amd64\"")) - w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size())) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) - if _, err := io.Copy(w, builtFile); err != nil { - log.Error().Err(err).Msg("Failed to stream proxy binary to client") + if _, err := w.Write(content); err != nil { + log.Error().Err(err).Msg("Failed to write proxy binary to client") } } @@ -3143,11 +3196,18 @@ func (r *Router) handleDownloadInstallerScript(w http.ResponseWriter, req *http. return } - scriptPath := filepath.Join(r.projectRoot, "scripts", "install-sensor-proxy.sh") + // Try pre-built location first (in container) + scriptPath := "/opt/pulse/scripts/install-sensor-proxy.sh" content, err := os.ReadFile(scriptPath) if err != nil { - writeErrorResponse(w, http.StatusInternalServerError, "read_error", "Failed to read installer script", nil) - return + // Fallback to project root (dev environment) + scriptPath = filepath.Join(r.projectRoot, "scripts", "install-sensor-proxy.sh") + content, err = os.ReadFile(scriptPath) + if err != nil { + log.Error().Err(err).Str("path", scriptPath).Msg("Failed to read installer script") + writeErrorResponse(w, http.StatusInternalServerError, "read_error", "Failed to read installer script", nil) + return + } } w.Header().Set("Content-Type", "text/x-shellscript") @@ -3157,6 +3217,33 @@ func (r *Router) handleDownloadInstallerScript(w http.ResponseWriter, req *http. } } +func (r *Router) handleDownloadDockerInstallerScript(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil) + return + } + + // Try pre-built location first (in container) + scriptPath := "/opt/pulse/scripts/install-docker.sh" + content, err := os.ReadFile(scriptPath) + if err != nil { + // Fallback to project root (dev environment) + scriptPath = filepath.Join(r.projectRoot, "scripts", "install-docker.sh") + content, err = os.ReadFile(scriptPath) + if err != nil { + log.Error().Err(err).Str("path", scriptPath).Msg("Failed to read Docker installer script") + writeErrorResponse(w, http.StatusInternalServerError, "read_error", "Failed to read Docker installer script", nil) + return + } + } + + w.Header().Set("Content-Type", "text/x-shellscript") + w.Header().Set("Content-Disposition", "attachment; filename=install-docker.sh") + if _, err := w.Write(content); err != nil { + log.Error().Err(err).Msg("Failed to write Docker installer script to client") + } +} + func (r *Router) resolvePublicURL(req *http.Request) string { if publicURL := strings.TrimSpace(r.config.PublicURL); publicURL != "" { return strings.TrimRight(publicURL, "/") diff --git a/internal/api/types.go b/internal/api/types.go index d99c4af99..bc191d281 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -17,6 +17,7 @@ type HealthResponse struct { LegacySSHDetected bool `json:"legacySSHDetected,omitempty"` RecommendProxyUpgrade bool `json:"recommendProxyUpgrade,omitempty"` ProxyInstallScriptAvailable bool `json:"proxyInstallScriptAvailable,omitempty"` + DevModeSSH bool `json:"devModeSSH,omitempty"` // DEV/TEST ONLY: SSH keys allowed in containers } // VersionResponse represents version information diff --git a/scripts/install-docker.sh b/scripts/install-docker.sh new file mode 100644 index 000000000..0f266f200 --- /dev/null +++ b/scripts/install-docker.sh @@ -0,0 +1,290 @@ +#!/bin/bash +# install-docker.sh - Turnkey Pulse installation for Docker hosts +# This script installs pulse-sensor-proxy and generates docker-compose.yml + +set -euo pipefail + +PULSE_IMAGE="${PULSE_IMAGE:-rcourtman/pulse:latest}" +PULSE_PORT="${PULSE_PORT:-7655}" + +# ============================================ +# Helper Functions +# ============================================ + +validate_socket() { + local socket_path="$1" + + # Check if it's a socket file + if [ ! -S "$socket_path" ]; then + return 1 + fi + + # Test if we can connect to it (using timeout to avoid hangs) + if command -v socat &>/dev/null; then + if timeout 2 socat -u OPEN:/dev/null UNIX-CONNECT:"$socket_path" 2>/dev/null; then + return 0 + else + return 1 + fi + fi + + # If socat not available, assume socket is valid if it exists as a socket + return 0 +} + +# ============================================ +# Pre-flight Checks +# ============================================ + +echo "============================================" +echo " Pulse Turnkey Docker Installation" +echo "============================================" +echo "" + +# Check if running as root (early check per Codex feedback) +if [ "$EUID" -ne 0 ]; then + echo "❌ ERROR: This script must be run as root" + echo "" + echo "Please run: sudo $0" + exit 1 +fi + +# Detect if running in a container +if [ -f /.dockerenv ] || [ -f /run/.containerenv ]; then + echo "❌ ERROR: This script must run on the Docker host, not inside a container" + echo "" + echo "Please run this script on your Docker host machine." + exit 1 +fi + +# Check if Docker is installed +if ! command -v docker &> /dev/null; then + echo "❌ ERROR: Docker is not installed" + echo "" + echo "Please install Docker first:" + echo " curl -fsSL https://get.docker.com | sh" + exit 1 +fi + +# Check if docker compose is available +if ! docker compose version &> /dev/null; then + echo "⚠️ Warning: 'docker compose' command not found" + echo " You may need to use 'docker-compose' instead" + echo "" +fi + +# ============================================ +# Socket Detection & Deconfliction +# ============================================ + +BIND_MOUNT_SOCKET="/mnt/pulse-proxy/pulse-sensor-proxy.sock" +LOCAL_SOCKET="/run/pulse-sensor-proxy/pulse-sensor-proxy.sock" +SOCKET_PATH="" +SKIP_INSTALLATION=false + +echo "Checking for existing pulse-sensor-proxy..." +echo "" + +# Check for bind-mounted socket (LXC scenario) +if [ -S "$BIND_MOUNT_SOCKET" ]; then + echo " Found socket at /mnt/pulse-proxy (bind-mounted from host)" + if validate_socket "$BIND_MOUNT_SOCKET"; then + echo " ✓ Socket is functional" + SOCKET_PATH="/mnt/pulse-proxy" + SKIP_INSTALLATION=true + + # Deconflict: if local proxy also exists, stop it + if systemctl is-active --quiet pulse-sensor-proxy 2>/dev/null; then + echo " ⚠️ Found conflicting local pulse-sensor-proxy service" + echo " Stopping local service to avoid conflicts..." + systemctl stop pulse-sensor-proxy + systemctl disable pulse-sensor-proxy 2>/dev/null || true + fi + else + echo " ⚠️ Socket exists but is not responsive - will install local proxy" + SKIP_INSTALLATION=false + fi +fi + +# Check for existing local installation +if [ -S "$LOCAL_SOCKET" ] && [ "$SKIP_INSTALLATION" = false ]; then + echo " Found socket at /run/pulse-sensor-proxy (local installation)" + if validate_socket "$LOCAL_SOCKET"; then + echo " ✓ Socket is functional" + SOCKET_PATH="/run/pulse-sensor-proxy" + SKIP_INSTALLATION=true + else + echo " ⚠️ Socket exists but is not responsive - will reinstall" + systemctl stop pulse-sensor-proxy 2>/dev/null || true + SKIP_INSTALLATION=false + fi +fi + +# ============================================ +# Proxy Installation (if needed) +# ============================================ + +if [ "$SKIP_INSTALLATION" = true ]; then + echo "" + echo "✓ Using existing pulse-sensor-proxy at ${SOCKET_PATH}" + echo "" +else + echo " No functional socket found - installing pulse-sensor-proxy..." + echo "" + + # Download and run the proxy installer + PROXY_INSTALLER="/tmp/install-sensor-proxy-$$.sh" + INSTALLER_URL="${PULSE_SERVER:-http://localhost:7655}/api/install/install-sensor-proxy.sh" + + if ! curl --fail --silent --location "$INSTALLER_URL" -o "$PROXY_INSTALLER" 2>/dev/null; then + echo "❌ ERROR: Could not download installer from Pulse server" + echo "" + echo "Please ensure:" + echo " 1. Pulse server is running at ${PULSE_SERVER:-http://localhost:7655}" + echo " 2. Network connectivity is available" + echo "" + echo "Alternatively, download from GitHub releases (coming soon)" + exit 1 + fi + + chmod +x "$PROXY_INSTALLER" + + # Set fallback URL for proxy binary download + export PULSE_SENSOR_PROXY_FALLBACK_URL="${PULSE_SERVER:-http://localhost:7655}/api/install/pulse-sensor-proxy" + + # Run installer in standalone mode (no container) + if ! "$PROXY_INSTALLER" --standalone --pulse-server "${PULSE_SERVER:-http://localhost:7655}" --quiet; then + echo "❌ Proxy installation failed" + rm -f "$PROXY_INSTALLER" + exit 1 + fi + + rm -f "$PROXY_INSTALLER" + + echo "" + echo "✓ pulse-sensor-proxy installed successfully" + echo "" + + # Validate newly installed socket + if ! validate_socket "$LOCAL_SOCKET"; then + echo "⚠️ Warning: Proxy installed but socket is not responsive" + echo " Temperature monitoring may not work correctly" + echo "" + fi + + SOCKET_PATH="/run/pulse-sensor-proxy" +fi + +# ============================================ +# Final Socket Validation +# ============================================ + +if [ -z "$SOCKET_PATH" ]; then + echo "❌ ERROR: No functional socket available after installation" + echo "" + echo "Please check:" + echo " 1. systemctl status pulse-sensor-proxy" + echo " 2. journalctl -u pulse-sensor-proxy -n 50" + exit 1 +fi + +# ============================================ +# Generate Docker Compose Configuration +# ============================================ + +COMPOSE_FILE="./docker-compose.yml" + +# Check if docker-compose.yml already exists (idempotency) +if [ -f "$COMPOSE_FILE" ]; then + echo "⚠️ docker-compose.yml already exists" + echo " Backing up to docker-compose.yml.backup" + cp "$COMPOSE_FILE" "${COMPOSE_FILE}.backup" +fi + +cat > "$COMPOSE_FILE" << 'COMPOSE_EOF' +version: '3.8' + +services: + pulse: + image: ${PULSE_IMAGE:-rcourtman/pulse:latest} + container_name: pulse + restart: unless-stopped + user: "1000:1000" + security_opt: + - apparmor=unconfined + ports: + - "${PULSE_PORT:-7655}:7655" + volumes: + - pulse-data:/data + # Secure temperature monitoring via host-side proxy +COMPOSE_EOF + +# Add socket mount with detected path +echo " - ${SOCKET_PATH}:/mnt/pulse-proxy:ro" >> "$COMPOSE_FILE" + +# Continue compose file +cat >> "$COMPOSE_FILE" << 'COMPOSE_EOF' + environment: + - TZ=${TZ:-UTC} + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:7655/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +volumes: + pulse-data: + driver: local +COMPOSE_EOF + +echo "✓ Generated docker-compose.yml" +echo " Socket mount: ${SOCKET_PATH}:/mnt/pulse-proxy:ro" +echo "" + +# Create .env file with defaults +ENV_FILE=".env" +if [ -f "$ENV_FILE" ]; then + echo "⚠️ .env file already exists - not overwriting" + echo "" +else + cat > "$ENV_FILE" << EOF +PULSE_IMAGE=${PULSE_IMAGE} +PULSE_PORT=${PULSE_PORT} +TZ=$(timedatectl show -p Timezone --value 2>/dev/null || echo "UTC") +EOF + echo "✓ Generated .env file" + echo "" +fi + +# ============================================ +# Installation Complete +# ============================================ + +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "✅ Installation Complete!" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "Socket location: ${SOCKET_PATH}" +echo "" +echo "Start Pulse with:" +echo " docker compose up -d" +echo "" +echo "Or with docker run:" +echo " docker run -d \\" +echo " --name pulse \\" +echo " --user 1000:1000 \\" +echo " --security-opt apparmor=unconfined \\" +echo " --restart unless-stopped \\" +echo " -p ${PULSE_PORT}:7655 \\" +echo " -v pulse-data:/data \\" +echo " -v ${SOCKET_PATH}:/mnt/pulse-proxy:ro \\" +echo " ${PULSE_IMAGE}" +echo "" +echo "Access Pulse at: http://$(hostname -I | awk '{print $1}'):${PULSE_PORT}" +echo "" +echo "Features enabled:" +echo " ✓ Secure temperature monitoring (via host-side proxy)" +echo " ✓ Automatic restarts" +echo " ✓ Persistent data storage" +echo "" diff --git a/scripts/install-sensor-proxy.sh b/scripts/install-sensor-proxy.sh index 32c6d453d..0456a35e7 100755 --- a/scripts/install-sensor-proxy.sh +++ b/scripts/install-sensor-proxy.sh @@ -59,18 +59,13 @@ configure_local_authorized_key() { fi } -# Check if running on Proxmox host -if ! command -v pvecm >/dev/null 2>&1; then - print_error "This script must be run on a Proxmox VE host" - exit 1 -fi - -# Parse arguments +# Parse arguments first to check for standalone mode CTID="" VERSION="latest" LOCAL_BINARY="" QUIET=false PULSE_SERVER="" +STANDALONE=false FALLBACK_BASE="${PULSE_SENSOR_PROXY_FALLBACK_URL:-}" while [[ $# -gt 0 ]]; do @@ -95,6 +90,10 @@ while [[ $# -gt 0 ]]; do QUIET=true shift ;; + --standalone) + STANDALONE=true + shift + ;; *) print_error "Unknown option: $1" exit 1 @@ -107,19 +106,35 @@ if [[ -n "$PULSE_SERVER" ]]; then FALLBACK_BASE="${PULSE_SERVER}/api/install/pulse-sensor-proxy" fi -if [[ -z "$CTID" ]]; then - print_error "Missing required argument: --ctid " - echo "Usage: $0 --ctid [--pulse-server ] [--version ] [--local-binary ]" - exit 1 +# Check if running on Proxmox host (only required for LXC mode) +if [[ "$STANDALONE" == false ]]; then + if ! command -v pvecm >/dev/null 2>&1; then + print_error "This script must be run on a Proxmox VE host" + exit 1 + fi fi -# Verify container exists -if ! pct status "$CTID" >/dev/null 2>&1; then - print_error "Container $CTID does not exist" - exit 1 +# Validate arguments based on mode +if [[ "$STANDALONE" == false ]]; then + if [[ -z "$CTID" ]]; then + print_error "Missing required argument: --ctid " + echo "Usage: $0 --ctid [--pulse-server ] [--version ] [--local-binary ]" + echo " Or: $0 --standalone [--pulse-server ] [--version ] [--local-binary ]" + exit 1 + fi + + # Verify container exists + if ! pct status "$CTID" >/dev/null 2>&1; then + print_error "Container $CTID does not exist" + exit 1 + fi fi -print_info "Installing pulse-sensor-proxy for container $CTID" +if [[ "$STANDALONE" == true ]]; then + print_info "Installing pulse-sensor-proxy for standalone/Docker deployment" +else + print_info "Installing pulse-sensor-proxy for container $CTID" +fi BINARY_PATH="/usr/local/bin/pulse-sensor-proxy" SERVICE_PATH="/etc/systemd/system/pulse-sensor-proxy.service" @@ -216,6 +231,24 @@ fi print_info "Setting up directories with proper ownership..." install -d -o pulse-sensor-proxy -g pulse-sensor-proxy -m 0750 /var/lib/pulse-sensor-proxy install -d -o pulse-sensor-proxy -g pulse-sensor-proxy -m 0700 "$SSH_DIR" +install -d -o pulse-sensor-proxy -g pulse-sensor-proxy -m 0755 /etc/pulse-sensor-proxy + +# Create config file with ACL for Docker containers (standalone mode) +if [[ "$STANDALONE" == true ]]; then + print_info "Creating config file with Docker container ACL..." + cat > /etc/pulse-sensor-proxy/config.yaml << 'EOF' +# Pulse Temperature Proxy Configuration +# Allow Docker containers (UID 1000) to connect +allowed_peer_uids: [1000] + +# Allow ID-mapped root (LXC containers with sub-UID mapping) +allow_idmapped_root: true +allowed_idmap_users: + - root +EOF + chown pulse-sensor-proxy:pulse-sensor-proxy /etc/pulse-sensor-proxy/config.yaml + chmod 0644 /etc/pulse-sensor-proxy/config.yaml +fi # Stop existing service if running (for upgrades) if systemctl is-active --quiet pulse-sensor-proxy 2>/dev/null; then @@ -225,7 +258,67 @@ fi # Install hardened systemd service print_info "Installing hardened systemd service..." -cat > "$SERVICE_PATH" << 'EOF' + +# Generate service file based on mode (Proxmox vs standalone) +if [[ "$STANDALONE" == true ]]; then + # Standalone/Docker mode - no Proxmox-specific paths + cat > "$SERVICE_PATH" << 'EOF' +[Unit] +Description=Pulse Temperature Proxy +Documentation=https://github.com/rcourtman/Pulse +After=network.target + +[Service] +Type=simple +User=pulse-sensor-proxy +Group=pulse-sensor-proxy +WorkingDirectory=/var/lib/pulse-sensor-proxy +ExecStart=/usr/local/bin/pulse-sensor-proxy --config /etc/pulse-sensor-proxy/config.yaml +Restart=on-failure +RestartSec=5s + +# Runtime dirs/sockets +RuntimeDirectory=pulse-sensor-proxy +RuntimeDirectoryMode=0775 +RuntimeDirectoryPreserve=yes +UMask=0007 + +# Core hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/var/lib/pulse-sensor-proxy +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +ProtectClock=true +PrivateTmp=true +PrivateDevices=true +ProtectProc=invisible +ProcSubset=pid +LockPersonality=true +RemoveIPC=true +RestrictSUIDSGID=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +RestrictNamespaces=true +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM +CapabilityBoundingSet= +AmbientCapabilities= +KeyringMode=private +LimitNOFILE=1024 + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=pulse-sensor-proxy + +[Install] +WantedBy=multi-user.target +EOF +else + # Proxmox mode - include Proxmox paths + cat > "$SERVICE_PATH" << 'EOF' [Unit] Description=Pulse Temperature Proxy Documentation=https://github.com/rcourtman/Pulse @@ -281,6 +374,7 @@ SyslogIdentifier=pulse-sensor-proxy [Install] WantedBy=multi-user.target EOF +fi # Reload systemd and start service print_info "Enabling and starting service..." @@ -641,10 +735,12 @@ else configure_local_authorized_key "$AUTH_LINE" fi -# Ensure container mount via mp configuration -print_info "Ensuring container socket mount configuration..." -MOUNT_TARGET="/mnt/pulse-proxy" -LXC_CONFIG="/etc/pve/lxc/${CTID}.conf" +# Container-specific configuration (skip for standalone mode) +if [[ "$STANDALONE" == false ]]; then + # Ensure container mount via mp configuration + print_info "Ensuring container socket mount configuration..." + MOUNT_TARGET="/mnt/pulse-proxy" + LXC_CONFIG="/etc/pve/lxc/${CTID}.conf" CONFIG_CONTENT=$(pct config "$CTID") CURRENT_MP=$(pct config "$CTID" | awk -v target="$MOUNT_TARGET" '$1 ~ /^mp[0-9]+:$/ && index($0, "mp=" target) {split($1, arr, ":"); print arr[1]; exit}') MOUNT_UPDATED=false @@ -762,6 +858,7 @@ if [ "$LEGACY_KEYS_FOUND" = true ] && [ "$QUIET" != true ]; then print_info "Legacy SSH keys removed from container for security" print_info "" fi +fi # End of container-specific configuration if [ "$QUIET" = true ]; then print_success "pulse-sensor-proxy installed and running"