#!/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 (including the YAML key and all list items) # This handles multi-line allowed_nodes blocks and associated comment headers if [[ -f "$config_file" ]]; then awk ' # When we hit allowed_nodes, mark section start and clear pending comments /^allowed_nodes:/ { in_section=1 delete pending_lines pending_count=0 next } # Inside section: skip all content until we hit a non-indented, non-comment line in_section && /^[^ \t#]/ { in_section=0 # Fall through to print this line } in_section { next } # Outside section: buffer comment lines (they might belong to allowed_nodes) !in_section && /^[ \t]*#/ { pending_lines[pending_count++] = $0 next } # Outside section: non-comment line - flush pending comments and print !in_section { for (i = 0; i < pending_count; i++) { print pending_lines[i] } delete pending_lines pending_count = 0 print } # At end of file, flush any remaining pending comments END { for (i = 0; i < pending_count; i++) { print pending_lines[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 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 " echo "Usage: $0 --ctid [--pulse-server ] [--version ] [--local-binary ]" echo " Or: $0 --standalone [--pulse-server ] [--version ] [--local-binary ]" 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 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" < "$SERVICE_PATH" <&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" </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 # 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@ '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="/usr/local/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" < "$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