Improve temperature proxy control-plane flow

This commit is contained in:
rcourtman
2025-11-15 21:49:51 +00:00
parent ad35a60cfe
commit 47d5c14aef
23 changed files with 1617 additions and 210 deletions

View File

@@ -39,11 +39,20 @@ type Config struct {
RateLimit *RateLimitConfig `yaml:"rate_limit,omitempty"`
// HTTP mode configuration
HTTPEnabled bool `yaml:"http_enabled"` // Enable HTTP server mode
HTTPListenAddr string `yaml:"http_listen_addr"` // Address to listen on (e.g., ":8443")
HTTPTLSCertFile string `yaml:"http_tls_cert"` // Path to TLS certificate
HTTPTLSKeyFile string `yaml:"http_tls_key"` // Path to TLS private key
HTTPAuthToken string `yaml:"http_auth_token"` // Bearer token for authentication
HTTPEnabled bool `yaml:"http_enabled"` // Enable HTTP server mode
HTTPListenAddr string `yaml:"http_listen_addr"` // Address to listen on (e.g., ":8443")
HTTPTLSCertFile string `yaml:"http_tls_cert"` // Path to TLS certificate
HTTPTLSKeyFile string `yaml:"http_tls_key"` // Path to TLS private key
HTTPAuthToken string `yaml:"http_auth_token"` // Bearer token for authentication
PulseControlPlane *ControlPlaneConfig `yaml:"pulse_control_plane"`
}
type ControlPlaneConfig struct {
URL string `yaml:"url"`
TokenFile string `yaml:"token_file"`
RefreshIntervalSec int `yaml:"refresh_interval"` // seconds
InsecureSkipVerify bool `yaml:"insecure_skip_verify"`
}
// PeerConfig represents a peer entry with capabilities.
@@ -323,6 +332,15 @@ func loadConfig(configPath string) (*Config, error) {
}
}
if cfg.PulseControlPlane != nil {
if cfg.PulseControlPlane.TokenFile == "" {
cfg.PulseControlPlane.TokenFile = defaultControlPlaneTokenPath
}
if cfg.PulseControlPlane.RefreshIntervalSec <= 0 {
cfg.PulseControlPlane.RefreshIntervalSec = defaultControlPlaneRefreshSecs
}
}
return cfg, nil
}
@@ -453,3 +471,8 @@ func parseAllowedSubnets(cfg []string) ([]string, error) {
return normalized, nil
}
const (
defaultControlPlaneTokenPath = "/etc/pulse-sensor-proxy/.pulse-control-token"
defaultControlPlaneRefreshSecs = 60
)

View File

@@ -4,11 +4,13 @@ import (
"bufio"
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/signal"
"os/user"
@@ -249,21 +251,24 @@ func lookupUserFromPasswd(username string) (*userSpec, error) {
// Proxy manages the temperature monitoring proxy
type Proxy struct {
socketPath string
sshKeyPath string
workDir string
knownHosts knownhosts.Manager
listener net.Listener
rateLimiter *rateLimiter
nodeGate *nodeGate
router map[string]handlerFunc
config *Config
metrics *ProxyMetrics
audit *auditLogger
nodeValidator *nodeValidator
readTimeout time.Duration
writeTimeout time.Duration
maxSSHOutputBytes int64
socketPath string
sshKeyPath string
workDir string
knownHosts knownhosts.Manager
listener net.Listener
rateLimiter *rateLimiter
nodeGate *nodeGate
router map[string]handlerFunc
config *Config
metrics *ProxyMetrics
audit *auditLogger
nodeValidator *nodeValidator
readTimeout time.Duration
writeTimeout time.Duration
maxSSHOutputBytes int64
controlPlaneCfg *ControlPlaneConfig
controlPlaneToken string
controlPlaneCancel context.CancelFunc
allowedPeerUIDs map[uint32]struct{}
allowedPeerGIDs map[uint32]struct{}
@@ -426,6 +431,7 @@ func runProxy() {
readTimeout: cfg.ReadTimeout,
writeTimeout: cfg.WriteTimeout,
maxSSHOutputBytes: cfg.MaxSSHOutputBytes,
controlPlaneCfg: cfg.PulseControlPlane,
}
if wd, err := os.Getwd(); err == nil {
@@ -453,6 +459,7 @@ func runProxy() {
if err := proxy.Start(); err != nil {
log.Fatal().Err(err).Msg("Failed to start proxy")
}
proxy.startControlPlaneSync()
// Start HTTP server if enabled
var httpServer *HTTPServer
@@ -537,12 +544,126 @@ func (p *Proxy) Start() error {
// Stop shuts down the proxy
func (p *Proxy) Stop() {
if p.controlPlaneCancel != nil {
p.controlPlaneCancel()
}
if p.listener != nil {
p.listener.Close()
os.Remove(p.socketPath)
}
}
func (p *Proxy) startControlPlaneSync() {
if p.controlPlaneCfg == nil || strings.TrimSpace(p.controlPlaneCfg.URL) == "" {
return
}
tokenBytes, err := os.ReadFile(p.controlPlaneCfg.TokenFile)
if err != nil {
log.Warn().Err(err).Str("token_file", p.controlPlaneCfg.TokenFile).Msg("Control plane token unavailable; skipping sync")
return
}
token := strings.TrimSpace(string(tokenBytes))
if token == "" {
log.Warn().Str("token_file", p.controlPlaneCfg.TokenFile).Msg("Control plane token is empty; skipping sync")
return
}
ctx, cancel := context.WithCancel(context.Background())
p.controlPlaneToken = token
p.controlPlaneCancel = cancel
go p.controlPlaneLoop(ctx)
log.Info().
Str("url", p.controlPlaneCfg.URL).
Int("refresh_interval", p.controlPlaneCfg.RefreshIntervalSec).
Msg("Control plane synchronization enabled")
}
func (p *Proxy) controlPlaneLoop(ctx context.Context) {
refresh := time.Duration(p.controlPlaneCfg.RefreshIntervalSec) * time.Second
if refresh <= 0 {
refresh = time.Duration(defaultControlPlaneRefreshSecs) * time.Second
}
client := &http.Client{Timeout: 10 * time.Second}
if p.controlPlaneCfg.InsecureSkipVerify {
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
}
}
for {
if err := p.fetchAuthorizedNodes(client); err != nil {
log.Warn().Err(err).Msg("Control plane sync failed")
}
select {
case <-ctx.Done():
return
case <-time.After(refresh):
}
}
}
func (p *Proxy) fetchAuthorizedNodes(client *http.Client) error {
cfg := p.controlPlaneCfg
if cfg == nil {
return nil
}
req, err := http.NewRequest(http.MethodGet, strings.TrimRight(cfg.URL, "/")+"/api/temperature-proxy/authorized-nodes", nil)
if err != nil {
return err
}
req.Header.Set("X-Proxy-Token", p.controlPlaneToken)
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("control plane responded %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var payload struct {
Nodes []struct {
Name string `json:"name"`
IP string `json:"ip"`
} `json:"nodes"`
Hash string `json:"hash"`
RefreshInterval int `json:"refresh_interval"`
GeneratedAt time.Time `json:"generated_at"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return fmt.Errorf("decode authorized-nodes response: %w", err)
}
if payload.RefreshInterval > 0 && cfg.RefreshIntervalSec != payload.RefreshInterval {
cfg.RefreshIntervalSec = payload.RefreshInterval
}
var entries []string
for _, node := range payload.Nodes {
if node.Name != "" {
entries = append(entries, node.Name)
}
if node.IP != "" {
entries = append(entries, node.IP)
}
}
if len(entries) == 0 {
return errors.New("authorized-nodes response empty")
}
p.nodeValidator.UpdateAllowlist(entries)
return nil
}
// acceptConnections handles incoming socket connections
func (p *Proxy) acceptConnections() {
for {

View File

@@ -221,6 +221,7 @@ const (
// nodeValidator enforces node allow-list and cluster membership checks
type nodeValidator struct {
mu sync.RWMutex
allowHosts map[string]struct{}
allowCIDRs []*net.IPNet
hasAllowlist bool
@@ -286,23 +287,7 @@ func newNodeValidator(cfg *Config, metrics *ProxyMetrics) (*nodeValidator, error
clock: time.Now,
}
for _, raw := range cfg.AllowedNodes {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
if _, network, err := net.ParseCIDR(entry); err == nil {
v.allowCIDRs = append(v.allowCIDRs, network)
continue
}
if normalized := normalizeAllowlistEntry(entry); normalized != "" {
v.allowHosts[normalized] = struct{}{}
}
}
v.hasAllowlist = len(v.allowHosts) > 0 || len(v.allowCIDRs) > 0
v.setAllowlist(cfg.AllowedNodes)
if v.hasAllowlist {
log.Info().
@@ -331,17 +316,67 @@ func newNodeValidator(cfg *Config, metrics *ProxyMetrics) (*nodeValidator, error
return v, nil
}
func (v *nodeValidator) setAllowlist(entries []string) {
v.allowHosts = make(map[string]struct{})
v.allowCIDRs = nil
for _, raw := range entries {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
if _, network, err := net.ParseCIDR(entry); err == nil {
v.allowCIDRs = append(v.allowCIDRs, network)
continue
}
if normalized := normalizeAllowlistEntry(entry); normalized != "" {
v.allowHosts[normalized] = struct{}{}
}
}
v.hasAllowlist = len(v.allowHosts) > 0 || len(v.allowCIDRs) > 0
}
func (v *nodeValidator) UpdateAllowlist(entries []string) {
if v == nil {
return
}
v.mu.Lock()
v.setAllowlist(entries)
hasAllowlist := v.hasAllowlist
hosts := len(v.allowHosts)
cidrs := len(v.allowCIDRs)
v.mu.Unlock()
if hasAllowlist {
log.Info().
Int("allowed_node_count", hosts).
Int("allowed_cidr_count", cidrs).
Msg("Updated node allow-list from control plane")
} else {
log.Warn().Msg("Control plane allow-list update produced empty set")
}
}
// Validate ensures the provided node is authorized before any SSH is attempted.
func (v *nodeValidator) Validate(ctx context.Context, node string) error {
if v == nil {
return nil
}
v.mu.RLock()
hasAllowlist := v.hasAllowlist
clusterEnabled := v.clusterEnabled
v.mu.RUnlock()
if ctx == nil {
ctx = context.Background()
}
if v.hasAllowlist {
if hasAllowlist {
allowed, err := v.matchesAllowlist(ctx, node)
if err != nil {
v.recordFailure(validationReasonResolutionFailed)
@@ -354,7 +389,7 @@ func (v *nodeValidator) Validate(ctx context.Context, node string) error {
return nil
}
if v.clusterEnabled {
if clusterEnabled {
allowed, err := v.matchesCluster(ctx, node)
if err != nil {
// Cluster query failed (e.g., IPC permission denied, running in LXC)
@@ -416,33 +451,39 @@ func (v *nodeValidator) validateAsLocalhost(ctx context.Context, node string) er
}
func (v *nodeValidator) matchesAllowlist(ctx context.Context, node string) (bool, error) {
v.mu.RLock()
allowHosts := v.allowHosts
allowCIDRs := v.allowCIDRs
resolver := v.resolver
v.mu.RUnlock()
normalized := normalizeAllowlistEntry(node)
if normalized != "" {
if _, ok := v.allowHosts[normalized]; ok {
if _, ok := allowHosts[normalized]; ok {
return true, nil
}
}
if ip := parseNodeIP(node); ip != nil {
if v.ipAllowed(ip) {
if ipAllowed(ip, allowHosts, allowCIDRs) {
return true, nil
}
// If the node itself is an IP and it didn't match, no need to resolve again.
return false, nil
}
if len(v.allowCIDRs) == 0 {
if len(allowCIDRs) == 0 {
return false, nil
}
host := stripNodeDelimiters(node)
ips, err := v.resolver.LookupIP(ctx, host)
ips, err := resolver.LookupIP(ctx, host)
if err != nil {
return false, fmt.Errorf("resolve node %q: %w", host, err)
}
for _, ip := range ips {
if v.ipAllowed(ip) {
if ipAllowed(ip, allowHosts, allowCIDRs) {
return true, nil
}
}
@@ -565,6 +606,23 @@ func (v *nodeValidator) ipAllowed(ip net.IP) bool {
return false
}
func ipAllowed(ip net.IP, hosts map[string]struct{}, cidrs []*net.IPNet) bool {
if ip == nil {
return false
}
if hosts != nil {
if _, ok := hosts[ip.String()]; ok {
return true
}
}
for _, network := range cidrs {
if network.Contains(ip) {
return true
}
}
return false
}
func (v *nodeValidator) recordFailure(reason string) {
if v.metrics != nil {
v.metrics.recordNodeValidationFailure(reason)

View File

@@ -43,6 +43,16 @@ If a node has an HTTPS proxy configured, Pulse does **not** fall back to socket
Use the socket path wherever Pulse is containerised. Use HTTP mode when the sensors live on machines Pulse cannot mount directly.
### Monitoring proxy health
Pulse surfaces the current transport status under **Settings → Diagnostics → Temperature proxy**.
- The **Control plane sync** table lists every proxy registered with the new control-plane channel (`install-sensor-proxy.sh` now configures this automatically). Each entry shows the last time the proxy fetched its authorized node list, the expected refresh interval, and whether it is healthy, stale, or offline.
- If a proxy falls behind more than one refresh interval you will see a yellow “Behind” badge; Pulse also adds a diagnostic note explaining which host is lagging. After four consecutive missed polls the badge turns red (“Offline”).
- HTTPS-mode proxies still appear under the **HTTPS proxies** section with reachability/error information, so you can see socket/HTTP transport issues side-by-side.
If a proxy never completes its first sync the diagnostics card will call that out explicitly (status “Pending”). Rerun the host installer or check the proxy journal (`journalctl -u pulse-sensor-proxy`) to resolve any startup problems, then refresh Diagnostics to confirm the sync is healthy.
## Docker in VM Setup
**Running Pulse in Docker inside a VM on Proxmox?** The proxy socket cannot cross VM boundaries, so use pulse-host-agent instead.
@@ -1337,6 +1347,38 @@ test -S /run/pulse-sensor-proxy/pulse-sensor-proxy.sock && echo "Socket OK" || e
**Contributions Welcome:** If any of these improvements interest you, open a GitHub issue to discuss implementation!
## Control-Plane Sync & Migration
As of v4.32 the sensor proxy registers with Pulse and syncs its authorized node list via `/api/temperature-proxy/authorized-nodes`. No more manual `allowed_nodes` maintenance or `/etc/pve` access is required.
### New installs
Always pass the Pulse URL when installing:
```bash
curl -sSL https://pulse.example.com/api/install/install-sensor-proxy.sh \
| sudo bash -s -- --ctid 108 --pulse-server http://192.168.0.149:7655
```
The installer now:
- Registers the proxy with Pulse (even for socket-only mode)
- Saves `/etc/pulse-sensor-proxy/.pulse-control-token`
- Appends a `pulse_control_plane` block to `/etc/pulse-sensor-proxy/config.yaml`
### Migrating existing hosts
If you installed before v4.32, run the migration helper on each host:
```bash
curl -sSL https://pulse.example.com/api/install/migrate-sensor-proxy-control-plane.sh \
| sudo bash -s -- --pulse-server http://192.168.0.149:7655
```
The script registers the existing proxy, writes the control token, updates the config, and restarts the service (use `--skip-restart` if you prefer to bounce it yourself). Once migrated, temperatures for every node defined in Pulse will continue working even if the proxy cant reach `/etc/pve` or Corosync IPC.
After migration you should see `Temperature data fetched successfully` entries for each node in `journalctl -u pulse-sensor-proxy`, and Settings → Diagnostics will show the last control-plane sync time.
### Getting Help
If temperature monitoring isn't working:

View File

@@ -0,0 +1,120 @@
# Pulse Temperature Proxy Control Plane Sync
## Goals
1. Make `pulse-sensor-proxy` trust Pulse itself instead of scraping `pvecm`/editing `/etc/pve`.
2. Ensure host installers always create a pulse-proxy registration, regardless of socket vs HTTP mode.
3. Keep backwards compatibility: existing `allowed_nodes` entries remain a fallback cache, but the runtime source of truth is Pulse.
## Overview
```
┌─────────────────────┐ HTTPS / Unix socket ┌─────────────────────┐
│ Pulse server (LXC) │ <═════════════════════════════> │ pulse-sensor-proxy │
│ │ /api/... │ (Proxmox host) │
│ - Stores nodes │ │ - Collects temps │
│ - Issues proxy token│ │ - Validates node │
└─────────────────────┘ │ via synced list │
└─────────────────────┘
```
1. Installer registers the proxy using `/api/temperature-proxy/register`.
- Response now includes `ctrl_token`, `instance_id`, and `allowed_nodes`.
- Pulse persists `{instance_id, ctrl_token, last_seen, allowed_nodes_cache}`.
2. Proxy writes:
```yaml
pulse_control_plane:
url: https://pulse.example.com:7655
token_file: /etc/pulse-sensor-proxy/.pulse-control-token
refresh_interval: 60s
```
3. Proxy boot sequence:
- Load cached `allowed_nodes` from YAML (fallback only).
- If `pulse_control_plane` configured, fetch `/api/temperature-proxy/authorized-nodes`.
- Replace in-memory allowlist atomically, log version/hash.
- Retry based on exponential backoff; stay on cached list if control plane unreachable.
## API Changes (Pulse)
1. **Extend existing registration endpoint**
- Request: `{hostname, proxy_url, kind}` (`kind` = `socket` or `http`).
- Response: `{success, token, ctrl_token, pve_instance, allowed_nodes, refresh_interval}`.
- Persist `ctrl_token` (or reuse `TemperatureProxyToken` field if `proxy_url` empty).
2. **New endpoint** `/api/temperature-proxy/authorized-nodes`
- Auth: `X-Proxy-Token: <ctrl_token>` or `Authorization: Bearer`.
- Response:
```json
{
"nodes": [
{"name": "delly", "ip": "192.168.0.5"},
{"name": "minipc", "ip": "192.168.0.134"}
],
"hash": "sha256:...",
"refresh_interval": 60,
"updated_at": "2025-11-15T20:47:00Z"
}
```
- Uses Pulse config (`nodes.enc` + cluster endpoints) to build list.
- Derives `ip` from cluster endpoints or stored host value; duplicates removed.
- Logs when proxies pull list (metrics + last_seen).
3. **Persistence**
- `config.PVEInstance` already has `TemperatureProxyURL`/`Token`. Add `TemperatureProxyControlToken` or reuse existing field when URL empty.
- Add `LastProxyPull`, `LastAllowlistHash`.
4. **Access control**
- Router should treat `/api/temperature-proxy/authorized-nodes` as public but requiring proxy token (bypasses user auth).
- Rate limit per proxy (maybe 12/min).
## Proxy Changes
1. **Config additions**
```yaml
pulse_control_plane:
url: https://pulse.lan:7655
token_file: /etc/pulse-sensor-proxy/.pulse-control-token
refresh_interval: 60s # default
insecure_skip_verify: false
```
2. **Startup**
- Read token from `token_file`.
- Launch goroutine: `syncAllowlist(ctx)` loops:
1. GET `/api/temperature-proxy/authorized-nodes`.
2. Validate response (non-empty, verify hash changes).
3. Replace `nodeValidator` allowlist in thread-safe way.
4. Write new snapshot to `allowed_nodes_cache` (optional).
5. Sleep `refresh_interval` (server-provided).
- If call fails: log warning, keep last known list, use fallback allowlist when empty.
3. **NodeValidator**
- Keep ability to parse static `allowed_nodes`.
- Add `SetAuthorizedNodes([]string)` to update hosts + CIDRs.
- When `hasAllowlist == false` but control-plane sync enabled, we never fall back to cluster detection.
- Provide metrics: last sync success timestamp, number of nodes, etc.
## Installer Changes
1. Host install path (`install.sh` invoking `install-sensor-proxy.sh`)
- Always pass `--pulse-server http://<container-ip>:<port>`.
- If `--pulse-server` not supplied manually, `install-sensor-proxy.sh` fetches from `PULSE_SERVER` env.
2. `install-sensor-proxy.sh`
- After downloading binary, run registration:
```
ctrl_token=$(register_with_pulse "$PULSE_SERVER" "$SHORT_HOSTNAME" "$PROXY_URL" "$MODE")
echo "$ctrl_token" > /etc/pulse-sensor-proxy/.pulse-control-token
```
- Append control-plane block to config if not present.
- After install, call new authorized-nodes endpoint once to prime the cache.
- Continue merging `allowed_nodes` for fallback, but treat as `# Legacy fallback`.
3. Provide migration flag `--legacy-allowlist` to skip control plane (for air-gapped hosts).
## Migration Plan
1. Ship allowlist merge fix (already done locally) so reruns stop causing YAML errors.
2. Release intermediate version where installer accepts `--pulse-server` and registers proxies; proxy ignores new config fields until next release.
3. Release proxy with control-plane sync; ensure it tolerates missing control block (for older installs).
4. Update docs + UI to show last proxy sync state (diagnostics tab).
## Open Questions / TODO
- Decide whether ctrl_token reuses `TemperatureProxyToken` (rename field) or is separate.
- How to handle multiple Pulse servers controlling the same host (future?). For now, one ctrl token per PVE instance.
- Should HTTP-mode proxies reuse the same sync endpoint (yes).

View File

@@ -30,6 +30,7 @@ export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: bool
const [lxcCtid, setLxcCtid] = createSignal<string>('');
const [dockerContainerName, setDockerContainerName] = createSignal<string>('');
const [showAlternatives, setShowAlternatives] = createSignal(false);
const [isValidatingToken, setIsValidatingToken] = createSignal(false);
const applyTheme = (mode: 'system' | 'light' | 'dark') => {
if (mode === 'light') {
@@ -102,13 +103,39 @@ export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: bool
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
};
const handleUnlock = () => {
const handleUnlock = async () => {
if (!bootstrapToken().trim()) {
showError('Please enter the bootstrap token');
return;
}
// Simple client-side unlock - actual validation happens during setup
setIsUnlocked(true);
setIsValidatingToken(true);
try {
const response = await fetch('/api/security/validate-bootstrap-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: bootstrapToken().trim() }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Invalid bootstrap setup token');
}
setIsUnlocked(true);
showSuccess('Bootstrap token verified. Continue with setup.');
} catch (error) {
if (error instanceof Error) {
showError(error.message || 'Failed to validate bootstrap token');
} else {
showError('Failed to validate bootstrap token');
}
} finally {
setIsValidatingToken(false);
}
};
const handleSetup = async () => {
@@ -399,7 +426,7 @@ IMPORTANT: Keep these credentials secure!
type="text"
value={bootstrapToken()}
onInput={(e) => setBootstrapToken(e.currentTarget.value)}
onKeyPress={(e) => e.key === 'Enter' && handleUnlock()}
onKeyPress={(e) => e.key === 'Enter' && handleUnlock()}
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
placeholder="Paste the token from your host"
autofocus
@@ -423,10 +450,10 @@ IMPORTANT: Keep these credentials secure!
<button
type="button"
onClick={handleUnlock}
disabled={!bootstrapToken().trim()}
disabled={isValidatingToken() || !bootstrapToken().trim()}
class="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white rounded-lg font-medium transition-colors disabled:cursor-not-allowed"
>
Unlock Wizard
{isValidatingToken() ? 'Validating...' : 'Unlock Wizard'}
</button>
</div>
</div>

View File

@@ -64,15 +64,84 @@ const resolveTemperatureTransport = (
globalEnabled: boolean,
): TemperatureTransportBadge => {
const monitoringEnabled = isTemperatureMonitoringEnabled(node, globalEnabled);
const normalizedTransport = (node.temperatureTransport || '').toLowerCase();
if (!monitoringEnabled) {
return {
label: 'Temp disabled',
badgeClass: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300',
};
}
if (normalizedTransport === 'disabled') {
return {
label: 'Temp disabled',
badgeClass: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300',
};
}
const key = (node.name || '').toLowerCase();
const httpEntry = info?.httpMap?.[key];
const socketStatus = info?.socketStatus;
const buildSocketBadge = (): TemperatureTransportBadge => {
if (socketStatus === 'error') {
return {
label: 'Socket error',
badgeClass: 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300',
description: 'Proxy socket not responding',
};
}
if (socketStatus === 'missing') {
return {
label: 'Socket missing',
badgeClass: 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300',
description: 'Mount /mnt/pulse-proxy inside the container',
};
}
return {
label: 'Socket proxy',
badgeClass: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300',
};
};
if (normalizedTransport) {
switch (normalizedTransport) {
case 'https-proxy':
if (httpEntry) {
if (httpEntry.reachable) {
return {
label: 'HTTPS proxy',
badgeClass: 'bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300',
description: httpEntry.url,
};
}
return {
label: 'HTTPS error',
badgeClass: 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300',
description: httpEntry.error || 'Proxy unreachable',
};
}
return {
label: 'HTTPS proxy',
badgeClass: 'bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300',
};
case 'socket-proxy':
return buildSocketBadge();
case 'ssh-blocked':
return {
label: 'Proxy required',
badgeClass: 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300',
description: 'Containerized Pulse requires pulse-sensor-proxy',
};
case 'ssh':
return {
label: 'SSH fallback',
badgeClass: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300',
};
default:
break;
}
}
if (httpEntry) {
if (httpEntry.reachable) {
return {
@@ -89,18 +158,8 @@ const resolveTemperatureTransport = (
}
if (info) {
if (info.socketStatus === 'healthy') {
return {
label: 'Socket proxy',
badgeClass: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300',
};
}
if (info.socketStatus === 'error') {
return {
label: 'Socket error',
badgeClass: 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300',
description: 'Proxy socket not responding',
};
if (info.socketStatus === 'healthy' || info.socketStatus === 'error' || info.socketStatus === 'missing') {
return buildSocketBadge();
}
}

View File

@@ -139,6 +139,14 @@ interface TemperatureProxyHTTPStatus {
error?: string;
}
interface TemperatureProxyControlPlaneState {
instance: string;
lastSync?: string;
refreshIntervalSeconds?: number;
secondsBehind?: number;
status?: string;
}
interface TemperatureProxyDiagnostic {
legacySSHDetected: boolean;
recommendProxyUpgrade: boolean;
@@ -155,6 +163,8 @@ interface TemperatureProxyDiagnostic {
proxyCapabilities?: string[];
notes?: string[];
httpProxies?: TemperatureProxyHTTPStatus[];
controlPlaneEnabled?: boolean;
controlPlaneStates?: TemperatureProxyControlPlaneState[];
}
interface APITokenSummary {
@@ -792,6 +802,33 @@ const Settings: Component<SettingsProps> = (props) => {
return formatRelativeTime(timestamp);
};
const controlPlaneStatusLabel = (status?: string) => {
switch (status) {
case 'healthy':
return 'Healthy';
case 'stale':
return 'Behind';
case 'offline':
return 'Offline';
case 'pending':
default:
return 'Pending';
}
};
const controlPlaneStatusClass = (status?: string) => {
switch (status) {
case 'healthy':
return 'bg-green-500';
case 'stale':
return 'bg-yellow-500';
case 'offline':
return 'bg-red-500';
default:
return 'bg-gray-500';
}
};
const formatUptime = (seconds: number) => {
if (!seconds || seconds <= 0) {
return 'Unknown';
@@ -814,15 +851,26 @@ const Settings: Component<SettingsProps> = (props) => {
};
const emitTemperatureProxyWarnings = (diag: DiagnosticsData | null) => {
if (!diag?.temperatureProxy?.httpProxies) {
if (!diag?.temperatureProxy) {
return;
}
const failing = (diag.temperatureProxy.httpProxies as TemperatureProxyHTTPStatus[]).filter(
(proxy) => proxy && proxy.node && !proxy.reachable,
);
if (failing.length > 0) {
const nodes = failing.map((proxy) => proxy.node || 'Unknown').join(', ');
showWarning(`Pulse cannot reach HTTPS temperature proxy on: ${nodes}`);
if (diag.temperatureProxy.httpProxies) {
const failing = (diag.temperatureProxy.httpProxies as TemperatureProxyHTTPStatus[]).filter(
(proxy) => proxy && proxy.node && !proxy.reachable,
);
if (failing.length > 0) {
const nodes = failing.map((proxy) => proxy.node || 'Unknown').join(', ');
showWarning(`Pulse cannot reach HTTPS temperature proxy on: ${nodes}`);
}
}
if (diag.temperatureProxy.controlPlaneStates) {
const stale = (diag.temperatureProxy.controlPlaneStates as TemperatureProxyControlPlaneState[]).filter(
(state) => state && (state.status === 'stale' || state.status === 'offline'),
);
if (stale.length > 0) {
const names = stale.map((state) => state.instance || 'Proxy').join(', ');
showWarning(`Temperature proxy control plane is behind on: ${names}`);
}
}
};
@@ -5210,17 +5258,71 @@ const Settings: Component<SettingsProps> = (props) => {
<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
<Show when={temp().legacySSHDetected}>
<div class="text-red-500">
Legacy SSH temperature collection detected
</div>
</Show>
</div>
<Show
when={
temp().controlPlaneStates &&
(temp().controlPlaneStates as TemperatureProxyControlPlaneState[]).length > 0
}
>
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-2">
<div class="flex items-center justify-between">
<div class="font-semibold text-gray-700 dark:text-gray-200">
Control plane sync
</div>
</Show>
<span
class={`px-2 py-0.5 rounded text-white text-xs ${
temp().controlPlaneEnabled ? 'bg-green-500' : 'bg-gray-500'
}`}
>
{temp().controlPlaneEnabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<For each={temp().controlPlaneStates || []}>
{(state) => (
<div class="rounded border border-gray-200 dark:border-gray-700 px-2 py-1.5 space-y-1">
<div class="flex items-center justify-between">
<div class="font-medium text-gray-700 dark:text-gray-200">
{state.instance || 'Proxy'}
</div>
<span
class={`px-2 py-0.5 rounded text-white text-xs ${controlPlaneStatusClass(
state.status,
)}`}
>
{controlPlaneStatusLabel(state.status)}
</span>
</div>
<Show when={state.lastSync}>
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
Last sync: {formatIsoRelativeTime(state.lastSync)}
</div>
</Show>
<Show when={typeof state.secondsBehind === 'number' && (state.secondsBehind || 0) > 0}>
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
Behind by ~{formatUptime(state.secondsBehind || 0)}
</div>
</Show>
<Show when={state.refreshIntervalSeconds}>
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
Target interval: {formatUptime(state.refreshIntervalSeconds || 0)}
</div>
</Show>
</div>
)}
</For>
</div>
<Show
when={
temp().httpProxies && (temp().httpProxies as TemperatureProxyHTTPStatus[]).length > 0
}
>
</Show>
<Show
when={
temp().httpProxies && (temp().httpProxies as TemperatureProxyHTTPStatus[]).length > 0
}
>
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-2">
<div class="font-semibold text-gray-700 dark:text-gray-200">
HTTPS proxies

View File

@@ -1,5 +1,12 @@
import type { Temperature } from '@/types/api';
export type TemperatureTransport =
| 'disabled'
| 'socket-proxy'
| 'https-proxy'
| 'ssh'
| 'ssh-blocked';
// Node configuration types
export interface ClusterEndpoint {
@@ -83,6 +90,7 @@ export type NodeConfig = (PVENodeConfig | PBSNodeConfig | PMGNodeConfig) & {
status?: 'connected' | 'disconnected' | 'offline' | 'error' | 'pending';
temperature?: Temperature;
displayName?: string;
temperatureTransport?: TemperatureTransport;
};
export interface NodesResponse {

View File

@@ -185,6 +185,11 @@ safe_systemctl() {
# Detect existing service name (pulse or pulse-backend)
detect_service_name() {
if ! command -v systemctl >/dev/null 2>&1; then
echo "pulse"
return
fi
if systemctl list-unit-files --no-legend | grep -q "^pulse-backend.service"; then
echo "pulse-backend"
elif systemctl list-unit-files --no-legend | grep -q "^pulse.service"; then
@@ -1565,7 +1570,7 @@ fi'; then
fi
# If building from source, copy the binary from the LXC instead of downloading
local proxy_install_args=(--ctid "$CTID" --skip-restart)
local proxy_install_args=(--ctid "$CTID" --skip-restart --pulse-server "http://${IP}:${frontend_port}")
local local_proxy_binary=""
if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
local_proxy_binary="/tmp/pulse-sensor-proxy-$CTID"
@@ -1770,20 +1775,29 @@ compare_versions() {
check_existing_installation() {
CURRENT_VERSION="" # Make it global so we can use it later
local BINARY_PATH=""
local detected_service="$SERVICE_NAME"
local service_available=false
# Check for the binary in expected locations
if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then
BINARY_PATH="$INSTALL_DIR/bin/pulse"
elif [[ -f "$INSTALL_DIR/pulse" ]]; then
BINARY_PATH="$INSTALL_DIR/pulse"
fi
# Detect actual service name if systemd is available
if command -v systemctl >/dev/null 2>&1; then
detected_service=$(detect_service_name)
SERVICE_NAME="$detected_service"
service_available=true
fi
# Try to get version if binary exists
if [[ -n "$BINARY_PATH" ]]; then
CURRENT_VERSION=$($BINARY_PATH --version 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.]+)?' | head -1 || echo "unknown")
fi
if systemctl is-active --quiet $SERVICE_NAME 2>/dev/null; then
if [[ "$service_available" == true ]] && systemctl is-active --quiet "$detected_service" 2>/dev/null; then
if [[ -n "$CURRENT_VERSION" && "$CURRENT_VERSION" != "unknown" ]]; then
print_info "Pulse $CURRENT_VERSION is currently running"
else

View File

@@ -3,8 +3,10 @@ package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
@@ -136,3 +138,43 @@ func (r *Router) clearBootstrapToken() {
r.bootstrapTokenHash = ""
r.bootstrapTokenPath = ""
}
func (r *Router) handleValidateBootstrapToken(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if r.bootstrapTokenHash == "" {
http.Error(w, "Bootstrap token unavailable. Reload the page or restart Pulse.", http.StatusConflict)
return
}
token := strings.TrimSpace(req.Header.Get(bootstrapTokenHeader))
if token == "" {
var payload struct {
Token string `json:"token"`
}
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
http.Error(w, "Invalid request payload", http.StatusBadRequest)
return
}
token = strings.TrimSpace(payload.Token)
}
if token == "" {
http.Error(w, "Bootstrap token is required", http.StatusBadRequest)
return
}
if !r.bootstrapTokenValid(token) {
log.Warn().
Str("ip", GetClientIP(req)).
Msg("Rejected invalid bootstrap token validation request")
http.Error(w, "Invalid bootstrap setup token", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -44,6 +44,14 @@ var (
setupAuthTokenPattern = regexp.MustCompile(`^[A-Fa-f0-9]{32,128}$`)
)
const (
temperatureTransportDisabled = "disabled"
temperatureTransportSocketProxy = "socket-proxy"
temperatureTransportHTTPSProxy = "https-proxy"
temperatureTransportSSHFallback = "ssh"
temperatureTransportSSHBlocked = "ssh-blocked"
)
func sanitizeInstallerURL(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
@@ -409,6 +417,7 @@ type NodeResponse struct {
MonitorBackups bool `json:"monitorBackups,omitempty"`
MonitorPhysicalDisks *bool `json:"monitorPhysicalDisks,omitempty"`
TemperatureMonitoringEnabled *bool `json:"temperatureMonitoringEnabled,omitempty"`
TemperatureTransport string `json:"temperatureTransport,omitempty"`
MonitorDatastores bool `json:"monitorDatastores,omitempty"`
MonitorSyncJobs bool `json:"monitorSyncJobs,omitempty"`
MonitorVerifyJobs bool `json:"monitorVerifyJobs,omitempty"`
@@ -424,6 +433,44 @@ type NodeResponse struct {
ClusterEndpoints []config.ClusterEndpoint `json:"clusterEndpoints,omitempty"`
}
func determineTemperatureTransport(enabled bool, proxyURL, proxyToken string, socketAvailable bool, containerSSHBlocked bool) string {
if !enabled {
return temperatureTransportDisabled
}
proxyURL = strings.TrimSpace(proxyURL)
proxyToken = strings.TrimSpace(proxyToken)
if proxyURL != "" && proxyToken != "" {
return temperatureTransportHTTPSProxy
}
if socketAvailable {
return temperatureTransportSocketProxy
}
if containerSSHBlocked {
return temperatureTransportSSHBlocked
}
return temperatureTransportSSHFallback
}
func isContainerSSHRestricted() bool {
isContainer := os.Getenv("PULSE_DOCKER") == "true" || system.InContainer()
if !isContainer {
return false
}
return strings.ToLower(strings.TrimSpace(os.Getenv("PULSE_DEV_ALLOW_CONTAINER_SSH"))) != "true"
}
func (h *ConfigHandlers) resolveTemperatureTransport(enabledOverride *bool, proxyURL, proxyToken string, socketAvailable bool, containerSSHBlocked bool) string {
enabled := h.config.TemperatureMonitoringEnabled
if enabledOverride != nil {
enabled = *enabledOverride
}
return determineTemperatureTransport(enabled, proxyURL, proxyToken, socketAvailable, containerSSHBlocked)
}
// deriveSchemeAndPort infers the scheme (without ://) and port from a base host URL.
// Defaults align with Proxmox expectations when details are omitted.
func deriveSchemeAndPort(baseHost string) (scheme string, port string) {
@@ -742,6 +789,8 @@ func detectPVECluster(clientConfig proxmox.ClientConfig, nodeName string, existi
// GetAllNodesForAPI returns all configured nodes for API responses
func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse {
nodes := []NodeResponse{}
socketAvailable := h.monitor != nil && h.monitor.HasSocketTemperatureProxy()
containerSSHBlocked := isContainerSSHRestricted()
// Add PVE nodes
for i, pve := range h.config.PVEInstances {
@@ -766,6 +815,7 @@ func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse {
MonitorBackups: pve.MonitorBackups,
MonitorPhysicalDisks: pve.MonitorPhysicalDisks,
TemperatureMonitoringEnabled: pve.TemperatureMonitoringEnabled,
TemperatureTransport: h.resolveTemperatureTransport(pve.TemperatureMonitoringEnabled, pve.TemperatureProxyURL, pve.TemperatureProxyToken, socketAvailable, containerSSHBlocked),
Status: h.getNodeStatus("pve", pve.Name),
IsCluster: pve.IsCluster,
ClusterName: pve.ClusterName,
@@ -788,6 +838,7 @@ func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse {
Fingerprint: pbs.Fingerprint,
VerifySSL: pbs.VerifySSL,
TemperatureMonitoringEnabled: pbs.TemperatureMonitoringEnabled,
TemperatureTransport: h.resolveTemperatureTransport(pbs.TemperatureMonitoringEnabled, "", "", socketAvailable, containerSSHBlocked),
MonitorDatastores: pbs.MonitorDatastores,
MonitorSyncJobs: pbs.MonitorSyncJobs,
MonitorVerifyJobs: pbs.MonitorVerifyJobs,
@@ -822,6 +873,7 @@ func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse {
MonitorQuarantine: pmgInst.MonitorQuarantine,
MonitorDomainStats: pmgInst.MonitorDomainStats,
Status: h.getNodeStatus("pmg", pmgInst.Name),
TemperatureTransport: h.resolveTemperatureTransport(pmgInst.TemperatureMonitoringEnabled, "", "", socketAvailable, containerSSHBlocked),
}
nodes = append(nodes, node)
}
@@ -884,6 +936,7 @@ func (h *ConfigHandlers) HandleGetNodes(w http.ResponseWriter, r *http.Request)
IsCluster: true,
ClusterName: "mock-cluster",
ClusterEndpoints: clusterEndpoints, // All cluster nodes
TemperatureTransport: temperatureTransportSocketProxy,
}
mockNodes = append(mockNodes, clusterNode)
}
@@ -910,6 +963,7 @@ func (h *ConfigHandlers) HandleGetNodes(w http.ResponseWriter, r *http.Request)
IsCluster: false, // Not part of a cluster
ClusterName: "",
ClusterEndpoints: []config.ClusterEndpoint{},
TemperatureTransport: temperatureTransportSocketProxy,
}
mockNodes = append(mockNodes, standaloneNode)
}
@@ -917,22 +971,23 @@ func (h *ConfigHandlers) HandleGetNodes(w http.ResponseWriter, r *http.Request)
// Add mock PBS instances
for i, pbs := range state.PBSInstances {
pbsNode := NodeResponse{
ID: generateNodeID("pbs", i),
Type: "pbs",
Name: pbs.Name,
Host: pbs.Host,
User: "pulse@pbs",
HasPassword: false,
TokenName: "pulse",
HasToken: true,
Fingerprint: "",
VerifySSL: false,
MonitorDatastores: true,
MonitorSyncJobs: true,
MonitorVerifyJobs: true,
MonitorPruneJobs: true,
MonitorGarbageJobs: true,
Status: "connected", // Always connected in mock mode
ID: generateNodeID("pbs", i),
Type: "pbs",
Name: pbs.Name,
Host: pbs.Host,
User: "pulse@pbs",
HasPassword: false,
TokenName: "pulse",
HasToken: true,
Fingerprint: "",
VerifySSL: false,
MonitorDatastores: true,
MonitorSyncJobs: true,
MonitorVerifyJobs: true,
MonitorPruneJobs: true,
MonitorGarbageJobs: true,
Status: "connected", // Always connected in mock mode
TemperatureTransport: temperatureTransportSocketProxy,
}
mockNodes = append(mockNodes, pbsNode)
}
@@ -940,17 +995,18 @@ func (h *ConfigHandlers) HandleGetNodes(w http.ResponseWriter, r *http.Request)
// Add mock PMG instances
for i, pmg := range state.PMGInstances {
pmgNode := NodeResponse{
ID: generateNodeID("pmg", i),
Type: "pmg",
Name: pmg.Name,
Host: pmg.Host,
User: "root@pam",
HasPassword: true,
TokenName: "pulse",
HasToken: true,
Fingerprint: "",
VerifySSL: false,
Status: "connected", // Always connected in mock mode
ID: generateNodeID("pmg", i),
Type: "pmg",
Name: pmg.Name,
Host: pmg.Host,
User: "root@pam",
HasPassword: true,
TokenName: "pulse",
HasToken: true,
Fingerprint: "",
VerifySSL: false,
Status: "connected", // Always connected in mock mode
TemperatureTransport: temperatureTransportSocketProxy,
}
mockNodes = append(mockNodes, pmgNode)
}

View File

@@ -0,0 +1,58 @@
package api
import "testing"
func TestDetermineTemperatureTransport(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
enabled bool
proxyURL string
proxyToken string
socketAvailable bool
containerSSHBlocked bool
expectedTransport string
}{
{
name: "disabled",
enabled: false,
expectedTransport: temperatureTransportDisabled,
},
{
name: "https proxy preferred when configured",
enabled: true,
proxyURL: " https://pve.example ",
proxyToken: "token",
expectedTransport: temperatureTransportHTTPSProxy,
},
{
name: "socket proxy when available",
enabled: true,
socketAvailable: true,
expectedTransport: temperatureTransportSocketProxy,
},
{
name: "ssh blocked in container without override",
enabled: true,
socketAvailable: false,
containerSSHBlocked: true,
expectedTransport: temperatureTransportSSHBlocked,
},
{
name: "ssh fallback when nothing else available",
enabled: true,
expectedTransport: temperatureTransportSSHFallback,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := determineTemperatureTransport(tc.enabled, tc.proxyURL, tc.proxyToken, tc.socketAvailable, tc.containerSSHBlocked)
if got != tc.expectedTransport {
t.Fatalf("expected %q, got %q", tc.expectedTransport, got)
}
})
}
}

View File

@@ -234,19 +234,29 @@ type SystemDiagnostic struct {
// TemperatureProxyDiagnostic summarizes proxy detection state
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"`
ProxyCapabilities []string `json:"proxyCapabilities,omitempty"`
Notes []string `json:"notes,omitempty"`
HTTPProxies []TemperatureProxyHTTPStatus `json:"httpProxies,omitempty"`
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"`
ProxyCapabilities []string `json:"proxyCapabilities,omitempty"`
Notes []string `json:"notes,omitempty"`
HTTPProxies []TemperatureProxyHTTPStatus `json:"httpProxies,omitempty"`
ControlPlaneEnabled bool `json:"controlPlaneEnabled"`
ControlPlaneStates []TemperatureProxyControlPlaneState `json:"controlPlaneStates,omitempty"`
}
type TemperatureProxyControlPlaneState struct {
Instance string `json:"instance"`
LastSync string `json:"lastSync,omitempty"`
RefreshIntervalSeconds int `json:"refreshIntervalSeconds,omitempty"`
SecondsBehind int `json:"secondsBehind,omitempty"`
Status string `json:"status,omitempty"`
}
type TemperatureProxyHTTPStatus struct {
@@ -403,7 +413,12 @@ func (r *Router) computeDiagnostics(ctx context.Context) DiagnosticsInfo {
MemoryMB: memStats.Alloc / 1024 / 1024,
}
diag.TemperatureProxy = buildTemperatureProxyDiagnostic(r.config)
var proxySync map[string]proxySyncState
if r.temperatureProxyHandlers != nil {
proxySync = r.temperatureProxyHandlers.SnapshotSyncStatus()
}
diag.TemperatureProxy = buildTemperatureProxyDiagnostic(r.config, proxySync)
diag.APITokens = buildAPITokenDiagnostic(r.config, r.monitor)
// Test each configured node
@@ -659,7 +674,7 @@ func buildDiscoveryDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *
return discovery
}
func buildTemperatureProxyDiagnostic(cfg *config.Config) *TemperatureProxyDiagnostic {
func buildTemperatureProxyDiagnostic(cfg *config.Config, syncStates map[string]proxySyncState) *TemperatureProxyDiagnostic {
diag := &TemperatureProxyDiagnostic{}
appendNote := func(note string) {
@@ -781,9 +796,75 @@ func buildTemperatureProxyDiagnostic(cfg *config.Config) *TemperatureProxyDiagno
status.Reachable = true
}
}
diag.HTTPProxies = append(diag.HTTPProxies, status)
}
controlStates := make([]TemperatureProxyControlPlaneState, 0)
now := time.Now()
lookupState := func(name string) (proxySyncState, bool) {
if len(syncStates) == 0 {
return proxySyncState{}, false
}
key := strings.ToLower(strings.TrimSpace(name))
if state, ok := syncStates[key]; ok {
return state, true
}
for _, state := range syncStates {
if strings.EqualFold(state.Instance, name) {
return state, true
}
}
return proxySyncState{}, false
}
for _, inst := range cfg.PVEInstances {
if strings.TrimSpace(inst.TemperatureProxyControlToken) == "" {
continue
}
state := TemperatureProxyControlPlaneState{
Instance: strings.TrimSpace(inst.Name),
Status: "pending",
RefreshIntervalSeconds: defaultProxyAllowlistRefreshSeconds,
}
diag.ControlPlaneEnabled = true
if syncState, ok := lookupState(inst.Name); ok {
if syncState.RefreshInterval > 0 {
state.RefreshIntervalSeconds = syncState.RefreshInterval
}
if !syncState.LastPull.IsZero() {
state.LastSync = syncState.LastPull.UTC().Format(time.RFC3339)
behind := int(now.Sub(syncState.LastPull).Seconds())
if behind < 0 {
behind = 0
}
state.SecondsBehind = behind
switch {
case behind <= state.RefreshIntervalSeconds+15:
state.Status = "healthy"
case behind <= state.RefreshIntervalSeconds*4:
state.Status = "stale"
appendNote(fmt.Sprintf("Proxy '%s' has not refreshed its authorized nodes for %d seconds (target %d). Verify pulse-sensor-proxy is running.", state.Instance, behind, state.RefreshIntervalSeconds))
default:
state.Status = "offline"
appendNote(fmt.Sprintf("Proxy '%s' missed the control plane for %d seconds. Check connectivity and restart pulse-sensor-proxy.", state.Instance, behind))
}
} else {
appendNote(fmt.Sprintf("Proxy '%s' registered for control plane sync but has not completed its first pull yet.", state.Instance))
}
} else {
appendNote(fmt.Sprintf("Proxy '%s' has control-plane sync enabled but has not contacted Pulse. Confirm the installer wrote the control token and the host has connectivity.", state.Instance))
}
controlStates = append(controlStates, state)
}
if len(controlStates) > 0 {
diag.ControlPlaneStates = controlStates
}
}
return diag

View File

@@ -107,6 +107,8 @@ func GetRateLimiterForEndpoint(path string, method string) *RateLimiter {
if strings.Contains(path, "/api/health") ||
strings.Contains(path, "/api/version") ||
strings.Contains(path, "/api/security/status") ||
strings.Contains(path, "/api/security/validate-bootstrap-token") ||
strings.Contains(path, "/api/temperature-proxy/authorized-nodes") ||
strings.Contains(path, "/metrics") {
return globalRateLimitConfig.PublicEndpoints
}

View File

@@ -184,6 +184,7 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/agents/host/lookup", RequireAuth(r.config, RequireScope(config.ScopeHostReport, r.hostAgentHandlers.HandleLookup)))
r.mux.HandleFunc("/api/agents/host/", RequireAdmin(r.config, RequireScope(config.ScopeHostManage, r.hostAgentHandlers.HandleDeleteHost)))
r.mux.HandleFunc("/api/temperature-proxy/register", r.temperatureProxyHandlers.HandleRegister)
r.mux.HandleFunc("/api/temperature-proxy/authorized-nodes", r.temperatureProxyHandlers.HandleAuthorizedNodes)
r.mux.HandleFunc("/api/temperature-proxy/unregister", RequireAdmin(r.config, r.temperatureProxyHandlers.HandleUnregister))
r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerReport, r.dockerAgentHandlers.HandleCommandAck)))
r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions)))
@@ -196,6 +197,7 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/diagnostics/docker/prepare-token", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, 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/install/migrate-sensor-proxy-control-plane.sh", r.handleDownloadMigrationScript)
r.mux.HandleFunc("/api/install/install-docker.sh", r.handleDownloadDockerInstallerScript)
r.mux.HandleFunc("/api/config", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleConfig)))
r.mux.HandleFunc("/api/backups", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleBackups)))
@@ -273,6 +275,7 @@ func (r *Router) setupRoutes() {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
r.mux.HandleFunc("/api/security/validate-bootstrap-token", r.handleValidateBootstrapToken)
// Test node configuration endpoint (for new nodes)
r.mux.HandleFunc("/api/config/nodes/test-config", func(w http.ResponseWriter, req *http.Request) {
@@ -1262,7 +1265,7 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if r.config.AllowedOrigins != "" {
w.Header().Set("Access-Control-Allow-Origin", r.config.AllowedOrigins)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Token, X-CSRF-Token")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Token, X-CSRF-Token, X-Setup-Token")
w.Header().Set("Access-Control-Expose-Headers", "X-CSRF-Token, X-Authenticated-User, X-Auth-Method")
}
@@ -1309,25 +1312,28 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
publicPaths := []string{
"/api/health",
"/api/security/status",
"/api/security/validate-bootstrap-token",
"/api/version",
"/api/login", // Add login endpoint as public
"/api/oidc/login",
config.DefaultOIDCCallbackPath,
"/install-docker-agent.sh", // Docker agent bootstrap script must be public
"/install-container-agent.sh", // Container agent bootstrap script must be public
"/download/pulse-docker-agent", // Agent binary download should not require auth
"/install-host-agent.sh", // Host agent bootstrap script must be public
"/install-host-agent.ps1", // Host agent PowerShell script must be public
"/uninstall-host-agent.sh", // Host agent uninstall script must be public
"/uninstall-host-agent.ps1", // Host agent uninstall script must be public
"/download/pulse-host-agent", // Host 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
"/api/install/install-docker.sh", // Docker turnkey installer
"/api/system/proxy-public-key", // Temperature proxy public key for setup script
"/api/temperature-proxy/register", // Temperature proxy registration (called by installer)
"/install-docker-agent.sh", // Docker agent bootstrap script must be public
"/install-container-agent.sh", // Container agent bootstrap script must be public
"/download/pulse-docker-agent", // Agent binary download should not require auth
"/install-host-agent.sh", // Host agent bootstrap script must be public
"/install-host-agent.ps1", // Host agent PowerShell script must be public
"/uninstall-host-agent.sh", // Host agent uninstall script must be public
"/uninstall-host-agent.ps1", // Host agent uninstall script must be public
"/download/pulse-host-agent", // Host 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
"/api/install/migrate-sensor-proxy-control-plane.sh", // Proxy migration helper
"/api/install/install-docker.sh", // Docker turnkey installer
"/api/system/proxy-public-key", // Temperature proxy public key for setup script
"/api/temperature-proxy/register", // Temperature proxy registration (called by installer)
"/api/temperature-proxy/authorized-nodes", // Proxy control-plane sync
}
// Also allow static assets without auth (JS, CSS, etc)
@@ -3765,6 +3771,31 @@ func (r *Router) handleDownloadDockerInstallerScript(w http.ResponseWriter, req
}
}
func (r *Router) handleDownloadMigrationScript(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 := "/opt/pulse/scripts/migrate-sensor-proxy-control-plane.sh"
content, err := os.ReadFile(scriptPath)
if err != nil {
scriptPath = filepath.Join(r.projectRoot, "scripts", "migrate-sensor-proxy-control-plane.sh")
content, err = os.ReadFile(scriptPath)
if err != nil {
log.Error().Err(err).Str("path", scriptPath).Msg("Failed to read migration script")
writeErrorResponse(w, http.StatusInternalServerError, "read_error", "Failed to read migration script", nil)
return
}
}
w.Header().Set("Content-Type", "text/x-shellscript")
w.Header().Set("Content-Disposition", "attachment; filename=migrate-sensor-proxy-control-plane.sh")
if _, err := w.Write(content); err != nil {
log.Error().Err(err).Msg("Failed to write migration script to client")
}
}
func (r *Router) resolvePublicURL(req *http.Request) string {
if publicURL := strings.TrimSpace(r.config.PublicURL); publicURL != "" {
return strings.TrimRight(publicURL, "/")

View File

@@ -115,6 +115,78 @@ func TestQuickSecuritySetupRequiresBootstrapToken(t *testing.T) {
}
}
func TestValidateBootstrapTokenEndpoint(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
resetTrustedProxyConfig()
dataDir := t.TempDir()
cfg := &config.Config{
DataPath: dataDir,
ConfigPath: dataDir,
}
router := &Router{config: cfg}
router.initializeBootstrapToken()
tokenPath := filepath.Join(cfg.DataPath, bootstrapTokenFilename)
content, err := os.ReadFile(tokenPath)
if err != nil {
t.Fatalf("read bootstrap token: %v", err)
}
token := strings.TrimSpace(string(content))
if token == "" {
t.Fatalf("bootstrap token should not be empty")
}
handler := http.HandlerFunc(router.handleValidateBootstrapToken)
// GET not allowed
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/security/validate-bootstrap-token", nil)
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected 405 for GET, got %d", rr.Code)
}
// Missing token payload
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader("{}"))
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for missing token, got %d (%s)", rr.Code, rr.Body.String())
}
// Invalid token
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{"token":"deadbeef"}`))
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for invalid token, got %d (%s)", rr.Code, rr.Body.String())
}
// Valid token
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{"token":"`+token+`"}`))
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("expected 204 for valid token, got %d (%s)", rr.Code, rr.Body.String())
}
// Bootstrap token should remain on disk after validation
if _, err := os.Stat(tokenPath); err != nil {
t.Fatalf("bootstrap token should remain after validation, got err=%v", err)
}
// Once token removed, endpoint should report conflict
router.clearBootstrapToken()
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{"token":"`+token+`"}`))
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusConflict {
t.Fatalf("expected 409 when bootstrap token unavailable, got %d (%s)", rr.Code, rr.Body.String())
}
}
func TestQuickSecuritySetupAllowsRecoveryTokenRotation(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
resetTrustedProxyConfig()

View File

@@ -2,11 +2,18 @@ package api
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
@@ -16,11 +23,136 @@ import (
// TemperatureProxyHandlers manages temperature proxy registration
type TemperatureProxyHandlers struct {
persistence *config.ConfigPersistence
syncMu sync.RWMutex
syncStatus map[string]proxySyncState
}
type proxySyncState struct {
Instance string
LastPull time.Time
RefreshInterval int
}
const defaultProxyAllowlistRefreshSeconds = 60
type authorizedNode struct {
Name string `json:"name,omitempty"`
IP string `json:"ip,omitempty"`
}
// NewTemperatureProxyHandlers constructs a new handler set for temperature proxy
func NewTemperatureProxyHandlers(persistence *config.ConfigPersistence) *TemperatureProxyHandlers {
return &TemperatureProxyHandlers{persistence: persistence}
return &TemperatureProxyHandlers{
persistence: persistence,
syncStatus: make(map[string]proxySyncState),
}
}
func (h *TemperatureProxyHandlers) recordSync(instance string, refreshSeconds int) {
if h == nil {
return
}
instance = strings.TrimSpace(instance)
if instance == "" {
return
}
if refreshSeconds <= 0 {
refreshSeconds = defaultProxyAllowlistRefreshSeconds
}
h.syncMu.Lock()
defer h.syncMu.Unlock()
key := strings.ToLower(instance)
h.syncStatus[key] = proxySyncState{
Instance: instance,
LastPull: time.Now(),
RefreshInterval: refreshSeconds,
}
}
func (h *TemperatureProxyHandlers) SnapshotSyncStatus() map[string]proxySyncState {
if h == nil {
return nil
}
h.syncMu.RLock()
defer h.syncMu.RUnlock()
if len(h.syncStatus) == 0 {
return nil
}
copy := make(map[string]proxySyncState, len(h.syncStatus))
for key, state := range h.syncStatus {
copy[key] = state
}
return copy
}
func extractHostPart(raw string) string {
host := strings.TrimSpace(raw)
if host == "" {
return ""
}
if strings.HasPrefix(host, "http://") || strings.HasPrefix(host, "https://") {
if parsed, err := url.Parse(host); err == nil {
return parsed.Hostname()
}
}
if idx := strings.Index(host, "/"); idx != -1 {
host = host[:idx]
}
if idx := strings.Index(host, ":"); idx != -1 {
host = host[:idx]
}
return strings.TrimSpace(host)
}
func buildAuthorizedNodeList(instance *config.PVEInstance) []authorizedNode {
if instance == nil {
return nil
}
nodes := make([]authorizedNode, 0)
seen := make(map[string]struct{})
add := func(name, ip string) {
name = strings.TrimSpace(name)
ip = strings.TrimSpace(ip)
if name == "" && ip == "" {
return
}
key := name + "|" + ip
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
nodes = append(nodes, authorizedNode{Name: name, IP: ip})
}
// Base instance host/name
add(instance.Name, extractHostPart(instance.Host))
add(instance.Name, extractHostPart(instance.GuestURL))
if instance.ClusterEndpoints != nil {
for _, ep := range instance.ClusterEndpoints {
name := ep.NodeName
ip := ep.IP
if ip == "" {
ip = extractHostPart(ep.Host)
}
if name == "" {
name = ep.Host
}
add(name, ip)
}
}
return nodes
}
// HandleRegister handles temperature proxy registration from the installer
@@ -39,6 +171,7 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http
var req struct {
Hostname string `json:"hostname"`
ProxyURL string `json:"proxy_url"`
Mode string `json:"mode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -60,11 +193,23 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http
return
}
// Validate proxy URL format
if !strings.HasPrefix(proxyURL, "https://") {
mode := strings.ToLower(strings.TrimSpace(req.Mode))
if mode == "" {
if proxyURL != "" {
mode = "http"
} else {
mode = "socket"
}
}
isHTTPMode := mode == "http"
if isHTTPMode && !strings.HasPrefix(proxyURL, "https://") {
writeErrorResponse(w, http.StatusBadRequest, "invalid_proxy_url", "Proxy URL must use HTTPS", nil)
return
}
if !isHTTPMode {
proxyURL = ""
}
// Load current config
nodesConfig, err := h.persistence.LoadNodesConfig()
@@ -111,8 +256,17 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http
return
}
// Generate a secure random token
token, err := generateSecureToken(32)
// Generate tokens
authToken := ""
if isHTTPMode {
authToken, err = generateSecureToken(32)
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate authentication token", map[string]string{"error": err.Error()})
return
}
}
ctrlToken, err := generateSecureToken(32)
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate authentication token", map[string]string{"error": err.Error()})
return
@@ -120,7 +274,10 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http
// Update the instance with proxy configuration
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyURL = proxyURL
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyToken = token
if isHTTPMode {
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyToken = authToken
}
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyControlToken = ctrlToken
// Save updated configuration
if err := h.persistence.SaveNodesConfig(nodesConfig.PVEInstances, nodesConfig.PBSInstances, nodesConfig.PMGInstances); err != nil {
@@ -131,14 +288,19 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http
log.Info().
Str("hostname", hostname).
Str("proxy_url", proxyURL).
Str("mode", mode).
Str("pve_instance", matchedInstance.Name).
Msg("Temperature proxy registered successfully")
allowed := buildAuthorizedNodeList(matchedInstance)
resp := map[string]any{
"success": true,
"token": token,
"pve_instance": matchedInstance.Name,
"message": fmt.Sprintf("Temperature proxy registered for instance '%s'", matchedInstance.Name),
"success": true,
"token": authToken,
"control_token": ctrlToken,
"pve_instance": matchedInstance.Name,
"allowed_nodes": allowed,
"refresh_interval": defaultProxyAllowlistRefreshSeconds,
"message": fmt.Sprintf("Temperature proxy registered for instance '%s'", matchedInstance.Name),
}
if err := utils.WriteJSONResponse(w, resp); err != nil {
@@ -146,6 +308,97 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http
}
}
// HandleAuthorizedNodes returns the list of nodes Pulse has authorized for a proxy.
//
// GET /api/temperature-proxy/authorized-nodes
// Headers:
//
// X-Proxy-Token: <control-plane token>
func (h *TemperatureProxyHandlers) HandleAuthorizedNodes(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil)
return
}
token := strings.TrimSpace(r.Header.Get("X-Proxy-Token"))
if token == "" {
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
token = strings.TrimSpace(authHeader[7:])
}
}
if token == "" {
writeErrorResponse(w, http.StatusUnauthorized, "missing_token", "X-Proxy-Token header required", nil)
return
}
nodesConfig, err := h.persistence.LoadNodesConfig()
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "config_load_failed", "Failed to load configuration", map[string]string{"error": err.Error()})
return
}
var matched *config.PVEInstance
for i := range nodesConfig.PVEInstances {
inst := &nodesConfig.PVEInstances[i]
switch {
case strings.TrimSpace(inst.TemperatureProxyControlToken) == token:
matched = inst
case inst.TemperatureProxyControlToken == "" && strings.TrimSpace(inst.TemperatureProxyToken) == token:
// Legacy HTTP-mode proxies reuse TemperatureProxyToken
matched = inst
}
if matched != nil {
break
}
}
refreshFromProxy := 0
if hdr := strings.TrimSpace(r.Header.Get("X-Proxy-Refresh")); hdr != "" {
if val, err := strconv.Atoi(hdr); err == nil && val > 0 {
refreshFromProxy = val
}
}
if matched == nil {
writeErrorResponse(w, http.StatusUnauthorized, "invalid_token", "Proxy token not recognized", nil)
return
}
refreshInterval := defaultProxyAllowlistRefreshSeconds
if refreshFromProxy > 0 {
refreshInterval = refreshFromProxy
}
nodes := buildAuthorizedNodeList(matched)
if len(nodes) == 0 {
// Always include at least the base instance name/host
nodes = append(nodes, authorizedNode{Name: matched.Name, IP: extractHostPart(matched.Host)})
}
hashMaterial := make([]string, 0, len(nodes))
for _, node := range nodes {
hashMaterial = append(hashMaterial, fmt.Sprintf("%s|%s", node.Name, node.IP))
}
sort.Strings(hashMaterial)
hashBytes := sha256.Sum256([]byte(strings.Join(hashMaterial, "\n")))
resp := map[string]interface{}{
"instance": matched.Name,
"nodes": nodes,
"hash": hex.EncodeToString(hashBytes[:]),
"refresh_interval": refreshInterval,
"generated_at": time.Now().UTC(),
}
if err := utils.WriteJSONResponse(w, resp); err != nil {
log.Error().Err(err).Msg("Failed to write authorized-nodes response")
} else {
h.recordSync(matched.Name, refreshInterval)
}
}
// HandleUnregister removes temperature proxy configuration from a PVE instance
//
// DELETE /api/temperature-proxy/unregister?hostname=pve1
@@ -200,6 +453,7 @@ func (h *TemperatureProxyHandlers) HandleUnregister(w http.ResponseWriter, r *ht
// Clear proxy configuration
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyURL = ""
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyToken = ""
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyControlToken = ""
// Save updated configuration
if err := h.persistence.SaveNodesConfig(nodesConfig.PVEInstances, nodesConfig.PBSInstances, nodesConfig.PMGInstances); err != nil {

View File

@@ -389,8 +389,8 @@ func splitAndTrim(value string) []string {
// PVEInstance represents a Proxmox VE connection
type PVEInstance struct {
Name string
Host string // Primary endpoint (user-provided)
GuestURL string // Optional guest-accessible URL (for navigation)
Host string // Primary endpoint (user-provided)
GuestURL string // Optional guest-accessible URL (for navigation)
User string
Password string
TokenName string
@@ -409,6 +409,8 @@ type PVEInstance struct {
// Temperature proxy configuration (for external PVE hosts)
TemperatureProxyURL string // Optional HTTPS URL to pulse-sensor-proxy (e.g., https://pve1.lan:8443)
TemperatureProxyToken string // Bearer token for proxy authentication
// Control-plane token for socket-mode proxies (Pulse -> proxy sync)
TemperatureProxyControlToken string
// Cluster support
IsCluster bool // True if this is a cluster
@@ -418,16 +420,16 @@ type PVEInstance struct {
// ClusterEndpoint represents a single node in a cluster
type ClusterEndpoint struct {
NodeID string // Node ID in cluster
NodeName string // Node name
Host string // Full URL (e.g., https://node1.lan:8006)
GuestURL string // Optional guest-accessible URL (for navigation)
IP string // IP address
Online bool // Current online status from Proxmox
LastSeen time.Time // Last successful connection
PulseReachable *bool // Pulse's view: can Pulse reach this endpoint? nil = not yet checked
LastPulseCheck *time.Time // Last time Pulse checked connectivity
PulseError string // Last error Pulse encountered connecting to this endpoint
NodeID string // Node ID in cluster
NodeName string // Node name
Host string // Full URL (e.g., https://node1.lan:8006)
GuestURL string // Optional guest-accessible URL (for navigation)
IP string // IP address
Online bool // Current online status from Proxmox
LastSeen time.Time // Last successful connection
PulseReachable *bool // Pulse's view: can Pulse reach this endpoint? nil = not yet checked
LastPulseCheck *time.Time // Last time Pulse checked connectivity
PulseError string // Last error Pulse encountered connecting to this endpoint
}
// PBSInstance represents a Proxmox Backup Server connection
@@ -627,21 +629,21 @@ func Load() (*Config, error) {
// Always load DiscoveryEnabled even if false
cfg.DiscoveryEnabled = systemSettings.DiscoveryEnabled
if systemSettings.DiscoverySubnet != "" {
cfg.DiscoverySubnet = systemSettings.DiscoverySubnet
}
cfg.Discovery = NormalizeDiscoveryConfig(CloneDiscoveryConfig(systemSettings.DiscoveryConfig))
cfg.TemperatureMonitoringEnabled = systemSettings.TemperatureMonitoringEnabled
// Load DNS cache timeout
if systemSettings.DNSCacheTimeout > 0 {
cfg.DNSCacheTimeout = time.Duration(systemSettings.DNSCacheTimeout) * time.Second
}
// Load SSH port
if systemSettings.SSHPort > 0 {
cfg.SSHPort = systemSettings.SSHPort
} else {
cfg.SSHPort = 22 // Default SSH port
}
// APIToken no longer loaded from system.json - only from .env
cfg.DiscoverySubnet = systemSettings.DiscoverySubnet
}
cfg.Discovery = NormalizeDiscoveryConfig(CloneDiscoveryConfig(systemSettings.DiscoveryConfig))
cfg.TemperatureMonitoringEnabled = systemSettings.TemperatureMonitoringEnabled
// Load DNS cache timeout
if systemSettings.DNSCacheTimeout > 0 {
cfg.DNSCacheTimeout = time.Duration(systemSettings.DNSCacheTimeout) * time.Second
}
// Load SSH port
if systemSettings.SSHPort > 0 {
cfg.SSHPort = systemSettings.SSHPort
} else {
cfg.SSHPort = 22 // Default SSH port
}
// APIToken no longer loaded from system.json - only from .env
log.Info().
Str("updateChannel", cfg.UpdateChannel).
Str("logLevel", cfg.LogLevel).

View File

@@ -3457,6 +3457,21 @@ func (m *Monitor) GetConnectionStatuses() map[string]bool {
return statuses
}
// HasSocketTemperatureProxy reports whether the local unix socket proxy is available.
func (m *Monitor) HasSocketTemperatureProxy() bool {
if m == nil {
return false
}
m.mu.RLock()
defer m.mu.RUnlock()
if m.tempCollector == nil {
return false
}
return m.tempCollector.SocketProxyAvailable()
}
// checkContainerizedTempMonitoring logs a security warning if Pulse is running
// in a container with SSH-based temperature monitoring enabled
func checkContainerizedTempMonitoring() {

View File

@@ -396,8 +396,8 @@ func (tc *TemperatureCollector) parseSensorsJSON(jsonStr string) (*models.Temper
strings.Contains(chipLower, "nct6796") || // Nuvoton NCT6796 SuperIO
strings.Contains(chipLower, "nct6797") || // Nuvoton NCT6797 SuperIO
strings.Contains(chipLower, "nct6798") || // Nuvoton NCT6798 SuperIO
strings.Contains(chipLower, "w83627") || // Winbond W83627 SuperIO series
strings.Contains(chipLower, "f71882") || // Fintek F71882 SuperIO
strings.Contains(chipLower, "w83627") || // Winbond W83627 SuperIO series
strings.Contains(chipLower, "f71882") || // Fintek F71882 SuperIO
strings.Contains(chipLower, "cpu_thermal") || // Raspberry Pi CPU temperature
strings.Contains(chipLower, "rpitemp") {
foundCPUChip = true
@@ -817,6 +817,11 @@ func (tc *TemperatureCollector) isProxyEnabled() bool {
return useProxy
}
// SocketProxyAvailable reports whether the unix socket proxy can currently be used.
func (tc *TemperatureCollector) SocketProxyAvailable() bool {
return tc != nil && tc.isProxyEnabled()
}
func (tc *TemperatureCollector) handleProxySuccess() {
if tc.proxyClient == nil {
return

View File

@@ -90,15 +90,35 @@ update_allowed_nodes() {
mkdir -p "$(dirname "$config_file")"
touch "$config_file"
# Gather existing nodes (if any) to preserve manual edits
local existing_nodes=()
if command -v perl >/dev/null 2>&1; then
mapfile -t existing_nodes < <(perl -0ne 'if (m/allowed_nodes:\n((?:[ \t]+-[^\n]*\n)+)/m) { @lines = split /\n/, $1; for (@lines) { s/^[ \t-]+//; s/\s+$//; next unless $_; print "$_\n"; } }' "$config_file" 2>/dev/null || true)
fi
# Merge and de-duplicate nodes
local merged_nodes=()
declare -A seen_nodes=()
for node in "${existing_nodes[@]}" "${nodes[@]}"; do
node=$(echo "$node" | awk '{$1=$1;print}')
if [[ -z "$node" ]]; then
continue
fi
if [[ -z "${seen_nodes[$node]+set}" ]]; then
merged_nodes+=("$node")
seen_nodes["$node"]=1
fi
done
# Remove any existing allowed_nodes block (including descriptive comments) to prevent duplicates
perl -0pi -e 's/\n(?:[ ]*#[^\n]*\n)*allowed_nodes:\n(?:(?:[ ]+-[^\n]*|[ ]*#[^\n]*)\n)*//g' "$config_file" 2>/dev/null || true
perl -0pi -e 's/(?:^[ \t]*#[^\n]*\n)*allowed_nodes:\n(?:(?:[ \t]+-[^\n]*|[ \t]*#[^\n]*)\n)*//mg' "$config_file" 2>/dev/null || true
{
echo ""
echo "# ${comment_line}"
echo "# These nodes are allowed to request temperature data when cluster IPC validation is unavailable"
echo "allowed_nodes:"
for node in "${nodes[@]}"; do
for node in "${merged_nodes[@]}"; do
echo " - $node"
done
} >> "$config_file"
@@ -468,6 +488,9 @@ FALLBACK_BASE="${PULSE_SENSOR_PROXY_FALLBACK_URL:-}"
SKIP_RESTART=false
UNINSTALL=false
PURGE=false
CONTROL_PLANE_TOKEN=""
CONTROL_PLANE_REFRESH=""
SHORT_HOSTNAME=$(hostname -s 2>/dev/null || hostname | cut -d'.' -f1)
while [[ $# -gt 0 ]]; do
case $1 in
@@ -981,33 +1004,45 @@ register_with_pulse() {
fi
done
# Parse token from response
local token
token=$(echo "$response" | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
if [[ -z "$token" ]]; then
print_error "Registration succeeded but no token received"
print_error "Response: $response"
return 1
fi
# Store token
echo "$token" > /etc/pulse-sensor-proxy/.http-auth-token
chmod 600 /etc/pulse-sensor-proxy/.http-auth-token
chown pulse-sensor-proxy:pulse-sensor-proxy /etc/pulse-sensor-proxy/.http-auth-token
# Output to stderr so it doesn't interfere with command substitution
print_success "Registered successfully - token received" >&2
print_success "Registered successfully" >&2
# Parse instance name from response for logging
local instance_name
instance_name=$(echo "$response" | grep -o '"pve_instance":"[^"]*"' | cut -d'"' -f4)
if [[ -n "$instance_name" ]]; then
print_info "Linked to PVE instance: $instance_name" >&2
# Return full response for caller parsing
echo "$response"
}
write_control_plane_token() {
local token="$1"
if [[ -z "$token" ]]; then
return
fi
print_info "Writing control plane token..."
echo "$token" > /etc/pulse-sensor-proxy/.pulse-control-token
chmod 600 /etc/pulse-sensor-proxy/.pulse-control-token
chown pulse-sensor-proxy:pulse-sensor-proxy /etc/pulse-sensor-proxy/.pulse-control-token
}
ensure_control_plane_config() {
local pulse_url="$1"
local refresh="$2"
if [[ -z "$pulse_url" ]]; then
return
fi
if [[ -z "$refresh" ]]; then
refresh=60
fi
if grep -q "^pulse_control_plane:" /etc/pulse-sensor-proxy/config.yaml 2>/dev/null; then
return
fi
# Return token for caller to use (stdout only)
echo "$token"
cat >> /etc/pulse-sensor-proxy/config.yaml << EOF
# Pulse control plane configuration (auto-generated)
pulse_control_plane:
url: "$pulse_url"
token_file: "/etc/pulse-sensor-proxy/.pulse-control-token"
refresh_interval: $refresh
EOF
}
# Create base config file if it doesn't exist
@@ -1032,6 +1067,25 @@ EOF
chmod 0644 /etc/pulse-sensor-proxy/config.yaml
fi
# Register socket-mode proxy with Pulse if server provided
if [[ "$HTTP_MODE" != true ]]; then
if [[ -z "$PULSE_SERVER" ]]; then
print_warn "PULSE_SERVER not provided; control plane sync disabled. Temperatures will only work on this host."
else
print_info "Registering socket proxy with Pulse server ${PULSE_SERVER}..."
registration_response=$(register_with_pulse "$PULSE_SERVER" "$SHORT_HOSTNAME" "" "socket")
if [[ $? -eq 0 && -n "$registration_response" ]]; then
CONTROL_PLANE_TOKEN=$(echo "$registration_response" | grep -o '"control_token":"[^"]*"' | head -1 | cut -d'"' -f4)
CONTROL_PLANE_REFRESH=$(echo "$registration_response" | grep -o '"refresh_interval":[0-9]*' | head -1 | awk -F: '{print $2}')
if [[ -z "$CONTROL_PLANE_REFRESH" ]]; then
CONTROL_PLANE_REFRESH="60"
fi
else
print_warn "Failed to register socket proxy with Pulse; continuing without control plane sync"
fi
fi
fi
# HTTP Mode Configuration
if [[ "$HTTP_MODE" == true ]]; then
echo ""
@@ -1114,16 +1168,31 @@ if [[ "$HTTP_MODE" == true ]]; then
PROXY_URL="https://${PRIMARY_IP}${HTTP_ADDR}"
print_info "Proxy will be accessible at: $PROXY_URL"
# Register with Pulse and get auth token
# Use short hostname for registration matching (PVE cluster endpoints use short names)
SHORT_HOSTNAME=$(hostname -s 2>/dev/null || hostname | cut -d'.' -f1)
HTTP_AUTH_TOKEN=$(register_with_pulse "$PULSE_SERVER" "$SHORT_HOSTNAME" "$PROXY_URL")
if [[ $? -ne 0 || -z "$HTTP_AUTH_TOKEN" ]]; then
# Register with Pulse and get auth/control tokens
registration_response=$(register_with_pulse "$PULSE_SERVER" "$SHORT_HOSTNAME" "$PROXY_URL" "http")
if [[ $? -ne 0 || -z "$registration_response" ]]; then
print_error "Failed to register with Pulse - aborting installation"
print_error "Fix the issue and re-run the installer"
exit 1
fi
HTTP_AUTH_TOKEN=$(echo "$registration_response" | grep -o '"token":"[^"]*"' | head -1 | cut -d'"' -f4)
CONTROL_PLANE_TOKEN=$(echo "$registration_response" | grep -o '"control_token":"[^"]*"' | head -1 | cut -d'"' -f4)
CONTROL_PLANE_REFRESH=$(echo "$registration_response" | grep -o '"refresh_interval":[0-9]*' | head -1 | awk -F: '{print $2}')
if [[ -z "$CONTROL_PLANE_REFRESH" ]]; then
CONTROL_PLANE_REFRESH="60"
fi
if [[ -z "$HTTP_AUTH_TOKEN" ]]; then
print_error "Registration succeeded but Pulse did not return an auth token"
print_error "Response: $registration_response"
exit 1
fi
echo "$HTTP_AUTH_TOKEN" > /etc/pulse-sensor-proxy/.http-auth-token
chmod 600 /etc/pulse-sensor-proxy/.http-auth-token
chown pulse-sensor-proxy:pulse-sensor-proxy /etc/pulse-sensor-proxy/.http-auth-token
# Backup config before modifying
if [[ -f /etc/pulse-sensor-proxy/config.yaml ]]; then
BACKUP_CONFIG="/etc/pulse-sensor-proxy/config.yaml.backup.$(date +%s)"
@@ -1176,6 +1245,11 @@ EOF
echo ""
fi
if [[ -n "$CONTROL_PLANE_TOKEN" && -n "$PULSE_SERVER" ]]; then
write_control_plane_token "$CONTROL_PLANE_TOKEN"
ensure_control_plane_config "$PULSE_SERVER" "$CONTROL_PLANE_REFRESH"
fi
# Stop existing service if running (for upgrades)
if systemctl is-active --quiet pulse-sensor-proxy 2>/dev/null; then
print_info "Stopping existing service for upgrade..."

View File

@@ -0,0 +1,141 @@
#!/bin/bash
# migrate-sensor-proxy-control-plane.sh
# Adds control-plane sync to existing pulse-sensor-proxy installations.
set -euo pipefail
PULSE_SERVER=""
SKIP_RESTART=false
print_usage() {
cat <<'EOF'
Usage: migrate-sensor-proxy-control-plane.sh --pulse-server https://pulse.example.com:7655 [--skip-restart]
Adds the Pulse control-plane token/config to an existing proxy installation.
EOF
}
log() { echo "[INFO] $*"; }
warn() { echo "[WARN] $*" >&2; }
err() { echo "[ERROR] $*" >&2; }
while [[ $# -gt 0 ]]; do
case "$1" in
--pulse-server)
PULSE_SERVER="$2"
shift 2
;;
--skip-restart)
SKIP_RESTART=true
shift
;;
-h|--help)
print_usage
exit 0
;;
*)
err "Unknown option: $1"
print_usage
exit 1
;;
esac
done
if [[ $EUID -ne 0 ]]; then
err "This script must be run as root"
exit 1
fi
if [[ -z "$PULSE_SERVER" ]]; then
err "--pulse-server is required"
exit 1
fi
CONFIG_FILE="/etc/pulse-sensor-proxy/config.yaml"
TOKEN_FILE="/etc/pulse-sensor-proxy/.pulse-control-token"
if [[ ! -f "$CONFIG_FILE" ]]; then
err "Config file not found at $CONFIG_FILE"
exit 1
fi
SHORT_HOSTNAME=$(hostname -s 2>/dev/null || hostname | cut -d'.' -f1)
register_proxy() {
local mode="$1"
local payload="{\"hostname\":\"${SHORT_HOSTNAME}\",\"proxy_url\":\"\",\"mode\":\"${mode}\"}"
curl -fsSL -X POST \
-H "Content-Type: application/json" \
-d "$payload" \
"${PULSE_SERVER%/}/api/temperature-proxy/register"
}
log "Registering proxy with Pulse..."
REGISTRATION_RESPONSE=$(register_proxy "socket") || {
err "Registration failed"
exit 1
}
CONTROL_TOKEN=$(echo "$REGISTRATION_RESPONSE" | grep -o '"control_token":"[^"]*"' | head -1 | cut -d'"' -f4)
REFRESH_INTERVAL=$(echo "$REGISTRATION_RESPONSE" | grep -o '"refresh_interval":[0-9]*' | head -1 | awk -F: '{print $2}')
if [[ -z "$CONTROL_TOKEN" ]]; then
err "Pulse did not return a control token. Response: $REGISTRATION_RESPONSE"
exit 1
fi
if [[ -z "$REFRESH_INTERVAL" ]]; then
REFRESH_INTERVAL=60
fi
log "Writing control-plane token..."
mkdir -p "$(dirname "$TOKEN_FILE")"
echo "$CONTROL_TOKEN" > "$TOKEN_FILE"
chmod 600 "$TOKEN_FILE"
chown pulse-sensor-proxy:pulse-sensor-proxy "$TOKEN_FILE"
remove_control_block() {
python3 - "$CONFIG_FILE" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
if not path.exists():
sys.exit(0)
lines = path.read_text().splitlines(keepends=True)
result = []
i = 0
while i < len(lines):
line = lines[i]
if line.startswith("# Pulse control plane configuration"):
i += 1
while i < len(lines) and (lines[i].startswith(" ") or lines[i].startswith("\t") or lines[i].strip() == ""):
i += 1
continue
if line.startswith("pulse_control_plane:"):
i += 1
while i < len(lines) and (lines[i].startswith(" ") or lines[i].startswith("\t") or lines[i].strip() == ""):
i += 1
continue
result.append(line)
i += 1
path.write_text("".join(result))
PY
}
log "Updating config..."
remove_control_block
cat >> "$CONFIG_FILE" <<EOF
# Pulse control plane configuration (added by migrate-sensor-proxy-control-plane.sh)
pulse_control_plane:
url: "$PULSE_SERVER"
token_file: "$TOKEN_FILE"
refresh_interval: $REFRESH_INTERVAL
EOF
if [[ "$SKIP_RESTART" == false ]]; then
log "Restarting pulse-sensor-proxy..."
systemctl restart pulse-sensor-proxy
else
warn "Skipping service restart; control-plane sync will start on next restart"
fi
log "Migration complete."