Pulse/scripts/install-sensor-proxy.sh
rcourtman fbfd086724 Fix missed /usr/local path migration and add backward compatibility
**Missed Migration**:
- Line 2204 still used /usr/local/bin/pulse-sensor-wrapper.sh in fallback path
- Updated to use /opt/pulse/sensor-proxy/bin/pulse-sensor-wrapper.sh

**Backward Compatibility**:
- When pushing SSH keys to cluster nodes, installer now checks if remote node
  has old installation (/usr/local/bin wrapper exists but /opt path doesn't)
- Automatically creates symlink on remote nodes to maintain compatibility
- Prevents temperature collection failures when cluster has mixed old/new installs

**Root Cause**:
When installer runs on upgraded node (delly), it pushes SSH keys with new forced
command path to all cluster nodes. If remote node (minipc) has old installation,
the forced command fails because wrapper doesn't exist at new path.

This fix ensures "it works straight out the box" by bridging old and new paths
automatically during SSH key deployment.
2025-11-15 08:37:44 +00:00

2567 lines
94 KiB
Bash
Executable file

#!/bin/bash
# install-sensor-proxy.sh - Installs pulse-sensor-proxy on Proxmox host for secure temperature monitoring
# Supports --uninstall [--purge] to remove the proxy and cleanup resources.
# This script is idempotent and can be safely re-run
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
print_info() {
if [ "$QUIET" != true ]; then
echo -e "${GREEN}[INFO]${NC} $1"
fi
}
print_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_success() {
echo -e "${GREEN}${NC} $1"
}
configure_local_authorized_key() {
local auth_line=$1
local auth_keys_file="/root/.ssh/authorized_keys"
local tmp_auth
tmp_auth=$(mktemp)
mkdir -p /root/.ssh
touch "$tmp_auth"
if [[ -f "$auth_keys_file" ]]; then
grep -vF '# pulse-managed-key' "$auth_keys_file" >"$tmp_auth" 2>/dev/null || true
chmod --reference="$auth_keys_file" "$tmp_auth" 2>/dev/null || chmod 600 "$tmp_auth"
chown --reference="$auth_keys_file" "$tmp_auth" 2>/dev/null || true
else
chmod 600 "$tmp_auth"
fi
echo "${auth_line}" >>"$tmp_auth"
if mv "$tmp_auth" "$auth_keys_file"; then
if [ "$QUIET" != true ]; then
print_success "SSH key configured on localhost"
fi
else
rm -f "$tmp_auth"
print_warn "Failed to configure SSH key on localhost"
print_info "Add this line manually to /root/.ssh/authorized_keys:"
print_info " ${auth_line}"
fi
}
# Helper function to safely update allowed_nodes section in config file
# Removes any existing allowed_nodes section before adding new one to prevent duplicates
update_allowed_nodes() {
local config_file="/etc/pulse-sensor-proxy/config.yaml"
local comment_line="$1"
shift
local nodes=("$@")
# Create temp file with unique prefix to avoid conflicts
local tmp_config
tmp_config=$(mktemp /tmp/pulse-config.XXXXXXXXXX)
# Ensure temp file is cleaned up on exit (success or failure)
trap "rm -f '$tmp_config'" RETURN
# Remove any existing allowed_nodes section using a robust state machine
# This removes the YAML key, all list items, and any comment lines immediately preceding it
if [[ -f "$config_file" ]]; then
awk '
BEGIN {
in_section = 0
comment_buffer_size = 0
}
# Detect start of allowed_nodes section
/^allowed_nodes:/ {
# Discard any buffered comment lines (they belong to allowed_nodes)
comment_buffer_size = 0
in_section = 1
next
}
# Inside allowed_nodes section: skip lines until we hit a non-indented, non-comment line
in_section {
# Empty lines within section: skip
if (/^[ \t]*$/) {
next
}
# Indented lines (list items): skip
if (/^[ \t]+-/) {
next
}
# Indented comments: skip
if (/^[ \t]+#/) {
next
}
# Non-indented, non-comment line: section ends
if (/^[^ \t#]/) {
in_section = 0
# Fall through to normal processing
}
}
# Outside section: buffer comment lines (might precede allowed_nodes)
!in_section && /^[ \t]*#/ {
comment_buffer[comment_buffer_size++] = $0
next
}
# Outside section: non-comment, non-empty line
!in_section && !/^[ \t]*$/ {
# Flush buffered comments (they don'\''t belong to allowed_nodes)
for (i = 0; i < comment_buffer_size; i++) {
print comment_buffer[i]
}
comment_buffer_size = 0
print
next
}
# Outside section: empty line
!in_section && /^[ \t]*$/ {
# Flush buffered comments and print empty line
for (i = 0; i < comment_buffer_size; i++) {
print comment_buffer[i]
}
comment_buffer_size = 0
print
next
}
# At end of file, flush any remaining buffered comments
END {
for (i = 0; i < comment_buffer_size; i++) {
print comment_buffer[i]
}
}
' "$config_file" > "$tmp_config"
# Preserve file permissions (but defer ownership change until after all writes)
chmod --reference="$config_file" "$tmp_config" 2>/dev/null || chmod 0644 "$tmp_config"
else
touch "$tmp_config"
chmod 0644 "$tmp_config"
fi
# Append new allowed_nodes section
{
echo ""
echo "# ${comment_line}"
echo "# These nodes are allowed to request temperature data when cluster IPC validation is unavailable"
echo "allowed_nodes:"
for node in "${nodes[@]}"; do
echo " - $node"
done
} >> "$tmp_config"
# Replace original file atomically
if ! mv "$tmp_config" "$config_file"; then
echo "ERROR: Failed to update $config_file - check permissions" >&2
return 1
fi
# Ensure proper permissions
chmod 0644 "$config_file" 2>/dev/null || true
chown pulse-sensor-proxy:pulse-sensor-proxy "$config_file" 2>/dev/null || true
}
# Installation root - writable location that works on read-only /usr systems
INSTALL_ROOT="/opt/pulse/sensor-proxy"
# Binaries and scripts (in writable location)
BINARY_PATH="${INSTALL_ROOT}/bin/pulse-sensor-proxy"
WRAPPER_SCRIPT="${INSTALL_ROOT}/bin/pulse-sensor-wrapper.sh"
CLEANUP_SCRIPT_PATH="${INSTALL_ROOT}/bin/pulse-sensor-cleanup.sh"
SELFHEAL_SCRIPT="${INSTALL_ROOT}/bin/pulse-sensor-proxy-selfheal.sh"
STORED_INSTALLER="${INSTALL_ROOT}/install-sensor-proxy.sh"
# System configuration (standard locations)
SERVICE_PATH="/etc/systemd/system/pulse-sensor-proxy.service"
RUNTIME_DIR="/run/pulse-sensor-proxy"
SOCKET_PATH="${RUNTIME_DIR}/pulse-sensor-proxy.sock"
WORK_DIR="/var/lib/pulse-sensor-proxy"
SSH_DIR="${WORK_DIR}/ssh"
CONFIG_DIR="/etc/pulse-sensor-proxy"
CTID_FILE="${CONFIG_DIR}/ctid"
CLEANUP_PATH_UNIT="/etc/systemd/system/pulse-sensor-cleanup.path"
CLEANUP_SERVICE_UNIT="/etc/systemd/system/pulse-sensor-cleanup.service"
CLEANUP_REQUEST_PATH="${WORK_DIR}/cleanup-request.json"
SERVICE_USER="pulse-sensor-proxy"
LOG_DIR="/var/log/pulse/sensor-proxy"
SELFHEAL_SERVICE_UNIT="/etc/systemd/system/pulse-sensor-proxy-selfheal.service"
SELFHEAL_TIMER_UNIT="/etc/systemd/system/pulse-sensor-proxy-selfheal.timer"
SCRIPT_SOURCE="$(readlink -f "${BASH_SOURCE[0]:-$0}" 2>/dev/null || printf '%s' "${BASH_SOURCE[0]:-$0}")"
GITHUB_REPO="rcourtman/Pulse"
LATEST_RELEASE_TAG=""
REQUESTED_VERSION=""
INSTALLER_CACHE_REASON=""
DEFER_SOCKET_VERIFICATION=false
cleanup_local_authorized_keys() {
local auth_keys_file="/root/.ssh/authorized_keys"
if [[ ! -f "$auth_keys_file" ]]; then
return
fi
if grep -q '# pulse-\(managed\|proxy\)-key$' "$auth_keys_file"; then
if sed -i -e '/# pulse-managed-key$/d' -e '/# pulse-proxy-key$/d' "$auth_keys_file"; then
print_info "Removed Pulse SSH keys from ${auth_keys_file}"
else
print_warn "Failed to clean Pulse SSH keys from ${auth_keys_file}"
fi
fi
}
cleanup_cluster_authorized_keys_manual() {
local nodes=()
if command -v pvecm >/dev/null 2>&1; then
while IFS= read -r node_ip; do
[[ -n "$node_ip" ]] && nodes+=("$node_ip")
done < <(pvecm status 2>/dev/null | awk '/0x[0-9a-f]+.*[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/ {print $3}' || true)
fi
if [[ ${#nodes[@]} -eq 0 ]]; then
cleanup_local_authorized_keys
return
fi
local local_ips
local_ips="$(hostname -I 2>/dev/null || echo "")"
local local_hostnames
local_hostnames="$(hostname 2>/dev/null || echo "") $(hostname -f 2>/dev/null || echo "")"
for node_ip in "${nodes[@]}"; do
local is_local=false
for local_ip in $local_ips; do
if [[ "$node_ip" == "$local_ip" ]]; then
is_local=true
break
fi
done
if [[ " $local_hostnames " == *" $node_ip "* ]]; then
is_local=true
fi
if [[ "$node_ip" == "127.0.0.1" || "$node_ip" == "localhost" ]]; then
is_local=true
fi
if [[ "$is_local" == true ]]; then
cleanup_local_authorized_keys
continue
fi
print_info "Removing Pulse SSH keys from node ${node_ip}"
if ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=5 root@"$node_ip" \
"sed -i -e '/# pulse-managed-key\$/d' -e '/# pulse-proxy-key\$/d' /root/.ssh/authorized_keys" 2>/dev/null; then
print_info " SSH keys cleaned on ${node_ip}"
else
print_warn " Unable to clean Pulse SSH keys on ${node_ip}"
fi
done
cleanup_local_authorized_keys
}
determine_installer_ref() {
if [[ -n "$REQUESTED_VERSION" && "$REQUESTED_VERSION" != "latest" && "$REQUESTED_VERSION" != "main" ]]; then
printf '%s' "$REQUESTED_VERSION"
return 0
fi
if [[ -n "$LATEST_RELEASE_TAG" ]]; then
printf '%s' "$LATEST_RELEASE_TAG"
return 0
fi
if [[ "$REQUESTED_VERSION" == "main" ]]; then
printf 'main'
return 0
fi
printf 'main'
}
cache_installer_for_self_heal() {
INSTALLER_CACHE_REASON=""
install -d "${INSTALL_ROOT}"
local source_issue=""
if [[ -n "$SCRIPT_SOURCE" && -f "$SCRIPT_SOURCE" ]]; then
if install -m 0755 "$SCRIPT_SOURCE" "$STORED_INSTALLER"; then
return 0
fi
source_issue="failed to copy ${SCRIPT_SOURCE}"
else
source_issue="no readable source"
fi
local repo="${GITHUB_REPO:-rcourtman/Pulse}"
local ref
ref="$(determine_installer_ref)"
[[ -z "$ref" ]] && ref="main"
local candidate_urls=()
if [[ "$ref" != "main" ]]; then
candidate_urls+=("https://github.com/${repo}/releases/download/${ref}/install-sensor-proxy.sh")
fi
candidate_urls+=("https://raw.githubusercontent.com/${repo}/${ref}/scripts/install-sensor-proxy.sh")
local tmp_file
tmp_file=$(mktemp)
local tmp_err
tmp_err=$(mktemp)
local last_error=""
for url in "${candidate_urls[@]}"; do
if curl --fail --silent --location --connect-timeout 10 --max-time 60 "$url" -o "$tmp_file" 2>"$tmp_err"; then
if install -m 0755 "$tmp_file" "$STORED_INSTALLER"; then
rm -f "$tmp_file" "$tmp_err"
if [[ "$QUIET" != true ]]; then
print_info "Cached installer script for self-heal from ${url}"
fi
return 0
fi
last_error="failed to write cached installer to ${STORED_INSTALLER}"
break
fi
if [[ -s "$tmp_err" ]]; then
last_error="$(cat "$tmp_err")"
else
last_error="HTTP error"
fi
: >"$tmp_err"
done
rm -f "$tmp_file" "$tmp_err"
if [[ -n "$source_issue" && -n "$last_error" ]]; then
INSTALLER_CACHE_REASON="${source_issue}; download failed (${last_error})"
elif [[ -n "$source_issue" ]]; then
INSTALLER_CACHE_REASON="$source_issue"
elif [[ -n "$last_error" ]]; then
INSTALLER_CACHE_REASON="download failed (${last_error})"
else
INSTALLER_CACHE_REASON="unknown failure"
fi
return 1
}
perform_uninstall() {
print_info "Starting pulse-sensor-proxy uninstall..."
if command -v systemctl >/dev/null 2>&1; then
print_info "Stopping pulse-sensor-proxy service"
systemctl stop pulse-sensor-proxy 2>/dev/null || true
print_info "Disabling pulse-sensor-proxy service"
systemctl disable pulse-sensor-proxy 2>/dev/null || true
print_info "Stopping cleanup path watcher"
systemctl stop pulse-sensor-cleanup.path 2>/dev/null || true
systemctl disable pulse-sensor-cleanup.path 2>/dev/null || true
systemctl stop pulse-sensor-cleanup.service 2>/dev/null || true
systemctl disable pulse-sensor-cleanup.service 2>/dev/null || true
else
print_warn "systemctl not available; skipping service disable"
fi
if [[ -x "$CLEANUP_SCRIPT_PATH" ]]; then
print_info "Invoking cleanup script to remove Pulse SSH keys"
mkdir -p "$WORK_DIR"
cat > "$CLEANUP_REQUEST_PATH" <<'EOF'
{"host":""}
EOF
if "$CLEANUP_SCRIPT_PATH"; then
print_success "Cleanup script removed Pulse SSH keys"
else
print_warn "Cleanup script reported errors; attempting manual cleanup"
cleanup_cluster_authorized_keys_manual
fi
rm -f "$CLEANUP_REQUEST_PATH"
else
cleanup_cluster_authorized_keys_manual
fi
if [[ -f "$BINARY_PATH" ]]; then
rm -f "$BINARY_PATH"
print_success "Removed binary ${BINARY_PATH}"
else
print_info "Binary already absent at ${BINARY_PATH}"
fi
if [[ -f "$SERVICE_PATH" ]]; then
rm -f "$SERVICE_PATH"
print_success "Removed service unit ${SERVICE_PATH}"
fi
if [[ -f "$CLEANUP_PATH_UNIT" ]]; then
rm -f "$CLEANUP_PATH_UNIT"
print_success "Removed cleanup path unit ${CLEANUP_PATH_UNIT}"
fi
if [[ -f "$CLEANUP_SERVICE_UNIT" ]]; then
rm -f "$CLEANUP_SERVICE_UNIT"
print_success "Removed cleanup service unit ${CLEANUP_SERVICE_UNIT}"
fi
if [[ -f "$SELFHEAL_TIMER_UNIT" ]]; then
systemctl stop pulse-sensor-proxy-selfheal.timer 2>/dev/null || true
systemctl disable pulse-sensor-proxy-selfheal.timer 2>/dev/null || true
rm -f "$SELFHEAL_TIMER_UNIT"
print_success "Removed self-heal timer ${SELFHEAL_TIMER_UNIT}"
fi
if [[ -f "$SELFHEAL_SERVICE_UNIT" ]]; then
systemctl stop pulse-sensor-proxy-selfheal.service 2>/dev/null || true
systemctl disable pulse-sensor-proxy-selfheal.service 2>/dev/null || true
rm -f "$SELFHEAL_SERVICE_UNIT"
print_success "Removed self-heal service ${SELFHEAL_SERVICE_UNIT}"
fi
if [[ -f "$SELFHEAL_SCRIPT" ]]; then
rm -f "$SELFHEAL_SCRIPT"
print_success "Removed self-heal helper ${SELFHEAL_SCRIPT}"
fi
if [[ -f "$STORED_INSTALLER" ]]; then
rm -f "$STORED_INSTALLER"
print_success "Removed cached installer ${STORED_INSTALLER}"
fi
if [[ -f "$CTID_FILE" ]]; then
rm -f "$CTID_FILE"
fi
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload 2>/dev/null || true
fi
rm -f "$CLEANUP_SCRIPT_PATH" "$CLEANUP_REQUEST_PATH" 2>/dev/null || true
rm -f "$SOCKET_PATH" 2>/dev/null || true
rm -rf "$RUNTIME_DIR" 2>/dev/null || true
# Always remove HTTP secrets and TLS material (security best practice)
if [[ -f "/etc/pulse-sensor-proxy/.http-auth-token" ]]; then
rm -f "/etc/pulse-sensor-proxy/.http-auth-token"
print_success "Removed HTTP auth token"
fi
if [[ -d "/etc/pulse-sensor-proxy/tls" ]]; then
rm -rf "/etc/pulse-sensor-proxy/tls"
print_success "Removed TLS certificates"
fi
# Check for and remove LXC bind mounts on any containers
if command -v pct >/dev/null 2>&1; then
print_info "Checking for LXC bind mounts..."
# Find all containers with pulse-sensor-proxy bind mounts
for ctid in $(pct list | awk 'NR>1 {print $1}'); do
if grep -q "pulse-sensor-proxy" /etc/pve/lxc/${ctid}.conf 2>/dev/null; then
CONTAINER_NAME=$(pct list | awk -v id="$ctid" '$1==id {print $3}')
print_info "Found bind mount in container $ctid ($CONTAINER_NAME)"
if sed -i '/pulse-sensor-proxy/d' /etc/pve/lxc/${ctid}.conf 2>/dev/null; then
print_success "Removed bind mount from container $ctid ($CONTAINER_NAME)"
print_warn "Container restart required for change to take effect"
else
print_warn "Failed to remove bind mount from container $ctid"
fi
fi
done
fi
if [[ "$PURGE" == true ]]; then
print_info "Purging Pulse sensor proxy state"
rm -rf "$WORK_DIR" "$CONFIG_DIR" 2>/dev/null || true
if [[ -d "$LOG_DIR" ]]; then
print_info "Removing log directory ${LOG_DIR}"
fi
rm -rf "$LOG_DIR" 2>/dev/null || true
if id -u "$SERVICE_USER" >/dev/null 2>&1; then
if userdel --remove "$SERVICE_USER" 2>/dev/null; then
print_success "Removed service user ${SERVICE_USER}"
elif userdel "$SERVICE_USER" 2>/dev/null; then
print_success "Removed service user ${SERVICE_USER}"
else
print_warn "Failed to remove service user ${SERVICE_USER}"
fi
fi
if getent group "$SERVICE_USER" >/dev/null 2>&1; then
if groupdel "$SERVICE_USER" 2>/dev/null; then
print_success "Removed service group ${SERVICE_USER}"
else
print_warn "Failed to remove service group ${SERVICE_USER}"
fi
fi
else
if [[ -d "$WORK_DIR" ]]; then
print_info "Preserving data directory ${WORK_DIR} (use --purge to remove)"
fi
if [[ -d "$CONFIG_DIR" ]]; then
print_info "Preserving config directory ${CONFIG_DIR} (use --purge to remove)"
fi
if [[ -d "$LOG_DIR" ]]; then
print_info "Preserving log directory ${LOG_DIR} (use --purge to remove)"
fi
fi
print_success "pulse-sensor-proxy uninstall complete"
}
# Parse arguments first to check for standalone mode
CTID=""
VERSION="latest"
LOCAL_BINARY=""
QUIET=false
PULSE_SERVER=""
STANDALONE=false
HTTP_MODE=false
HTTP_ADDR=":8443"
FALLBACK_BASE="${PULSE_SENSOR_PROXY_FALLBACK_URL:-}"
SKIP_RESTART=false
UNINSTALL=false
PURGE=false
while [[ $# -gt 0 ]]; do
case $1 in
--ctid)
CTID="$2"
shift 2
;;
--version)
VERSION="$2"
shift 2
;;
--local-binary)
LOCAL_BINARY="$2"
shift 2
;;
--pulse-server)
PULSE_SERVER="$2"
shift 2
;;
--quiet)
QUIET=true
shift
;;
--standalone)
STANDALONE=true
shift
;;
--http-mode)
HTTP_MODE=true
shift
;;
--http-addr)
HTTP_ADDR="$2"
shift 2
;;
--skip-restart)
SKIP_RESTART=true
shift
;;
--uninstall)
UNINSTALL=true
shift
;;
--purge)
PURGE=true
shift
;;
*)
print_error "Unknown option: $1"
exit 1
;;
esac
done
if [[ "$PURGE" == true && "$UNINSTALL" != true ]]; then
print_warn "--purge is only valid together with --uninstall; ignoring"
PURGE=false
fi
if [[ "$UNINSTALL" == true ]]; then
perform_uninstall
exit 0
fi
REQUESTED_VERSION="${VERSION:-latest}"
# If --pulse-server was provided, use it as the fallback base
if [[ -n "$PULSE_SERVER" ]]; then
FALLBACK_BASE="${PULSE_SERVER}/api/install/pulse-sensor-proxy"
fi
# Preflight checks
if [[ $EUID -ne 0 ]]; then
print_error "This script must be run as root"
print_error "Use: sudo $0 $*"
exit 1
fi
# Check required commands
REQUIRED_CMDS="curl openssl systemctl useradd groupadd install chmod chown mkdir"
if [[ "$HTTP_MODE" == true ]]; then
REQUIRED_CMDS="$REQUIRED_CMDS hostname awk"
fi
if [[ "$STANDALONE" == false ]]; then
REQUIRED_CMDS="$REQUIRED_CMDS pvecm"
fi
for cmd in $REQUIRED_CMDS; do
if ! command -v "$cmd" >/dev/null 2>&1; then
print_error "Required command not found: $cmd"
print_error "Please install it and try again"
exit 1
fi
done
# 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
# Validate arguments based on mode
if [[ "$STANDALONE" == false ]]; then
if [[ -z "$CTID" ]]; then
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>]"
echo " Or: $0 --uninstall [--purge]"
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
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
# Create dedicated service account if it doesn't exist
if ! id -u pulse-sensor-proxy >/dev/null 2>&1; then
print_info "Creating pulse-sensor-proxy service account..."
useradd --system --user-group --no-create-home --shell /usr/sbin/nologin pulse-sensor-proxy
print_info "Service account created"
else
print_info "Service account pulse-sensor-proxy already exists"
fi
# Add pulse-sensor-proxy user to www-data group for Proxmox IPC access (pvecm commands)
if ! groups pulse-sensor-proxy | grep -q '\bwww-data\b'; then
print_info "Adding pulse-sensor-proxy to www-data group for Proxmox IPC access..."
usermod -aG www-data pulse-sensor-proxy
fi
# Create installation directories before binary installation (handles fresh installs and upgrades)
print_info "Setting up installation directories..."
install -d -o root -g root -m 0755 "${INSTALL_ROOT}"
install -d -o root -g root -m 0755 "${INSTALL_ROOT}/bin"
# Install binary - either from local file or download from GitHub
if [[ -n "$LOCAL_BINARY" ]]; then
# Use local binary for testing
print_info "Using local binary: $LOCAL_BINARY"
if [[ ! -f "$LOCAL_BINARY" ]]; then
print_error "Local binary not found: $LOCAL_BINARY"
exit 1
fi
cp "$LOCAL_BINARY" "$BINARY_PATH"
chmod +x "$BINARY_PATH"
print_info "Binary installed to $BINARY_PATH"
else
# Detect architecture
ARCH=$(uname -m)
case $ARCH in
x86_64)
BINARY_NAME="pulse-sensor-proxy-linux-amd64"
ARCH_LABEL="linux-amd64"
;;
aarch64|arm64)
BINARY_NAME="pulse-sensor-proxy-linux-arm64"
ARCH_LABEL="linux-arm64"
;;
armv7l|armhf)
BINARY_NAME="pulse-sensor-proxy-linux-armv7"
ARCH_LABEL="linux-armv7"
;;
armv6l)
BINARY_NAME="pulse-sensor-proxy-linux-armv6"
ARCH_LABEL="linux-armv6"
;;
i386|i686)
BINARY_NAME="pulse-sensor-proxy-linux-386"
ARCH_LABEL="linux-386"
;;
*)
print_error "Unsupported architecture: $ARCH"
exit 1
;;
esac
DOWNLOAD_SUCCESS=false
ATTEMPTED_SOURCES=()
fetch_latest_release_tag() {
local api_url="https://api.github.com/repos/$GITHUB_REPO/releases?per_page=25"
local tmp_err
tmp_err=$(mktemp)
local response
response=$(curl --fail --silent --location --connect-timeout 10 --max-time 30 "$api_url" 2>"$tmp_err")
local status=$?
if [[ $status -ne 0 ]]; then
if [[ -s "$tmp_err" ]]; then
print_warn "Failed to resolve latest GitHub release: $(cat "$tmp_err")"
else
print_warn "Failed to resolve latest GitHub release (HTTP $status)"
fi
rm -f "$tmp_err"
return 1
fi
rm -f "$tmp_err"
local tag=""
if command -v python3 >/dev/null 2>&1; then
if ! tag=$(printf '%s' "$response" | python3 -c '
import json
import sys
binary_name = sys.argv[1]
arch_label = sys.argv[2]
tar_suffix = arch_label or ""
def has_sensor_assets(tag, assets):
names = {asset.get("name") for asset in assets if isinstance(asset, dict) and asset.get("name")}
if binary_name and binary_name in names:
return True
if tar_suffix:
tarball = f"pulse-{tag}-{tar_suffix}.tar.gz"
if tarball in names or f"{tarball}.sha256" in names:
return True
universal = f"pulse-{tag}.tar.gz"
if universal in names or f"{universal}.sha256" in names:
return True
return False
try:
releases = json.load(sys.stdin)
except json.JSONDecodeError:
sys.exit(1)
for release in releases:
tag_name = (release.get("tag_name") or "").strip()
if not tag_name or tag_name.startswith("helm-chart"):
continue
assets = release.get("assets") or []
if has_sensor_assets(tag_name, assets):
sys.stdout.write(tag_name)
sys.exit(0)
sys.exit(0)
' "$BINARY_NAME" "$ARCH_LABEL"); then
print_warn "Failed to parse GitHub releases via python3; falling back to heuristic tag detection"
tag=""
fi
fi
if [[ -z "$tag" ]]; then
tag=$(printf '%s\n' "$response" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4 | grep -Ev '^helm-chart-' | head -n 1 || true)
fi
if [[ -n "$tag" ]]; then
tag="${tag%%$'\n'*}"
fi
if [[ -z "$tag" ]]; then
print_warn "Could not determine latest GitHub release for pulse-sensor-proxy"
return 1
fi
LATEST_RELEASE_TAG="$tag"
return 0
}
attempt_github_asset_or_tarball() {
local tag="$1"
[[ -z "$tag" ]] && return 1
local asset_url="https://github.com/$GITHUB_REPO/releases/download/${tag}/${BINARY_NAME}"
ATTEMPTED_SOURCES+=("GitHub release asset ${tag}")
print_info "Downloading $BINARY_NAME from GitHub release ${tag}..."
local tmp_err
tmp_err=$(mktemp)
if curl --fail --silent --location --connect-timeout 10 --max-time 120 "$asset_url" -o "$BINARY_PATH.tmp" 2>"$tmp_err"; then
rm -f "$tmp_err"
DOWNLOAD_SUCCESS=true
return 0
fi
local asset_error=""
if [[ -s "$tmp_err" ]]; then
asset_error="$(cat "$tmp_err")"
fi
rm -f "$tmp_err"
rm -f "$BINARY_PATH.tmp" 2>/dev/null || true
local tarball_name="pulse-${tag}-linux-${ARCH_LABEL#linux-}.tar.gz"
local tarball_url="https://github.com/$GITHUB_REPO/releases/download/${tag}/${tarball_name}"
ATTEMPTED_SOURCES+=("GitHub release tarball ${tarball_name}")
print_info "Downloading ${tarball_name} to extract pulse-sensor-proxy..."
tmp_err=$(mktemp)
local tarball_tmp
tarball_tmp=$(mktemp)
if curl --fail --silent --location --connect-timeout 10 --max-time 240 "$tarball_url" -o "$tarball_tmp" 2>"$tmp_err"; then
if tar -tzf "$tarball_tmp" >/dev/null 2>&1 && tar -xzf "$tarball_tmp" -C "$(dirname "$tarball_tmp")" ./bin/pulse-sensor-proxy >/dev/null 2>&1; then
mv "$(dirname "$tarball_tmp")/bin/pulse-sensor-proxy" "$BINARY_PATH.tmp"
rm -f "$tarball_tmp" "$tmp_err"
DOWNLOAD_SUCCESS=true
return 0
else
print_warn "Release tarball did not contain expected ./bin/pulse-sensor-proxy"
fi
else
if [[ -s "$tmp_err" ]]; then
print_warn "Tarball download failed: $(cat "$tmp_err")"
else
print_warn "Tarball download failed (HTTP error)"
fi
fi
rm -f "$tarball_tmp" "$tmp_err"
if [[ -n "$asset_error" ]]; then
print_warn "GitHub release asset error: $asset_error"
fi
return 1
}
if [[ "$REQUESTED_VERSION" == "latest" || "$REQUESTED_VERSION" == "main" || -z "$REQUESTED_VERSION" ]]; then
if fetch_latest_release_tag; then
attempt_github_asset_or_tarball "$LATEST_RELEASE_TAG" || true
fi
else
attempt_github_asset_or_tarball "$REQUESTED_VERSION" || true
fi
if [[ "$DOWNLOAD_SUCCESS" != true ]] && [[ -n "$FALLBACK_BASE" ]]; then
fallback_url="$FALLBACK_BASE"
if [[ "$fallback_url" == *"?"* ]]; then
fallback_url="$fallback_url"
elif [[ "$fallback_url" == *"pulse-sensor-proxy-"* ]]; then
fallback_url="${fallback_url}"
else
fallback_url="${fallback_url%/}?arch=${ARCH_LABEL}"
fi
ATTEMPTED_SOURCES+=("Fallback ${fallback_url}")
print_info "Downloading $BINARY_NAME from fallback source..."
fallback_err=$(mktemp)
if curl --fail --silent --location --connect-timeout 10 --max-time 120 "$fallback_url" -o "$BINARY_PATH.tmp" 2>"$fallback_err"; then
rm -f "$fallback_err"
DOWNLOAD_SUCCESS=true
else
if [[ -s "$fallback_err" ]]; then
print_error "Fallback download failed: $(cat "$fallback_err")"
else
print_error "Fallback download failed (HTTP error)"
fi
rm -f "$fallback_err"
rm -f "$BINARY_PATH.tmp" 2>/dev/null || true
fi
fi
if [[ "$DOWNLOAD_SUCCESS" != true ]] && [[ -n "$CTID" ]] && command -v pct >/dev/null 2>&1; then
pull_targets=(
"/opt/pulse/bin/${BINARY_NAME}"
"/opt/pulse/bin/pulse-sensor-proxy"
)
for src in "${pull_targets[@]}"; do
tmp_pull=$(mktemp)
if pct pull "$CTID" "$src" "$tmp_pull" >/dev/null 2>&1; then
mv "$tmp_pull" "$BINARY_PATH.tmp"
print_info "Copied pulse-sensor-proxy binary from container $CTID ($src)"
DOWNLOAD_SUCCESS=true
break
fi
rm -f "$tmp_pull"
done
fi
if [[ "$DOWNLOAD_SUCCESS" != true ]]; then
print_error "Unable to download pulse-sensor-proxy binary."
if [[ ${#ATTEMPTED_SOURCES[@]} -gt 0 ]]; then
print_error "Sources attempted:"
for src in "${ATTEMPTED_SOURCES[@]}"; do
print_error " - $src"
done
fi
print_error "Publish a GitHub release with binary assets or ensure a Pulse server is reachable."
exit 1
fi
chmod +x "$BINARY_PATH.tmp"
mv "$BINARY_PATH.tmp" "$BINARY_PATH"
print_info "Binary installed to $BINARY_PATH"
fi
# Create remaining directories with proper ownership (handles fresh installs and upgrades)
print_info "Setting up service 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 -m 0600 -o pulse-sensor-proxy -g pulse-sensor-proxy /dev/null "$SSH_DIR/known_hosts"
install -d -o pulse-sensor-proxy -g pulse-sensor-proxy -m 0755 /etc/pulse-sensor-proxy
if [[ -n "$CTID" ]]; then
echo "$CTID" > "$CTID_FILE"
chmod 0644 "$CTID_FILE"
fi
# HTTP Mode Setup Functions
setup_tls_certificates() {
local cert_path="$1"
local key_path="$2"
# Create TLS directory
install -d -o root -g pulse-sensor-proxy -m 0750 /etc/pulse-sensor-proxy/tls
if [[ -n "$cert_path" && -n "$key_path" ]]; then
# Use provided certificates
print_info "Using provided TLS certificates..."
cp "$cert_path" /etc/pulse-sensor-proxy/tls/server.crt
cp "$key_path" /etc/pulse-sensor-proxy/tls/server.key
chmod 640 /etc/pulse-sensor-proxy/tls/server.crt
chmod 640 /etc/pulse-sensor-proxy/tls/server.key
chown root:pulse-sensor-proxy /etc/pulse-sensor-proxy/tls/server.crt
chown root:pulse-sensor-proxy /etc/pulse-sensor-proxy/tls/server.key
else
# Generate self-signed certificate
print_info "Generating self-signed TLS certificate..."
# Get hostname and IPs for SAN
HOSTNAME=$(hostname -f 2>/dev/null || hostname)
IP_ADDRESSES=$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -v '^$' | head -5)
# Build SAN list
SAN="DNS:${HOSTNAME},DNS:localhost"
for ip in $IP_ADDRESSES; do
SAN="${SAN},IP:${ip}"
done
# Generate 4096-bit RSA key and self-signed cert valid for 10 years
openssl req -newkey rsa:4096 -nodes -x509 -days 3650 \
-subj "/CN=${HOSTNAME}/O=Pulse Sensor Proxy" \
-addext "subjectAltName=${SAN}" \
-keyout /etc/pulse-sensor-proxy/tls/server.key \
-out /etc/pulse-sensor-proxy/tls/server.crt \
2>/dev/null || {
print_error "Failed to generate TLS certificate"
exit 1
}
chmod 640 /etc/pulse-sensor-proxy/tls/server.key
chmod 640 /etc/pulse-sensor-proxy/tls/server.crt
chown root:pulse-sensor-proxy /etc/pulse-sensor-proxy/tls/server.key
chown root:pulse-sensor-proxy /etc/pulse-sensor-proxy/tls/server.crt
# Log certificate fingerprint for audit
CERT_FINGERPRINT=$(openssl x509 -in /etc/pulse-sensor-proxy/tls/server.crt -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2)
print_success "TLS certificate generated (SHA256: ${CERT_FINGERPRINT})"
fi
}
register_with_pulse() {
local pulse_url="$1"
local hostname="$2"
local proxy_url="$3"
# Output to stderr so it doesn't interfere with command substitution
print_info "Registering temperature proxy with Pulse at $pulse_url..." >&2
# Build registration request with retry logic
local response
local http_code
local attempt
local max_attempts=3
local register_url="${pulse_url}/api/temperature-proxy/register"
for attempt in $(seq 1 $max_attempts); do
if [[ $attempt -gt 1 ]]; then
print_info "Retry attempt $attempt/$max_attempts..." >&2
sleep 2
fi
# Capture both HTTP code and response body
response=$(curl -w "\n%{http_code}" -f -s -X POST \
-H "Content-Type: application/json" \
-d "{\"hostname\":\"${hostname}\",\"proxy_url\":\"${proxy_url}\"}" \
"$register_url" 2>&1)
local curl_exit=$?
http_code=$(echo "$response" | tail -1)
response=$(echo "$response" | head -n -1)
if [[ $curl_exit -eq 0 ]]; then
break
fi
if [[ $attempt -eq $max_attempts ]]; then
print_error "Failed to register with Pulse API after $max_attempts attempts" >&2
print_error "" >&2
print_error "═══════════════════════════════════════════════════════" >&2
print_error "Registration Details:" >&2
print_error "═══════════════════════════════════════════════════════" >&2
print_error "URL: $register_url" >&2
print_error "HTTP Code: $http_code" >&2
print_error "Hostname: $hostname" >&2
print_error "Proxy URL: $proxy_url" >&2
print_error "Response: $response" >&2
print_error "" >&2
print_error "Troubleshooting:" >&2
print_error " 1. Ensure this PVE instance is added to Pulse first" >&2
print_error " 2. Verify hostname matches instance name: $hostname" >&2
print_error " 3. Check Pulse logs: docker logs pulse | tail -50" >&2
print_error " 4. Test registration manually:" >&2
print_error " curl -X POST -H 'Content-Type: application/json' \\" >&2
print_error " -d '{\"hostname\":\"${hostname}\",\"proxy_url\":\"${proxy_url}\"}' \\" >&2
print_error " $register_url" >&2
return 1
fi
done
# Parse token from response
local token
token=$(echo "$response" | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
if [[ -z "$token" ]]; then
print_error "Registration succeeded but no token received"
print_error "Response: $response"
return 1
fi
# Store token
echo "$token" > /etc/pulse-sensor-proxy/.http-auth-token
chmod 600 /etc/pulse-sensor-proxy/.http-auth-token
chown pulse-sensor-proxy:pulse-sensor-proxy /etc/pulse-sensor-proxy/.http-auth-token
# Output to stderr so it doesn't interfere with command substitution
print_success "Registered successfully - token received" >&2
# Parse instance name from response for logging
local instance_name
instance_name=$(echo "$response" | grep -o '"pve_instance":"[^"]*"' | cut -d'"' -f4)
if [[ -n "$instance_name" ]]; then
print_info "Linked to PVE instance: $instance_name" >&2
fi
# Return token for caller to use (stdout only)
echo "$token"
}
# Create base config file if it doesn't exist
if [[ ! -f /etc/pulse-sensor-proxy/config.yaml ]]; then
print_info "Creating base configuration file..."
cat > /etc/pulse-sensor-proxy/config.yaml << 'EOF'
# Pulse Temperature Proxy Configuration
allowed_peer_uids: [1000]
# Allow ID-mapped root (LXC containers with sub-UID mapping)
allow_idmapped_root: true
allowed_idmap_users:
- root
metrics_address: "127.0.0.1:9127"
rate_limit:
per_peer_interval_ms: 333
per_peer_burst: 10
EOF
chown pulse-sensor-proxy:pulse-sensor-proxy /etc/pulse-sensor-proxy/config.yaml
chmod 0644 /etc/pulse-sensor-proxy/config.yaml
fi
# HTTP Mode Configuration
if [[ "$HTTP_MODE" == true ]]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " HTTP Mode Setup (External PVE Host)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Validate required parameters
if [[ -z "$PULSE_SERVER" ]]; then
print_error "HTTP mode requires --pulse-server parameter"
print_error "Example: --pulse-server https://pulse.example.com:7655"
exit 1
fi
# Test Pulse server reachability before proceeding
print_info "Testing connection to Pulse server..."
if ! curl -f -s -m 5 "${PULSE_SERVER}/api/health" >/dev/null 2>&1; then
print_error "Cannot reach Pulse server at: $PULSE_SERVER"
print_error ""
print_error "Troubleshooting:"
print_error " 1. Verify Pulse is running: docker ps | grep pulse"
print_error " 2. Check URL is correct (include protocol and port)"
print_error " 3. Test connectivity: curl -v ${PULSE_SERVER}/api/health"
print_error " 4. Check firewall allows access from this host"
print_error ""
print_error "Installation aborted to prevent incomplete setup"
exit 1
fi
print_success "Pulse server is reachable"
# Check if port is already in use
PORT_NUMBER="${HTTP_ADDR#:}"
if ss -ltn | grep -q ":${PORT_NUMBER} "; then
print_error "Port ${PORT_NUMBER} is already in use"
print_error ""
print_error "Currently using port ${PORT_NUMBER}:"
ss -ltnp | grep ":${PORT_NUMBER} " || true
print_error ""
print_error "Options:"
print_error " 1. Stop the conflicting service"
print_error " 2. Use a different port: --http-addr :PORT"
print_error " 3. If this is a previous sensor-proxy, uninstall first:"
print_error " $0 --uninstall"
exit 1
fi
# Setup TLS certificates
setup_tls_certificates "" "" # Empty params = auto-generate
# Determine proxy URL - use IP address for reliable network access
PRIMARY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
if [[ -z "$PRIMARY_IP" ]]; then
print_error "Failed to determine primary IP address"
print_error "Use --proxy-url to specify manually"
exit 1
fi
# Validate it's an IPv4 address (not IPv6)
if [[ ! "$PRIMARY_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
print_warn "Primary IP appears to be IPv6 or invalid: $PRIMARY_IP"
print_warn "Attempting to find first IPv4 address..."
PRIMARY_IP=$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -1)
if [[ -z "$PRIMARY_IP" ]]; then
print_error "No IPv4 address found"
print_error "Use --proxy-url https://YOUR_IP${HTTP_ADDR} to specify manually"
exit 1
fi
print_info "Using IPv4 address: $PRIMARY_IP"
fi
# Warn if using loopback
if [[ "$PRIMARY_IP" == "127.0.0.1" ]]; then
print_warn "Primary IP is loopback (127.0.0.1)"
print_warn "Pulse will not be able to reach this proxy!"
print_error "Use --proxy-url https://YOUR_REAL_IP${HTTP_ADDR} to specify a reachable address"
exit 1
fi
PROXY_URL="https://${PRIMARY_IP}${HTTP_ADDR}"
print_info "Proxy will be accessible at: $PROXY_URL"
# Register with Pulse and get auth token
# Use short hostname for registration matching (PVE cluster endpoints use short names)
SHORT_HOSTNAME=$(hostname -s 2>/dev/null || hostname | cut -d'.' -f1)
HTTP_AUTH_TOKEN=$(register_with_pulse "$PULSE_SERVER" "$SHORT_HOSTNAME" "$PROXY_URL")
if [[ $? -ne 0 || -z "$HTTP_AUTH_TOKEN" ]]; then
print_error "Failed to register with Pulse - aborting installation"
print_error "Fix the issue and re-run the installer"
exit 1
fi
# Backup config before modifying
if [[ -f /etc/pulse-sensor-proxy/config.yaml ]]; then
BACKUP_CONFIG="/etc/pulse-sensor-proxy/config.yaml.backup.$(date +%s)"
cp /etc/pulse-sensor-proxy/config.yaml "$BACKUP_CONFIG"
print_info "Config backed up to: $BACKUP_CONFIG"
# Remove any existing HTTP configuration to prevent duplicates
if grep -q "^# HTTP Mode Configuration" /etc/pulse-sensor-proxy/config.yaml; then
print_info "Removing existing HTTP configuration..."
# Remove from "# HTTP Mode Configuration" to end of file
sed -i '/^# HTTP Mode Configuration/,$ d' /etc/pulse-sensor-proxy/config.yaml
fi
fi
# Extract Pulse server IP/hostname for allowed_source_subnets
# Remove protocol and port to get just the host
PULSE_HOST=$(echo "$PULSE_SERVER" | sed -E 's#^https?://##' | sed -E 's#:[0-9]+$##')
# Try to resolve to IP if it's a hostname
PULSE_IP=$(getent hosts "$PULSE_HOST" 2>/dev/null | awk '{print $1; exit}')
if [[ -z "$PULSE_IP" ]]; then
# Fallback: assume PULSE_HOST is already an IP or use it as-is
PULSE_IP="$PULSE_HOST"
fi
print_info "Pulse server detected at: $PULSE_IP"
# Append HTTP configuration to config.yaml
print_info "Configuring HTTP mode..."
cat >> /etc/pulse-sensor-proxy/config.yaml << EOF
# HTTP Mode Configuration (External PVE Host)
http_enabled: true
http_listen_addr: "$HTTP_ADDR"
http_tls_cert: /etc/pulse-sensor-proxy/tls/server.crt
http_tls_key: /etc/pulse-sensor-proxy/tls/server.key
http_auth_token: "$HTTP_AUTH_TOKEN"
# Allow HTTP connections from Pulse server and localhost (for self-monitoring)
allowed_source_subnets:
- $PULSE_IP/32
- 127.0.0.1/32
EOF
print_success "HTTP mode configured successfully"
echo ""
print_info "Firewall configuration required:"
print_info " Allow inbound TCP connections on port ${HTTP_ADDR#:} from Pulse server"
print_info " Command: ufw allow from <pulse-server-ip> to any port ${HTTP_ADDR#:}"
echo ""
fi
# Stop existing service if running (for upgrades)
if systemctl is-active --quiet pulse-sensor-proxy 2>/dev/null; then
print_info "Stopping existing service for upgrade..."
systemctl stop pulse-sensor-proxy
fi
# Install hardened systemd service
print_info "Installing hardened systemd service..."
# 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=${BINARY_PATH} --config /etc/pulse-sensor-proxy/config.yaml
Restart=on-failure
RestartSec=5s
# Runtime dirs/sockets
RuntimeDirectory=pulse-sensor-proxy
RuntimeDirectoryMode=0775
RuntimeDirectoryPreserve=yes
LogsDirectory=pulse/sensor-proxy
LogsDirectoryMode=0750
UMask=0007
# Core hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/var/lib/pulse-sensor-proxy
ReadWritePaths=-/run/corosync
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectClock=true
PrivateTmp=true
PrivateDevices=true
ProtectProc=invisible
ProcSubset=pid
LockPersonality=true
RestrictSUIDSGID=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
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
After=network.target
[Service]
Type=simple
User=pulse-sensor-proxy
Group=pulse-sensor-proxy
SupplementaryGroups=www-data
WorkingDirectory=/var/lib/pulse-sensor-proxy
ExecStart=${BINARY_PATH}
Restart=on-failure
RestartSec=5s
# Runtime dirs/sockets
RuntimeDirectory=pulse-sensor-proxy
RuntimeDirectoryMode=0775
RuntimeDirectoryPreserve=yes
LogsDirectory=pulse/sensor-proxy
LogsDirectoryMode=0750
UMask=0007
# Core hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/var/lib/pulse-sensor-proxy
ReadWritePaths=-/run/corosync
ReadOnlyPaths=/run/pve-cluster /etc/pve
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectClock=true
PrivateTmp=true
PrivateDevices=true
ProtectProc=invisible
ProcSubset=pid
LockPersonality=true
RestrictSUIDSGID=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
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
fi
# Add HTTP mode paths to service file if needed
if [[ "$HTTP_MODE" == true ]]; then
print_info "Updating service file for HTTP mode..."
# Add ReadOnlyPaths for TLS directory (after ReadWritePaths line)
sed -i '/^ReadWritePaths=\/var\/lib\/pulse-sensor-proxy/a ReadOnlyPaths=/etc/pulse-sensor-proxy/tls' "$SERVICE_PATH"
print_success "Service file updated for HTTP mode"
fi
# Reload systemd and start service
print_info "Enabling and starting service..."
if ! systemctl daemon-reload; then
print_error "Failed to reload systemd daemon"
journalctl -u pulse-sensor-proxy -n 20 --no-pager
exit 1
fi
if ! systemctl enable pulse-sensor-proxy.service; then
print_error "Failed to enable pulse-sensor-proxy service"
journalctl -u pulse-sensor-proxy -n 20 --no-pager
exit 1
fi
if ! systemctl restart pulse-sensor-proxy.service; then
print_error "Failed to start pulse-sensor-proxy service"
print_error ""
# Attempt rollback if HTTP mode and we have a backup
if [[ "$HTTP_MODE" == true && -n "$BACKUP_CONFIG" && -f "$BACKUP_CONFIG" ]]; then
print_warn "Attempting to rollback to previous configuration..."
if cp "$BACKUP_CONFIG" /etc/pulse-sensor-proxy/config.yaml; then
print_info "Config restored from backup"
if systemctl restart pulse-sensor-proxy.service; then
print_success "Service restarted with previous configuration"
print_error ""
print_error "HTTP mode installation failed but previous config restored"
print_error "Temperature monitoring should still work via Unix socket"
print_error "Review the error above and fix before retrying"
exit 1
else
print_error "Rollback failed - service won't start even with old config"
fi
else
print_error "Failed to restore config from backup"
fi
fi
print_error "═══════════════════════════════════════════════════════"
print_error "Service Status:"
print_error "═══════════════════════════════════════════════════════"
systemctl status pulse-sensor-proxy --no-pager --lines=0 2>&1 || true
print_error ""
print_error "═══════════════════════════════════════════════════════"
print_error "Recent Logs (last 40 lines):"
print_error "═══════════════════════════════════════════════════════"
journalctl -u pulse-sensor-proxy -n 40 --no-pager 2>&1 || true
print_error ""
print_error "═══════════════════════════════════════════════════════"
print_error "Common Issues:"
print_error "═══════════════════════════════════════════════════════"
print_error "1. Missing user: Run 'useradd --system --no-create-home --group pulse-sensor-proxy'"
print_error "2. Permission errors: Check ownership of /var/lib/pulse-sensor-proxy"
print_error "3. lm-sensors not installed: Run 'apt-get install lm-sensors && sensors-detect --auto'"
print_error "4. Standalone node detection: If you see 'pvecm' errors, this is expected for non-clustered hosts"
print_error "5. Port already in use: Check 'ss -tlnp | grep ${HTTP_ADDR#:}'"
print_error ""
print_error "For more help: https://github.com/rcourtman/Pulse/blob/main/docs/TROUBLESHOOTING.md"
exit 1
fi
# Wait for socket to appear
print_info "Waiting for socket..."
for i in {1..10}; do
if [[ -S "$SOCKET_PATH" ]]; then
break
fi
sleep 1
done
if [[ ! -S "$SOCKET_PATH" ]]; then
print_error "Socket did not appear at $SOCKET_PATH after 10 seconds"
print_error ""
print_error "═══════════════════════════════════════════════════════"
print_error "Diagnostics:"
print_error "═══════════════════════════════════════════════════════"
print_error "Service Status:"
systemctl status pulse-sensor-proxy --no-pager 2>&1 || true
print_error ""
print_error "Socket Directory Permissions:"
ls -la /run/pulse-sensor-proxy/ 2>&1 || echo "Directory does not exist"
print_error ""
print_error "Recent Logs:"
journalctl -u pulse-sensor-proxy -n 20 --no-pager 2>&1 || true
print_error ""
print_error "Common Causes:"
print_error " • Service failed to start (check logs above)"
print_error " • RuntimeDirectory permissions issue"
print_error " • Systemd socket creation failed"
print_error ""
print_error "Try: systemctl restart pulse-sensor-proxy && watch -n 0.5 'ls -la /run/pulse-sensor-proxy/'"
exit 1
fi
print_info "Socket ready at $SOCKET_PATH"
# If socket verification was deferred because the runtime directory was
# missing earlier, test the container mount now that the proxy is running.
if [[ "$STANDALONE" == false && "$DEFER_SOCKET_VERIFICATION" = true ]]; then
print_info "Validating container socket visibility now that host proxy is running..."
if pct exec "$CTID" -- test -S "${MOUNT_TARGET}/pulse-sensor-proxy.sock"; then
print_info "✓ Secure socket communication ready"
DEFER_SOCKET_VERIFICATION=false
[ -n "$LXC_CONFIG_BACKUP" ] && rm -f "$LXC_CONFIG_BACKUP"
else
print_error "Socket not visible at ${MOUNT_TARGET}/pulse-sensor-proxy.sock"
print_error "Bind mount exists but container still cannot access the proxy socket"
print_error "This usually indicates the container needs a restart or the mount failed to attach"
if [ -n "$LXC_CONFIG_BACKUP" ] && [ -f "$LXC_CONFIG_BACKUP" ]; then
print_warn "Rolling back container configuration changes..."
cp "$LXC_CONFIG_BACKUP" "$LXC_CONFIG"
rm -f "$LXC_CONFIG_BACKUP"
print_info "Container configuration restored to previous state"
fi
exit 1
fi
fi
# Validate HTTP endpoint if HTTP mode is enabled
if [[ "$HTTP_MODE" == true ]]; then
print_info "Validating HTTP endpoint..."
# Wait a moment for HTTP server to fully start
sleep 2
# Test HTTP endpoint
HTTP_CHECK_URL="https://${PRIMARY_IP}${HTTP_ADDR}/health"
if curl -f -s -k -m 5 \
-H "Authorization: Bearer ${HTTP_AUTH_TOKEN}" \
"$HTTP_CHECK_URL" >/dev/null 2>&1; then
print_success "HTTP endpoint validated successfully"
else
print_error "HTTP endpoint validation failed"
print_error "URL: $HTTP_CHECK_URL"
print_error ""
print_error "Troubleshooting:"
print_error " 1. Check if port ${HTTP_ADDR#:} is listening: ss -tlnp | grep ${HTTP_ADDR#:}"
print_error " 2. Check sensor-proxy logs: journalctl -u pulse-sensor-proxy -n 50"
print_error " 3. Test manually: curl -k -H 'Authorization: Bearer \$TOKEN' $HTTP_CHECK_URL"
print_error ""
print_warn "Service is running but HTTP endpoint may not be accessible"
print_warn "Temperature monitoring may not work properly"
fi
fi
# Install sensor wrapper script for combined sensor and SMART data collection
print_info "Installing sensor wrapper script..."
cat > "$WRAPPER_SCRIPT" << 'WRAPPER_EOF'
#!/bin/bash
#
# pulse-sensor-wrapper.sh
# Combined sensor and SMART temperature collection for Pulse monitoring
#
# This script is deployed as the SSH forced command for the sensor proxy.
# It collects CPU/GPU temps via sensors and disk temps via smartctl,
# returning a unified JSON payload.
set -euo pipefail
# Configuration
CACHE_DIR="/var/cache/pulse-sensor-proxy"
SMART_CACHE_TTL=1800 # 30 minutes
MAX_SMARTCTL_TIME=5 # seconds per disk
# Ensure cache directory exists
mkdir -p "$CACHE_DIR" 2>/dev/null || true
# Function to get cached SMART data
get_cached_smart() {
local cache_file="$CACHE_DIR/smart-temps.json"
local now=$(date +%s)
# Check if cache exists and is fresh
if [[ -f "$cache_file" ]]; then
local mtime=$(stat -c %Y "$cache_file" 2>/dev/null || echo 0)
local age=$((now - mtime))
if [[ $age -lt $SMART_CACHE_TTL ]]; then
cat "$cache_file"
return 0
fi
fi
# Cache miss or stale - return empty array and trigger background refresh
echo "[]"
# Trigger async refresh if not already running (use lock file)
local lock_file="$CACHE_DIR/smart-refresh.lock"
if ! [ -f "$lock_file" ]; then
(refresh_smart_cache &)
fi
return 0
}
# Function to refresh SMART cache in background
refresh_smart_cache() {
local lock_file="$CACHE_DIR/smart-refresh.lock"
local cache_file="$CACHE_DIR/smart-temps.json"
local temp_file="${cache_file}.tmp.$$"
# Create lock file and ensure cleanup on exit
touch "$lock_file" 2>/dev/null || return 1
trap "rm -f '$lock_file' '$temp_file'" EXIT
local disks=()
# Find all physical disks (skip partitions, loop devices, etc.)
while IFS= read -r dev; do
[[ -b "$dev" ]] && disks+=("$dev")
done < <(lsblk -nd -o NAME,TYPE | awk '$2=="disk" {print "/dev/"$1}')
local results=()
for dev in "${disks[@]}"; do
# Use smartctl with standby check to avoid waking sleeping drives
# -n standby: skip if drive is in standby/sleep mode
# --json=o: output original smartctl JSON format
# timeout: prevent hanging on problematic drives
local output
if output=$(timeout ${MAX_SMARTCTL_TIME}s smartctl -n standby -A --json=o "$dev" 2>/dev/null); then
# Parse the JSON output
local temp=$(echo "$output" | jq -r '
.temperature.current //
(.ata_smart_attributes.table[] | select(.id == 194) | .raw.value) //
(.nvme_smart_health_information_log.temperature // empty)
' 2>/dev/null)
local serial=$(echo "$output" | jq -r '.serial_number // empty' 2>/dev/null)
local wwn=$(echo "$output" | jq -r '.wwn.naa // .wwn.oui // empty' 2>/dev/null)
local model=$(echo "$output" | jq -r '.model_name // .model_family // empty' 2>/dev/null)
local transport=$(echo "$output" | jq -r '.device.type // empty' 2>/dev/null)
# Only include if we got a valid temperature
if [[ -n "$temp" && "$temp" != "null" && "$temp" =~ ^[0-9]+$ ]]; then
local entry=$(jq -n \
--arg dev "$dev" \
--arg serial "$serial" \
--arg wwn "$wwn" \
--arg model "$model" \
--arg transport "$transport" \
--argjson temp "$temp" \
--arg updated "$(date -Iseconds)" \
'{
device: $dev,
serial: $serial,
wwn: $wwn,
model: $model,
type: $transport,
temperature: $temp,
lastUpdated: $updated,
standbySkipped: false
}')
results+=("$entry")
fi
elif echo "$output" | grep -q "standby"; then
# Drive is in standby - record it but don't wake it
local entry=$(jq -n \
--arg dev "$dev" \
--arg updated "$(date -Iseconds)" \
'{
device: $dev,
temperature: null,
lastUpdated: $updated,
standbySkipped: true
}')
results+=("$entry")
fi
# Small delay between disks to avoid saturating SATA controller
sleep 0.1
done
# Build final JSON array
if [[ ${#results[@]} -gt 0 ]]; then
local json=$(printf '%s\n' "${results[@]}" | jq -s '.')
else
local json="[]"
fi
# Atomic write to cache
echo "$json" > "$temp_file"
mv "$temp_file" "$cache_file"
chmod 644 "$cache_file" 2>/dev/null || true
}
# Main execution
# Collect sensor data (CPU, GPU temps)
sensors_data=$(sensors -j 2>/dev/null || echo '{}')
# Get SMART data from cache
smart_data=$(get_cached_smart)
# Combine into unified payload
jq -n \
--argjson sensors "$sensors_data" \
--argjson smart "$smart_data" \
'{
sensors: $sensors,
smart: $smart
}'
WRAPPER_EOF
chmod +x "$WRAPPER_SCRIPT"
print_success "Sensor wrapper installed at $WRAPPER_SCRIPT"
# Install cleanup system for full Pulse removal when nodes are deleted
print_info "Installing cleanup system..."
# Install cleanup script
cat > "$CLEANUP_SCRIPT_PATH" <<'CLEANUP_EOF'
#!/bin/bash
# pulse-sensor-cleanup.sh - Complete Pulse footprint removal when nodes are removed
# Removes: SSH keys, proxy service, binaries, API tokens, and LXC bind mounts
# This script is triggered by systemd path unit when cleanup-request.json is created
set -euo pipefail
# Configuration
WORK_DIR="/var/lib/pulse-sensor-proxy"
CLEANUP_REQUEST="${WORK_DIR}/cleanup-request.json"
LOCKFILE="${WORK_DIR}/cleanup.lock"
LOG_TAG="pulse-sensor-cleanup"
INSTALLER_PATH="/opt/pulse/sensor-proxy/install-sensor-proxy.sh"
# Logging functions
log_info() {
logger -t "$LOG_TAG" -p user.info "$1"
echo "[INFO] $1"
}
log_warn() {
logger -t "$LOG_TAG" -p user.warning "$1"
echo "[WARN] $1"
}
log_error() {
logger -t "$LOG_TAG" -p user.err "$1"
echo "[ERROR] $1" >&2
}
# Acquire exclusive lock to prevent concurrent cleanup runs
exec 200>"$LOCKFILE"
if ! flock -n 200; then
log_info "Another cleanup instance is running, exiting"
exit 0
fi
# Check if cleanup request file exists
if [[ ! -f "$CLEANUP_REQUEST" ]]; then
log_info "No cleanup request found at $CLEANUP_REQUEST"
exit 0
fi
log_info "Processing cleanup request from $CLEANUP_REQUEST"
# Read and parse the cleanup request
CLEANUP_DATA=$(cat "$CLEANUP_REQUEST")
HOST=$(echo "$CLEANUP_DATA" | grep -o '"host":"[^"]*"' | cut -d'"' -f4 || echo "")
REQUESTED_AT=$(echo "$CLEANUP_DATA" | grep -o '"requestedAt":"[^"]*"' | cut -d'"' -f4 || echo "")
log_info "Cleanup requested at: ${REQUESTED_AT:-unknown}"
# Rename request file to .processing to prevent re-triggering while allowing retry on failure
PROCESSING_FILE="${CLEANUP_REQUEST}.processing"
mv "$CLEANUP_REQUEST" "$PROCESSING_FILE" 2>/dev/null || {
log_warn "Failed to rename cleanup request file, may have been processed by another instance"
exit 0
}
# If no specific host was provided, clean up all known nodes
if [[ -z "$HOST" ]]; then
log_info "No specific host provided - cleaning up all cluster nodes"
# Discover cluster nodes
if command -v pvecm >/dev/null 2>&1; then
CLUSTER_NODES=$(pvecm status 2>/dev/null | awk '/0x[0-9a-f]+.*[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/ {print $3}' || true)
if [[ -n "$CLUSTER_NODES" ]]; then
for node_ip in $CLUSTER_NODES; do
log_info "Cleaning up SSH keys on node $node_ip"
# Remove both pulse-managed-key and pulse-proxy-key entries
ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=5 root@"$node_ip" \
"sed -i -e '/# pulse-managed-key\$/d' -e '/# pulse-proxy-key\$/d' /root/.ssh/authorized_keys" 2>&1 | \
logger -t "$LOG_TAG" -p user.info || \
log_warn "Failed to clean up SSH keys on $node_ip"
done
log_info "Cluster cleanup completed"
else
# Standalone node - clean up localhost
log_info "Standalone node detected - cleaning up localhost"
sed -i -e '/# pulse-managed-key$/d' -e '/# pulse-proxy-key$/d' /root/.ssh/authorized_keys 2>&1 | \
logger -t "$LOG_TAG" -p user.info || \
log_warn "Failed to clean up SSH keys on localhost"
fi
else
log_warn "pvecm command not available - cleaning up localhost only"
sed -i -e '/# pulse-managed-key$/d' -e '/# pulse-proxy-key$/d' /root/.ssh/authorized_keys 2>&1 | \
logger -t "$LOG_TAG" -p user.info || \
log_warn "Failed to clean up SSH keys on localhost"
fi
else
log_info "Cleaning up specific host: $HOST"
# Extract hostname/IP from host URL
HOST_CLEAN=$(echo "$HOST" | sed -e 's|^https\?://||' -e 's|:.*$||')
# Check if this is localhost (by IP, hostname, or FQDN)
LOCAL_IPS=$(hostname -I 2>/dev/null || echo "")
LOCAL_HOSTNAME=$(hostname 2>/dev/null || echo "")
LOCAL_FQDN=$(hostname -f 2>/dev/null || echo "")
IS_LOCAL=false
# Check against all local IPs
for local_ip in $LOCAL_IPS; do
if [[ "$HOST_CLEAN" == "$local_ip" ]]; then
IS_LOCAL=true
break
fi
done
# Check against hostname and FQDN
if [[ "$HOST_CLEAN" == "127.0.0.1" || "$HOST_CLEAN" == "localhost" || \
"$HOST_CLEAN" == "$LOCAL_HOSTNAME" || "$HOST_CLEAN" == "$LOCAL_FQDN" ]]; then
IS_LOCAL=true
fi
if [[ "$IS_LOCAL" == true ]]; then
log_info "Performing full cleanup on localhost"
# 1. Remove SSH keys
log_info "Removing SSH keys from authorized_keys"
sed -i -e '/# pulse-managed-key$/d' -e '/# pulse-proxy-key$/d' /root/.ssh/authorized_keys 2>&1 | \
logger -t "$LOG_TAG" -p user.info || \
log_warn "Failed to clean up SSH keys"
# 2. Delete API tokens and user
log_info "Removing Proxmox API tokens and pulse-monitor user"
if command -v pveum >/dev/null 2>&1; then
# Try JSON output first (pveum with --output-format json)
TOKEN_IDS=""
if command -v python3 >/dev/null 2>&1; then
# Try pveum with JSON output
if TOKEN_JSON=$(pveum user token list pulse-monitor@pam --output-format json 2>/dev/null); then
TOKEN_IDS=$(echo "$TOKEN_JSON" | python3 -c '
import sys, json
try:
data = json.load(sys.stdin)
if isinstance(data, list):
for item in data:
if "tokenid" in item:
print(item["tokenid"])
except: pass
' || true)
fi
fi
# Fall back to pvesh JSON API if pveum JSON didn't work
if [[ -z "$TOKEN_IDS" ]] && command -v pvesh >/dev/null 2>&1; then
if TOKEN_JSON=$(pvesh get /access/users/pulse-monitor@pam/token 2>/dev/null); then
TOKEN_IDS=$(echo "$TOKEN_JSON" | python3 -c '
import sys, json
try:
data = json.load(sys.stdin)
if isinstance(data, dict) and "data" in data:
for item in data["data"]:
if "tokenid" in item:
print(item["tokenid"])
except: pass
' 2>/dev/null || true)
fi
fi
# Last resort: parse table output with better filtering
if [[ -z "$TOKEN_IDS" ]]; then
TOKEN_IDS=$(pveum user token list pulse-monitor@pam 2>/dev/null | \
awk 'NR>1 && /^[[:space:]]*pulse/ {print $1}' | grep -v '^[│┌└╞─]' | grep -v '^$' || true)
fi
if [[ -n "$TOKEN_IDS" ]]; then
for token_id in $TOKEN_IDS; do
log_info "Deleting API token: $token_id"
pveum user token remove pulse-monitor@pam "${token_id}" 2>&1 | \
logger -t "$LOG_TAG" -p user.info || \
log_warn "Failed to delete token $token_id"
done
else
log_info "No API tokens found for pulse-monitor@pam"
fi
# Remove the pulse-monitor user
log_info "Removing pulse-monitor@pam user"
pveum user delete pulse-monitor@pam 2>&1 | \
logger -t "$LOG_TAG" -p user.info || \
log_warn "pulse-monitor@pam user not found or already removed"
else
log_warn "pveum command not available, skipping API token cleanup"
fi
# 3. Remove LXC bind mounts
log_info "Removing LXC bind mounts from container configs"
if command -v pct >/dev/null 2>&1; then
for ctid in $(pct list 2>/dev/null | awk 'NR>1 {print $1}' || true); do
CONF_FILE="/etc/pve/lxc/${ctid}.conf"
if [[ -f "$CONF_FILE" ]]; then
# Find pulse-sensor-proxy mount points and remove them using pct
for mp_key in $(grep -o "^mp[0-9]\+:" "$CONF_FILE" | grep -f <(grep "pulse-sensor-proxy" "$CONF_FILE" | grep -o "^mp[0-9]\+:") || true); do
mp_num="${mp_key%:}"
log_info "Removing ${mp_num} (pulse-sensor-proxy) from container $ctid"
if pct set "$ctid" -delete "${mp_num}" 2>&1 | logger -t "$LOG_TAG" -p user.info; then
log_info "Successfully removed ${mp_num} from container $ctid"
else
log_warn "Failed to remove ${mp_num} from container $ctid"
fi
done
fi
done
fi
# 4. Uninstall proxy service and remove binaries via isolated transient unit
log_info "Starting full uninstallation (service, binaries, configs)"
if [[ -x "$INSTALLER_PATH" ]]; then
# Use systemd-run to create isolated transient unit that won't be killed
# when we stop pulse-sensor-proxy.service
if command -v systemd-run >/dev/null 2>&1; then
# Use UUID for unique unit name (prevents same-second collisions)
UNINSTALL_UUID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || date +%s%N)
UNINSTALL_UNIT="pulse-uninstall-${UNINSTALL_UUID}"
log_info "Spawning isolated uninstaller unit: $UNINSTALL_UNIT"
systemd-run \
--unit="${UNINSTALL_UNIT}" \
--property="Type=oneshot" \
--property="Conflicts=pulse-sensor-proxy.service" \
--property="After=pulse-sensor-cleanup.service" \
--collect \
--wait \
--quiet \
-- bash -c "$INSTALLER_PATH --uninstall --purge --quiet >> /var/log/pulse/sensor-proxy/uninstall.log 2>&1" \
2>&1 | logger -t "$LOG_TAG" -p user.info
UNINSTALL_EXIT=$?
if [[ $UNINSTALL_EXIT -eq 0 ]]; then
log_info "Uninstaller completed successfully"
else
log_error "Uninstaller failed with exit code $UNINSTALL_EXIT"
exit 1
fi
else
log_warn "systemd-run not available, attempting direct uninstall (may fail)"
bash "$INSTALLER_PATH" --uninstall --quiet >> /var/log/pulse/sensor-proxy/uninstall.log 2>&1 || \
log_error "Uninstaller failed - manual cleanup may be required"
fi
else
log_warn "Installer not found at $INSTALLER_PATH, cannot run uninstaller"
log_info "Manual cleanup required: systemctl stop pulse-sensor-proxy && systemctl disable pulse-sensor-proxy"
fi
log_info "Localhost cleanup initiated (uninstaller running in background)"
else
log_info "Cleaning up remote host: $HOST_CLEAN"
# Try to use proxy's SSH key first (for standalone nodes), fall back to default
PROXY_KEY="/var/lib/pulse-sensor-proxy/ssh/id_ed25519"
SSH_CMD="ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=5"
if [[ -f "$PROXY_KEY" ]]; then
log_info "Using proxy SSH key for cleanup"
SSH_CMD="$SSH_CMD -i $PROXY_KEY"
fi
# Remove both pulse-managed-key and pulse-proxy-key entries from remote host
CLEANUP_OUTPUT=$($SSH_CMD root@"$HOST_CLEAN" \
"sed -i -e '/# pulse-managed-key\$/d' -e '/# pulse-proxy-key\$/d' /root/.ssh/authorized_keys && echo 'SUCCESS'" 2>&1)
if echo "$CLEANUP_OUTPUT" | grep -q "SUCCESS"; then
log_info "Successfully cleaned up SSH keys on $HOST_CLEAN"
else
# Check if this is a standalone node with forced commands (common case)
if echo "$CLEANUP_OUTPUT" | grep -q "cpu_thermal\|coretemp\|k10temp"; then
log_warn "Cannot cleanup standalone node $HOST_CLEAN (forced command prevents cleanup)"
log_info "Standalone node keys are read-only (sensors -j) - low security risk"
log_info "Manual cleanup: ssh root@$HOST_CLEAN \"sed -i '/# pulse-proxy-key\$/d' /root/.ssh/authorized_keys\""
else
log_error "Failed to clean up SSH keys on $HOST_CLEAN: $CLEANUP_OUTPUT"
exit 1
fi
fi
fi
fi
# Remove processing file on success
rm -f "$PROCESSING_FILE"
log_info "Cleanup completed successfully"
exit 0
CLEANUP_EOF
chmod +x "$CLEANUP_SCRIPT_PATH"
print_info "Cleanup script installed"
# Install systemd path unit
CLEANUP_PATH_UNIT="/etc/systemd/system/pulse-sensor-cleanup.path"
cat > "$CLEANUP_PATH_UNIT" << 'PATH_EOF'
[Unit]
Description=Watch for Pulse sensor cleanup requests
Documentation=https://github.com/rcourtman/Pulse
[Path]
# Watch for the cleanup request file
PathChanged=/var/lib/pulse-sensor-proxy/cleanup-request.json
# Also watch for modifications
PathModified=/var/lib/pulse-sensor-proxy/cleanup-request.json
[Install]
WantedBy=multi-user.target
PATH_EOF
# Install systemd service unit
CLEANUP_SERVICE_UNIT="/etc/systemd/system/pulse-sensor-cleanup.service"
cat > "$CLEANUP_SERVICE_UNIT" <<SERVICE_EOF
[Unit]
Description=Pulse Sensor Cleanup Service
Documentation=https://github.com/rcourtman/Pulse
After=network.target
[Service]
Type=oneshot
ExecStart=${CLEANUP_SCRIPT_PATH}
User=root
Group=root
WorkingDirectory=/var/lib/pulse-sensor-proxy
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=pulse-sensor-cleanup
# Security hardening (less restrictive than the proxy since we need SSH access and Proxmox config access)
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/pulse-sensor-proxy /root/.ssh /etc/pve /etc/systemd/system
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
PrivateTmp=true
RestrictSUIDSGID=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
LimitNOFILE=1024
[Install]
# This service is triggered by the .path unit, no need to enable it directly
SERVICE_EOF
# Enable and start the path unit
systemctl daemon-reload || true
systemctl enable pulse-sensor-cleanup.path
systemctl start pulse-sensor-cleanup.path
print_info "Cleanup system installed and enabled"
# Configure SSH keys for cluster temperature monitoring
print_info "Configuring proxy SSH access to cluster nodes..."
# Wait for proxy to generate SSH keys
PROXY_KEY_FILE="$SSH_DIR/id_ed25519.pub"
for i in {1..10}; do
if [[ -f "$PROXY_KEY_FILE" ]]; then
break
fi
sleep 1
done
if [[ ! -f "$PROXY_KEY_FILE" ]]; then
print_error "Proxy SSH key not generated after 10 seconds"
print_info "Check service logs: journalctl -u pulse-sensor-proxy -n 50"
exit 1
fi
PROXY_PUBLIC_KEY=$(cat "$PROXY_KEY_FILE")
print_info "Proxy public key: ${PROXY_PUBLIC_KEY:0:50}..."
# Discover cluster nodes
if command -v pvecm >/dev/null 2>&1; then
# Extract node IPs from pvecm status
CLUSTER_NODES=$(pvecm status 2>/dev/null | awk '/0x[0-9a-f]+.*[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/ {print $3}' || true)
# Extract node names from pvecm nodes (for allowlist)
CLUSTER_NODE_NAMES=$(pvecm nodes 2>/dev/null | awk 'NR>2 {print $3}' || true)
if [[ -n "$CLUSTER_NODES" ]]; then
print_info "Discovered cluster nodes: $(echo $CLUSTER_NODES | tr '\n' ' ')"
# Configure SSH key with forced command restriction
FORCED_CMD='command="/opt/pulse/sensor-proxy/bin/pulse-sensor-wrapper.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_KEY} # pulse-managed-key"
# Track SSH key push results
SSH_SUCCESS_COUNT=0
SSH_FAILURE_COUNT=0
declare -a SSH_FAILED_NODES=()
LOCAL_IPS=$(hostname -I 2>/dev/null || echo "")
LOCAL_HOSTNAMES="$(hostname 2>/dev/null || echo "") $(hostname -f 2>/dev/null || echo "")"
LOCAL_HANDLED=false
# Push key to each cluster node
for node_ip in $CLUSTER_NODES; do
print_info "Authorizing proxy key on node $node_ip..."
IS_LOCAL=false
# Check if node_ip matches any of the local IPs (exact match with word boundaries)
for local_ip in $LOCAL_IPS; do
if [[ "$node_ip" == "$local_ip" ]]; then
IS_LOCAL=true
break
fi
done
if [[ " $LOCAL_HOSTNAMES " == *" $node_ip "* ]]; then
IS_LOCAL=true
fi
if [[ "$node_ip" == "127.0.0.1" || "$node_ip" == "localhost" ]]; then
IS_LOCAL=true
fi
if [[ "$IS_LOCAL" = true ]]; then
configure_local_authorized_key "$AUTH_LINE"
LOCAL_HANDLED=true
((SSH_SUCCESS_COUNT+=1))
continue
fi
# Remove any existing proxy keys first
ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=5 root@"$node_ip" \
"sed -i '/# pulse-managed-key\$/d' /root/.ssh/authorized_keys" 2>/dev/null || true
# Ensure wrapper compatibility on remote node (supports old installations)
# Create symlink if old wrapper exists but new path doesn't
ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=5 root@"$node_ip" \
"if [[ -f /usr/local/bin/pulse-sensor-wrapper.sh && ! -f /opt/pulse/sensor-proxy/bin/pulse-sensor-wrapper.sh ]]; then \
mkdir -p /opt/pulse/sensor-proxy/bin && \
ln -sf /usr/local/bin/pulse-sensor-wrapper.sh /opt/pulse/sensor-proxy/bin/pulse-sensor-wrapper.sh; \
fi" 2>/dev/null || true
# Add new key with forced command
SSH_ERROR=$(ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=5 root@"$node_ip" \
"echo '${AUTH_LINE}' >> /root/.ssh/authorized_keys" 2>&1)
if [[ $? -eq 0 ]]; then
print_success "SSH key configured on $node_ip"
((SSH_SUCCESS_COUNT+=1))
else
print_warn "Failed to configure SSH key on $node_ip"
((SSH_FAILURE_COUNT+=1))
SSH_FAILED_NODES+=("$node_ip")
# Log detailed error for debugging
if [[ -n "$SSH_ERROR" ]]; then
print_info " Error details: $(echo "$SSH_ERROR" | head -1)"
fi
fi
done
# Print summary
print_info ""
print_info "SSH key configuration summary:"
print_info " ✓ Success: $SSH_SUCCESS_COUNT node(s)"
if [[ $SSH_FAILURE_COUNT -gt 0 ]]; then
print_warn " ✗ Failed: $SSH_FAILURE_COUNT node(s) - ${SSH_FAILED_NODES[*]}"
print_info ""
print_info "To retry failed nodes, re-run this script or manually run:"
print_info " ssh root@<node> 'echo \"${AUTH_LINE}\" >> /root/.ssh/authorized_keys'"
fi
if [[ "$LOCAL_HANDLED" = false ]]; then
configure_local_authorized_key "$AUTH_LINE"
((SSH_SUCCESS_COUNT+=1))
fi
# Add discovered cluster nodes to config file for allowlist validation
print_info "Updating proxy configuration with discovered cluster nodes..."
# Collect all nodes (IPs and hostnames) into array
all_nodes=()
for node_ip in $CLUSTER_NODES; do
all_nodes+=("$node_ip")
done
for node_name in $CLUSTER_NODE_NAMES; do
all_nodes+=("$node_name")
done
# Use helper function to safely update allowed_nodes (prevents duplicates on re-run)
update_allowed_nodes "Cluster nodes (auto-discovered during installation)" "${all_nodes[@]}"
else
# No cluster found - configure standalone node
print_info "No cluster detected, configuring standalone node..."
# Configure SSH key with forced command restriction
FORCED_CMD='command="/opt/pulse/sensor-proxy/bin/pulse-sensor-wrapper.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_KEY} # pulse-managed-key"
print_info "Authorizing proxy key on localhost..."
configure_local_authorized_key "$AUTH_LINE"
print_info ""
print_info "Standalone node configuration complete"
# Add localhost to config file for allowlist validation
print_info "Updating proxy configuration for standalone mode..."
LOCAL_IPS=$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -v '^$' || echo "127.0.0.1")
# Collect all local IPs and localhost variants into array
all_nodes=()
for local_ip in $LOCAL_IPS; do
all_nodes+=("$local_ip")
done
# Always include localhost variants
all_nodes+=("127.0.0.1" "localhost")
# Use helper function to safely update allowed_nodes (prevents duplicates on re-run)
update_allowed_nodes "Standalone node configuration (auto-configured during installation)" "${all_nodes[@]}"
fi
else
# Proxmox host but pvecm not available (shouldn't happen, but handle it)
print_warn "pvecm command not available"
print_info "Configuring SSH key for localhost..."
# Configure localhost as fallback
FORCED_CMD='command="/opt/pulse/sensor-proxy/bin/pulse-sensor-wrapper.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_KEY} # pulse-managed-key"
configure_local_authorized_key "$AUTH_LINE"
# Add localhost to config file for allowlist validation
print_info "Updating proxy configuration for localhost fallback..."
LOCAL_IPS=$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -v '^$' || echo "127.0.0.1")
# Collect all local IPs and localhost variants into array
all_nodes=()
for local_ip in $LOCAL_IPS; do
all_nodes+=("$local_ip")
done
# Always include localhost variants
all_nodes+=("127.0.0.1" "localhost")
# Use helper function to safely update allowed_nodes (prevents duplicates on re-run)
update_allowed_nodes "Localhost fallback configuration (pvecm unavailable)" "${all_nodes[@]}"
fi
# Container-specific configuration (skip for standalone mode)
if [[ "$STANDALONE" == false ]]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Secure Container Communication Setup"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Setting up secure socket mount for temperature monitoring:"
echo " • Container communicates with host proxy via Unix socket"
echo " • No SSH keys exposed inside container (enhanced security)"
echo " • Proxy on host manages all temperature collection"
echo ""
# Ensure container mount via mp configuration
print_info "Configuring socket bind mount..."
MOUNT_TARGET="/mnt/pulse-proxy"
HOST_SOCKET_SOURCE="/run/pulse-sensor-proxy"
LXC_CONFIG="/etc/pve/lxc/${CTID}.conf"
# Back up container config before modifying
LXC_CONFIG_BACKUP=$(mktemp)
cp "$LXC_CONFIG" "$LXC_CONFIG_BACKUP" 2>/dev/null || {
print_warn "Could not back up container config (may not exist yet)"
LXC_CONFIG_BACKUP=""
}
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
HOTPLUG_FAILED=false
CT_RUNNING=false
if pct status "$CTID" 2>/dev/null | grep -q "running"; then
CT_RUNNING=true
fi
if [[ -z "$CURRENT_MP" ]]; then
for idx in $(seq 0 9); do
if ! printf "%s\n" "$CONFIG_CONTENT" | grep -q "^mp${idx}:"; then
CURRENT_MP="mp${idx}"
break
fi
done
if [[ -z "$CURRENT_MP" ]]; then
print_error "Unable to find available mp slot for container mount"
exit 1
fi
print_info "Configuring container mount using $CURRENT_MP..."
SET_ERROR=$(pct set "$CTID" -${CURRENT_MP} "${HOST_SOCKET_SOURCE},mp=${MOUNT_TARGET},replicate=0" 2>&1)
if [ $? -eq 0 ]; then
MOUNT_UPDATED=true
else
HOTPLUG_FAILED=true
if [ -n "$SET_ERROR" ]; then
print_warn "pct set failed: $SET_ERROR"
fi
fi
else
desired_pattern="^${CURRENT_MP}: ${HOST_SOCKET_SOURCE},mp=${MOUNT_TARGET}"
if pct config "$CTID" | grep -q "$desired_pattern"; then
print_info "Container already has socket mount configured ($CURRENT_MP)"
else
print_info "Updating container mount configuration ($CURRENT_MP)..."
SET_ERROR=$(pct set "$CTID" -${CURRENT_MP} "${HOST_SOCKET_SOURCE},mp=${MOUNT_TARGET},replicate=0" 2>&1)
if [ $? -eq 0 ]; then
MOUNT_UPDATED=true
else
HOTPLUG_FAILED=true
if [ -n "$SET_ERROR" ]; then
print_warn "pct set failed: $SET_ERROR"
fi
fi
fi
fi
if [[ "$HOTPLUG_FAILED" = true ]]; then
print_warn "Hot-plugging socket mount failed (container may be running). Updating config directly."
CURRENT_MP_LINE="${CURRENT_MP}: ${HOST_SOCKET_SOURCE},mp=${MOUNT_TARGET},replicate=0"
if ! grep -q "^${CURRENT_MP}:" "$LXC_CONFIG" 2>/dev/null; then
echo "$CURRENT_MP_LINE" >> "$LXC_CONFIG"
else
sed -i "s#^${CURRENT_MP}:.*#${CURRENT_MP_LINE}#" "$LXC_CONFIG"
fi
MOUNT_UPDATED=true
fi
# Verify mount configuration actually persisted
if ! pct config "$CTID" | grep -q "^${CURRENT_MP}:"; then
print_error "Failed to persist mount configuration for $CURRENT_MP"
print_error "Expected mount not found in container config"
exit 1
fi
print_info "✓ Mount configuration verified in container config"
# Remove legacy lxc.mount.entry directives if present
if grep -q "lxc.mount.entry: ${HOST_SOCKET_SOURCE}" "$LXC_CONFIG"; then
print_info "Removing legacy lxc.mount.entry directives for pulse-sensor-proxy"
sed -i '/lxc\.mount\.entry: \/run\/pulse-sensor-proxy/d' "$LXC_CONFIG"
MOUNT_UPDATED=true
fi
# Restart container to apply mount if configuration changed or mount missing
if [[ "$MOUNT_UPDATED" = true ]]; then
if [[ "$SKIP_RESTART" = true ]]; then
if [[ "$CT_RUNNING" = true ]]; then
print_info "Skipping container restart (--skip-restart provided)."
else
print_info "Skipping automatic container start (--skip-restart provided)."
fi
else
print_info "Restarting container to activate secure communication..."
if [[ "$CT_RUNNING" = true ]]; then
pct stop "$CTID" && sleep 2 && pct start "$CTID"
else
pct start "$CTID"
fi
sleep 5
fi
fi
# Verify socket directory and file inside container
if [[ "$HOTPLUG_FAILED" = true && "$CT_RUNNING" = true ]]; then
print_warn "Skipping socket verification until container $CTID is restarted."
print_warn "Please restart container and verify socket manually:"
print_warn " pct stop $CTID && sleep 2 && pct start $CTID"
print_warn " pct exec $CTID -- test -S ${MOUNT_TARGET}/pulse-sensor-proxy.sock && echo 'Socket OK'"
elif [[ "$SKIP_RESTART" = true && "$CT_RUNNING" = false ]]; then
print_warn "Socket verification deferred. Start container $CTID and run:"
print_warn " pct exec $CTID -- test -S ${MOUNT_TARGET}/pulse-sensor-proxy.sock && echo 'Socket OK'"
else
if [[ ! -S "$SOCKET_PATH" ]]; then
print_warn "Host proxy socket not available yet; deferring container verification until service starts."
DEFER_SOCKET_VERIFICATION=true
else
print_info "Verifying secure communication channel..."
if pct exec "$CTID" -- test -S "${MOUNT_TARGET}/pulse-sensor-proxy.sock"; then
print_info "✓ Secure socket communication ready"
# Clean up backup since verification succeeded
[ -n "$LXC_CONFIG_BACKUP" ] && rm -f "$LXC_CONFIG_BACKUP"
else
print_error "Socket not visible at ${MOUNT_TARGET}/pulse-sensor-proxy.sock"
print_error "Mount configuration verified but socket not accessible in container"
print_error "This indicates a mount or restart issue"
# Rollback container config changes
if [ -n "$LXC_CONFIG_BACKUP" ] && [ -f "$LXC_CONFIG_BACKUP" ]; then
print_warn "Rolling back container configuration changes..."
cp "$LXC_CONFIG_BACKUP" "$LXC_CONFIG"
rm -f "$LXC_CONFIG_BACKUP"
print_info "Container configuration restored to previous state"
fi
exit 1
fi
fi
fi
# Configure Pulse backend environment override inside container
print_info "Configuring Pulse to use proxy..."
# Check if Pulse service exists in container before configuring
if ! pct exec "$CTID" -- systemctl status pulse >/dev/null 2>&1; then
print_warn "Pulse service not found in container $CTID; skipping proxy configuration"
print_info "Install Pulse in the container first, then re-run this installer"
else
pct exec "$CTID" -- bash -lc "mkdir -p /etc/systemd/system/pulse.service.d"
pct exec "$CTID" -- bash -lc "cat <<'EOF' >/etc/systemd/system/pulse.service.d/10-pulse-proxy.conf
[Service]
Environment=PULSE_SENSOR_PROXY_SOCKET=${MOUNT_TARGET}/pulse-sensor-proxy.sock
EOF"
pct exec "$CTID" -- systemctl daemon-reload || true
# Restart Pulse service to apply the new environment variable
if pct exec "$CTID" -- systemctl is-active --quiet pulse 2>/dev/null; then
print_info "Restarting Pulse service to apply configuration..."
pct exec "$CTID" -- systemctl restart pulse
sleep 2
print_success "Pulse service restarted with proxy configuration"
fi
fi
# Test proxy status
print_info "Testing proxy status..."
if systemctl is-active --quiet pulse-sensor-proxy; then
print_info "${GREEN}${NC} pulse-sensor-proxy is running"
else
print_error "pulse-sensor-proxy is not running"
print_info "Check logs: journalctl -u pulse-sensor-proxy -n 50"
exit 1
fi
# Check for and remove legacy SSH keys from container
print_info "Checking for legacy SSH keys in container..."
LEGACY_KEYS_FOUND=false
for key_type in id_rsa id_dsa id_ecdsa id_ed25519; do
if pct exec "$CTID" -- test -f "/root/.ssh/$key_type" 2>/dev/null; then
LEGACY_KEYS_FOUND=true
if [ "$QUIET" != true ]; then
print_warn "Found legacy SSH key: /root/.ssh/$key_type"
fi
pct exec "$CTID" -- rm -f "/root/.ssh/$key_type" "/root/.ssh/${key_type}.pub"
print_info " Removed /root/.ssh/$key_type"
fi
done
if [ "$LEGACY_KEYS_FOUND" = true ] && [ "$QUIET" != true ]; then
print_info ""
print_info "Legacy SSH keys removed from container for security"
print_info ""
fi
fi # End of container-specific configuration
# Install self-heal safeguards to keep proxy available
print_info "Configuring self-heal safeguards..."
if ! cache_installer_for_self_heal; then
if [[ -n "$INSTALLER_CACHE_REASON" ]]; then
print_warn "Unable to cache installer script for self-heal (${INSTALLER_CACHE_REASON})"
else
print_warn "Unable to cache installer script for self-heal"
fi
fi
cat > "$SELFHEAL_SCRIPT" <<'EOF'
#!/bin/bash
set -euo pipefail
SERVICE="pulse-sensor-proxy"
INSTALLER="/opt/pulse/sensor-proxy/install-sensor-proxy.sh"
CTID_FILE="/etc/pulse-sensor-proxy/ctid"
LOG_TAG="pulse-sensor-proxy-selfheal"
log() {
logger -t "$LOG_TAG" "$1"
}
if ! command -v systemctl >/dev/null 2>&1; then
exit 0
fi
if ! systemctl list-unit-files 2>/dev/null | grep -q "^${SERVICE}\\.service"; then
if [[ -x "$INSTALLER" && -f "$CTID_FILE" ]]; then
log "Service unit missing; attempting reinstall"
bash "$INSTALLER" --ctid "$(cat "$CTID_FILE")" --skip-restart --quiet || log "Reinstall attempt failed"
fi
exit 0
fi
if ! systemctl is-active --quiet "${SERVICE}.service"; then
systemctl start "${SERVICE}.service" || true
sleep 2
fi
if ! systemctl is-active --quiet "${SERVICE}.service"; then
if [[ -x "$INSTALLER" && -f "$CTID_FILE" ]]; then
log "Service failed to start; attempting reinstall"
bash "$INSTALLER" --ctid "$(cat "$CTID_FILE")" --skip-restart --quiet || log "Reinstall attempt failed"
systemctl start "${SERVICE}.service" || true
fi
fi
EOF
chmod 0755 "$SELFHEAL_SCRIPT"
cat > "$SELFHEAL_SERVICE_UNIT" <<EOF
[Unit]
Description=Pulse Sensor Proxy Self-Heal
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=${SELFHEAL_SCRIPT}
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
EOF
cat > "$SELFHEAL_TIMER_UNIT" <<'EOF'
[Unit]
Description=Ensure pulse-sensor-proxy stays installed and running
[Timer]
OnBootSec=5min
OnUnitActiveSec=30min
Unit=pulse-sensor-proxy-selfheal.service
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now pulse-sensor-proxy-selfheal.timer >/dev/null 2>&1 || true
if [ "$QUIET" = true ]; then
print_success "pulse-sensor-proxy installed and running"
else
print_info "${GREEN}Installation complete!${NC}"
print_info ""
print_info "Temperature monitoring will use the secure host-side proxy"
print_info ""
if [[ "$STANDALONE" == true ]]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Docker Container Configuration Required"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
print_info "${YELLOW}IMPORTANT:${NC} If Pulse is running in Docker, add this bind mount to your docker-compose.yml:"
echo ""
echo " volumes:"
echo " - pulse-data:/data"
echo " - /run/pulse-sensor-proxy:/run/pulse-sensor-proxy:ro"
echo ""
print_info "Then restart your Pulse container:"
echo " docker-compose down && docker-compose up -d"
echo ""
print_info "Or if using Docker directly:"
echo " docker restart pulse"
echo ""
fi
print_info "To check proxy status:"
print_info " systemctl status pulse-sensor-proxy"
if [[ "$STANDALONE" == true ]]; then
echo ""
print_info "After restarting Pulse, verify the socket is accessible:"
print_info " docker exec pulse ls -l /run/pulse-sensor-proxy/pulse-sensor-proxy.sock"
echo ""
print_info "Check Pulse logs for temperature proxy detection:"
print_info " docker logs pulse | grep -i 'temperature.*proxy'"
echo ""
print_info "For detailed documentation, see:"
print_info " https://github.com/rcourtman/Pulse/blob/main/docs/TEMPERATURE_MONITORING.md"
fi
fi
exit 0