feat: add comprehensive node cleanup system

Implements automated cleanup workflow when nodes are deleted from Pulse, removing all monitoring footprint from the host. Changes include a new RPC handler in the sensor proxy for cleanup requests, enhanced node deletion modal with detailed cleanup explanations, and improved SSH key management with proper tagging for atomic updates.
This commit is contained in:
rcourtman 2025-10-17 18:53:45 +00:00
parent d0f7fd6404
commit 123e0f04ca
8 changed files with 487 additions and 130 deletions

View file

@ -0,0 +1,88 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/rs/zerolog"
)
const cleanupRequestFilename = "cleanup-request.json"
func (p *Proxy) cleanupRequestPath() (string, error) {
workDir := p.workDir
if workDir == "" {
workDir = defaultWorkDir()
}
if workDir == "" {
return "", errors.New("cleanup working directory not configured")
}
return filepath.Join(workDir, cleanupRequestFilename), nil
}
func (p *Proxy) handleRequestCleanup(ctx context.Context, req *RPCRequest, logger zerolog.Logger) (interface{}, error) {
cleanupPath, err := p.cleanupRequestPath()
if err != nil {
return nil, err
}
dir := filepath.Dir(cleanupPath)
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("ensure cleanup directory: %w", err)
}
payload := map[string]any{
"requestedAt": time.Now().UTC().Format(time.RFC3339),
}
if req != nil && req.Params != nil {
if host, ok := req.Params["host"].(string); ok && host != "" {
payload["host"] = host
}
if reason, ok := req.Params["reason"].(string); ok && reason != "" {
payload["reason"] = reason
}
}
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("encode cleanup payload: %w", err)
}
tmpFile, err := os.CreateTemp(dir, "cleanup-request-*.tmp")
if err != nil {
return nil, fmt.Errorf("prepare cleanup signal: %w", err)
}
tmpName := tmpFile.Name()
if _, err := tmpFile.Write(append(data, '\n')); err != nil {
tmpFile.Close()
os.Remove(tmpName)
return nil, fmt.Errorf("write cleanup payload: %w", err)
}
if err := tmpFile.Chmod(0o600); err != nil {
logger.Warn().Err(err).Str("path", tmpName).Msg("Failed to set cleanup payload permissions")
}
if err := tmpFile.Close(); err != nil {
os.Remove(tmpName)
return nil, fmt.Errorf("close cleanup payload: %w", err)
}
// Replace any existing request atomically so systemd path units trigger on change.
if err := os.Rename(tmpName, cleanupPath); err != nil {
os.Remove(tmpName)
return nil, fmt.Errorf("activate cleanup payload: %w", err)
}
logger.Info().
Str("path", cleanupPath).
Interface("payload", payload).
Msg("Cleanup request signalled")
return map[string]any{"queued": true}, nil
}

View file

@ -35,6 +35,10 @@ const (
maxRequestBytes = 16 * 1024 // 16 KiB max request size
)
func defaultWorkDir() string {
return "/var/lib/pulse-sensor-proxy"
}
var rootCmd = &cobra.Command{
Use: "pulse-sensor-proxy",
Short: "Pulse Sensor Proxy - Secure sensor data bridge for containerized Pulse",
@ -74,6 +78,7 @@ func main() {
type Proxy struct {
socketPath string
sshKeyPath string
workDir string
listener net.Listener
rateLimiter *rateLimiter
nodeGate *nodeGate
@ -93,6 +98,7 @@ const (
RPCRegisterNodes = "register_nodes"
RPCGetTemperature = "get_temperature"
RPCGetStatus = "get_status"
RPCRequestCleanup = "request_cleanup"
)
// RPCRequest represents a request from Pulse
@ -158,12 +164,20 @@ func runProxy() {
metrics: metrics,
}
if wd, err := os.Getwd(); err == nil {
proxy.workDir = wd
} else {
log.Warn().Err(err).Msg("Failed to determine working directory; using default")
proxy.workDir = defaultWorkDir()
}
// Register RPC method handlers
proxy.router = map[string]handlerFunc{
RPCGetStatus: proxy.handleGetStatusV2,
RPCEnsureClusterKeys: proxy.handleEnsureClusterKeysV2,
RPCRegisterNodes: proxy.handleRegisterNodesV2,
RPCGetTemperature: proxy.handleGetTemperatureV2,
RPCRequestCleanup: proxy.handleRequestCleanup,
}
if err := proxy.initAuthRules(); err != nil {

View file

@ -136,7 +136,7 @@ func (p *Proxy) testSSHConnection(nodeHost string) error {
privKeyPath := filepath.Join(p.sshKeyPath, "id_ed25519")
cmd := fmt.Sprintf(
`ssh -i %s -o StrictHostKeyChecking=no -o ConnectTimeout=5 root@%s "echo test"`,
`ssh -i %[1]s -T -n -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=5 root@%[2]s "echo test"`,
privKeyPath,
nodeHost,
)
@ -166,7 +166,7 @@ func (p *Proxy) getTemperatureViaSSH(nodeHost string) (string, error) {
// Since we use ForceCommand="sensors -j", any SSH command will run sensors
// We don't need to specify the command
cmd := fmt.Sprintf(
`ssh -i %s -o StrictHostKeyChecking=no -o ConnectTimeout=5 root@%s ""`,
`ssh -i %[1]s -T -n -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=5 root@%[2]s ""`,
privKeyPath,
nodeHost,
)

View file

@ -335,6 +335,9 @@ const Settings: Component<SettingsProps> = (props) => {
const [currentNodeType, setCurrentNodeType] = createSignal<'pve' | 'pbs' | 'pmg'>('pve');
const [modalResetKey, setModalResetKey] = createSignal(0);
const [showPasswordModal, setShowPasswordModal] = createSignal(false);
const [showDeleteNodeModal, setShowDeleteNodeModal] = createSignal(false);
const [nodePendingDelete, setNodePendingDelete] = createSignal<NodeConfigWithStatus | null>(null);
const [deleteNodeLoading, setDeleteNodeLoading] = createSignal(false);
const [initialLoadComplete, setInitialLoadComplete] = createSignal(false);
const [discoveryScanStatus, setDiscoveryScanStatus] = createSignal<DiscoveryScanStatus>({
scanning: false,
@ -1091,15 +1094,53 @@ const Settings: Component<SettingsProps> = (props) => {
}
};
const deleteNode = async (nodeId: string) => {
if (!confirm('Are you sure you want to delete this node?')) return;
const nodePendingDeleteLabel = () => {
const node = nodePendingDelete();
if (!node) return '';
return node.displayName || node.name || node.host || node.id;
};
const nodePendingDeleteHost = () => nodePendingDelete()?.host || '';
const nodePendingDeleteType = () => nodePendingDelete()?.type || '';
const nodePendingDeleteTypeLabel = () => {
switch (nodePendingDeleteType()) {
case 'pve':
return 'Proxmox VE node';
case 'pbs':
return 'Proxmox Backup Server';
case 'pmg':
return 'Proxmox Mail Gateway';
default:
return 'Pulse node';
}
};
const requestDeleteNode = (node: NodeConfigWithStatus) => {
setNodePendingDelete(node);
setShowDeleteNodeModal(true);
};
const cancelDeleteNode = () => {
if (deleteNodeLoading()) return;
setShowDeleteNodeModal(false);
setNodePendingDelete(null);
};
const deleteNode = async () => {
const pending = nodePendingDelete();
if (!pending) return;
setDeleteNodeLoading(true);
try {
await NodesAPI.deleteNode(nodeId);
setNodes(nodes().filter((n) => n.id !== nodeId));
showSuccess('Node deleted successfully');
await NodesAPI.deleteNode(pending.id);
setNodes(nodes().filter((n) => n.id !== pending.id));
const label = pending.displayName || pending.name || pending.host || pending.id;
showSuccess(`${label} removed successfully`);
} catch (error) {
showError(error instanceof Error ? error.message : 'Failed to delete node');
} finally {
setDeleteNodeLoading(false);
setShowDeleteNodeModal(false);
setNodePendingDelete(null);
}
};
@ -1869,7 +1910,7 @@ const Settings: Component<SettingsProps> = (props) => {
</button>
<button
type="button"
onClick={() => deleteNode(node.id)}
onClick={() => requestDeleteNode(node)}
class="p-2 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
<svg
@ -2302,7 +2343,7 @@ const Settings: Component<SettingsProps> = (props) => {
</button>
<button
type="button"
onClick={() => deleteNode(node.id)}
onClick={() => requestDeleteNode(node)}
class="p-2 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
<svg
@ -2710,9 +2751,9 @@ const Settings: Component<SettingsProps> = (props) => {
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button
type="button"
onClick={() => deleteNode(pmgNode.id)}
<button
type="button"
onClick={() => requestDeleteNode(pmgNode)}
class="p-2 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
title="Delete node"
>
@ -5437,6 +5478,78 @@ const Settings: Component<SettingsProps> = (props) => {
</div>
{/* Delete Node Modal */}
<Show when={showDeleteNodeModal()}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<Card padding="lg" class="w-full max-w-lg space-y-5">
<SectionHeader
title={`Remove ${nodePendingDeleteLabel()}`}
size="md"
class="mb-1"
/>
<div class="space-y-3 text-sm text-gray-600 dark:text-gray-300">
<p>
Removing this {nodePendingDeleteTypeLabel().toLowerCase()} also scrubs the Pulse
footprint on the host the proxy service, SSH key, API token, and bind mount are
all cleaned up automatically.
</p>
<div class="rounded-lg border border-blue-200 bg-blue-50 p-3 text-sm leading-relaxed dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-100">
<p class="font-medium text-blue-900 dark:text-blue-100">What happens next</p>
<ul class="mt-2 list-disc space-y-1 pl-4 text-blue-800 dark:text-blue-200 text-sm">
<li>Pulse removes the node entry and clears related alerts.</li>
<li>
{nodePendingDeleteHost() ? (
<>
The host{' '}
<span class="font-semibold">{nodePendingDeleteHost()}</span> loses the
proxy service, SSH key, and API token.
</>
) : (
'The host loses the proxy service, SSH key, and API token.'
)}
</li>
<li>
If the host comes back later, rerunning the setup script reinstalls everything
with a fresh key.
</li>
<Show when={nodePendingDeleteType() === 'pbs'}>
<li>
Backup user tokens on the PBS are removed, so jobs referencing them will no
longer authenticate until the node is re-added.
</li>
</Show>
<Show when={nodePendingDeleteType() === 'pmg'}>
<li>
Mail gateway tokens are removed as part of the cleanup; re-enroll to restore
outbound telemetry.
</li>
</Show>
</ul>
</div>
</div>
<div class="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={cancelDeleteNode}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
disabled={deleteNodeLoading()}
>
Keep node
</button>
<button
type="button"
onClick={deleteNode}
disabled={deleteNodeLoading()}
class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-red-500 dark:hover:bg-red-400"
>
{deleteNodeLoading() ? 'Removing…' : 'Remove node'}
</button>
</div>
</Card>
</div>
</Show>
{/* Node Modal - Use separate modals for PVE and PBS to ensure clean state */}
<Show when={showNodeModal() && currentNodeType() === 'pve'}>
<NodeModal

View file

@ -27,6 +27,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/tempproxy"
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
"github.com/rcourtman/pulse-go-rewrite/pkg/discovery"
"github.com/rcourtman/pulse-go-rewrite/pkg/pbs"
@ -2006,10 +2007,36 @@ func (h *ConfigHandlers) HandleDeleteNode(w http.ResponseWriter, r *http.Request
}()
}
if deletedNodeType == "pve" && deletedNodeHost != "" {
go h.triggerPVEHostCleanup(deletedNodeHost)
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
func (h *ConfigHandlers) triggerPVEHostCleanup(host string) {
client := tempproxy.NewClient()
if client == nil || !client.IsAvailable() {
log.Debug().
Str("host", host).
Msg("Skipping PVE cleanup request; sensor proxy socket unavailable")
return
}
if err := client.RequestCleanup(host); err != nil {
log.Warn().
Err(err).
Str("host", host).
Msg("Failed to queue PVE host cleanup via sensor proxy")
return
}
log.Info().
Str("host", host).
Msg("Queued PVE host cleanup via sensor proxy")
}
// HandleTestExistingNode tests a connection for an existing node using stored credentials
func (h *ConfigHandlers) HandleTestExistingNode(w http.ResponseWriter, r *http.Request) {
nodeID := strings.TrimPrefix(r.URL.Path, "/api/config/nodes/")
@ -3234,98 +3261,126 @@ if [[ $MAIN_ACTION =~ ^[2Rr]$ ]]; then
echo "Removing Pulse monitoring components..."
echo ""
# Stop and remove pulse-sensor-proxy service
if command -v systemctl &> /dev/null; then
if systemctl is-active --quiet pulse-sensor-proxy 2>/dev/null; then
echo " • Stopping pulse-sensor-proxy service..."
systemctl stop pulse-sensor-proxy || true
fi
if systemctl is-enabled --quiet pulse-sensor-proxy 2>/dev/null; then
echo " • Disabling pulse-sensor-proxy service..."
systemctl disable pulse-sensor-proxy || true
fi
if [ -f /etc/systemd/system/pulse-sensor-proxy.service ]; then
echo " • Removing systemd unit file..."
rm -f /etc/systemd/system/pulse-sensor-proxy.service
systemctl daemon-reload || true
CLEANUP_HELPER_USED=false
if [ -x /usr/local/bin/pulse-sensor-cleanup.sh ]; then
echo " • Running cleanup helper..."
if /usr/local/bin/pulse-sensor-cleanup.sh; then
CLEANUP_HELPER_USED=true
else
echo " ✗ Cleanup helper failed, falling back to manual removal."
fi
echo ""
fi
# Remove pulse-sensor-proxy binary
if [ -f /usr/local/bin/pulse-sensor-proxy ]; then
echo " • Removing pulse-sensor-proxy binary..."
rm -f /usr/local/bin/pulse-sensor-proxy
fi
# Remove pulse-sensor-proxy data directory
if [ -d /var/lib/pulse-sensor-proxy ]; then
echo " • Removing pulse-sensor-proxy data directory..."
rm -rf /var/lib/pulse-sensor-proxy
fi
# Remove pulse-sensor-proxy user
if id -u pulse-sensor-proxy >/dev/null 2>&1; then
echo " • Removing pulse-sensor-proxy system user..."
userdel pulse-sensor-proxy 2>/dev/null || true
fi
# Remove SSH keys from authorized_keys (only Pulse-managed entries)
if [ -f /root/.ssh/authorized_keys ]; then
echo " • Removing SSH keys from authorized_keys..."
# Create temporary file for safe atomic update
TMP_AUTH_KEYS=$(mktemp)
if [ -f "$TMP_AUTH_KEYS" ]; then
# Remove only lines with pulse-managed-key marker (preserves user keys)
grep -vF '# pulse-managed-key' /root/.ssh/authorized_keys > "$TMP_AUTH_KEYS" 2>/dev/null
GREP_EXIT=$?
# Exit 0 = lines remain, Exit 1 = all lines removed (both are success)
if [ $GREP_EXIT -eq 0 ] || [ $GREP_EXIT -eq 1 ]; then
# Preserve ownership and permissions from original
chmod --reference=/root/.ssh/authorized_keys "$TMP_AUTH_KEYS" 2>/dev/null || chmod 600 "$TMP_AUTH_KEYS"
chown --reference=/root/.ssh/authorized_keys "$TMP_AUTH_KEYS" 2>/dev/null || true
# Atomically replace (abort if mv fails)
if mv "$TMP_AUTH_KEYS" /root/.ssh/authorized_keys; then
:
else
# Cleanup temp file if move failed
rm -f "$TMP_AUTH_KEYS"
fi
else
# Cleanup temp file if grep had a real error (exit > 1)
rm -f "$TMP_AUTH_KEYS"
if [ "$CLEANUP_HELPER_USED" != true ]; then
# Stop and remove pulse-sensor services
if command -v systemctl &> /dev/null; then
if systemctl is-active --quiet pulse-sensor-proxy 2>/dev/null; then
echo " • Stopping pulse-sensor-proxy service..."
systemctl stop pulse-sensor-proxy || true
fi
if systemctl is-enabled --quiet pulse-sensor-proxy 2>/dev/null; then
echo " • Disabling pulse-sensor-proxy service..."
systemctl disable pulse-sensor-proxy || true
fi
if systemctl is-active --quiet pulse-sensor-cleanup.path 2>/dev/null; then
echo " • Stopping pulse-sensor-cleanup.path..."
systemctl stop pulse-sensor-cleanup.path || true
fi
if systemctl is-enabled --quiet pulse-sensor-cleanup.path 2>/dev/null; then
echo " • Disabling pulse-sensor-cleanup.path..."
systemctl disable pulse-sensor-cleanup.path || true
fi
if systemctl is-enabled --quiet pulse-sensor-cleanup.service 2>/dev/null; then
echo " • Disabling pulse-sensor-cleanup.service..."
systemctl disable pulse-sensor-cleanup.service || true
fi
if [ -f /etc/systemd/system/pulse-sensor-proxy.service ] || \
[ -f /etc/systemd/system/pulse-sensor-cleanup.service ] || \
[ -f /etc/systemd/system/pulse-sensor-cleanup.path ]; then
echo " • Removing systemd unit files..."
rm -f /etc/systemd/system/pulse-sensor-proxy.service
rm -f /etc/systemd/system/pulse-sensor-cleanup.service
rm -f /etc/systemd/system/pulse-sensor-cleanup.path
systemctl daemon-reload || true
fi
fi
fi
# Remove LXC bind mounts from all container configs
if [ -d /etc/pve/lxc ]; then
echo " • Removing LXC bind mounts from container configs..."
if compgen -G "/etc/pve/lxc/*.conf" > /dev/null; then
for conf in /etc/pve/lxc/*.conf; do
if [ -f "$conf" ] && grep -q "pulse-sensor-proxy" "$conf" 2>/dev/null; then
sed -i '/pulse-sensor-proxy/d' "$conf" || true
fi
done
# Remove pulse-sensor-proxy binary
if [ -f /usr/local/bin/pulse-sensor-proxy ]; then
echo " • Removing pulse-sensor-proxy binary..."
rm -f /usr/local/bin/pulse-sensor-proxy
fi
fi
# Remove Pulse monitoring API tokens and user
echo " • Removing Pulse monitoring API tokens and user..."
if command -v pveum &> /dev/null; then
# List all tokens for pulse-monitor@pam and remove them (idempotent)
TOKEN_LIST=$(pveum user token list pulse-monitor@pam 2>/dev/null | awk 'NR>3 {print $2}' | grep -v '^$' || printf '')
if [ -n "$TOKEN_LIST" ]; then
while IFS= read -r TOKEN; do
if [ -n "$TOKEN" ]; then
pveum user token remove pulse-monitor@pam "$TOKEN" 2>/dev/null || true
fi
done <<< "$TOKEN_LIST"
# Remove cleanup helper script
if [ -f /usr/local/bin/pulse-sensor-cleanup.sh ]; then
echo " • Removing cleanup helper script..."
rm -f /usr/local/bin/pulse-sensor-cleanup.sh
fi
# Remove pulse-sensor-proxy data directory
if [ -d /var/lib/pulse-sensor-proxy ]; then
echo " • Removing pulse-sensor-proxy data directory..."
rm -rf /var/lib/pulse-sensor-proxy
fi
# Remove pulse-sensor-proxy user
if id -u pulse-sensor-proxy >/dev/null 2>&1; then
echo " • Removing pulse-sensor-proxy system user..."
userdel pulse-sensor-proxy 2>/dev/null || true
fi
# Remove SSH keys from authorized_keys (only Pulse-managed entries)
if [ -f /root/.ssh/authorized_keys ]; then
echo " • Removing SSH keys from authorized_keys..."
TMP_AUTH_KEYS=$(mktemp)
if [ -f "$TMP_AUTH_KEYS" ]; then
grep -vF '# pulse-managed-key' /root/.ssh/authorized_keys > "$TMP_AUTH_KEYS" 2>/dev/null
GREP_EXIT=$?
if [ $GREP_EXIT -eq 0 ] || [ $GREP_EXIT -eq 1 ]; then
chmod --reference=/root/.ssh/authorized_keys "$TMP_AUTH_KEYS" 2>/dev/null || chmod 600 "$TMP_AUTH_KEYS"
chown --reference=/root/.ssh/authorized_keys "$TMP_AUTH_KEYS" 2>/dev/null || true
if mv "$TMP_AUTH_KEYS" /root/.ssh/authorized_keys; then
:
else
rm -f "$TMP_AUTH_KEYS"
fi
else
rm -f "$TMP_AUTH_KEYS"
fi
fi
fi
# Remove LXC bind mounts from all container configs
if [ -d /etc/pve/lxc ]; then
echo " • Removing LXC bind mounts from container configs..."
if compgen -G "/etc/pve/lxc/*.conf" > /dev/null; then
for conf in /etc/pve/lxc/*.conf; do
if [ -f "$conf" ] && grep -q "pulse-sensor-proxy" "$conf" 2>/dev/null; then
sed -i '/pulse-sensor-proxy/d' "$conf" || true
fi
done
fi
fi
# Remove Pulse monitoring API tokens and user
echo " • Removing Pulse monitoring API tokens and user..."
if command -v pveum &> /dev/null; then
TOKEN_LIST=$(pveum user token list pulse-monitor@pam 2>/dev/null | awk 'NR>3 {print $2}' | grep -v '^$' || printf '')
if [ -n "$TOKEN_LIST" ]; then
while IFS= read -r TOKEN; do
if [ -n "$TOKEN" ]; then
pveum user token remove pulse-monitor@pam "$TOKEN" 2>/dev/null || true
fi
done <<< "$TOKEN_LIST"
fi
pveum user delete pulse-monitor@pam 2>/dev/null || true
pveum role delete PulseMonitor 2>/dev/null || true
fi
if command -v proxmox-backup-manager &> /dev/null; then
proxmox-backup-manager user delete pulse-monitor@pbs 2>/dev/null || true
fi
# Remove the user (idempotent)
pveum user delete pulse-monitor@pam 2>/dev/null || true
# Remove custom roles (idempotent)
pveum role delete PulseMonitor 2>/dev/null || true
fi
echo ""
@ -3670,7 +3725,18 @@ if [ "$PULSE_IS_CONTAINERIZED" = true ] && [ -n "$PULSE_CTID" ]; then
export PULSE_SENSOR_PROXY_FALLBACK_URL="%s/api/install/pulse-sensor-proxy"
# Run installer
if "$PROXY_INSTALLER" --ctid "$PULSE_CTID" --quiet 2>&1 | grep -E "✓|⚠️|ERROR"; then
INSTALL_OUTPUT=$("$PROXY_INSTALLER" --ctid "$PULSE_CTID" --quiet 2>&1)
INSTALL_STATUS=$?
if [ -n "$INSTALL_OUTPUT" ]; then
echo "$INSTALL_OUTPUT" | grep -E "✓|⚠️|ERROR" || true
fi
if [ -n "$INSTALL_OUTPUT" ]; then
echo "$INSTALL_OUTPUT" | grep -E "✓|⚠️|ERROR" || true
fi
if [ $INSTALL_STATUS -eq 0 ]; then
# Verify proxy health
PROXY_HEALTHY=false
if systemctl is-active --quiet pulse-sensor-proxy 2>/dev/null; then
@ -3735,6 +3801,11 @@ if [ "$PULSE_IS_CONTAINERIZED" = true ] && [ -n "$PULSE_CTID" ]; then
else
echo ""
echo "⚠️ Proxy installation had issues - you may need to configure manually"
if [ -n "$INSTALL_OUTPUT" ]; then
echo ""
echo "$INSTALL_OUTPUT" | tail -n 40
echo ""
fi
fi
rm -f "$PROXY_INSTALLER"

View file

@ -17,10 +17,10 @@ import (
// TemperatureCollector handles SSH-based temperature collection from Proxmox nodes
type TemperatureCollector struct {
sshUser string // SSH user (typically "root" or "pulse-monitor")
sshKeyPath string // Path to SSH private key
sshUser string // SSH user (typically "root" or "pulse-monitor")
sshKeyPath string // Path to SSH private key
proxyClient *tempproxy.Client // Optional: unix socket client for proxy
useProxy bool // Whether to use proxy for temperature collection
useProxy bool // Whether to use proxy for temperature collection
}
// NewTemperatureCollector creates a new temperature collector
@ -103,6 +103,7 @@ func (tc *TemperatureCollector) runSSHCommand(ctx context.Context, host, command
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=5",
"-o", "BatchMode=yes", // No password prompts
"-o", "LogLevel=ERROR", // Suppress host key warnings that break JSON parsing
}
// Add key if specified
@ -123,7 +124,17 @@ func (tc *TemperatureCollector) runSSHCommand(ctx context.Context, host, command
return "", fmt.Errorf("ssh command failed: %w", err)
}
return string(output), nil
outputStr := strings.TrimSpace(string(output))
// Strip any leading SSH noise (e.g., "Warning: Permanently added ...") so sensors JSON parses cleanly.
if idx := strings.Index(outputStr, "{"); idx > 0 {
outputStr = outputStr[idx:]
}
if idx := strings.LastIndex(outputStr, "}"); idx != -1 && idx < len(outputStr)-1 {
outputStr = outputStr[:idx+1]
}
return outputStr, nil
}
// parseSensorsJSON parses the JSON output from `sensors -j`
@ -166,11 +177,11 @@ func (tc *TemperatureCollector) parseSensorsJSON(jsonStr string) (*models.Temper
// Handle CPU temperature sensors
chipLower := strings.ToLower(chipName)
if strings.Contains(chipLower, "coretemp") ||
strings.Contains(chipLower, "k10temp") ||
strings.Contains(chipLower, "zenpower") ||
strings.Contains(chipLower, "k8temp") ||
strings.Contains(chipLower, "acpitz") ||
strings.Contains(chipLower, "it87") {
strings.Contains(chipLower, "k10temp") ||
strings.Contains(chipLower, "zenpower") ||
strings.Contains(chipLower, "k8temp") ||
strings.Contains(chipLower, "acpitz") ||
strings.Contains(chipLower, "it87") {
foundCPUChip = true
tc.parseCPUTemps(chipMap, temp)
}

View file

@ -168,3 +168,25 @@ func (c *Client) GetTemperature(nodeHost string) (string, error) {
return tempStr, nil
}
// RequestCleanup signals the proxy to trigger host-side cleanup workflow.
func (c *Client) RequestCleanup(host string) error {
params := make(map[string]interface{}, 1)
if host != "" {
params["host"] = host
}
resp, err := c.call("request_cleanup", params)
if err != nil {
return err
}
if !resp.Success {
if resp.Error != "" {
return fmt.Errorf("proxy error: %s", resp.Error)
}
return fmt.Errorf("proxy rejected cleanup request")
}
return nil
}

View file

@ -29,6 +29,36 @@ 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
}
# Check if running on Proxmox host
if ! command -v pvecm >/dev/null 2>&1; then
print_error "This script must be run on a Proxmox VE host"
@ -306,20 +336,41 @@ if command -v pvecm >/dev/null 2>&1; then
# Configure SSH key with forced command restriction
FORCED_CMD='command="sensors -j",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_KEY}"
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
if [[ " $LOCAL_IPS " == *" $node_ip "* ]]; then
IS_LOCAL=true
fi
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++))
continue
fi
# Remove any existing proxy keys first
ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=5 root@"$node_ip" \
"sed -i '/pulse-sensor-proxy\$/d' /root/.ssh/authorized_keys" 2>/dev/null || true
"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" \
@ -348,30 +399,22 @@ if command -v pvecm >/dev/null 2>&1; then
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++))
fi
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="sensors -j",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_KEY}"
AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_KEY} # pulse-managed-key"
# Configure localhost
print_info "Authorizing proxy key on localhost..."
# Remove any existing proxy keys first
sed -i '/pulse-sensor-proxy$/d' /root/.ssh/authorized_keys 2>/dev/null || touch /root/.ssh/authorized_keys
# Add new key with forced command
if echo "${AUTH_LINE}" >> /root/.ssh/authorized_keys; then
print_success "SSH key configured on standalone node"
print_info ""
print_info "Standalone node configuration complete"
else
print_warn "Failed to configure SSH key on localhost"
print_info "Manually add this line to /root/.ssh/authorized_keys:"
print_info " ${AUTH_LINE}"
fi
configure_local_authorized_key "$AUTH_LINE"
print_info ""
print_info "Standalone node configuration complete"
fi
else
# Proxmox host but pvecm not available (shouldn't happen, but handle it)
@ -380,14 +423,9 @@ else
# Configure localhost as fallback
FORCED_CMD='command="sensors -j",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_KEY}"
AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_KEY} # pulse-managed-key"
sed -i '/pulse-sensor-proxy$/d' /root/.ssh/authorized_keys 2>/dev/null || touch /root/.ssh/authorized_keys
if echo "${AUTH_LINE}" >> /root/.ssh/authorized_keys; then
print_success "SSH key configured on localhost"
else
print_warn "Failed to configure SSH key"
fi
configure_local_authorized_key "$AUTH_LINE"
fi
# Ensure container mount via mp configuration