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
This commit is contained in:
parent
a841a1a6fe
commit
049f79987f
4 changed files with 537 additions and 62 deletions
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"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/diagnostics/docker/prepare-token", RequireAdmin(r.config, r.handleDiagnosticsDockerPrepareToken))
|
||||||
r.mux.HandleFunc("/api/install/pulse-sensor-proxy", r.handleDownloadPulseSensorProxy)
|
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-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/config", r.handleConfig)
|
||||||
r.mux.HandleFunc("/api/backups", r.handleBackups)
|
r.mux.HandleFunc("/api/backups", r.handleBackups)
|
||||||
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/server/info", // Server info for installer script
|
||||||
"/api/install/install-sensor-proxy.sh", // Temperature proxy installer fallback
|
"/api/install/install-sensor-proxy.sh", // Temperature proxy installer fallback
|
||||||
"/api/install/pulse-sensor-proxy", // Temperature proxy binary 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)
|
// 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")
|
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{
|
response := HealthResponse{
|
||||||
Status: "healthy",
|
Status: "healthy",
|
||||||
Timestamp: time.Now().Unix(),
|
Timestamp: time.Now().Unix(),
|
||||||
|
|
@ -1436,6 +1441,7 @@ func (r *Router) handleHealth(w http.ResponseWriter, req *http.Request) {
|
||||||
LegacySSHDetected: legacySSH,
|
LegacySSHDetected: legacySSH,
|
||||||
RecommendProxyUpgrade: recommendProxy,
|
RecommendProxyUpgrade: recommendProxy,
|
||||||
ProxyInstallScriptAvailable: true, // Install script is always available
|
ProxyInstallScriptAvailable: true, // Install script is always available
|
||||||
|
DevModeSSH: devModeSSH,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := utils.WriteJSONResponse(w, response); err != nil {
|
if err := utils.WriteJSONResponse(w, response); err != nil {
|
||||||
|
|
@ -3081,59 +3087,106 @@ func (r *Router) handleDownloadPulseSensorProxy(w http.ResponseWriter, req *http
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get requested architecture from query param
|
||||||
arch := strings.TrimSpace(req.URL.Query().Get("arch"))
|
arch := strings.TrimSpace(req.URL.Query().Get("arch"))
|
||||||
if arch == "" {
|
if arch == "" {
|
||||||
arch = runtime.GOARCH
|
arch = "linux-amd64" // Default to amd64
|
||||||
}
|
}
|
||||||
|
|
||||||
if runtime.GOOS != "linux" && arch != "linux-amd64" {
|
var binaryPath string
|
||||||
writeErrorResponse(w, http.StatusBadRequest, "unsupported_arch", "Only linux-amd64 builds are supported in this environment", nil)
|
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
|
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 {
|
if err != nil {
|
||||||
writeErrorResponse(w, http.StatusInternalServerError, "tempfile_error", "Failed to create temporary file", nil)
|
// Try generic pulse-sensor-proxy binary (built for host arch)
|
||||||
return
|
genericPath := "/opt/pulse/bin/pulse-sensor-proxy"
|
||||||
}
|
content, err = os.ReadFile(genericPath)
|
||||||
tmpFileName := tmpFile.Name()
|
if err == nil {
|
||||||
tmpFile.Close()
|
log.Info().
|
||||||
defer os.Remove(tmpFileName)
|
Str("arch", arch).
|
||||||
|
Str("path", genericPath).
|
||||||
cmd := exec.Command("go", "build", "-o", tmpFileName, "./cmd/pulse-sensor-proxy")
|
Int("size", len(content)).
|
||||||
cmd.Dir = r.projectRoot
|
Msg("Serving generic pulse-sensor-proxy binary (built for host arch)")
|
||||||
cmd.Env = append(os.Environ(),
|
binaryPath = genericPath
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
builtFile, err := os.Open(tmpFileName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErrorResponse(w, http.StatusInternalServerError, "open_error", "Failed to open built proxy binary", nil)
|
// Fallback: Try to build on-the-fly for dev environments
|
||||||
return
|
log.Info().
|
||||||
}
|
Str("arch", arch).
|
||||||
defer builtFile.Close()
|
Str("tried_path", binaryPath).
|
||||||
|
Msg("Pre-built binary not found, attempting to build on-the-fly (dev mode)")
|
||||||
|
|
||||||
stat, err := builtFile.Stat()
|
if !strings.HasPrefix(arch, "linux-amd64") && runtime.GOARCH != strings.TrimPrefix(arch, "linux-") {
|
||||||
if err != nil {
|
writeErrorResponse(w, http.StatusBadRequest, "cross_compile_unsupported", "Cross-compilation not supported in dev mode", nil)
|
||||||
writeErrorResponse(w, http.StatusInternalServerError, "stat_error", "Failed to stat built binary", nil)
|
return
|
||||||
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-Type", "application/octet-stream")
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"pulse-sensor-proxy-linux-amd64\""))
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
||||||
|
|
||||||
if _, err := io.Copy(w, builtFile); err != nil {
|
if _, err := w.Write(content); err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to stream proxy binary to client")
|
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
|
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)
|
content, err := os.ReadFile(scriptPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErrorResponse(w, http.StatusInternalServerError, "read_error", "Failed to read installer script", nil)
|
// Fallback to project root (dev environment)
|
||||||
return
|
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")
|
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 {
|
func (r *Router) resolvePublicURL(req *http.Request) string {
|
||||||
if publicURL := strings.TrimSpace(r.config.PublicURL); publicURL != "" {
|
if publicURL := strings.TrimSpace(r.config.PublicURL); publicURL != "" {
|
||||||
return strings.TrimRight(publicURL, "/")
|
return strings.TrimRight(publicURL, "/")
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ type HealthResponse struct {
|
||||||
LegacySSHDetected bool `json:"legacySSHDetected,omitempty"`
|
LegacySSHDetected bool `json:"legacySSHDetected,omitempty"`
|
||||||
RecommendProxyUpgrade bool `json:"recommendProxyUpgrade,omitempty"`
|
RecommendProxyUpgrade bool `json:"recommendProxyUpgrade,omitempty"`
|
||||||
ProxyInstallScriptAvailable bool `json:"proxyInstallScriptAvailable,omitempty"`
|
ProxyInstallScriptAvailable bool `json:"proxyInstallScriptAvailable,omitempty"`
|
||||||
|
DevModeSSH bool `json:"devModeSSH,omitempty"` // DEV/TEST ONLY: SSH keys allowed in containers
|
||||||
}
|
}
|
||||||
|
|
||||||
// VersionResponse represents version information
|
// VersionResponse represents version information
|
||||||
|
|
|
||||||
290
scripts/install-docker.sh
Normal file
290
scripts/install-docker.sh
Normal file
|
|
@ -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 ""
|
||||||
|
|
@ -59,18 +59,13 @@ configure_local_authorized_key() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if running on Proxmox host
|
# Parse arguments first to check for standalone mode
|
||||||
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
|
|
||||||
CTID=""
|
CTID=""
|
||||||
VERSION="latest"
|
VERSION="latest"
|
||||||
LOCAL_BINARY=""
|
LOCAL_BINARY=""
|
||||||
QUIET=false
|
QUIET=false
|
||||||
PULSE_SERVER=""
|
PULSE_SERVER=""
|
||||||
|
STANDALONE=false
|
||||||
FALLBACK_BASE="${PULSE_SENSOR_PROXY_FALLBACK_URL:-}"
|
FALLBACK_BASE="${PULSE_SENSOR_PROXY_FALLBACK_URL:-}"
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
|
|
@ -95,6 +90,10 @@ while [[ $# -gt 0 ]]; do
|
||||||
QUIET=true
|
QUIET=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--standalone)
|
||||||
|
STANDALONE=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
print_error "Unknown option: $1"
|
print_error "Unknown option: $1"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
@ -107,19 +106,35 @@ if [[ -n "$PULSE_SERVER" ]]; then
|
||||||
FALLBACK_BASE="${PULSE_SERVER}/api/install/pulse-sensor-proxy"
|
FALLBACK_BASE="${PULSE_SERVER}/api/install/pulse-sensor-proxy"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "$CTID" ]]; then
|
# Check if running on Proxmox host (only required for LXC mode)
|
||||||
print_error "Missing required argument: --ctid <container-id>"
|
if [[ "$STANDALONE" == false ]]; then
|
||||||
echo "Usage: $0 --ctid <container-id> [--pulse-server <url>] [--version <version>] [--local-binary <path>]"
|
if ! command -v pvecm >/dev/null 2>&1; then
|
||||||
exit 1
|
print_error "This script must be run on a Proxmox VE host"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Verify container exists
|
# Validate arguments based on mode
|
||||||
if ! pct status "$CTID" >/dev/null 2>&1; then
|
if [[ "$STANDALONE" == false ]]; then
|
||||||
print_error "Container $CTID does not exist"
|
if [[ -z "$CTID" ]]; then
|
||||||
exit 1
|
print_error "Missing required argument: --ctid <container-id>"
|
||||||
|
echo "Usage: $0 --ctid <container-id> [--pulse-server <url>] [--version <version>] [--local-binary <path>]"
|
||||||
|
echo " Or: $0 --standalone [--pulse-server <url>] [--version <version>] [--local-binary <path>]"
|
||||||
|
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
|
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"
|
BINARY_PATH="/usr/local/bin/pulse-sensor-proxy"
|
||||||
SERVICE_PATH="/etc/systemd/system/pulse-sensor-proxy.service"
|
SERVICE_PATH="/etc/systemd/system/pulse-sensor-proxy.service"
|
||||||
|
|
@ -216,6 +231,24 @@ fi
|
||||||
print_info "Setting up directories with proper ownership..."
|
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 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 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)
|
# Stop existing service if running (for upgrades)
|
||||||
if systemctl is-active --quiet pulse-sensor-proxy 2>/dev/null; then
|
if systemctl is-active --quiet pulse-sensor-proxy 2>/dev/null; then
|
||||||
|
|
@ -225,7 +258,67 @@ fi
|
||||||
|
|
||||||
# Install hardened systemd service
|
# Install hardened systemd service
|
||||||
print_info "Installing 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]
|
[Unit]
|
||||||
Description=Pulse Temperature Proxy
|
Description=Pulse Temperature Proxy
|
||||||
Documentation=https://github.com/rcourtman/Pulse
|
Documentation=https://github.com/rcourtman/Pulse
|
||||||
|
|
@ -281,6 +374,7 @@ SyslogIdentifier=pulse-sensor-proxy
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
# Reload systemd and start service
|
# Reload systemd and start service
|
||||||
print_info "Enabling and starting service..."
|
print_info "Enabling and starting service..."
|
||||||
|
|
@ -641,10 +735,12 @@ else
|
||||||
configure_local_authorized_key "$AUTH_LINE"
|
configure_local_authorized_key "$AUTH_LINE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure container mount via mp configuration
|
# Container-specific configuration (skip for standalone mode)
|
||||||
print_info "Ensuring container socket mount configuration..."
|
if [[ "$STANDALONE" == false ]]; then
|
||||||
MOUNT_TARGET="/mnt/pulse-proxy"
|
# Ensure container mount via mp configuration
|
||||||
LXC_CONFIG="/etc/pve/lxc/${CTID}.conf"
|
print_info "Ensuring container socket mount configuration..."
|
||||||
|
MOUNT_TARGET="/mnt/pulse-proxy"
|
||||||
|
LXC_CONFIG="/etc/pve/lxc/${CTID}.conf"
|
||||||
CONFIG_CONTENT=$(pct config "$CTID")
|
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}')
|
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
|
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 "Legacy SSH keys removed from container for security"
|
||||||
print_info ""
|
print_info ""
|
||||||
fi
|
fi
|
||||||
|
fi # End of container-specific configuration
|
||||||
|
|
||||||
if [ "$QUIET" = true ]; then
|
if [ "$QUIET" = true ]; then
|
||||||
print_success "pulse-sensor-proxy installed and running"
|
print_success "pulse-sensor-proxy installed and running"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue