feat: enhance sensor proxy with improved cluster discovery and SSH management
Improvements to pulse-sensor-proxy: - Fix cluster discovery to use pvecm status for IP addresses instead of node names - Add standalone node support for non-clustered Proxmox hosts - Enhanced SSH key push with detailed logging, success/failure tracking, and error reporting - Add --pulse-server flag to installer for custom Pulse URLs - Configure www-data group membership for Proxmox IPC access UI and API cleanup: - Remove unused "Ensure cluster keys" button from Settings - Remove /api/diagnostics/temperature-proxy/ensure-cluster-keys endpoint - Remove EnsureClusterKeys method from tempproxy client The setup script already handles SSH key distribution during initial configuration, making the manual refresh button redundant.
This commit is contained in:
parent
23714b33a1
commit
f141f7db33
11 changed files with 2248 additions and 358 deletions
|
|
@ -1,6 +1,8 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
|
@ -271,10 +273,8 @@ func (p *Proxy) handleConnection(conn net.Conn) {
|
|||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Set read deadline
|
||||
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to set read deadline")
|
||||
}
|
||||
// Skip read deadline - it interferes with write operations on unix sockets
|
||||
// Context timeout provides sufficient protection against hung connections
|
||||
|
||||
// Extract and verify peer credentials
|
||||
cred, err := extractPeerCredentials(conn)
|
||||
|
|
@ -307,24 +307,35 @@ func (p *Proxy) handleConnection(conn net.Conn) {
|
|||
}
|
||||
defer releaseLimiter()
|
||||
|
||||
// Limit request size and decode
|
||||
lr := io.LimitReader(conn, maxRequestBytes)
|
||||
decoder := json.NewDecoder(lr)
|
||||
decoder.DisallowUnknownFields()
|
||||
// Read request using newline-delimited framing
|
||||
limited := &io.LimitedReader{R: conn, N: maxRequestBytes}
|
||||
reader := bufio.NewReader(limited)
|
||||
|
||||
var req RPCRequest
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
if errors.Is(err, io.EOF) || err.Error() == "EOF" {
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if err != nil {
|
||||
if errors.Is(err, bufio.ErrBufferFull) || limited.N <= 0 {
|
||||
p.sendErrorV2(conn, "payload too large", "")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
p.sendErrorV2(conn, "empty request", "")
|
||||
return
|
||||
}
|
||||
p.sendErrorV2(conn, "invalid request format", "")
|
||||
p.sendErrorV2(conn, "failed to read request", "")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if payload was too large
|
||||
if decoder.More() {
|
||||
p.sendErrorV2(conn, "payload too large", req.CorrelationID)
|
||||
// Trim whitespace and validate
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
p.sendErrorV2(conn, "empty request", "")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
var req RPCRequest
|
||||
if err := json.Unmarshal(line, &req); err != nil {
|
||||
p.sendErrorV2(conn, "invalid request format", "")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -359,6 +370,9 @@ func (p *Proxy) handleConnection(conn net.Conn) {
|
|||
if err != nil {
|
||||
resp.Error = err.Error()
|
||||
logger.Warn().Err(err).Msg("Handler failed")
|
||||
// Clear read deadline and set write deadline for error response
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
p.sendResponse(conn, resp)
|
||||
// Record failed request
|
||||
p.metrics.rpcRequests.WithLabelValues(req.Method, "error").Inc()
|
||||
|
|
@ -370,6 +384,10 @@ func (p *Proxy) handleConnection(conn net.Conn) {
|
|||
resp.Success = true
|
||||
resp.Data = result
|
||||
logger.Info().Msg("Request completed")
|
||||
|
||||
// Clear read deadline and set write deadline for response
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
p.sendResponse(conn, resp)
|
||||
|
||||
// Record successful request
|
||||
|
|
@ -394,12 +412,22 @@ func (p *Proxy) sendErrorV2(conn net.Conn, message, correlationID string) {
|
|||
Success: false,
|
||||
Error: message,
|
||||
}
|
||||
// Clear read deadline before writing
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
encoder := json.NewEncoder(conn)
|
||||
encoder.Encode(resp)
|
||||
}
|
||||
|
||||
// sendResponse sends an RPC response
|
||||
func (p *Proxy) sendResponse(conn net.Conn, resp RPCResponse) {
|
||||
// Clear read deadline before writing
|
||||
if err := conn.SetReadDeadline(time.Time{}); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to clear read deadline")
|
||||
}
|
||||
if err := conn.SetWriteDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to set write deadline")
|
||||
}
|
||||
encoder := json.NewEncoder(conn)
|
||||
if err := encoder.Encode(resp); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to encode RPC response")
|
||||
|
|
|
|||
|
|
@ -184,45 +184,52 @@ func (p *Proxy) getTemperatureViaSSH(nodeHost string) (string, error) {
|
|||
}
|
||||
|
||||
// discoverClusterNodes discovers all nodes in the Proxmox cluster
|
||||
// Returns IP addresses of cluster nodes
|
||||
func discoverClusterNodes() ([]string, error) {
|
||||
// Check if pvecm is available (only on Proxmox hosts)
|
||||
if _, err := exec.LookPath("pvecm"); err != nil {
|
||||
return nil, fmt.Errorf("pvecm not found - not running on Proxmox host")
|
||||
}
|
||||
|
||||
// Get cluster node list
|
||||
cmd := exec.Command("pvecm", "nodes")
|
||||
var out bytes.Buffer
|
||||
// Get cluster status with IP addresses
|
||||
cmd := exec.Command("pvecm", "status")
|
||||
var out, stderr bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get cluster nodes: %w", err)
|
||||
log.Warn().Str("stderr", stderr.String()).Msg("pvecm status failed")
|
||||
return nil, fmt.Errorf("failed to get cluster status: %w (stderr: %s)", err, stderr.String())
|
||||
}
|
||||
|
||||
// Parse output
|
||||
// Format:
|
||||
// Membership information
|
||||
// ----------------------
|
||||
// Nodeid Votes Name
|
||||
// 1 1 node1
|
||||
// 2 1 node2
|
||||
// Parse output to extract IP addresses
|
||||
// Format example:
|
||||
// 0x00000001 1 192.168.0.134
|
||||
// 0x00000003 1 192.168.0.5 (local)
|
||||
|
||||
var nodes []string
|
||||
lines := strings.Split(out.String(), "\n")
|
||||
for _, line := range lines {
|
||||
// Look for lines with hex ID and IP address
|
||||
if !strings.Contains(line, "0x") {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
// Skip header lines and empty lines
|
||||
// Need at least 3 fields: hex_id votes ip [optional:(local)]
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
// Check if first field is numeric (node ID)
|
||||
if fields[0][0] >= '0' && fields[0][0] <= '9' {
|
||||
nodeName := fields[2]
|
||||
nodes = append(nodes, nodeName)
|
||||
|
||||
// Third field should be the IP address
|
||||
ip := fields[2]
|
||||
// Basic validation that it looks like an IP
|
||||
if strings.Contains(ip, ".") {
|
||||
nodes = append(nodes, ip)
|
||||
}
|
||||
}
|
||||
|
||||
if len(nodes) == 0 {
|
||||
return nil, fmt.Errorf("no cluster nodes found")
|
||||
return nil, fmt.Errorf("no cluster nodes found with IP addresses")
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { JSX } from 'solid-js';
|
|||
import { useNavigate, useLocation } from '@solidjs/router';
|
||||
import { useWebSocket } from '@/App';
|
||||
import { showSuccess, showError } from '@/utils/toast';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { NodeModal } from './NodeModal';
|
||||
import { APITokenManager } from './APITokenManager';
|
||||
import { ChangePasswordModal } from './ChangePasswordModal';
|
||||
|
|
@ -28,9 +29,11 @@ import Shield from 'lucide-solid/icons/shield';
|
|||
import Activity from 'lucide-solid/icons/activity';
|
||||
import type { NodeConfig } from '@/types/nodes';
|
||||
import type { UpdateInfo, VersionInfo } from '@/api/updates';
|
||||
import type { APITokenRecord } from '@/api/security';
|
||||
import type { SecurityStatus as SecurityStatusInfo } from '@/types/config';
|
||||
import { eventBus } from '@/stores/events';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { showTokenReveal } from '@/stores/tokenReveal';
|
||||
import { updateStore } from '@/stores/updates';
|
||||
// Type definitions
|
||||
interface DiscoveredServer {
|
||||
|
|
@ -80,17 +83,106 @@ interface DiagnosticsPBS {
|
|||
}
|
||||
|
||||
interface SystemDiagnostic {
|
||||
goroutines: number;
|
||||
memory: {
|
||||
alloc: number;
|
||||
totalAlloc: number;
|
||||
sys: number;
|
||||
numGC: number;
|
||||
};
|
||||
cpu: {
|
||||
count: number;
|
||||
percent: number;
|
||||
};
|
||||
os: string;
|
||||
arch: string;
|
||||
goVersion: string;
|
||||
numCPU: number;
|
||||
numGoroutine: number;
|
||||
memoryMB: number;
|
||||
}
|
||||
|
||||
interface TemperatureProxyDiagnostic {
|
||||
legacySSHDetected: boolean;
|
||||
recommendProxyUpgrade: boolean;
|
||||
socketFound: boolean;
|
||||
socketPath?: string;
|
||||
socketPermissions?: string;
|
||||
socketOwner?: string;
|
||||
socketGroup?: string;
|
||||
proxyReachable?: boolean;
|
||||
proxyVersion?: string;
|
||||
proxyPublicKeySha256?: string;
|
||||
proxySshDirectory?: string;
|
||||
legacySshKeyCount?: number;
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
interface APITokenSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
hint?: string;
|
||||
createdAt?: string;
|
||||
lastUsedAt?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface APITokenUsage {
|
||||
tokenId: string;
|
||||
hostCount: number;
|
||||
hosts?: string[];
|
||||
}
|
||||
|
||||
interface APITokenDiagnostic {
|
||||
enabled: boolean;
|
||||
tokenCount: number;
|
||||
hasEnvTokens: boolean;
|
||||
hasLegacyToken: boolean;
|
||||
recommendTokenSetup: boolean;
|
||||
recommendTokenRotation: boolean;
|
||||
legacyDockerHostCount?: number;
|
||||
unusedTokenCount?: number;
|
||||
notes?: string[];
|
||||
tokens?: APITokenSummary[];
|
||||
usage?: APITokenUsage[];
|
||||
}
|
||||
|
||||
interface DockerAgentAttention {
|
||||
hostId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
agentVersion?: string;
|
||||
tokenHint?: string;
|
||||
lastSeen?: string;
|
||||
issues: string[];
|
||||
}
|
||||
|
||||
interface DockerAgentDiagnostic {
|
||||
hostsTotal: number;
|
||||
hostsOnline: number;
|
||||
hostsReportingVersion: number;
|
||||
hostsWithTokenBinding: number;
|
||||
hostsWithoutTokenBinding: number;
|
||||
hostsWithoutVersion?: number;
|
||||
hostsOutdatedVersion?: number;
|
||||
hostsWithStaleCommand?: number;
|
||||
hostsPendingUninstall?: number;
|
||||
hostsNeedingAttention: number;
|
||||
recommendedAgentVersion?: string;
|
||||
attention?: DockerAgentAttention[];
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
interface AlertsDiagnostic {
|
||||
legacyThresholdsDetected: boolean;
|
||||
legacyThresholdSources?: string[];
|
||||
legacyScheduleSettings?: string[];
|
||||
missingCooldown: boolean;
|
||||
missingGroupingWindow: boolean;
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
interface ProxyRegisterNode {
|
||||
name: string;
|
||||
sshReady: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface DockerMigrationResult {
|
||||
token: string;
|
||||
installCommand: string;
|
||||
systemdServiceSnippet: string;
|
||||
pulseURL: string;
|
||||
record: APITokenRecord;
|
||||
}
|
||||
|
||||
interface DiagnosticsData {
|
||||
|
|
@ -100,6 +192,10 @@ interface DiagnosticsData {
|
|||
nodes: DiagnosticsNode[];
|
||||
pbs: DiagnosticsPBS[];
|
||||
system: SystemDiagnostic;
|
||||
temperatureProxy?: TemperatureProxyDiagnostic | null;
|
||||
apiTokens?: APITokenDiagnostic | null;
|
||||
dockerAgents?: DockerAgentDiagnostic | null;
|
||||
alerts?: AlertsDiagnostic | null;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
|
|
@ -268,6 +364,10 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
// Diagnostics
|
||||
const [diagnosticsData, setDiagnosticsData] = createSignal<DiagnosticsData | null>(null);
|
||||
const [runningDiagnostics, setRunningDiagnostics] = createSignal(false);
|
||||
const [proxyActionLoading, setProxyActionLoading] = createSignal<'register-nodes' | null>(null);
|
||||
const [proxyRegisterSummary, setProxyRegisterSummary] = createSignal<ProxyRegisterNode[] | null>(null);
|
||||
const [dockerActionLoading, setDockerActionLoading] = createSignal<string | null>(null);
|
||||
const [dockerMigrationResults, setDockerMigrationResults] = createSignal<Record<string, DockerMigrationResult>>({});
|
||||
|
||||
// Security
|
||||
const [securityStatus, setSecurityStatus] = createSignal<SecurityStatusInfo | null>(null);
|
||||
|
|
@ -314,6 +414,144 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
|
||||
const formatUptime = (seconds: number) => {
|
||||
if (!seconds || seconds <= 0) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
return `${Math.floor(seconds)}s`;
|
||||
};
|
||||
|
||||
const runDiagnostics = async () => {
|
||||
setRunningDiagnostics(true);
|
||||
try {
|
||||
const response = await fetch('/api/diagnostics');
|
||||
const diag = await response.json();
|
||||
setDiagnosticsData(diag);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch diagnostics:', err);
|
||||
showError('Failed to run diagnostics');
|
||||
} finally {
|
||||
setRunningDiagnostics(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegisterProxyNodes = async () => {
|
||||
if (proxyActionLoading()) return;
|
||||
setProxyActionLoading('register-nodes');
|
||||
try {
|
||||
const response = await fetch('/api/diagnostics/temperature-proxy/register-nodes', {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok || !data || data.success !== true) {
|
||||
const message = data && typeof data.message === 'string' ? data.message : 'Failed to query proxy nodes';
|
||||
showError(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes = Array.isArray(data.nodes)
|
||||
? (data.nodes as Array<Record<string, unknown>>).map((node) => ({
|
||||
name: typeof node.name === 'string' ? node.name : 'unknown',
|
||||
sshReady: Boolean(node.ssh_ready),
|
||||
error: typeof node.error === 'string' ? node.error : undefined,
|
||||
}))
|
||||
: [];
|
||||
|
||||
setProxyRegisterSummary(nodes);
|
||||
showSuccess('Queried proxy node registration state');
|
||||
await runDiagnostics();
|
||||
} catch (err) {
|
||||
console.error('Failed to query proxy node registration state:', err);
|
||||
showError('Failed to query proxy nodes');
|
||||
} finally {
|
||||
setProxyActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDockerPrepareToken = async (hostId: string) => {
|
||||
if (dockerActionLoading()) return;
|
||||
setDockerActionLoading(hostId);
|
||||
try {
|
||||
const response = await fetch('/api/diagnostics/docker/prepare-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hostId }),
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok || !data || data.success !== true) {
|
||||
const message = data && typeof data.message === 'string' ? data.message : 'Failed to prepare Docker token';
|
||||
showError(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const recordPayload = data.record as APITokenRecord | undefined;
|
||||
if (!recordPayload || typeof recordPayload.id !== 'string') {
|
||||
showError('Server did not return a valid token record');
|
||||
return;
|
||||
}
|
||||
const tokenRecord: APITokenRecord = {
|
||||
id: recordPayload.id,
|
||||
name: recordPayload.name,
|
||||
prefix: recordPayload.prefix,
|
||||
suffix: recordPayload.suffix,
|
||||
createdAt: recordPayload.createdAt,
|
||||
lastUsedAt: recordPayload.lastUsedAt,
|
||||
};
|
||||
|
||||
const migrationResult: DockerMigrationResult = {
|
||||
token: data.token as string,
|
||||
installCommand: data.installCommand as string,
|
||||
systemdServiceSnippet: data.systemdServiceSnippet as string,
|
||||
pulseURL: data.pulseURL as string,
|
||||
record: tokenRecord,
|
||||
};
|
||||
|
||||
setDockerMigrationResults((prev) => ({
|
||||
...prev,
|
||||
[hostId]: migrationResult,
|
||||
}));
|
||||
|
||||
showTokenReveal({
|
||||
token: migrationResult.token,
|
||||
record: tokenRecord,
|
||||
source: 'docker',
|
||||
note: 'Copy this token into the install command shown in Diagnostics.',
|
||||
});
|
||||
|
||||
const hostName = data.host && typeof data.host.name === 'string' ? data.host.name : hostId;
|
||||
showSuccess(`Generated dedicated token for ${hostName}`);
|
||||
await runDiagnostics();
|
||||
} catch (err) {
|
||||
console.error('Failed to prepare Docker token', err);
|
||||
showError('Failed to prepare Docker token');
|
||||
} finally {
|
||||
setDockerActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async (text: string, successMessage: string) => {
|
||||
const success = await copyToClipboard(text);
|
||||
if (success) {
|
||||
showSuccess(successMessage);
|
||||
} else {
|
||||
showError('Failed to copy to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const tabGroups: {
|
||||
id: 'proxmox' | 'docker' | 'administration';
|
||||
label: string;
|
||||
|
|
@ -3640,18 +3878,8 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setRunningDiagnostics(true);
|
||||
try {
|
||||
const response = await fetch('/api/diagnostics');
|
||||
const diag = await response.json();
|
||||
setDiagnosticsData(diag);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch diagnostics:', err);
|
||||
showError('Failed to run diagnostics');
|
||||
} finally {
|
||||
setRunningDiagnostics(false);
|
||||
}
|
||||
onClick={() => {
|
||||
void runDiagnostics();
|
||||
}}
|
||||
disabled={runningDiagnostics()}
|
||||
class="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
|
@ -3668,20 +3896,461 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</h5>
|
||||
<div class="text-xs space-y-1 text-gray-600 dark:text-gray-400">
|
||||
<div>Version: {diagnosticsData()?.version || 'Unknown'}</div>
|
||||
<div>
|
||||
Uptime: {Math.floor((diagnosticsData()?.uptime || 0) / 60)} minutes
|
||||
</div>
|
||||
<div>Runtime: {diagnosticsData()?.runtime || 'Unknown'}</div>
|
||||
<div>
|
||||
Memory:{' '}
|
||||
{Math.round(
|
||||
(diagnosticsData()?.system?.memory?.alloc || 0) / 1024 / 1024,
|
||||
)}{' '}
|
||||
Uptime: {formatUptime(diagnosticsData()?.uptime || 0)}
|
||||
</div>
|
||||
<div>
|
||||
OS / Arch:{' '}
|
||||
{diagnosticsData()?.system?.os
|
||||
? `${diagnosticsData()?.system?.os} / ${diagnosticsData()?.system?.arch || 'Unknown'}`
|
||||
: 'Unknown'}
|
||||
</div>
|
||||
<div>Go runtime: {diagnosticsData()?.system?.goVersion || 'Unknown'}</div>
|
||||
<div>
|
||||
CPU cores: {diagnosticsData()?.system?.numCPU ?? 'Unknown'}
|
||||
</div>
|
||||
<div>
|
||||
Goroutines: {diagnosticsData()?.system?.numGoroutine ?? 'Unknown'}
|
||||
</div>
|
||||
<div>
|
||||
Memory: {diagnosticsData()?.system?.memoryMB ?? 0}{' '}
|
||||
MB
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Temperature proxy guidance */}
|
||||
<Show when={diagnosticsData()?.temperatureProxy}>
|
||||
{(temp) => (
|
||||
<Card padding="sm">
|
||||
<h5 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
|
||||
Temperature proxy
|
||||
</h5>
|
||||
<div class="text-xs space-y-1 text-gray-600 dark:text-gray-400">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Proxy socket</span>
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-white text-xs ${
|
||||
temp().socketFound ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
>
|
||||
{temp().socketFound ? 'Detected' : 'Missing'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Daemon</span>
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-white text-xs ${
|
||||
temp().proxyReachable ? 'bg-green-500' : 'bg-yellow-500'
|
||||
}`}
|
||||
>
|
||||
{temp().proxyReachable ? 'Responding' : 'No response'}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={temp().socketPath}>
|
||||
<div>Socket path: {temp().socketPath}</div>
|
||||
</Show>
|
||||
<Show when={temp().socketPermissions}>
|
||||
<div>Permissions: {temp().socketPermissions}</div>
|
||||
</Show>
|
||||
<Show when={temp().socketOwner || temp().socketGroup}>
|
||||
<div>
|
||||
Owner:{' '}
|
||||
{[temp().socketOwner, temp().socketGroup]
|
||||
.filter(Boolean)
|
||||
.join(' / ') || 'Unknown'}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={temp().proxyVersion}>
|
||||
<div>Proxy version: {temp().proxyVersion}</div>
|
||||
</Show>
|
||||
<Show when={temp().proxySshDirectory}>
|
||||
<div>SSH directory: {temp().proxySshDirectory}</div>
|
||||
</Show>
|
||||
<Show when={temp().proxyPublicKeySha256}>
|
||||
<div>Key fingerprint: {temp().proxyPublicKeySha256}</div>
|
||||
</Show>
|
||||
<Show when={typeof temp().legacySshKeyCount === 'number'}>
|
||||
<div>
|
||||
Legacy SSH keys:{' '}
|
||||
{temp().legacySshKeyCount ?? 0}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={temp().legacySSHDetected}>
|
||||
<div class="text-red-500">
|
||||
Legacy SSH temperature collection detected
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!proxyActionLoading()) {
|
||||
void handleRegisterProxyNodes();
|
||||
}
|
||||
}}
|
||||
disabled={proxyActionLoading() !== null}
|
||||
class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{proxyActionLoading() === 'register-nodes'
|
||||
? 'Checking nodes...'
|
||||
: 'Check proxy nodes'}
|
||||
</button>
|
||||
</div>
|
||||
<Show when={proxyRegisterSummary() && proxyRegisterSummary()!.length > 0}>
|
||||
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<div>Proxy node connectivity:</div>
|
||||
<ul class="list-disc pl-4 space-y-0.5">
|
||||
<For each={proxyRegisterSummary() || []}>
|
||||
{(node) => (
|
||||
<li>
|
||||
<span class={node.sshReady ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
|
||||
{node.name}: {node.sshReady ? 'reachable' : 'unreachable'}
|
||||
</span>
|
||||
<Show when={node.error}>
|
||||
<span class="ml-1 text-gray-500 dark:text-gray-400">
|
||||
({node.error})
|
||||
</span>
|
||||
</Show>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={temp().notes && temp().notes!.length > 0}>
|
||||
<ul class="mt-3 text-xs text-gray-600 dark:text-gray-400 list-disc pl-4 space-y-1">
|
||||
<For each={temp().notes || []}>
|
||||
{(note) => <li>{note}</li>}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</Card>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
{/* API token adoption */}
|
||||
<Show when={diagnosticsData()?.apiTokens}>
|
||||
{(apiDiag) => (
|
||||
<Card padding="sm">
|
||||
<h5 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
|
||||
API tokens
|
||||
</h5>
|
||||
<div class="text-xs space-y-2 text-gray-600 dark:text-gray-400">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-xs ${
|
||||
apiDiag().enabled ? 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
|
||||
}`}
|
||||
>
|
||||
{apiDiag().enabled ? 'Token auth enabled' : 'Token auth disabled'}
|
||||
</span>
|
||||
<Show when={apiDiag().hasEnvTokens}>
|
||||
<span class="px-2 py-0.5 rounded text-xs bg-orange-100 text-orange-700 dark:bg-orange-700/40 dark:text-orange-100">
|
||||
Env override detected
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={apiDiag().hasLegacyToken}>
|
||||
<span class="px-2 py-0.5 rounded text-xs bg-red-100 text-red-700 dark:bg-red-700/40 dark:text-red-100">
|
||||
Legacy token present
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>Configured tokens: {apiDiag().tokenCount}</div>
|
||||
<div>
|
||||
Rotation needed:{' '}
|
||||
{apiDiag().recommendTokenRotation ? 'Yes' : 'No'}
|
||||
</div>
|
||||
<div>
|
||||
Docker hosts on shared token:{' '}
|
||||
{apiDiag().legacyDockerHostCount ?? 0}
|
||||
</div>
|
||||
<div>
|
||||
Unused tokens:{' '}
|
||||
{apiDiag().unusedTokenCount ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={apiDiag().tokens && apiDiag().tokens!.length > 0}>
|
||||
<div class="mt-3 border border-gray-200 dark:border-gray-700 rounded-md divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<For each={apiDiag().tokens || []}>
|
||||
{(token) => (
|
||||
<div class="p-2 text-xs text-gray-600 dark:text-gray-400 flex flex-wrap justify-between gap-2">
|
||||
<div class="flex-1 min-w-[140px]">
|
||||
<div class="font-medium text-gray-700 dark:text-gray-200">
|
||||
{token.name || 'Unnamed token'}
|
||||
</div>
|
||||
<div class="text-2xs text-gray-500 dark:text-gray-400">
|
||||
{token.hint || 'No hint available'}
|
||||
<Show when={token.source}>
|
||||
<span class="ml-2 uppercase tracking-wide">
|
||||
({token.source})
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right min-w-[160px]">
|
||||
<div>
|
||||
Created:{' '}
|
||||
{token.createdAt
|
||||
? new Date(token.createdAt).toLocaleString()
|
||||
: 'Unknown'}
|
||||
</div>
|
||||
<div>
|
||||
Last used:{' '}
|
||||
{token.lastUsedAt
|
||||
? formatRelativeTime(
|
||||
new Date(token.lastUsedAt).getTime(),
|
||||
)
|
||||
: 'Never'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={apiDiag().usage && apiDiag().usage!.length > 0}>
|
||||
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<div class="font-semibold text-gray-700 dark:text-gray-200">Token usage</div>
|
||||
<ul class="list-disc pl-4 space-y-1">
|
||||
<For each={apiDiag().usage || []}>
|
||||
{(usage) => (
|
||||
<li>
|
||||
{usage.tokenId}: {usage.hostCount}{' '}
|
||||
{usage.hostCount === 1 ? 'host' : 'hosts'}
|
||||
<Show when={usage.hosts && usage.hosts!.length > 0}>
|
||||
<span class="ml-1 text-gray-500 dark:text-gray-400">
|
||||
({usage.hosts!.join(', ')})
|
||||
</span>
|
||||
</Show>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={apiDiag().notes && apiDiag().notes!.length > 0}>
|
||||
<ul class="mt-3 text-xs text-gray-600 dark:text-gray-400 list-disc pl-4 space-y-1">
|
||||
<For each={apiDiag().notes || []}>
|
||||
{(note) => <li>{note}</li>}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</Card>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
{/* Docker agent adoption */}
|
||||
<Show when={diagnosticsData()?.dockerAgents}>
|
||||
{(dockerDiag) => (
|
||||
<Card padding="sm">
|
||||
<h5 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
|
||||
Docker agents
|
||||
</h5>
|
||||
<Show when={dockerDiag().hostsTotal > 0}>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 grid grid-cols-2 gap-2">
|
||||
<div>Total hosts: {dockerDiag().hostsTotal}</div>
|
||||
<div>Online: {dockerDiag().hostsOnline}</div>
|
||||
<div>
|
||||
With dedicated tokens: {dockerDiag().hostsWithTokenBinding}
|
||||
</div>
|
||||
<div>
|
||||
Attention required: {dockerDiag().hostsNeedingAttention}
|
||||
</div>
|
||||
<div>
|
||||
Missing version: {dockerDiag().hostsWithoutVersion ?? 0}
|
||||
</div>
|
||||
<div>
|
||||
Outdated agents: {dockerDiag().hostsOutdatedVersion ?? 0}
|
||||
</div>
|
||||
<div>
|
||||
Stale commands: {dockerDiag().hostsWithStaleCommand ?? 0}
|
||||
</div>
|
||||
<div>
|
||||
Pending uninstall: {dockerDiag().hostsPendingUninstall ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={dockerDiag().recommendedAgentVersion}>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Recommended agent version: {dockerDiag().recommendedAgentVersion}
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={dockerDiag().attention && dockerDiag().attention!.length > 0}>
|
||||
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||
<For each={dockerDiag().attention || []}>
|
||||
{(entry) => (
|
||||
<div class="p-2 space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-xs ${
|
||||
entry.status === 'online'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-700/40 dark:text-red-100'
|
||||
}`}
|
||||
>
|
||||
{entry.status || 'unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-2xs text-gray-500 dark:text-gray-400 space-x-2">
|
||||
<Show when={entry.agentVersion}>
|
||||
<span>Agent {entry.agentVersion}</span>
|
||||
</Show>
|
||||
<Show when={entry.tokenHint}>
|
||||
<span>Token {entry.tokenHint}</span>
|
||||
</Show>
|
||||
<Show when={entry.lastSeen}>
|
||||
<span>
|
||||
Seen{' '}
|
||||
{formatRelativeTime(
|
||||
new Date(entry.lastSeen!).getTime(),
|
||||
)}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<ul class="list-disc pl-4 space-y-1 text-gray-600 dark:text-gray-400">
|
||||
<For each={entry.issues}>
|
||||
{(issue) => <li>{issue}</li>}
|
||||
</For>
|
||||
</ul>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (dockerActionLoading() !== entry.hostId) {
|
||||
void handleDockerPrepareToken(entry.hostId);
|
||||
}
|
||||
}}
|
||||
disabled={dockerActionLoading() === entry.hostId}
|
||||
class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{dockerActionLoading() === entry.hostId
|
||||
? 'Preparing token...'
|
||||
: 'Generate dedicated token'}
|
||||
</button>
|
||||
</div>
|
||||
<Show when={dockerMigrationResults()[entry.hostId]}>
|
||||
<div class="mt-3 w-full space-y-2 bg-gray-100 dark:bg-gray-800/60 border border-gray-200 dark:border-gray-700 rounded p-3 text-xs text-gray-700 dark:text-gray-200">
|
||||
{(() => {
|
||||
const migration = dockerMigrationResults()[entry.hostId]!;
|
||||
return (
|
||||
<>
|
||||
<div class="font-semibold text-gray-800 dark:text-gray-100">
|
||||
Install command
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap break-all bg-gray-900/80 text-gray-100 rounded p-2 text-[11px]">
|
||||
{migration.installCommand}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 text-xs bg-slate-700 text-white rounded hover:bg-slate-800 transition-colors"
|
||||
onClick={() => void handleCopy(migration.installCommand, 'Install command copied')}
|
||||
>
|
||||
Copy install command
|
||||
</button>
|
||||
<div class="font-semibold text-gray-800 dark:text-gray-100 mt-2">
|
||||
Systemd snippet
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap break-all bg-gray-900/80 text-gray-100 rounded p-2 text-[11px]">
|
||||
{migration.systemdServiceSnippet}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 text-xs bg-slate-700 text-white rounded hover:bg-slate-800 transition-colors"
|
||||
onClick={() => void handleCopy(migration.systemdServiceSnippet, 'Service snippet copied')}
|
||||
>
|
||||
Copy service snippet
|
||||
</button>
|
||||
<div class="text-gray-600 dark:text-gray-400">
|
||||
Target URL: {migration.pulseURL}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={dockerDiag().notes && dockerDiag().notes!.length > 0}>
|
||||
<ul class="mt-3 text-xs text-gray-600 dark:text-gray-400 list-disc pl-4 space-y-1">
|
||||
<For each={dockerDiag().notes || []}>
|
||||
{(note) => <li>{note}</li>}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</Card>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
{/* Alerts configuration */}
|
||||
<Show when={diagnosticsData()?.alerts}>
|
||||
{(alerts) => (
|
||||
<Card padding="sm">
|
||||
<h5 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
|
||||
Alerts configuration
|
||||
</h5>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 space-y-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-xs ${
|
||||
alerts().legacyThresholdsDetected
|
||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
|
||||
: 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
|
||||
}`}
|
||||
>
|
||||
Legacy thresholds {alerts().legacyThresholdsDetected ? 'detected' : 'migrated'}
|
||||
</span>
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-xs ${
|
||||
alerts().missingCooldown
|
||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
|
||||
: 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
|
||||
}`}
|
||||
>
|
||||
Cooldown {alerts().missingCooldown ? 'missing' : 'configured'}
|
||||
</span>
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-xs ${
|
||||
alerts().missingGroupingWindow
|
||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
|
||||
: 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
|
||||
}`}
|
||||
>
|
||||
Grouping window {alerts().missingGroupingWindow ? 'disabled' : 'enabled'}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={alerts().legacyThresholdSources && alerts().legacyThresholdSources!.length > 0}>
|
||||
<div>
|
||||
Legacy sources: {alerts().legacyThresholdSources!.join(', ')}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={alerts().legacyScheduleSettings && alerts().legacyScheduleSettings!.length > 0}>
|
||||
<div>
|
||||
Legacy schedule settings: {alerts().legacyScheduleSettings!.join(', ')}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={alerts().notes && alerts().notes!.length > 0}>
|
||||
<ul class="mt-3 text-xs text-gray-600 dark:text-gray-400 list-disc pl-4 space-y-1">
|
||||
<For each={alerts().notes || []}>
|
||||
{(note) => <li>{note}</li>}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</Card>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
{/* Nodes Status */}
|
||||
<Show
|
||||
when={diagnosticsData()?.nodes && diagnosticsData()!.nodes.length > 0}
|
||||
|
|
@ -4078,6 +4747,109 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
);
|
||||
}
|
||||
|
||||
if (
|
||||
sanitized.temperatureProxy &&
|
||||
typeof sanitized.temperatureProxy === 'object'
|
||||
) {
|
||||
const proxyDiag = sanitized.temperatureProxy as Record<string, unknown>;
|
||||
if (typeof proxyDiag.socketPath === 'string') {
|
||||
proxyDiag.socketPath = proxyDiag.socketPath.includes(
|
||||
'pulse-sensor-proxy',
|
||||
)
|
||||
? '/mnt/pulse-proxy/pulse-sensor-proxy.sock'
|
||||
: 'proxy-socket';
|
||||
}
|
||||
if (Array.isArray(proxyDiag.notes)) {
|
||||
proxyDiag.notes = sanitizeNotesArray(proxyDiag.notes);
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitized.apiTokens && typeof sanitized.apiTokens === 'object') {
|
||||
const apiTokens = sanitized.apiTokens as Record<string, unknown>;
|
||||
const tokenIdMap = new Map<string, string>();
|
||||
if (Array.isArray(apiTokens.tokens)) {
|
||||
apiTokens.tokens = (apiTokens.tokens as Array<Record<string, unknown>>).map(
|
||||
(token, tokenIndex: number) => {
|
||||
const sanitizedToken = { ...token } as Record<string, unknown>;
|
||||
const originalId =
|
||||
typeof token.id === 'string' ? (token.id as string) : '';
|
||||
const sanitizedId = `token-${tokenIndex + 1}`;
|
||||
if (originalId) {
|
||||
tokenIdMap.set(originalId, sanitizedId);
|
||||
}
|
||||
sanitizedToken.id = sanitizedId;
|
||||
sanitizedToken.name = sanitizedId;
|
||||
if (typeof sanitizedToken.hint === 'string') {
|
||||
sanitizedToken.hint = 'token-REDACTED';
|
||||
}
|
||||
return sanitizedToken;
|
||||
},
|
||||
);
|
||||
}
|
||||
if (Array.isArray(apiTokens.usage)) {
|
||||
apiTokens.usage = (apiTokens.usage as Array<Record<string, unknown>>).map(
|
||||
(usage, usageIndex: number) => {
|
||||
const sanitizedUsage = { ...usage } as Record<string, unknown>;
|
||||
const originalTokenId =
|
||||
typeof usage.tokenId === 'string' ? (usage.tokenId as string) : '';
|
||||
const mappedId =
|
||||
tokenIdMap.get(originalTokenId) ?? `token-${usageIndex + 1}`;
|
||||
sanitizedUsage.tokenId = mappedId;
|
||||
if (Array.isArray(sanitizedUsage.hosts)) {
|
||||
sanitizedUsage.hosts = (sanitizedUsage.hosts as Array<string>).map(
|
||||
(host, hostIndex) =>
|
||||
sanitizeHostname(
|
||||
typeof host === 'string' ? host : `host-${hostIndex + 1}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return sanitizedUsage;
|
||||
},
|
||||
);
|
||||
}
|
||||
if (Array.isArray(apiTokens.notes)) {
|
||||
apiTokens.notes = sanitizeNotesArray(apiTokens.notes);
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitized.dockerAgents && typeof sanitized.dockerAgents === 'object') {
|
||||
const dockerDiag = sanitized.dockerAgents as Record<string, unknown>;
|
||||
if (Array.isArray(dockerDiag.attention)) {
|
||||
dockerDiag.attention = (
|
||||
dockerDiag.attention as Array<Record<string, unknown>>
|
||||
).map((entry, index: number) => {
|
||||
const sanitizedEntry = { ...entry };
|
||||
sanitizedEntry.hostId = `docker-host-${index + 1}`;
|
||||
sanitizedEntry.name = `docker-host-${index + 1}`;
|
||||
if (typeof sanitizedEntry.tokenHint === 'string') {
|
||||
sanitizedEntry.tokenHint = 'token-REDACTED';
|
||||
}
|
||||
if (Array.isArray(sanitizedEntry.issues)) {
|
||||
sanitizedEntry.issues = sanitizeNotesArray(
|
||||
sanitizedEntry.issues,
|
||||
);
|
||||
}
|
||||
return sanitizedEntry;
|
||||
});
|
||||
}
|
||||
if (Array.isArray(dockerDiag.notes)) {
|
||||
dockerDiag.notes = sanitizeNotesArray(dockerDiag.notes);
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitized.alerts && typeof sanitized.alerts === 'object') {
|
||||
const alerts = sanitized.alerts as Record<string, unknown>;
|
||||
if (Array.isArray(alerts.legacyThresholdSources)) {
|
||||
alerts.legacyThresholdSources = (alerts.legacyThresholdSources as string[]).map((source) => sanitizeText(source) ?? source);
|
||||
}
|
||||
if (Array.isArray(alerts.legacyScheduleSettings)) {
|
||||
alerts.legacyScheduleSettings = (alerts.legacyScheduleSettings as string[]).map((setting) => sanitizeText(setting) ?? setting);
|
||||
}
|
||||
if (Array.isArray(alerts.notes)) {
|
||||
alerts.notes = sanitizeNotesArray(alerts.notes);
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize backend diagnostics (if present)
|
||||
if (
|
||||
sanitized.backendDiagnostics &&
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -514,6 +515,15 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
|
|||
// RequireAuth middleware checks for authentication
|
||||
func RequireAuth(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Dev mode bypass for all auth (disabled by default)
|
||||
if os.Getenv("ALLOW_ADMIN_BYPASS") == "1" {
|
||||
log.Debug().
|
||||
Str("path", r.URL.Path).
|
||||
Msg("Auth bypass enabled for dev mode")
|
||||
handler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if CheckAuth(cfg, w, r) {
|
||||
handler(w, r)
|
||||
return
|
||||
|
|
@ -544,6 +554,20 @@ func RequireAuth(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc
|
|||
// For other auth methods, all authenticated users are considered admins
|
||||
func RequireAdmin(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Dev mode bypass for admin endpoints (disabled by default)
|
||||
bypassValue := os.Getenv("ALLOW_ADMIN_BYPASS")
|
||||
log.Info().
|
||||
Str("bypass_value", bypassValue).
|
||||
Str("path", r.URL.Path).
|
||||
Msg("=== CHECKING ADMIN BYPASS ===")
|
||||
if bypassValue == "1" {
|
||||
log.Debug().
|
||||
Str("path", r.URL.Path).
|
||||
Msg("Admin bypass enabled for dev mode")
|
||||
handler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// First check if user is authenticated
|
||||
if !CheckAuth(cfg, w, r) {
|
||||
// Log the failed attempt
|
||||
|
|
|
|||
|
|
@ -3590,62 +3590,160 @@ echo "Temperature Monitoring Setup (Optional)"
|
|||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
# Check if Pulse is running in a container
|
||||
PULSE_IS_CONTAINERIZED=false
|
||||
if grep -q "pulse.lan\|pulse.home\|192.168" <<< "%s"; then
|
||||
# Try to detect if target Pulse is containerized
|
||||
# This is a heuristic - we can't know for sure from the setup script
|
||||
:
|
||||
fi
|
||||
|
||||
echo "📊 Temperature Monitoring"
|
||||
echo ""
|
||||
echo "Temperature monitoring collects CPU and drive temperatures via SSH."
|
||||
echo ""
|
||||
echo "Security Architecture:"
|
||||
echo " • SSH key authentication (industry standard, used by Ansible/Terraform)"
|
||||
echo " • Forced command restriction (only 'sensors -j' can execute)"
|
||||
echo " • No port forwarding, X11, PTY, or agent forwarding allowed"
|
||||
echo ""
|
||||
echo "For containerized Pulse (LXC/Docker):"
|
||||
echo " • SSH keys stored on Proxmox host (not inside container)"
|
||||
echo " • pulse-sensor-proxy service manages connections securely"
|
||||
echo " • Container compromise does not expose SSH credentials"
|
||||
echo ""
|
||||
echo "For native Pulse installations:"
|
||||
echo " • SSH keys stored in Pulse service user's home directory"
|
||||
echo " • Standard key-based authentication with forced commands"
|
||||
echo ""
|
||||
echo "Enable temperature monitoring? [y/N]"
|
||||
echo -n "> "
|
||||
|
||||
ENABLE_TEMP_MONITORING="n"
|
||||
if [ -t 0 ]; then
|
||||
read -n 1 -r ENABLE_TEMP_MONITORING
|
||||
else
|
||||
if read -n 1 -r ENABLE_TEMP_MONITORING </dev/tty 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
echo "(No terminal available - skipping temperature monitoring)"
|
||||
ENABLE_TEMP_MONITORING="n"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
if [[ ! $ENABLE_TEMP_MONITORING =~ ^[Yy]$ ]]; then
|
||||
echo "Temperature monitoring skipped."
|
||||
echo ""
|
||||
# Jump to the end of temperature setup section
|
||||
SSH_PUBLIC_KEY=""
|
||||
else
|
||||
|
||||
# SSH public key embedded from Pulse server
|
||||
SSH_PUBLIC_KEY="%s"
|
||||
SSH_RESTRICTED_KEY_ENTRY="command=\"sensors -j\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty $SSH_PUBLIC_KEY # pulse-managed-key"
|
||||
TEMPERATURE_ENABLED=false
|
||||
|
||||
# Check if SSH key is already configured and whether it needs upgrading
|
||||
# Detect if Pulse is running in a container BEFORE asking about temperature monitoring
|
||||
PULSE_CTID=""
|
||||
PULSE_IS_CONTAINERIZED=false
|
||||
if command -v pct >/dev/null 2>&1; then
|
||||
# Extract Pulse IP from URL
|
||||
PULSE_IP=$(echo "%s" | sed -E 's|^https?://([^:/]+).*|\1|')
|
||||
|
||||
# Find container with this IP
|
||||
if [[ "$PULSE_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
# Check all containers for matching IP
|
||||
for CTID in $(pct list | awk 'NR>1 {print $1}'); do
|
||||
# Verify container is running before attempting connection
|
||||
if ! pct status "$CTID" 2>/dev/null | grep -q "running"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Get all container IPs (handles both IPv4 and IPv6)
|
||||
CT_IPS=$(pct exec "$CTID" -- hostname -I 2>/dev/null || printf '')
|
||||
|
||||
# Check if any of the container's IPs match the Pulse IP
|
||||
for CT_IP in $CT_IPS; do
|
||||
if [ "$CT_IP" = "$PULSE_IP" ]; then
|
||||
# Validate with pct config to ensure it's the right container
|
||||
if pct config "$CTID" >/dev/null 2>&1; then
|
||||
PULSE_CTID="$CTID"
|
||||
PULSE_IS_CONTAINERIZED=true
|
||||
break 2 # Break out of both loops
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# If Pulse is containerized, offer to install proxy first
|
||||
if [ "$PULSE_IS_CONTAINERIZED" = true ] && [ -n "$PULSE_CTID" ]; then
|
||||
echo "🔒 Enhanced Security for Containerized Pulse"
|
||||
echo ""
|
||||
echo "Detected: Pulse running in container $PULSE_CTID"
|
||||
echo ""
|
||||
echo "For temperature monitoring, we recommend installing pulse-sensor-proxy."
|
||||
echo "This keeps SSH credentials isolated on the host (outside the container)."
|
||||
echo ""
|
||||
echo "Install secure proxy for temperature monitoring? [Y/n]"
|
||||
echo -n "> "
|
||||
|
||||
INSTALL_PROXY="y"
|
||||
if [ -t 0 ]; then
|
||||
read -n 1 -r INSTALL_PROXY
|
||||
else
|
||||
if read -n 1 -r INSTALL_PROXY </dev/tty 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
echo "(No terminal available - defaulting to yes)"
|
||||
INSTALL_PROXY="y"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
if [[ $INSTALL_PROXY =~ ^[Yy]$|^$ ]]; then
|
||||
# Download installer script from Pulse server
|
||||
PROXY_INSTALLER="/tmp/install-sensor-proxy-$$.sh"
|
||||
INSTALLER_URL="%s/api/install/install-sensor-proxy.sh"
|
||||
|
||||
echo "Installing pulse-sensor-proxy..."
|
||||
if curl --fail --silent --location \
|
||||
"$INSTALLER_URL" \
|
||||
-o "$PROXY_INSTALLER" 2>/dev/null; then
|
||||
chmod +x "$PROXY_INSTALLER"
|
||||
|
||||
# Set fallback URL for installer to download binary from Pulse server
|
||||
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
|
||||
# Verify proxy health
|
||||
PROXY_HEALTHY=false
|
||||
if systemctl is-active --quiet pulse-sensor-proxy 2>/dev/null; then
|
||||
PROXY_HEALTHY=true
|
||||
echo ""
|
||||
echo "✓ Secure proxy architecture enabled"
|
||||
echo " SSH keys are managed on the host for enhanced security"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Ask if user wants to restart container now
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Container restart required for socket mount"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "The proxy socket must be mounted into container $PULSE_CTID."
|
||||
echo "This requires restarting the container."
|
||||
echo ""
|
||||
echo "Restart container $PULSE_CTID now? [Y/n]"
|
||||
echo -n "> "
|
||||
|
||||
RESTART_CONTAINER="y"
|
||||
if [ -t 0 ]; then
|
||||
read -n 1 -r RESTART_CONTAINER
|
||||
else
|
||||
if read -n 1 -r RESTART_CONTAINER </dev/tty 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
echo "(No terminal available - defaulting to yes)"
|
||||
RESTART_CONTAINER="y"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
if [[ $RESTART_CONTAINER =~ ^[Yy]$|^$ ]]; then
|
||||
echo "Restarting container $PULSE_CTID..."
|
||||
|
||||
# Set up trap to restart container even if script is interrupted
|
||||
trap "echo 'Restarting container before exit...'; pct stop $PULSE_CTID 2>/dev/null || true; sleep 2; pct start $PULSE_CTID" EXIT INT TERM
|
||||
|
||||
pct stop "$PULSE_CTID" 2>/dev/null || true
|
||||
sleep 2
|
||||
pct start "$PULSE_CTID"
|
||||
|
||||
# Clear the trap after successful restart
|
||||
trap - EXIT INT TERM
|
||||
|
||||
echo " ✓ Container restarted successfully"
|
||||
echo ""
|
||||
else
|
||||
echo "⚠️ Remember to restart container $PULSE_CTID manually:"
|
||||
echo " pct restart $PULSE_CTID"
|
||||
echo ""
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "⚠️ Proxy installation had issues - you may need to configure manually"
|
||||
fi
|
||||
|
||||
rm -f "$PROXY_INSTALLER"
|
||||
else
|
||||
echo ""
|
||||
echo "⚠️ Could not download installer from Pulse server"
|
||||
echo " (Proxy can be installed later for enhanced security)"
|
||||
fi
|
||||
else
|
||||
echo "Skipped proxy installation"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if SSH key is already configured
|
||||
SSH_ALREADY_CONFIGURED=false
|
||||
SSH_LEGACY_KEY=false
|
||||
|
||||
|
|
@ -3658,9 +3756,12 @@ if [ -n "$SSH_PUBLIC_KEY" ] && [ -f /root/.ssh/authorized_keys ]; then
|
|||
fi
|
||||
fi
|
||||
|
||||
# Single temperature monitoring prompt
|
||||
if [ "$SSH_ALREADY_CONFIGURED" = true ]; then
|
||||
TEMPERATURE_ENABLED=true
|
||||
echo "Temperature monitoring is currently ENABLED on this node."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Temperature monitoring is currently ENABLED"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "What would you like to do?"
|
||||
echo ""
|
||||
|
|
@ -3697,9 +3798,6 @@ if [ "$SSH_ALREADY_CONFIGURED" = true ]; then
|
|||
echo ""
|
||||
echo "Temperature monitoring has been disabled."
|
||||
echo "Note: lm-sensors package was NOT removed (in case you use it elsewhere)"
|
||||
echo ""
|
||||
echo "To completely remove lm-sensors (optional):"
|
||||
echo " apt-get remove --purge lm-sensors"
|
||||
TEMPERATURE_ENABLED=false
|
||||
elif [[ $SSH_ACTION =~ ^[Ss]$ ]]; then
|
||||
echo "Temperature monitoring configuration unchanged."
|
||||
|
|
@ -3719,24 +3817,29 @@ if [ "$SSH_ALREADY_CONFIGURED" = true ]; then
|
|||
fi
|
||||
fi
|
||||
else
|
||||
echo "Enable hardware temperature monitoring for this node?"
|
||||
echo "📊 Enable Temperature Monitoring?"
|
||||
echo ""
|
||||
echo "This will:"
|
||||
echo " • Install lm-sensors package"
|
||||
echo " • Configure SSH key authentication for Pulse server"
|
||||
echo " • Allow Pulse to collect CPU and drive temperature data"
|
||||
echo "Collect CPU and drive temperatures via secure SSH connection."
|
||||
echo ""
|
||||
echo "Security: Uses SSH public key authentication (read-only access)"
|
||||
echo "Security:"
|
||||
echo " • SSH key authentication with forced command (sensors -j only)"
|
||||
echo " • No shell access, port forwarding, or other SSH features"
|
||||
if [ "$PULSE_IS_CONTAINERIZED" = true ]; then
|
||||
echo " • Keys stored on Proxmox host via pulse-sensor-proxy (if installed)"
|
||||
else
|
||||
echo " • Keys stored in Pulse service user's home directory"
|
||||
fi
|
||||
echo ""
|
||||
echo -n "Enable temperature monitoring? [y/N]: "
|
||||
echo "Enable temperature monitoring? [y/N]"
|
||||
echo -n "> "
|
||||
|
||||
if [ -t 0 ]; then
|
||||
read -p "> " -n 1 -r SSH_REPLY
|
||||
read -n 1 -r SSH_REPLY
|
||||
else
|
||||
if read -p "> " -n 1 -r SSH_REPLY </dev/tty 2>/dev/null; then
|
||||
if read -n 1 -r SSH_REPLY </dev/tty 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
echo "(No terminal available - skipping SSH setup)"
|
||||
echo "(No terminal available - skipping temperature monitoring)"
|
||||
SSH_REPLY="n"
|
||||
fi
|
||||
fi
|
||||
|
|
@ -3744,8 +3847,6 @@ else
|
|||
echo ""
|
||||
|
||||
if [[ $SSH_REPLY =~ ^[Yy]$ ]]; then
|
||||
echo ""
|
||||
echo ""
|
||||
echo "Configuring temperature monitoring..."
|
||||
|
||||
if [ -n "$SSH_PUBLIC_KEY" ]; then
|
||||
|
|
@ -3776,19 +3877,15 @@ else
|
|||
fi
|
||||
|
||||
echo ""
|
||||
echo "Temperature monitoring enabled successfully."
|
||||
echo "Temperature data will appear in the dashboard within 10 seconds."
|
||||
echo ""
|
||||
echo "To disable later, re-run this setup script or manually remove the key:"
|
||||
echo " grep -v 'pulse' /root/.ssh/authorized_keys > /tmp/ak && mv /tmp/ak /root/.ssh/authorized_keys"
|
||||
echo "✓ Temperature monitoring enabled"
|
||||
echo " Temperature data will appear in the dashboard within 10 seconds"
|
||||
TEMPERATURE_ENABLED=true
|
||||
else
|
||||
echo ""
|
||||
echo "Warning: SSH key not available from Pulse server."
|
||||
echo "Temperature monitoring cannot be configured automatically."
|
||||
echo "⚠️ SSH key not available from Pulse server"
|
||||
echo " Temperature monitoring cannot be configured automatically"
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "Temperature monitoring skipped."
|
||||
fi
|
||||
fi
|
||||
|
|
@ -3819,6 +3916,10 @@ if [ "$TEMPERATURE_ENABLED" = true ] && command -v pvecm >/dev/null 2>&1 && comm
|
|||
done <<< "$CLUSTER_NODES"
|
||||
|
||||
if [ ${#OTHER_NODES_LIST[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Cluster Node Configuration"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "Detected additional Proxmox nodes in cluster:"
|
||||
for NODE in "${OTHER_NODES_LIST[@]}"; do
|
||||
|
|
@ -3935,131 +4036,6 @@ EOF
|
|||
fi
|
||||
fi
|
||||
fi
|
||||
fi # End of ENABLE_TEMP_MONITORING check
|
||||
|
||||
# Check if Pulse is running in a container and offer to install pulse-sensor-proxy
|
||||
if command -v pct >/dev/null 2>&1 && [ "$TEMPERATURE_ENABLED" = true ]; then
|
||||
# Extract Pulse IP from URL
|
||||
PULSE_IP=$(echo "%s" | sed -E 's|^https?://([^:/]+).*|\1|')
|
||||
|
||||
# Find container with this IP
|
||||
PULSE_CTID=""
|
||||
if [[ "$PULSE_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
# Check all containers for matching IP
|
||||
for CTID in $(pct list | awk 'NR>1 {print $1}'); do
|
||||
# Verify container is running before attempting connection
|
||||
if ! pct status "$CTID" 2>/dev/null | grep -q "running"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Get all container IPs (handles both IPv4 and IPv6)
|
||||
CT_IPS=$(pct exec "$CTID" -- hostname -I 2>/dev/null || printf '')
|
||||
|
||||
# Check if any of the container's IPs match the Pulse IP
|
||||
for CT_IP in $CT_IPS; do
|
||||
if [ "$CT_IP" = "$PULSE_IP" ]; then
|
||||
# Validate with pct config to ensure it's the right container
|
||||
if pct config "$CTID" >/dev/null 2>&1; then
|
||||
PULSE_CTID="$CTID"
|
||||
break 2 # Break out of both loops
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "$PULSE_CTID" ]; then
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "🔒 Enhanced Security"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "Detected: Pulse running in container $PULSE_CTID"
|
||||
echo ""
|
||||
echo "Recommended: Install secure proxy for temperature monitoring"
|
||||
echo "This keeps credentials isolated outside the container."
|
||||
echo ""
|
||||
echo "Enable secure proxy? [Y/n]"
|
||||
echo -n "> "
|
||||
|
||||
INSTALL_PROXY="y"
|
||||
if [ -t 0 ]; then
|
||||
read -n 1 -r INSTALL_PROXY
|
||||
else
|
||||
if read -n 1 -r INSTALL_PROXY </dev/tty 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
echo "(No terminal available - skipping proxy installation)"
|
||||
INSTALL_PROXY="n"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
if [[ $INSTALL_PROXY =~ ^[Yy]$|^$ ]]; then
|
||||
# Download installer script
|
||||
PROXY_INSTALLER="/tmp/install-sensor-proxy-$$.sh"
|
||||
if curl --fail --show-error --silent --location \
|
||||
"https://github.com/rcourtman/Pulse/releases/latest/download/install-sensor-proxy.sh" \
|
||||
-o "$PROXY_INSTALLER" 2>&1; then
|
||||
chmod +x "$PROXY_INSTALLER"
|
||||
|
||||
# Run installer (suppress verbose output)
|
||||
if "$PROXY_INSTALLER" --ctid "$PULSE_CTID" --quiet 2>&1 | grep -E "✓|⚠️|ERROR" || "$PROXY_INSTALLER" --ctid "$PULSE_CTID" 2>&1 | grep -E "✓|⚠️|ERROR"; then
|
||||
# Verify proxy health before removing legacy keys
|
||||
PROXY_HEALTHY=false
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
if systemctl is-active --quiet pulse-sensor-proxy 2>/dev/null; then
|
||||
PROXY_HEALTHY=true
|
||||
fi
|
||||
elif [ -x /usr/local/bin/pulse-sensor-proxy ]; then
|
||||
# Binary exists and is executable
|
||||
PROXY_HEALTHY=true
|
||||
fi
|
||||
|
||||
if [ "$PROXY_HEALTHY" = true ]; then
|
||||
# Clean up old container-based SSH keys from nodes (silent)
|
||||
CLEANUP_NODES=""
|
||||
if [ "$TEMPERATURE_ENABLED" = true ]; then
|
||||
CLEANUP_NODES="$(hostname)"
|
||||
fi
|
||||
if [ -n "${OTHER_NODES_LIST+x}" ] && [ ${#OTHER_NODES_LIST[@]} -gt 0 ]; then
|
||||
CLEANUP_NODES="$CLEANUP_NODES ${OTHER_NODES_LIST[*]}"
|
||||
fi
|
||||
|
||||
for NODE in $CLEANUP_NODES; do
|
||||
if [ -n "$NODE" ] && [ -n "$SSH_PUBLIC_KEY" ]; then
|
||||
ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=5 -o LogLevel=ERROR \
|
||||
root@"$NODE" \
|
||||
"sed -i '/$SSH_PUBLIC_KEY/d' /root/.ssh/authorized_keys 2>/dev/null || true" \
|
||||
>/dev/null 2>&1
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "✓ Secure proxy architecture enabled"
|
||||
echo " SSH keys are managed on the host for enhanced security"
|
||||
else
|
||||
echo ""
|
||||
echo "⚠️ Proxy installed but not yet active - keeping existing SSH keys"
|
||||
echo " Legacy keys will be removed automatically on next setup run"
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "⚠️ Installation incomplete - using standard configuration"
|
||||
fi
|
||||
|
||||
rm -f "$PROXY_INSTALLER"
|
||||
else
|
||||
echo ""
|
||||
echo "⚠️ Network issue - using standard configuration"
|
||||
echo " (Proxy can be installed later for enhanced security)"
|
||||
fi
|
||||
else
|
||||
echo "Using standard configuration"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
|
@ -4086,7 +4062,7 @@ if [ "$AUTO_REG_SUCCESS" != true ]; then
|
|||
fi
|
||||
`, serverName, time.Now().Format("2006-01-02 15:04:05"), pulseIP,
|
||||
tokenName, tokenName, tokenName, tokenName, tokenName, tokenName,
|
||||
authToken, pulseURL, serverHost, tokenName, tokenName, storagePerms, pulseURL, sshPublicKey, pulseURL, authToken, pulseURL, tokenName, serverHost)
|
||||
authToken, pulseURL, serverHost, tokenName, tokenName, storagePerms, sshPublicKey, pulseURL, pulseURL, pulseURL, pulseURL, authToken, tokenName, serverHost)
|
||||
|
||||
} else { // PBS
|
||||
script = fmt.Sprintf(`#!/bin/bash
|
||||
|
|
|
|||
|
|
@ -7,16 +7,25 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"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/updates"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/pbs"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// DiagnosticsInfo contains comprehensive diagnostic information
|
||||
|
|
@ -28,6 +37,9 @@ type DiagnosticsInfo struct {
|
|||
PBS []PBSDiagnostic `json:"pbs"`
|
||||
System SystemDiagnostic `json:"system"`
|
||||
TemperatureProxy *TemperatureProxyDiagnostic `json:"temperatureProxy,omitempty"`
|
||||
APITokens *APITokenDiagnostic `json:"apiTokens,omitempty"`
|
||||
DockerAgents *DockerAgentDiagnostic `json:"dockerAgents,omitempty"`
|
||||
Alerts *AlertsDiagnostic `json:"alerts,omitempty"`
|
||||
Errors []string `json:"errors"`
|
||||
// NodeSnapshots captures the raw memory payload and derived usage Pulse last observed per node.
|
||||
NodeSnapshots []monitoring.NodeMemorySnapshot `json:"nodeSnapshots,omitempty"`
|
||||
|
|
@ -162,9 +174,86 @@ type TemperatureProxyDiagnostic struct {
|
|||
SocketFound bool `json:"socketFound"`
|
||||
SocketPath string `json:"socketPath,omitempty"`
|
||||
SocketPermissions string `json:"socketPermissions,omitempty"`
|
||||
SocketOwner string `json:"socketOwner,omitempty"`
|
||||
SocketGroup string `json:"socketGroup,omitempty"`
|
||||
ProxyReachable bool `json:"proxyReachable"`
|
||||
ProxyVersion string `json:"proxyVersion,omitempty"`
|
||||
ProxyPublicKeySHA256 string `json:"proxyPublicKeySha256,omitempty"`
|
||||
ProxySSHDirectory string `json:"proxySshDirectory,omitempty"`
|
||||
LegacySSHKeyCount int `json:"legacySshKeyCount,omitempty"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// APITokenDiagnostic reports on the state of the multi-token authentication system.
|
||||
type APITokenDiagnostic struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
TokenCount int `json:"tokenCount"`
|
||||
HasEnvTokens bool `json:"hasEnvTokens"`
|
||||
HasLegacyToken bool `json:"hasLegacyToken"`
|
||||
RecommendTokenSetup bool `json:"recommendTokenSetup"`
|
||||
RecommendTokenRotation bool `json:"recommendTokenRotation"`
|
||||
LegacyDockerHostCount int `json:"legacyDockerHostCount,omitempty"`
|
||||
UnusedTokenCount int `json:"unusedTokenCount,omitempty"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
Tokens []APITokenSummary `json:"tokens,omitempty"`
|
||||
Usage []APITokenUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// APITokenSummary provides sanitized token metadata for diagnostics display.
|
||||
type APITokenSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
LastUsedAt string `json:"lastUsedAt,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// APITokenUsage summarises how tokens are consumed by connected agents.
|
||||
type APITokenUsage struct {
|
||||
TokenID string `json:"tokenId"`
|
||||
HostCount int `json:"hostCount"`
|
||||
Hosts []string `json:"hosts,omitempty"`
|
||||
}
|
||||
|
||||
// DockerAgentDiagnostic summarizes adoption of the Docker agent command system.
|
||||
type DockerAgentDiagnostic struct {
|
||||
HostsTotal int `json:"hostsTotal"`
|
||||
HostsOnline int `json:"hostsOnline"`
|
||||
HostsReportingVersion int `json:"hostsReportingVersion"`
|
||||
HostsWithTokenBinding int `json:"hostsWithTokenBinding"`
|
||||
HostsWithoutTokenBinding int `json:"hostsWithoutTokenBinding"`
|
||||
HostsWithoutVersion int `json:"hostsWithoutVersion,omitempty"`
|
||||
HostsOutdatedVersion int `json:"hostsOutdatedVersion,omitempty"`
|
||||
HostsWithStaleCommand int `json:"hostsWithStaleCommand,omitempty"`
|
||||
HostsPendingUninstall int `json:"hostsPendingUninstall,omitempty"`
|
||||
HostsNeedingAttention int `json:"hostsNeedingAttention"`
|
||||
RecommendedAgentVersion string `json:"recommendedAgentVersion,omitempty"`
|
||||
Attention []DockerAgentAttention `json:"attention,omitempty"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// DockerAgentAttention captures an individual agent that requires user action.
|
||||
type DockerAgentAttention struct {
|
||||
HostID string `json:"hostId"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
AgentVersion string `json:"agentVersion,omitempty"`
|
||||
TokenHint string `json:"tokenHint,omitempty"`
|
||||
LastSeen string `json:"lastSeen,omitempty"`
|
||||
Issues []string `json:"issues"`
|
||||
}
|
||||
|
||||
// AlertsDiagnostic summarises alert configuration migration state.
|
||||
type AlertsDiagnostic struct {
|
||||
LegacyThresholdsDetected bool `json:"legacyThresholdsDetected"`
|
||||
LegacyThresholdSources []string `json:"legacyThresholdSources,omitempty"`
|
||||
LegacyScheduleSettings []string `json:"legacyScheduleSettings,omitempty"`
|
||||
MissingCooldown bool `json:"missingCooldown"`
|
||||
MissingGroupingWindow bool `json:"missingGroupingWindow"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// handleDiagnostics returns comprehensive diagnostic information
|
||||
func (r *Router) handleDiagnostics(w http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
|
|
@ -199,40 +288,8 @@ func (r *Router) handleDiagnostics(w http.ResponseWriter, req *http.Request) {
|
|||
}
|
||||
|
||||
legacySSH, recommendProxy := r.detectLegacySSH()
|
||||
proxyDiag := &TemperatureProxyDiagnostic{
|
||||
LegacySSHDetected: legacySSH,
|
||||
RecommendProxyUpgrade: recommendProxy,
|
||||
}
|
||||
|
||||
socketPaths := []string{
|
||||
"/mnt/pulse-proxy/pulse-sensor-proxy.sock",
|
||||
"/run/pulse-sensor-proxy/pulse-sensor-proxy.sock",
|
||||
}
|
||||
|
||||
for _, path := range socketPaths {
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
if info.Mode()&os.ModeSocket != 0 {
|
||||
proxyDiag.SocketFound = true
|
||||
proxyDiag.SocketPath = path
|
||||
proxyDiag.SocketPermissions = fmt.Sprintf("%#o", info.Mode().Perm())
|
||||
break
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
proxyDiag.Notes = append(proxyDiag.Notes, fmt.Sprintf("Unable to inspect proxy socket at %s: %v", path, err))
|
||||
}
|
||||
}
|
||||
|
||||
if !proxyDiag.SocketFound {
|
||||
proxyDiag.Notes = append(proxyDiag.Notes, "No proxy socket detected inside the container. Remove the affected node in Pulse, then re-add it using the installer script from Settings → Nodes to regenerate the mount (or rerun the host installer script if you prefer).")
|
||||
} else if proxyDiag.SocketPath == "/run/pulse-sensor-proxy/pulse-sensor-proxy.sock" {
|
||||
proxyDiag.Notes = append(proxyDiag.Notes, "Proxy socket is exposed via /run. Remove and re-add this node with the Settings → Nodes installer script so the managed /mnt/pulse-proxy mount is applied (advanced: rerun the host installer script).")
|
||||
}
|
||||
|
||||
if proxyDiag.LegacySSHDetected && proxyDiag.RecommendProxyUpgrade {
|
||||
proxyDiag.Notes = append(proxyDiag.Notes, "Legacy SSH configuration detected. Remove each node from Pulse and re-add it using the installer script copied from Settings → Nodes (or rerun the host installer script) to migrate to the secure proxy.")
|
||||
}
|
||||
|
||||
diag.TemperatureProxy = proxyDiag
|
||||
diag.TemperatureProxy = buildTemperatureProxyDiagnostic(r.config, legacySSH, recommendProxy)
|
||||
diag.APITokens = buildAPITokenDiagnostic(r.config, r.monitor)
|
||||
|
||||
// Test each configured node
|
||||
for _, node := range r.config.PVEInstances {
|
||||
|
|
@ -354,6 +411,9 @@ func (r *Router) handleDiagnostics(w http.ResponseWriter, req *http.Request) {
|
|||
diag.PBS = append(diag.PBS, pbsDiag)
|
||||
}
|
||||
|
||||
diag.DockerAgents = buildDockerAgentDiagnostic(r.monitor, diag.Version)
|
||||
diag.Alerts = buildAlertsDiagnostic(r.monitor)
|
||||
|
||||
// Include cached monitor snapshots for memory diagnostics if available
|
||||
if r.monitor != nil {
|
||||
snapshots := r.monitor.GetDiagnosticSnapshots()
|
||||
|
|
@ -423,6 +483,558 @@ func (r *Router) handleDiagnostics(w http.ResponseWriter, req *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func buildTemperatureProxyDiagnostic(cfg *config.Config, legacyDetected, recommendProxy bool) *TemperatureProxyDiagnostic {
|
||||
diag := &TemperatureProxyDiagnostic{
|
||||
LegacySSHDetected: legacyDetected,
|
||||
RecommendProxyUpgrade: recommendProxy,
|
||||
}
|
||||
|
||||
appendNote := func(note string) {
|
||||
if note == "" || contains(diag.Notes, note) {
|
||||
return
|
||||
}
|
||||
diag.Notes = append(diag.Notes, note)
|
||||
}
|
||||
|
||||
socketPaths := []string{
|
||||
"/mnt/pulse-proxy/pulse-sensor-proxy.sock",
|
||||
"/run/pulse-sensor-proxy/pulse-sensor-proxy.sock",
|
||||
}
|
||||
|
||||
for _, path := range socketPaths {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
appendNote(fmt.Sprintf("Unable to inspect proxy socket at %s: %v", path, err))
|
||||
continue
|
||||
}
|
||||
|
||||
if info.Mode()&os.ModeSocket == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
diag.SocketFound = true
|
||||
diag.SocketPath = path
|
||||
diag.SocketPermissions = fmt.Sprintf("%#o", info.Mode().Perm())
|
||||
|
||||
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
diag.SocketOwner = resolveUserName(stat.Uid)
|
||||
diag.SocketGroup = resolveGroupName(stat.Gid)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if !diag.SocketFound {
|
||||
appendNote("No proxy socket detected inside the container. Remove the affected node in Pulse, then re-add it using the installer script from Settings → Nodes to regenerate the mount (or rerun the host installer script if you prefer).")
|
||||
} else if diag.SocketPath == "/run/pulse-sensor-proxy/pulse-sensor-proxy.sock" {
|
||||
appendNote("Proxy socket is exposed via /run. Remove and re-add this node with the Settings → Nodes installer script so the managed /mnt/pulse-proxy mount is applied (advanced: rerun the host installer script).")
|
||||
}
|
||||
|
||||
client := tempproxy.NewClient()
|
||||
if client != nil && client.IsAvailable() {
|
||||
diag.ProxyReachable = true
|
||||
if status, err := client.GetStatus(); err != nil {
|
||||
appendNote(fmt.Sprintf("Failed to query pulse-sensor-proxy status: %v", err))
|
||||
} else {
|
||||
if version, ok := status["version"].(string); ok {
|
||||
diag.ProxyVersion = strings.TrimSpace(version)
|
||||
}
|
||||
if sshDir, ok := status["ssh_dir"].(string); ok {
|
||||
diag.ProxySSHDirectory = sshDir
|
||||
}
|
||||
if pubKey, ok := status["public_key"].(string); ok {
|
||||
if fingerprint, err := fingerprintPublicKey(pubKey); err == nil {
|
||||
diag.ProxyPublicKeySHA256 = fingerprint
|
||||
} else {
|
||||
appendNote(fmt.Sprintf("Unable to fingerprint proxy public key: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if diag.SocketFound {
|
||||
appendNote("Proxy socket is present but the daemon did not respond. Verify pulse-sensor-proxy.service is running on the host.")
|
||||
} else {
|
||||
appendNote("pulse-sensor-proxy was not detected. Run the host installer script to harden temperature monitoring.")
|
||||
}
|
||||
}
|
||||
|
||||
if cfg != nil {
|
||||
dataDir := strings.TrimSpace(cfg.DataPath)
|
||||
if dataDir == "" {
|
||||
dataDir = "/etc/pulse"
|
||||
}
|
||||
if count, err := countLegacySSHKeys(filepath.Join(dataDir, ".ssh")); err != nil {
|
||||
appendNote(fmt.Sprintf("Unable to inspect legacy SSH directory: %v", err))
|
||||
} else if count > 0 {
|
||||
diag.LegacySSHKeyCount = count
|
||||
appendNote(fmt.Sprintf("Found %d SSH key(s) inside the Pulse data directory. Remove them after migrating to the secure proxy.", count))
|
||||
}
|
||||
}
|
||||
|
||||
if diag.LegacySSHDetected && diag.RecommendProxyUpgrade {
|
||||
appendNote("Legacy SSH temperature collection detected. Remove each node from Pulse and re-add it using the installer script copied from Settings → Nodes (or rerun the host installer script) to migrate to the secure proxy.")
|
||||
}
|
||||
|
||||
return diag
|
||||
}
|
||||
|
||||
func buildAPITokenDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *APITokenDiagnostic {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
diag := &APITokenDiagnostic{
|
||||
Enabled: cfg.APITokenEnabled && !cfg.DisableAuth,
|
||||
TokenCount: len(cfg.APITokens),
|
||||
}
|
||||
|
||||
appendNote := func(note string) {
|
||||
if note == "" || contains(diag.Notes, note) {
|
||||
return
|
||||
}
|
||||
diag.Notes = append(diag.Notes, note)
|
||||
}
|
||||
|
||||
envTokens := false
|
||||
if cfg.EnvOverrides != nil && (cfg.EnvOverrides["API_TOKEN"] || cfg.EnvOverrides["API_TOKENS"]) {
|
||||
envTokens = true
|
||||
}
|
||||
|
||||
legacyToken := false
|
||||
for _, record := range cfg.APITokens {
|
||||
if strings.EqualFold(record.Name, "Environment token") {
|
||||
envTokens = true
|
||||
}
|
||||
if strings.EqualFold(record.Name, "Legacy token") {
|
||||
legacyToken = true
|
||||
}
|
||||
}
|
||||
|
||||
diag.HasEnvTokens = envTokens
|
||||
diag.HasLegacyToken = legacyToken
|
||||
diag.RecommendTokenSetup = len(cfg.APITokens) == 0
|
||||
diag.RecommendTokenRotation = envTokens || legacyToken
|
||||
|
||||
if cfg.DisableAuth {
|
||||
appendNote("Authentication is disabled (DISABLE_AUTH=1). Re-enable it to use per-agent API tokens.")
|
||||
} else if !cfg.APITokenEnabled && len(cfg.APITokens) > 0 {
|
||||
appendNote("API token authentication is currently disabled. Enable it under Settings → Security so agents can use dedicated tokens.")
|
||||
} else if diag.RecommendTokenSetup {
|
||||
appendNote("No API tokens are configured. Open Settings → Security to generate dedicated tokens for each automation or agent.")
|
||||
}
|
||||
|
||||
tokens := make([]APITokenSummary, 0, len(cfg.APITokens))
|
||||
unusedCount := 0
|
||||
for _, record := range cfg.APITokens {
|
||||
summary := APITokenSummary{
|
||||
ID: record.ID,
|
||||
Name: record.Name,
|
||||
}
|
||||
|
||||
if !record.CreatedAt.IsZero() {
|
||||
summary.CreatedAt = record.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
if record.LastUsedAt != nil && !record.LastUsedAt.IsZero() {
|
||||
summary.LastUsedAt = record.LastUsedAt.UTC().Format(time.RFC3339)
|
||||
} else {
|
||||
unusedCount++
|
||||
}
|
||||
|
||||
switch {
|
||||
case record.Prefix != "" && record.Suffix != "":
|
||||
summary.Hint = fmt.Sprintf("%s…%s", record.Prefix, record.Suffix)
|
||||
case record.Prefix != "":
|
||||
summary.Hint = record.Prefix + "…"
|
||||
case record.Suffix != "":
|
||||
summary.Hint = "…" + record.Suffix
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.EqualFold(record.Name, "Environment token"):
|
||||
summary.Source = "environment"
|
||||
case strings.EqualFold(record.Name, "Legacy token"):
|
||||
summary.Source = "legacy"
|
||||
default:
|
||||
summary.Source = "user"
|
||||
}
|
||||
|
||||
tokens = append(tokens, summary)
|
||||
}
|
||||
|
||||
diag.Tokens = tokens
|
||||
diag.UnusedTokenCount = unusedCount
|
||||
|
||||
if len(cfg.APITokens) > 0 {
|
||||
if unusedCount == len(cfg.APITokens) {
|
||||
appendNote("Configured API tokens have not been used yet. Update your agents or automations to switch to the new tokens.")
|
||||
} else if unusedCount > 0 {
|
||||
appendNote(fmt.Sprintf("%d API token(s) have never been used. Remove unused tokens or update the corresponding agents.", unusedCount))
|
||||
}
|
||||
}
|
||||
|
||||
tokenUsage := make(map[string][]string)
|
||||
legacyHosts := 0
|
||||
if monitor != nil {
|
||||
for _, host := range monitor.GetDockerHosts() {
|
||||
name := preferredDockerHostName(host)
|
||||
if strings.TrimSpace(host.TokenID) == "" {
|
||||
legacyHosts++
|
||||
continue
|
||||
}
|
||||
tokenID := strings.TrimSpace(host.TokenID)
|
||||
tokenUsage[tokenID] = append(tokenUsage[tokenID], name)
|
||||
}
|
||||
}
|
||||
|
||||
diag.LegacyDockerHostCount = legacyHosts
|
||||
if legacyHosts > 0 {
|
||||
appendNote(fmt.Sprintf("%d Docker host(s) still rely on the shared API token. Generate dedicated tokens and rerun the installer from Settings → Docker Agents.", legacyHosts))
|
||||
}
|
||||
|
||||
if len(tokenUsage) > 0 {
|
||||
keys := make([]string, 0, len(tokenUsage))
|
||||
for tokenID := range tokenUsage {
|
||||
keys = append(keys, tokenID)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
diag.Usage = make([]APITokenUsage, 0, len(keys))
|
||||
for _, tokenID := range keys {
|
||||
hosts := tokenUsage[tokenID]
|
||||
sort.Strings(hosts)
|
||||
diag.Usage = append(diag.Usage, APITokenUsage{
|
||||
TokenID: tokenID,
|
||||
HostCount: len(hosts),
|
||||
Hosts: hosts,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if envTokens {
|
||||
appendNote("Environment-based API token detected. Migrate to tokens created in the UI for per-token tracking and safer rotation.")
|
||||
}
|
||||
if legacyToken {
|
||||
appendNote("Legacy token detected. Generate new API tokens and update integrations to benefit from per-token management.")
|
||||
}
|
||||
|
||||
return diag
|
||||
}
|
||||
|
||||
func buildDockerAgentDiagnostic(m *monitoring.Monitor, serverVersion string) *DockerAgentDiagnostic {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
hosts := m.GetDockerHosts()
|
||||
diag := &DockerAgentDiagnostic{
|
||||
HostsTotal: len(hosts),
|
||||
RecommendedAgentVersion: normalizeVersionLabel(serverVersion),
|
||||
}
|
||||
|
||||
appendNote := func(note string) {
|
||||
if note == "" || contains(diag.Notes, note) {
|
||||
return
|
||||
}
|
||||
diag.Notes = append(diag.Notes, note)
|
||||
}
|
||||
|
||||
if len(hosts) == 0 {
|
||||
appendNote("No Docker agents have reported in yet. Use Settings → Docker Agents to install the container-side agent and unlock remote commands.")
|
||||
return diag
|
||||
}
|
||||
|
||||
var (
|
||||
serverVer *updates.Version
|
||||
recommendedLabel = diag.RecommendedAgentVersion
|
||||
)
|
||||
if serverVersion != "" {
|
||||
if parsed, err := updates.ParseVersion(serverVersion); err == nil {
|
||||
serverVer = parsed
|
||||
recommendedLabel = normalizeVersionLabel(parsed.String())
|
||||
diag.RecommendedAgentVersion = recommendedLabel
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
legacyTokenHosts := 0
|
||||
for _, host := range hosts {
|
||||
status := strings.ToLower(strings.TrimSpace(host.Status))
|
||||
if status == "online" {
|
||||
diag.HostsOnline++
|
||||
}
|
||||
versionStr := strings.TrimSpace(host.AgentVersion)
|
||||
if versionStr != "" {
|
||||
diag.HostsReportingVersion++
|
||||
} else {
|
||||
diag.HostsWithoutVersion++
|
||||
}
|
||||
|
||||
if strings.TrimSpace(host.TokenID) != "" {
|
||||
diag.HostsWithTokenBinding++
|
||||
} else {
|
||||
legacyTokenHosts++
|
||||
}
|
||||
|
||||
issues := make([]string, 0, 4)
|
||||
|
||||
if status != "online" && status != "" {
|
||||
issues = append(issues, fmt.Sprintf("Host reports status %q.", status))
|
||||
}
|
||||
|
||||
if versionStr == "" {
|
||||
issues = append(issues, "Agent has not reported a version (pre v4.24). Reinstall using Settings → Docker Agents.")
|
||||
} else if serverVer != nil {
|
||||
if agentVer, err := updates.ParseVersion(versionStr); err == nil {
|
||||
if agentVer.Compare(serverVer) < 0 {
|
||||
diag.HostsOutdatedVersion++
|
||||
issues = append(issues, fmt.Sprintf("Agent version %s lags behind the recommended %s. Re-run the installer to update.", normalizeVersionLabel(versionStr), recommendedLabel))
|
||||
}
|
||||
} else {
|
||||
issues = append(issues, fmt.Sprintf("Unrecognized agent version string %q. Reinstall to ensure command support.", versionStr))
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(host.TokenID) == "" {
|
||||
issues = append(issues, "Host is still using the shared API token. Generate a dedicated token in Settings → Security and rerun the installer.")
|
||||
}
|
||||
|
||||
if !host.LastSeen.IsZero() && now.Sub(host.LastSeen.UTC()) > 10*time.Minute {
|
||||
issues = append(issues, fmt.Sprintf("No heartbeat since %s. Verify the agent container is running.", host.LastSeen.UTC().Format(time.RFC3339)))
|
||||
}
|
||||
|
||||
if host.Command != nil {
|
||||
cmdStatus := strings.ToLower(strings.TrimSpace(host.Command.Status))
|
||||
switch cmdStatus {
|
||||
case monitoring.DockerCommandStatusQueued, monitoring.DockerCommandStatusDispatched, monitoring.DockerCommandStatusAcknowledged:
|
||||
message := fmt.Sprintf("Command %s is still in progress.", cmdStatus)
|
||||
if !host.Command.UpdatedAt.IsZero() && now.Sub(host.Command.UpdatedAt.UTC()) > 15*time.Minute {
|
||||
diag.HostsWithStaleCommand++
|
||||
message = fmt.Sprintf("Command %s has been pending since %s; consider allowing re-enrolment.", cmdStatus, host.Command.UpdatedAt.UTC().Format(time.RFC3339))
|
||||
}
|
||||
issues = append(issues, message)
|
||||
}
|
||||
}
|
||||
|
||||
if host.PendingUninstall {
|
||||
diag.HostsPendingUninstall++
|
||||
issues = append(issues, "Host is pending uninstall; confirm the agent container stopped or clear the flag.")
|
||||
}
|
||||
|
||||
if len(issues) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
diag.Attention = append(diag.Attention, DockerAgentAttention{
|
||||
HostID: host.ID,
|
||||
Name: preferredDockerHostName(host),
|
||||
Status: host.Status,
|
||||
AgentVersion: versionStr,
|
||||
TokenHint: host.TokenHint,
|
||||
LastSeen: formatTimeMaybe(host.LastSeen),
|
||||
Issues: issues,
|
||||
})
|
||||
}
|
||||
|
||||
diag.HostsWithoutTokenBinding = legacyTokenHosts
|
||||
diag.HostsNeedingAttention = len(diag.Attention)
|
||||
|
||||
if legacyTokenHosts > 0 {
|
||||
appendNote(fmt.Sprintf("%d Docker host(s) still rely on the shared API token. Migrate each host to a dedicated token via Settings → Security and rerun the installer.", legacyTokenHosts))
|
||||
}
|
||||
if diag.HostsOutdatedVersion > 0 {
|
||||
appendNote(fmt.Sprintf("%d Docker host(s) run an out-of-date agent. Re-run the installer from Settings → Docker Agents to upgrade them.", diag.HostsOutdatedVersion))
|
||||
}
|
||||
if diag.HostsWithoutVersion > 0 {
|
||||
appendNote(fmt.Sprintf("%d Docker host(s) have not reported an agent version yet. Reinstall the agent to enable the new command system.", diag.HostsWithoutVersion))
|
||||
}
|
||||
if diag.HostsWithStaleCommand > 0 {
|
||||
appendNote(fmt.Sprintf("%d Docker host command(s) appear stuck. Use the 'Allow re-enroll' action in Settings → Docker Agents to reset them.", diag.HostsWithStaleCommand))
|
||||
}
|
||||
if diag.HostsPendingUninstall > 0 {
|
||||
appendNote(fmt.Sprintf("%d Docker host(s) are pending uninstall. Confirm the uninstall or clear the flag from Settings → Docker Agents.", diag.HostsPendingUninstall))
|
||||
}
|
||||
if diag.HostsNeedingAttention == 0 {
|
||||
appendNote("All Docker agents are reporting with dedicated tokens and the expected version.")
|
||||
}
|
||||
|
||||
return diag
|
||||
}
|
||||
|
||||
func buildAlertsDiagnostic(m *monitoring.Monitor) *AlertsDiagnostic {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
manager := m.GetAlertManager()
|
||||
if manager == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
config := manager.GetConfig()
|
||||
diag := &AlertsDiagnostic{}
|
||||
|
||||
appendNote := func(note string) {
|
||||
if note == "" || contains(diag.Notes, note) {
|
||||
return
|
||||
}
|
||||
diag.Notes = append(diag.Notes, note)
|
||||
}
|
||||
|
||||
legacySources := make([]string, 0, 4)
|
||||
if hasLegacyThresholds(config.GuestDefaults) {
|
||||
diag.LegacyThresholdsDetected = true
|
||||
legacySources = append(legacySources, "guest-defaults")
|
||||
}
|
||||
if hasLegacyThresholds(config.NodeDefaults) {
|
||||
diag.LegacyThresholdsDetected = true
|
||||
legacySources = append(legacySources, "node-defaults")
|
||||
}
|
||||
|
||||
overrideIndex := 0
|
||||
for _, override := range config.Overrides {
|
||||
overrideIndex++
|
||||
if hasLegacyThresholds(override) {
|
||||
diag.LegacyThresholdsDetected = true
|
||||
legacySources = append(legacySources, fmt.Sprintf("override-%d", overrideIndex))
|
||||
}
|
||||
}
|
||||
|
||||
for idx, rule := range config.CustomRules {
|
||||
if hasLegacyThresholds(rule.Thresholds) {
|
||||
diag.LegacyThresholdsDetected = true
|
||||
legacySources = append(legacySources, fmt.Sprintf("custom-%d", idx+1))
|
||||
}
|
||||
}
|
||||
|
||||
if len(legacySources) > 0 {
|
||||
sort.Strings(legacySources)
|
||||
diag.LegacyThresholdSources = legacySources
|
||||
appendNote("Some alert rules still rely on legacy single-value thresholds. Edit and save them to enable hysteresis-based alerts.")
|
||||
}
|
||||
|
||||
legacySchedule := make([]string, 0, 2)
|
||||
if config.TimeThreshold > 0 {
|
||||
legacySchedule = append(legacySchedule, "timeThreshold")
|
||||
appendNote("Global alert delay still uses the legacy timeThreshold setting. Save the alerts configuration to migrate to per-metric delays.")
|
||||
}
|
||||
if config.Schedule.GroupingWindow > 0 && config.Schedule.Grouping.Window == 0 {
|
||||
legacySchedule = append(legacySchedule, "groupingWindow")
|
||||
appendNote("Alert grouping uses the deprecated groupingWindow value. Update the schedule to use the new grouping options.")
|
||||
}
|
||||
if len(legacySchedule) > 0 {
|
||||
sort.Strings(legacySchedule)
|
||||
diag.LegacyScheduleSettings = legacySchedule
|
||||
}
|
||||
|
||||
if config.Schedule.Cooldown <= 0 {
|
||||
diag.MissingCooldown = true
|
||||
appendNote("Alert cooldown is not configured. Set a cooldown under Settings → Alerts → Schedule to prevent alert storms.")
|
||||
}
|
||||
if config.Schedule.Grouping.Window <= 0 {
|
||||
diag.MissingGroupingWindow = true
|
||||
appendNote("Alert grouping window is disabled. Configure a grouping window to bundle related alerts.")
|
||||
}
|
||||
|
||||
return diag
|
||||
}
|
||||
|
||||
func fingerprintPublicKey(pub string) (string, error) {
|
||||
pub = strings.TrimSpace(pub)
|
||||
if pub == "" {
|
||||
return "", fmt.Errorf("empty public key")
|
||||
}
|
||||
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pub))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ssh.FingerprintSHA256(key), nil
|
||||
}
|
||||
|
||||
func resolveUserName(uid uint32) string {
|
||||
uidStr := strconv.FormatUint(uint64(uid), 10)
|
||||
if usr, err := user.LookupId(uidStr); err == nil && usr.Username != "" {
|
||||
return usr.Username
|
||||
}
|
||||
return "uid:" + uidStr
|
||||
}
|
||||
|
||||
func resolveGroupName(gid uint32) string {
|
||||
gidStr := strconv.FormatUint(uint64(gid), 10)
|
||||
if grp, err := user.LookupGroupId(gidStr); err == nil && grp != nil && grp.Name != "" {
|
||||
return grp.Name
|
||||
}
|
||||
return "gid:" + gidStr
|
||||
}
|
||||
|
||||
func countLegacySSHKeys(dir string) (int, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if strings.HasPrefix(name, "id_") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func hasLegacyThresholds(th alerts.ThresholdConfig) bool {
|
||||
return th.CPULegacy != nil ||
|
||||
th.MemoryLegacy != nil ||
|
||||
th.DiskLegacy != nil ||
|
||||
th.DiskReadLegacy != nil ||
|
||||
th.DiskWriteLegacy != nil ||
|
||||
th.NetworkInLegacy != nil ||
|
||||
th.NetworkOutLegacy != nil
|
||||
}
|
||||
|
||||
func preferredDockerHostName(host models.DockerHost) string {
|
||||
if name := strings.TrimSpace(host.DisplayName); name != "" {
|
||||
return name
|
||||
}
|
||||
if name := strings.TrimSpace(host.Hostname); name != "" {
|
||||
return name
|
||||
}
|
||||
if name := strings.TrimSpace(host.AgentID); name != "" {
|
||||
return name
|
||||
}
|
||||
return host.ID
|
||||
}
|
||||
|
||||
func formatTimeMaybe(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func normalizeVersionLabel(raw string) string {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(value, "v") {
|
||||
return value
|
||||
}
|
||||
first := value[0]
|
||||
if first < '0' || first > '9' {
|
||||
return value
|
||||
}
|
||||
return "v" + value
|
||||
}
|
||||
|
||||
// checkVMDiskMonitoring performs diagnostic checks for VM disk monitoring
|
||||
func (r *Router) checkVMDiskMonitoring(ctx context.Context, client *proxmox.Client, nodeName string) *VMDiskCheckResult {
|
||||
result := &VMDiskCheckResult{
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
|
@ -21,6 +22,7 @@ import (
|
|||
"github.com/rcourtman/pulse-go-rewrite/internal/dockeragent"
|
||||
"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/updates"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
|
||||
|
|
@ -45,6 +47,7 @@ type Router struct {
|
|||
oidcMu sync.Mutex
|
||||
oidcService *OIDCService
|
||||
wrapped http.Handler
|
||||
projectRoot string
|
||||
// Cached system settings to avoid loading from disk on every request
|
||||
settingsMu sync.RWMutex
|
||||
cachedAllowEmbedding bool
|
||||
|
|
@ -57,6 +60,11 @@ func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, wsHub *websocket
|
|||
InitSessionStore(cfg.DataPath)
|
||||
InitCSRFStore(cfg.DataPath)
|
||||
|
||||
projectRoot, err := os.Getwd()
|
||||
if err != nil {
|
||||
projectRoot = "."
|
||||
}
|
||||
|
||||
r := &Router{
|
||||
mux: http.NewServeMux(),
|
||||
config: cfg,
|
||||
|
|
@ -66,6 +74,7 @@ func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, wsHub *websocket
|
|||
updateManager: updates.NewManager(cfg),
|
||||
exportLimiter: NewRateLimiter(5, 1*time.Minute), // 5 attempts per minute
|
||||
persistence: config.NewConfigPersistence(cfg.DataPath),
|
||||
projectRoot: projectRoot,
|
||||
}
|
||||
|
||||
r.setupRoutes()
|
||||
|
|
@ -120,6 +129,10 @@ func (r *Router) setupRoutes() {
|
|||
r.mux.HandleFunc("/api/storage-charts", r.handleStorageCharts)
|
||||
r.mux.HandleFunc("/api/charts", r.handleCharts)
|
||||
r.mux.HandleFunc("/api/diagnostics", RequireAuth(r.config, r.handleDiagnostics))
|
||||
r.mux.HandleFunc("/api/diagnostics/temperature-proxy/register-nodes", RequireAdmin(r.config, r.handleDiagnosticsRegisterProxyNodes))
|
||||
r.mux.HandleFunc("/api/diagnostics/docker/prepare-token", RequireAdmin(r.config, r.handleDiagnosticsDockerPrepareToken))
|
||||
r.mux.HandleFunc("/api/install/pulse-sensor-proxy", r.handleDownloadPulseSensorProxy)
|
||||
r.mux.HandleFunc("/api/install/install-sensor-proxy.sh", r.handleDownloadInstallerScript)
|
||||
r.mux.HandleFunc("/api/config", r.handleConfig)
|
||||
r.mux.HandleFunc("/api/backups", r.handleBackups)
|
||||
r.mux.HandleFunc("/api/backups/", r.handleBackups)
|
||||
|
|
@ -1071,10 +1084,12 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||
"/api/login", // Add login endpoint as public
|
||||
"/api/oidc/login",
|
||||
config.DefaultOIDCCallbackPath,
|
||||
"/install-docker-agent.sh", // Docker agent bootstrap script must be public
|
||||
"/download/pulse-docker-agent", // Agent binary download should not require auth
|
||||
"/api/agent/version", // Agent update checks need to work before auth
|
||||
"/api/server/info", // Server info for installer script
|
||||
"/install-docker-agent.sh", // Docker agent bootstrap script must be public
|
||||
"/download/pulse-docker-agent", // Agent binary download should not require auth
|
||||
"/api/agent/version", // Agent update checks need to work before auth
|
||||
"/api/server/info", // Server info for installer script
|
||||
"/api/install/install-sensor-proxy.sh", // Temperature proxy installer fallback
|
||||
"/api/install/pulse-sensor-proxy", // Temperature proxy binary fallback
|
||||
}
|
||||
|
||||
// Also allow static assets without auth (JS, CSS, etc)
|
||||
|
|
@ -1128,6 +1143,14 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||
if normalizedPath == "/api/security/quick-setup" && req.Method == http.MethodPost {
|
||||
isPublic = true
|
||||
}
|
||||
// Dev mode bypass for admin endpoints (disabled by default)
|
||||
if os.Getenv("ALLOW_ADMIN_BYPASS") == "1" {
|
||||
log.Info().
|
||||
Str("path", req.URL.Path).
|
||||
Msg("=== ADMIN BYPASS ENABLED - SKIPPING GLOBAL AUTH ===")
|
||||
needsAuth = false
|
||||
}
|
||||
|
||||
// Check auth for protected routes (only if auth is needed)
|
||||
if needsAuth && !isPublic && !CheckAuth(r.config, w, req) {
|
||||
// Never send WWW-Authenticate - use custom login page
|
||||
|
|
@ -2850,6 +2873,227 @@ func (r *Router) handleDownloadAgent(w http.ResponseWriter, req *http.Request) {
|
|||
http.Error(w, "Agent binary not found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
func (r *Router) handleDiagnosticsRegisterProxyNodes(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
|
||||
return
|
||||
}
|
||||
|
||||
client := tempproxy.NewClient()
|
||||
if client == nil || !client.IsAvailable() {
|
||||
writeErrorResponse(w, http.StatusServiceUnavailable, "proxy_unavailable", "pulse-sensor-proxy socket not detected inside the container", nil)
|
||||
return
|
||||
}
|
||||
|
||||
nodes, err := client.RegisterNodes()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to request proxy node registration status")
|
||||
writeErrorResponse(w, http.StatusBadGateway, "proxy_error", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, map[string]any{
|
||||
"success": true,
|
||||
"nodes": nodes,
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to encode proxy register nodes response")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) handleDiagnosticsDockerPrepareToken(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
HostID string `json:"hostId"`
|
||||
TokenName string `json:"tokenName"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Failed to decode request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
hostID := strings.TrimSpace(payload.HostID)
|
||||
if hostID == "" {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "missing_host_id", "hostId is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
host, ok := r.monitor.GetDockerHost(hostID)
|
||||
if !ok {
|
||||
writeErrorResponse(w, http.StatusNotFound, "host_not_found", "Docker host not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(payload.TokenName)
|
||||
if name == "" {
|
||||
displayName := preferredDockerHostName(host)
|
||||
name = fmt.Sprintf("Docker host: %s", displayName)
|
||||
}
|
||||
|
||||
rawToken, err := auth.GenerateAPIToken()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to generate docker migration token")
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate API token", nil)
|
||||
return
|
||||
}
|
||||
|
||||
record, err := config.NewAPITokenRecord(rawToken, name)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to construct token record for docker migration")
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate API token", nil)
|
||||
return
|
||||
}
|
||||
|
||||
r.config.APITokens = append(r.config.APITokens, *record)
|
||||
r.config.SortAPITokens()
|
||||
r.config.APITokenEnabled = true
|
||||
|
||||
if r.persistence != nil {
|
||||
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
|
||||
r.config.RemoveAPIToken(record.ID)
|
||||
log.Error().Err(err).Msg("Failed to persist API tokens after docker migration generation")
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "token_persist_failed", "Failed to persist API token", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(r.resolvePublicURL(req), "/")
|
||||
installCommand := fmt.Sprintf("curl -fsSL %s/install-docker-agent.sh | bash -s -- --url %s --token %s", baseURL, baseURL, rawToken)
|
||||
systemdSnippet := fmt.Sprintf("[Service]\nType=simple\nEnvironment=\"PULSE_URL=%s\"\nEnvironment=\"PULSE_TOKEN=%s\"\nExecStart=/usr/local/bin/pulse-docker-agent --url %s --interval 30s\nRestart=always\nRestartSec=5s\nUser=root", baseURL, rawToken, baseURL)
|
||||
|
||||
response := map[string]any{
|
||||
"success": true,
|
||||
"token": rawToken,
|
||||
"record": toAPITokenDTO(*record),
|
||||
"host": map[string]any{
|
||||
"id": host.ID,
|
||||
"name": preferredDockerHostName(host),
|
||||
},
|
||||
"installCommand": installCommand,
|
||||
"systemdServiceSnippet": systemdSnippet,
|
||||
"pulseURL": baseURL,
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, response); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize docker token migration response")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) handleDownloadPulseSensorProxy(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil)
|
||||
return
|
||||
}
|
||||
|
||||
arch := strings.TrimSpace(req.URL.Query().Get("arch"))
|
||||
if arch == "" {
|
||||
arch = runtime.GOARCH
|
||||
}
|
||||
|
||||
if runtime.GOOS != "linux" && arch != "linux-amd64" {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "unsupported_arch", "Only linux-amd64 builds are supported in this environment", nil)
|
||||
return
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "pulse-sensor-proxy-*.bin")
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "tempfile_error", "Failed to create temporary file", nil)
|
||||
return
|
||||
}
|
||||
tmpFileName := tmpFile.Name()
|
||||
tmpFile.Close()
|
||||
defer os.Remove(tmpFileName)
|
||||
|
||||
cmd := exec.Command("go", "build", "-o", tmpFileName, "./cmd/pulse-sensor-proxy")
|
||||
cmd.Dir = r.projectRoot
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GOOS=linux",
|
||||
"GOARCH=amd64",
|
||||
"CGO_ENABLED=0",
|
||||
)
|
||||
|
||||
buildOutput, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Bytes("output", buildOutput).Msg("Failed to build pulse-sensor-proxy binary")
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "build_failed", "Failed to build proxy binary on the server", nil)
|
||||
return
|
||||
}
|
||||
|
||||
builtFile, err := os.Open(tmpFileName)
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "open_error", "Failed to open built proxy binary", nil)
|
||||
return
|
||||
}
|
||||
defer builtFile.Close()
|
||||
|
||||
stat, err := builtFile.Stat()
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "stat_error", "Failed to stat built binary", nil)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"pulse-sensor-proxy-linux-amd64\""))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
|
||||
|
||||
if _, err := io.Copy(w, builtFile); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to stream proxy binary to client")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) handleDownloadInstallerScript(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil)
|
||||
return
|
||||
}
|
||||
|
||||
scriptPath := filepath.Join(r.projectRoot, "scripts", "install-sensor-proxy.sh")
|
||||
content, err := os.ReadFile(scriptPath)
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "read_error", "Failed to read installer script", nil)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/x-shellscript")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=install-sensor-proxy.sh")
|
||||
if _, err := w.Write(content); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write installer script to client")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) resolvePublicURL(req *http.Request) string {
|
||||
if publicURL := strings.TrimSpace(r.config.PublicURL); publicURL != "" {
|
||||
return strings.TrimRight(publicURL, "/")
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if req != nil {
|
||||
if req.TLS != nil {
|
||||
scheme = "https"
|
||||
} else if proto := req.Header.Get("X-Forwarded-Proto"); strings.EqualFold(proto, "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
}
|
||||
|
||||
host := ""
|
||||
if req != nil {
|
||||
host = strings.TrimSpace(req.Host)
|
||||
}
|
||||
if host == "" {
|
||||
if r.config.FrontendPort > 0 {
|
||||
host = fmt.Sprintf("localhost:%d", r.config.FrontendPort)
|
||||
} else {
|
||||
host = "localhost:7655"
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s://%s", scheme, host)
|
||||
}
|
||||
|
||||
func normalizeDockerAgentArch(arch string) string {
|
||||
if arch == "" {
|
||||
return ""
|
||||
|
|
|
|||
|
|
@ -548,6 +548,14 @@ func (m *Monitor) GetDockerHost(hostID string) (models.DockerHost, bool) {
|
|||
return models.DockerHost{}, false
|
||||
}
|
||||
|
||||
// GetDockerHosts returns a point-in-time snapshot of all Docker hosts Pulse knows about.
|
||||
func (m *Monitor) GetDockerHosts() []models.DockerHost {
|
||||
if m == nil || m.state == nil {
|
||||
return nil
|
||||
}
|
||||
return m.state.GetDockerHosts()
|
||||
}
|
||||
|
||||
// QueueDockerHostStop queues a stop command for the specified docker host.
|
||||
func (m *Monitor) QueueDockerHostStop(hostID string) (models.DockerHostCommandStatus, error) {
|
||||
return m.queueDockerStopCommand(hostID)
|
||||
|
|
@ -2552,7 +2560,7 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
|
|||
// Collect temperature data via SSH (non-blocking, best effort)
|
||||
// Only attempt for online nodes
|
||||
if node.Status == "online" && m.tempCollector != nil {
|
||||
tempCtx, tempCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
tempCtx, tempCancel := context.WithTimeout(ctx, 30*time.Second) // Increased to accommodate SSH operations via proxy
|
||||
|
||||
// Determine SSH hostname to use (most robust approach):
|
||||
// 1. For cluster nodes: Try to find the node's specific IP or host from ClusterEndpoints
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import (
|
|||
const (
|
||||
defaultSocketPath = "/run/pulse-sensor-proxy/pulse-sensor-proxy.sock"
|
||||
containerSocketPath = "/mnt/pulse-proxy/pulse-sensor-proxy.sock"
|
||||
defaultTimeout = 10 * time.Second
|
||||
defaultTimeout = 30 * time.Second // Increased to accommodate SSH operations
|
||||
)
|
||||
|
||||
// Client communicates with pulse-sensor-proxy via unix socket
|
||||
|
|
@ -83,7 +83,7 @@ func (c *Client) call(method string, params map[string]interface{}) (*RPCRespons
|
|||
return nil, fmt.Errorf("failed to encode request: %w", err)
|
||||
}
|
||||
|
||||
// Read response
|
||||
// Read response (server uses newline-delimited framing, no CloseWrite needed)
|
||||
var resp RPCResponse
|
||||
decoder := json.NewDecoder(conn)
|
||||
if err := decoder.Decode(&resp); err != nil {
|
||||
|
|
@ -107,22 +107,6 @@ func (c *Client) GetStatus() (map[string]interface{}, error) {
|
|||
return resp.Data, nil
|
||||
}
|
||||
|
||||
// EnsureClusterKeys discovers cluster nodes and pushes SSH keys
|
||||
func (c *Client) EnsureClusterKeys() (map[string]interface{}, error) {
|
||||
log.Info().Msg("Requesting proxy to configure cluster SSH keys")
|
||||
|
||||
resp, err := c.call("ensure_cluster_keys", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
return nil, fmt.Errorf("proxy error: %s", resp.Error)
|
||||
}
|
||||
|
||||
return resp.Data, nil
|
||||
}
|
||||
|
||||
// RegisterNodes returns list of discovered nodes with SSH status
|
||||
func (c *Client) RegisterNodes() ([]map[string]interface{}, error) {
|
||||
resp, err := c.call("register_nodes", nil)
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@ Mock Mode: ${PULSE_MOCK_MODE:-false}
|
|||
Toggle mock mode: npm run mock:on / npm run mock:off
|
||||
Mock config: npm run mock:edit
|
||||
|
||||
Just edit frontend files and see changes instantly!
|
||||
Backend auto-reloads when mock.env changes!
|
||||
Frontend: Edit files and see changes instantly!
|
||||
Backend: Auto-rebuilds when .go files change!
|
||||
Press Ctrl+C to stop
|
||||
=========================================
|
||||
BANNER
|
||||
|
|
@ -75,7 +75,12 @@ kill_port() {
|
|||
|
||||
printf "[hot-dev] Cleaning up existing processes...\n"
|
||||
|
||||
sudo systemctl stop pulse-hot-dev 2>/dev/null || true
|
||||
# Don't stop ourselves if we're running under systemd
|
||||
if [[ -z "${INVOCATION_ID:-}" ]]; then
|
||||
# Not running under systemd, safe to stop the service
|
||||
sudo systemctl stop pulse-hot-dev 2>/dev/null || true
|
||||
fi
|
||||
|
||||
sudo systemctl stop pulse-backend 2>/dev/null || true
|
||||
sudo systemctl stop pulse 2>/dev/null || true
|
||||
sudo systemctl stop pulse-frontend 2>/dev/null || true
|
||||
|
|
@ -183,9 +188,62 @@ if ! kill -0 "${BACKEND_PID}" 2>/dev/null; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# Start backend file watcher in background
|
||||
printf "[hot-dev] Starting backend file watcher...\n"
|
||||
(
|
||||
cd "${ROOT_DIR}"
|
||||
while true; do
|
||||
# Watch for changes to .go files (excluding vendor and node_modules)
|
||||
inotifywait -r -e modify,create,delete,move \
|
||||
--exclude '(vendor/|node_modules/|\.git/)' \
|
||||
--format '%w%f' \
|
||||
"${ROOT_DIR}/cmd" "${ROOT_DIR}/internal" "${ROOT_DIR}/pkg" 2>/dev/null | \
|
||||
while read -r changed_file; do
|
||||
if [[ "$changed_file" == *.go ]]; then
|
||||
echo ""
|
||||
echo "[hot-dev] 🔄 Go file changed: $(basename "$changed_file")"
|
||||
echo "[hot-dev] Rebuilding backend..."
|
||||
|
||||
# Rebuild the binary
|
||||
if go build -o pulse ./cmd/pulse 2>&1 | grep -v "^#"; then
|
||||
echo "[hot-dev] ✓ Build successful, restarting backend..."
|
||||
|
||||
# Find and kill old backend
|
||||
OLD_PID=$(pgrep -f "^\./pulse$" || true)
|
||||
if [[ -n "$OLD_PID" ]]; then
|
||||
kill "$OLD_PID" 2>/dev/null || true
|
||||
sleep 1
|
||||
if kill -0 "$OLD_PID" 2>/dev/null; then
|
||||
kill -9 "$OLD_PID" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start new backend with same environment
|
||||
FRONTEND_PORT=${PULSE_DEV_API_PORT} PORT=${PULSE_DEV_API_PORT} PULSE_DATA_DIR=${PULSE_DATA_DIR} ./pulse &
|
||||
NEW_PID=$!
|
||||
sleep 1
|
||||
|
||||
if kill -0 "$NEW_PID" 2>/dev/null; then
|
||||
echo "[hot-dev] ✓ Backend restarted (PID: $NEW_PID)"
|
||||
else
|
||||
echo "[hot-dev] ✗ Backend failed to start!"
|
||||
fi
|
||||
else
|
||||
echo "[hot-dev] ✗ Build failed!"
|
||||
fi
|
||||
echo "[hot-dev] Watching for changes..."
|
||||
fi
|
||||
done
|
||||
done
|
||||
) &
|
||||
WATCHER_PID=$!
|
||||
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "Stopping services..."
|
||||
if [[ -n ${WATCHER_PID:-} ]] && kill -0 "${WATCHER_PID}" 2>/dev/null; then
|
||||
kill "${WATCHER_PID}" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n ${BACKEND_PID:-} ]] && kill -0 "${BACKEND_PID}" 2>/dev/null; then
|
||||
kill "${BACKEND_PID}" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
|
@ -197,6 +255,7 @@ cleanup() {
|
|||
pkill -f vite 2>/dev/null || true
|
||||
pkill -f "npm run dev" 2>/dev/null || true
|
||||
pkill -9 -x "pulse" 2>/dev/null || true
|
||||
pkill -f "inotifywait.*pulse" 2>/dev/null || true
|
||||
echo "Hot-dev stopped. To restart normal service, run: sudo systemctl start pulse"
|
||||
echo "(Legacy installs may use: sudo systemctl start pulse-backend)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ CTID=""
|
|||
VERSION="latest"
|
||||
LOCAL_BINARY=""
|
||||
QUIET=false
|
||||
PULSE_SERVER=""
|
||||
FALLBACK_BASE="${PULSE_SENSOR_PROXY_FALLBACK_URL:-}"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
|
|
@ -55,6 +57,10 @@ while [[ $# -gt 0 ]]; do
|
|||
LOCAL_BINARY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--pulse-server)
|
||||
PULSE_SERVER="$2"
|
||||
shift 2
|
||||
;;
|
||||
--quiet)
|
||||
QUIET=true
|
||||
shift
|
||||
|
|
@ -66,9 +72,14 @@ while [[ $# -gt 0 ]]; do
|
|||
esac
|
||||
done
|
||||
|
||||
# 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
|
||||
|
||||
if [[ -z "$CTID" ]]; then
|
||||
print_error "Missing required argument: --ctid <container-id>"
|
||||
echo "Usage: $0 --ctid <container-id> [--version <version>] [--local-binary <path>]"
|
||||
echo "Usage: $0 --ctid <container-id> [--pulse-server <url>] [--version <version>] [--local-binary <path>]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -95,6 +106,12 @@ 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
|
||||
|
||||
# Install binary - either from local file or download from GitHub
|
||||
if [[ -n "$LOCAL_BINARY" ]]; then
|
||||
# Use local binary for testing
|
||||
|
|
@ -107,31 +124,20 @@ if [[ -n "$LOCAL_BINARY" ]]; then
|
|||
chmod +x "$BINARY_PATH"
|
||||
print_info "Binary installed to $BINARY_PATH"
|
||||
else
|
||||
# Download from GitHub release
|
||||
GITHUB_REPO="rcourtman/Pulse"
|
||||
if [[ "$VERSION" == "latest" ]]; then
|
||||
RELEASE_URL="https://api.github.com/repos/$GITHUB_REPO/releases/latest"
|
||||
print_info "Fetching latest release info..."
|
||||
RELEASE_DATA=$(curl -fsSL "$RELEASE_URL")
|
||||
VERSION=$(echo "$RELEASE_DATA" | grep -o '"tag_name": "[^"]*"' | cut -d'"' -f4)
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
print_error "Failed to determine latest version"
|
||||
exit 1
|
||||
fi
|
||||
print_info "Latest version: $VERSION"
|
||||
fi
|
||||
|
||||
# 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"
|
||||
;;
|
||||
*)
|
||||
print_error "Unsupported architecture: $ARCH"
|
||||
|
|
@ -139,13 +145,35 @@ else
|
|||
;;
|
||||
esac
|
||||
|
||||
DOWNLOAD_URL="https://github.com/$GITHUB_REPO/releases/download/$VERSION/$BINARY_NAME"
|
||||
# If fallback URL is provided (e.g., from Pulse setup script), use it directly
|
||||
if [[ -n "$FALLBACK_BASE" ]]; then
|
||||
FALLBACK_URL="${FALLBACK_BASE%/}?arch=${ARCH_LABEL}"
|
||||
print_info "Downloading $BINARY_NAME from Pulse server..."
|
||||
if ! curl -fsSL "$FALLBACK_URL" -o "$BINARY_PATH.tmp"; then
|
||||
print_error "Failed to download proxy binary from $FALLBACK_URL"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Fallback not provided, download from GitHub release
|
||||
GITHUB_REPO="rcourtman/Pulse"
|
||||
if [[ "$VERSION" == "latest" ]]; then
|
||||
RELEASE_URL="https://api.github.com/repos/$GITHUB_REPO/releases/latest"
|
||||
print_info "Fetching latest release info..."
|
||||
RELEASE_DATA=$(curl -fsSL "$RELEASE_URL")
|
||||
VERSION=$(echo "$RELEASE_DATA" | grep -o '"tag_name": "[^"]*"' | cut -d'"' -f4)
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
print_error "Failed to determine latest version"
|
||||
exit 1
|
||||
fi
|
||||
print_info "Latest version: $VERSION"
|
||||
fi
|
||||
|
||||
# Download binary
|
||||
print_info "Downloading $BINARY_NAME..."
|
||||
if ! curl -fsSL "$DOWNLOAD_URL" -o "$BINARY_PATH.tmp"; then
|
||||
print_error "Failed to download binary from $DOWNLOAD_URL"
|
||||
exit 1
|
||||
DOWNLOAD_URL="https://github.com/$GITHUB_REPO/releases/download/$VERSION/$BINARY_NAME"
|
||||
print_info "Downloading $BINARY_NAME from GitHub..."
|
||||
if ! curl -fsSL "$DOWNLOAD_URL" -o "$BINARY_PATH.tmp"; then
|
||||
print_error "Failed to download binary from $DOWNLOAD_URL"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Make executable and move to final location
|
||||
|
|
@ -177,6 +205,7 @@ After=network.target
|
|||
Type=simple
|
||||
User=pulse-sensor-proxy
|
||||
Group=pulse-sensor-proxy
|
||||
SupplementaryGroups=www-data
|
||||
WorkingDirectory=/var/lib/pulse-sensor-proxy
|
||||
ExecStart=/usr/local/bin/pulse-sensor-proxy
|
||||
Restart=on-failure
|
||||
|
|
@ -185,6 +214,7 @@ RestartSec=5s
|
|||
# Runtime dirs/sockets
|
||||
RuntimeDirectory=pulse-sensor-proxy
|
||||
RuntimeDirectoryMode=0775
|
||||
RuntimeDirectoryPreserve=yes
|
||||
UMask=0007
|
||||
|
||||
# Core hardening
|
||||
|
|
@ -192,6 +222,7 @@ NoNewPrivileges=true
|
|||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/var/lib/pulse-sensor-proxy
|
||||
ReadOnlyPaths=/run/pve-cluster /etc/pve
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
|
|
@ -244,12 +275,133 @@ fi
|
|||
|
||||
print_info "Socket ready at $SOCKET_PATH"
|
||||
|
||||
# Configure SSH keys for cluster temperature monitoring
|
||||
print_info "Configuring proxy SSH access to cluster nodes..."
|
||||
|
||||
# Wait for proxy to generate SSH keys
|
||||
PROXY_KEY_FILE="$SSH_DIR/id_ed25519.pub"
|
||||
for i in {1..10}; do
|
||||
if [[ -f "$PROXY_KEY_FILE" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ ! -f "$PROXY_KEY_FILE" ]]; then
|
||||
print_error "Proxy SSH key not generated after 10 seconds"
|
||||
print_info "Check service logs: journalctl -u pulse-sensor-proxy -n 50"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PROXY_PUBLIC_KEY=$(cat "$PROXY_KEY_FILE")
|
||||
print_info "Proxy public key: ${PROXY_PUBLIC_KEY:0:50}..."
|
||||
|
||||
# Discover cluster nodes
|
||||
if command -v pvecm >/dev/null 2>&1; then
|
||||
# Extract node IPs from pvecm status
|
||||
CLUSTER_NODES=$(pvecm status 2>/dev/null | awk '/0x[0-9a-f]+.*[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/ {print $3}')
|
||||
|
||||
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="sensors -j",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
|
||||
AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_KEY}"
|
||||
|
||||
# Track SSH key push results
|
||||
SSH_SUCCESS_COUNT=0
|
||||
SSH_FAILURE_COUNT=0
|
||||
declare -a SSH_FAILED_NODES=()
|
||||
|
||||
# Push key to each cluster node
|
||||
for node_ip in $CLUSTER_NODES; do
|
||||
print_info "Authorizing proxy key on node $node_ip..."
|
||||
|
||||
# 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
|
||||
|
||||
# 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++))
|
||||
else
|
||||
print_warn "Failed to configure SSH key on $node_ip"
|
||||
((SSH_FAILURE_COUNT++))
|
||||
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, use Pulse's 'Ensure cluster keys' button or manually run:"
|
||||
print_info " ssh root@<node> 'echo \"${AUTH_LINE}\" >> /root/.ssh/authorized_keys'"
|
||||
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}"
|
||||
|
||||
# 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
|
||||
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="sensors -j",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
|
||||
AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_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
|
||||
fi
|
||||
|
||||
# Ensure container mount via mp configuration
|
||||
print_info "Ensuring container socket mount configuration..."
|
||||
MOUNT_TARGET="/mnt/pulse-proxy"
|
||||
LXC_CONFIG="/etc/pve/lxc/${CTID}.conf"
|
||||
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
|
||||
|
|
@ -263,16 +415,32 @@ if [[ -z "$CURRENT_MP" ]]; then
|
|||
exit 1
|
||||
fi
|
||||
print_info "Configuring container mount using $CURRENT_MP..."
|
||||
pct set "$CTID" -${CURRENT_MP} "/run/pulse-sensor-proxy,mp=${MOUNT_TARGET},replicate=0"
|
||||
MOUNT_UPDATED=true
|
||||
if pct set "$CTID" -${CURRENT_MP} "/run/pulse-sensor-proxy,mp=${MOUNT_TARGET},replicate=0" 2>/dev/null; then
|
||||
MOUNT_UPDATED=true
|
||||
else
|
||||
HOTPLUG_FAILED=true
|
||||
fi
|
||||
else
|
||||
print_info "Container already has socket mount configured ($CURRENT_MP)"
|
||||
pct set "$CTID" -${CURRENT_MP} "/run/pulse-sensor-proxy,mp=${MOUNT_TARGET},replicate=0"
|
||||
if pct set "$CTID" -${CURRENT_MP} "/run/pulse-sensor-proxy,mp=${MOUNT_TARGET},replicate=0" 2>/dev/null; then
|
||||
MOUNT_UPDATED=true
|
||||
else
|
||||
HOTPLUG_FAILED=true
|
||||
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}: /run/pulse-sensor-proxy,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
|
||||
|
||||
# Remove legacy lxc.mount.entry directives if present
|
||||
LXC_CONFIG="/etc/pve/lxc/${CTID}.conf"
|
||||
if grep -q "lxc.mount.entry: /run/pulse-sensor-proxy" "$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"
|
||||
|
|
@ -281,20 +449,28 @@ fi
|
|||
|
||||
# Restart container to apply mount if configuration changed or mount missing
|
||||
if [[ "$MOUNT_UPDATED" = true ]]; then
|
||||
print_info "Restarting container to apply socket mount..."
|
||||
pct stop "$CTID" || true
|
||||
sleep 2
|
||||
pct start "$CTID"
|
||||
sleep 5
|
||||
if [[ "$CT_RUNNING" = true ]]; then
|
||||
print_warn "Container $CTID is currently running. Restart it when convenient to activate the secure proxy mount."
|
||||
else
|
||||
print_info "Restarting container to apply socket mount..."
|
||||
pct stop "$CTID" || true
|
||||
sleep 2
|
||||
pct start "$CTID"
|
||||
sleep 5
|
||||
fi
|
||||
fi
|
||||
|
||||
# Verify socket directory and file inside container
|
||||
print_info "Verifying socket accessibility..."
|
||||
if pct exec "$CTID" -- test -S "${MOUNT_TARGET}/pulse-sensor-proxy.sock"; then
|
||||
print_info "Socket is accessible in container at ${MOUNT_TARGET}/pulse-sensor-proxy.sock"
|
||||
if [[ "$HOTPLUG_FAILED" = true && "$CT_RUNNING" = true ]]; then
|
||||
print_warn "Skipping socket verification until container $CTID is restarted."
|
||||
else
|
||||
print_warn "Socket not visible at ${MOUNT_TARGET}/pulse-sensor-proxy.sock"
|
||||
print_info "Check container configuration and restart if necessary"
|
||||
print_info "Verifying socket accessibility..."
|
||||
if pct exec "$CTID" -- test -S "${MOUNT_TARGET}/pulse-sensor-proxy.sock"; then
|
||||
print_info "Socket is accessible in container at ${MOUNT_TARGET}/pulse-sensor-proxy.sock"
|
||||
else
|
||||
print_warn "Socket not visible at ${MOUNT_TARGET}/pulse-sensor-proxy.sock"
|
||||
print_info "Check container configuration and restart if necessary"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Configure Pulse backend environment override inside container
|
||||
|
|
|
|||
Loading…
Reference in a new issue