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:
parent
d0f7fd6404
commit
123e0f04ca
8 changed files with 487 additions and 130 deletions
88
cmd/pulse-sensor-proxy/cleanup.go
Normal file
88
cmd/pulse-sensor-proxy/cleanup.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue