Improve temperature proxy control-plane flow

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

View file

@ -39,11 +39,20 @@ type Config struct {
RateLimit *RateLimitConfig `yaml:"rate_limit,omitempty"` RateLimit *RateLimitConfig `yaml:"rate_limit,omitempty"`
// HTTP mode configuration // HTTP mode configuration
HTTPEnabled bool `yaml:"http_enabled"` // Enable HTTP server mode HTTPEnabled bool `yaml:"http_enabled"` // Enable HTTP server mode
HTTPListenAddr string `yaml:"http_listen_addr"` // Address to listen on (e.g., ":8443") HTTPListenAddr string `yaml:"http_listen_addr"` // Address to listen on (e.g., ":8443")
HTTPTLSCertFile string `yaml:"http_tls_cert"` // Path to TLS certificate HTTPTLSCertFile string `yaml:"http_tls_cert"` // Path to TLS certificate
HTTPTLSKeyFile string `yaml:"http_tls_key"` // Path to TLS private key HTTPTLSKeyFile string `yaml:"http_tls_key"` // Path to TLS private key
HTTPAuthToken string `yaml:"http_auth_token"` // Bearer token for authentication 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. // 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 return cfg, nil
} }
@ -453,3 +471,8 @@ func parseAllowedSubnets(cfg []string) ([]string, error) {
return normalized, nil return normalized, nil
} }
const (
defaultControlPlaneTokenPath = "/etc/pulse-sensor-proxy/.pulse-control-token"
defaultControlPlaneRefreshSecs = 60
)

View file

@ -4,11 +4,13 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"context" "context"
"crypto/tls"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net" "net"
"net/http"
"os" "os"
"os/signal" "os/signal"
"os/user" "os/user"
@ -249,21 +251,24 @@ func lookupUserFromPasswd(username string) (*userSpec, error) {
// Proxy manages the temperature monitoring proxy // Proxy manages the temperature monitoring proxy
type Proxy struct { type Proxy struct {
socketPath string socketPath string
sshKeyPath string sshKeyPath string
workDir string workDir string
knownHosts knownhosts.Manager knownHosts knownhosts.Manager
listener net.Listener listener net.Listener
rateLimiter *rateLimiter rateLimiter *rateLimiter
nodeGate *nodeGate nodeGate *nodeGate
router map[string]handlerFunc router map[string]handlerFunc
config *Config config *Config
metrics *ProxyMetrics metrics *ProxyMetrics
audit *auditLogger audit *auditLogger
nodeValidator *nodeValidator nodeValidator *nodeValidator
readTimeout time.Duration readTimeout time.Duration
writeTimeout time.Duration writeTimeout time.Duration
maxSSHOutputBytes int64 maxSSHOutputBytes int64
controlPlaneCfg *ControlPlaneConfig
controlPlaneToken string
controlPlaneCancel context.CancelFunc
allowedPeerUIDs map[uint32]struct{} allowedPeerUIDs map[uint32]struct{}
allowedPeerGIDs map[uint32]struct{} allowedPeerGIDs map[uint32]struct{}
@ -426,6 +431,7 @@ func runProxy() {
readTimeout: cfg.ReadTimeout, readTimeout: cfg.ReadTimeout,
writeTimeout: cfg.WriteTimeout, writeTimeout: cfg.WriteTimeout,
maxSSHOutputBytes: cfg.MaxSSHOutputBytes, maxSSHOutputBytes: cfg.MaxSSHOutputBytes,
controlPlaneCfg: cfg.PulseControlPlane,
} }
if wd, err := os.Getwd(); err == nil { if wd, err := os.Getwd(); err == nil {
@ -453,6 +459,7 @@ func runProxy() {
if err := proxy.Start(); err != nil { if err := proxy.Start(); err != nil {
log.Fatal().Err(err).Msg("Failed to start proxy") log.Fatal().Err(err).Msg("Failed to start proxy")
} }
proxy.startControlPlaneSync()
// Start HTTP server if enabled // Start HTTP server if enabled
var httpServer *HTTPServer var httpServer *HTTPServer
@ -537,12 +544,126 @@ func (p *Proxy) Start() error {
// Stop shuts down the proxy // Stop shuts down the proxy
func (p *Proxy) Stop() { func (p *Proxy) Stop() {
if p.controlPlaneCancel != nil {
p.controlPlaneCancel()
}
if p.listener != nil { if p.listener != nil {
p.listener.Close() p.listener.Close()
os.Remove(p.socketPath) 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 // acceptConnections handles incoming socket connections
func (p *Proxy) acceptConnections() { func (p *Proxy) acceptConnections() {
for { for {

View file

@ -221,6 +221,7 @@ const (
// nodeValidator enforces node allow-list and cluster membership checks // nodeValidator enforces node allow-list and cluster membership checks
type nodeValidator struct { type nodeValidator struct {
mu sync.RWMutex
allowHosts map[string]struct{} allowHosts map[string]struct{}
allowCIDRs []*net.IPNet allowCIDRs []*net.IPNet
hasAllowlist bool hasAllowlist bool
@ -286,23 +287,7 @@ func newNodeValidator(cfg *Config, metrics *ProxyMetrics) (*nodeValidator, error
clock: time.Now, clock: time.Now,
} }
for _, raw := range cfg.AllowedNodes { v.setAllowlist(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
if v.hasAllowlist { if v.hasAllowlist {
log.Info(). log.Info().
@ -331,17 +316,67 @@ func newNodeValidator(cfg *Config, metrics *ProxyMetrics) (*nodeValidator, error
return v, nil 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. // Validate ensures the provided node is authorized before any SSH is attempted.
func (v *nodeValidator) Validate(ctx context.Context, node string) error { func (v *nodeValidator) Validate(ctx context.Context, node string) error {
if v == nil { if v == nil {
return nil return nil
} }
v.mu.RLock()
hasAllowlist := v.hasAllowlist
clusterEnabled := v.clusterEnabled
v.mu.RUnlock()
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
if v.hasAllowlist { if hasAllowlist {
allowed, err := v.matchesAllowlist(ctx, node) allowed, err := v.matchesAllowlist(ctx, node)
if err != nil { if err != nil {
v.recordFailure(validationReasonResolutionFailed) v.recordFailure(validationReasonResolutionFailed)
@ -354,7 +389,7 @@ func (v *nodeValidator) Validate(ctx context.Context, node string) error {
return nil return nil
} }
if v.clusterEnabled { if clusterEnabled {
allowed, err := v.matchesCluster(ctx, node) allowed, err := v.matchesCluster(ctx, node)
if err != nil { if err != nil {
// Cluster query failed (e.g., IPC permission denied, running in LXC) // 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) { 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) normalized := normalizeAllowlistEntry(node)
if normalized != "" { if normalized != "" {
if _, ok := v.allowHosts[normalized]; ok { if _, ok := allowHosts[normalized]; ok {
return true, nil return true, nil
} }
} }
if ip := parseNodeIP(node); ip != nil { if ip := parseNodeIP(node); ip != nil {
if v.ipAllowed(ip) { if ipAllowed(ip, allowHosts, allowCIDRs) {
return true, nil return true, nil
} }
// If the node itself is an IP and it didn't match, no need to resolve again. // If the node itself is an IP and it didn't match, no need to resolve again.
return false, nil return false, nil
} }
if len(v.allowCIDRs) == 0 { if len(allowCIDRs) == 0 {
return false, nil return false, nil
} }
host := stripNodeDelimiters(node) host := stripNodeDelimiters(node)
ips, err := v.resolver.LookupIP(ctx, host) ips, err := resolver.LookupIP(ctx, host)
if err != nil { if err != nil {
return false, fmt.Errorf("resolve node %q: %w", host, err) return false, fmt.Errorf("resolve node %q: %w", host, err)
} }
for _, ip := range ips { for _, ip := range ips {
if v.ipAllowed(ip) { if ipAllowed(ip, allowHosts, allowCIDRs) {
return true, nil return true, nil
} }
} }
@ -565,6 +606,23 @@ func (v *nodeValidator) ipAllowed(ip net.IP) bool {
return false 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) { func (v *nodeValidator) recordFailure(reason string) {
if v.metrics != nil { if v.metrics != nil {
v.metrics.recordNodeValidationFailure(reason) 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. 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 ## 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. **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! **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 ### Getting Help
If temperature monitoring isn't working: 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 [lxcCtid, setLxcCtid] = createSignal<string>('');
const [dockerContainerName, setDockerContainerName] = createSignal<string>(''); const [dockerContainerName, setDockerContainerName] = createSignal<string>('');
const [showAlternatives, setShowAlternatives] = createSignal(false); const [showAlternatives, setShowAlternatives] = createSignal(false);
const [isValidatingToken, setIsValidatingToken] = createSignal(false);
const applyTheme = (mode: 'system' | 'light' | 'dark') => { const applyTheme = (mode: 'system' | 'light' | 'dark') => {
if (mode === 'light') { 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(''); return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
}; };
const handleUnlock = () => { const handleUnlock = async () => {
if (!bootstrapToken().trim()) { if (!bootstrapToken().trim()) {
showError('Please enter the bootstrap token'); showError('Please enter the bootstrap token');
return; 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 () => { const handleSetup = async () => {
@ -399,7 +426,7 @@ IMPORTANT: Keep these credentials secure!
type="text" type="text"
value={bootstrapToken()} value={bootstrapToken()}
onInput={(e) => setBootstrapToken(e.currentTarget.value)} 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" 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" placeholder="Paste the token from your host"
autofocus autofocus
@ -423,10 +450,10 @@ IMPORTANT: Keep these credentials secure!
<button <button
type="button" type="button"
onClick={handleUnlock} 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" 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> </button>
</div> </div>
</div> </div>

View file

@ -64,15 +64,84 @@ const resolveTemperatureTransport = (
globalEnabled: boolean, globalEnabled: boolean,
): TemperatureTransportBadge => { ): TemperatureTransportBadge => {
const monitoringEnabled = isTemperatureMonitoringEnabled(node, globalEnabled); const monitoringEnabled = isTemperatureMonitoringEnabled(node, globalEnabled);
const normalizedTransport = (node.temperatureTransport || '').toLowerCase();
if (!monitoringEnabled) { if (!monitoringEnabled) {
return { return {
label: 'Temp disabled', label: 'Temp disabled',
badgeClass: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300', 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 key = (node.name || '').toLowerCase();
const httpEntry = info?.httpMap?.[key]; 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) {
if (httpEntry.reachable) { if (httpEntry.reachable) {
return { return {
@ -89,18 +158,8 @@ const resolveTemperatureTransport = (
} }
if (info) { if (info) {
if (info.socketStatus === 'healthy') { if (info.socketStatus === 'healthy' || info.socketStatus === 'error' || info.socketStatus === 'missing') {
return { return buildSocketBadge();
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',
};
} }
} }

View file

@ -139,6 +139,14 @@ interface TemperatureProxyHTTPStatus {
error?: string; error?: string;
} }
interface TemperatureProxyControlPlaneState {
instance: string;
lastSync?: string;
refreshIntervalSeconds?: number;
secondsBehind?: number;
status?: string;
}
interface TemperatureProxyDiagnostic { interface TemperatureProxyDiagnostic {
legacySSHDetected: boolean; legacySSHDetected: boolean;
recommendProxyUpgrade: boolean; recommendProxyUpgrade: boolean;
@ -155,6 +163,8 @@ interface TemperatureProxyDiagnostic {
proxyCapabilities?: string[]; proxyCapabilities?: string[];
notes?: string[]; notes?: string[];
httpProxies?: TemperatureProxyHTTPStatus[]; httpProxies?: TemperatureProxyHTTPStatus[];
controlPlaneEnabled?: boolean;
controlPlaneStates?: TemperatureProxyControlPlaneState[];
} }
interface APITokenSummary { interface APITokenSummary {
@ -792,6 +802,33 @@ const Settings: Component<SettingsProps> = (props) => {
return formatRelativeTime(timestamp); 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) => { const formatUptime = (seconds: number) => {
if (!seconds || seconds <= 0) { if (!seconds || seconds <= 0) {
return 'Unknown'; return 'Unknown';
@ -814,15 +851,26 @@ const Settings: Component<SettingsProps> = (props) => {
}; };
const emitTemperatureProxyWarnings = (diag: DiagnosticsData | null) => { const emitTemperatureProxyWarnings = (diag: DiagnosticsData | null) => {
if (!diag?.temperatureProxy?.httpProxies) { if (!diag?.temperatureProxy) {
return; return;
} }
const failing = (diag.temperatureProxy.httpProxies as TemperatureProxyHTTPStatus[]).filter( if (diag.temperatureProxy.httpProxies) {
(proxy) => proxy && proxy.node && !proxy.reachable, 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(', '); if (failing.length > 0) {
showWarning(`Pulse cannot reach HTTPS temperature proxy on: ${nodes}`); 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'}> <Show when={typeof temp().legacySshKeyCount === 'number'}>
<div>Legacy SSH keys: {temp().legacySshKeyCount ?? 0}</div> <div>Legacy SSH keys: {temp().legacySshKeyCount ?? 0}</div>
</Show> </Show>
<Show when={temp().legacySSHDetected}> <Show when={temp().legacySSHDetected}>
<div class="text-red-500"> <div class="text-red-500">
Legacy SSH temperature collection detected 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> </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> </div>
<Show </Show>
when={ <Show
temp().httpProxies && (temp().httpProxies as TemperatureProxyHTTPStatus[]).length > 0 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="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"> <div class="font-semibold text-gray-700 dark:text-gray-200">
HTTPS proxies HTTPS proxies

View file

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

View file

@ -185,6 +185,11 @@ safe_systemctl() {
# Detect existing service name (pulse or pulse-backend) # Detect existing service name (pulse or pulse-backend)
detect_service_name() { 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 if systemctl list-unit-files --no-legend | grep -q "^pulse-backend.service"; then
echo "pulse-backend" echo "pulse-backend"
elif systemctl list-unit-files --no-legend | grep -q "^pulse.service"; then elif systemctl list-unit-files --no-legend | grep -q "^pulse.service"; then
@ -1565,7 +1570,7 @@ fi'; then
fi fi
# If building from source, copy the binary from the LXC instead of downloading # 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="" local local_proxy_binary=""
if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
local_proxy_binary="/tmp/pulse-sensor-proxy-$CTID" local_proxy_binary="/tmp/pulse-sensor-proxy-$CTID"
@ -1770,20 +1775,29 @@ compare_versions() {
check_existing_installation() { check_existing_installation() {
CURRENT_VERSION="" # Make it global so we can use it later CURRENT_VERSION="" # Make it global so we can use it later
local BINARY_PATH="" local BINARY_PATH=""
local detected_service="$SERVICE_NAME"
local service_available=false
# Check for the binary in expected locations # Check for the binary in expected locations
if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then
BINARY_PATH="$INSTALL_DIR/bin/pulse" BINARY_PATH="$INSTALL_DIR/bin/pulse"
elif [[ -f "$INSTALL_DIR/pulse" ]]; then elif [[ -f "$INSTALL_DIR/pulse" ]]; then
BINARY_PATH="$INSTALL_DIR/pulse" BINARY_PATH="$INSTALL_DIR/pulse"
fi 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 # Try to get version if binary exists
if [[ -n "$BINARY_PATH" ]]; then 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") 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 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 if [[ -n "$CURRENT_VERSION" && "$CURRENT_VERSION" != "unknown" ]]; then
print_info "Pulse $CURRENT_VERSION is currently running" print_info "Pulse $CURRENT_VERSION is currently running"
else else

View file

@ -3,8 +3,10 @@ package api
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -136,3 +138,43 @@ func (r *Router) clearBootstrapToken() {
r.bootstrapTokenHash = "" r.bootstrapTokenHash = ""
r.bootstrapTokenPath = "" 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}$`) 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) { func sanitizeInstallerURL(raw string) (string, error) {
trimmed := strings.TrimSpace(raw) trimmed := strings.TrimSpace(raw)
if trimmed == "" { if trimmed == "" {
@ -409,6 +417,7 @@ type NodeResponse struct {
MonitorBackups bool `json:"monitorBackups,omitempty"` MonitorBackups bool `json:"monitorBackups,omitempty"`
MonitorPhysicalDisks *bool `json:"monitorPhysicalDisks,omitempty"` MonitorPhysicalDisks *bool `json:"monitorPhysicalDisks,omitempty"`
TemperatureMonitoringEnabled *bool `json:"temperatureMonitoringEnabled,omitempty"` TemperatureMonitoringEnabled *bool `json:"temperatureMonitoringEnabled,omitempty"`
TemperatureTransport string `json:"temperatureTransport,omitempty"`
MonitorDatastores bool `json:"monitorDatastores,omitempty"` MonitorDatastores bool `json:"monitorDatastores,omitempty"`
MonitorSyncJobs bool `json:"monitorSyncJobs,omitempty"` MonitorSyncJobs bool `json:"monitorSyncJobs,omitempty"`
MonitorVerifyJobs bool `json:"monitorVerifyJobs,omitempty"` MonitorVerifyJobs bool `json:"monitorVerifyJobs,omitempty"`
@ -424,6 +433,44 @@ type NodeResponse struct {
ClusterEndpoints []config.ClusterEndpoint `json:"clusterEndpoints,omitempty"` 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. // deriveSchemeAndPort infers the scheme (without ://) and port from a base host URL.
// Defaults align with Proxmox expectations when details are omitted. // Defaults align with Proxmox expectations when details are omitted.
func deriveSchemeAndPort(baseHost string) (scheme string, port string) { 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 // GetAllNodesForAPI returns all configured nodes for API responses
func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse { func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse {
nodes := []NodeResponse{} nodes := []NodeResponse{}
socketAvailable := h.monitor != nil && h.monitor.HasSocketTemperatureProxy()
containerSSHBlocked := isContainerSSHRestricted()
// Add PVE nodes // Add PVE nodes
for i, pve := range h.config.PVEInstances { for i, pve := range h.config.PVEInstances {
@ -766,6 +815,7 @@ func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse {
MonitorBackups: pve.MonitorBackups, MonitorBackups: pve.MonitorBackups,
MonitorPhysicalDisks: pve.MonitorPhysicalDisks, MonitorPhysicalDisks: pve.MonitorPhysicalDisks,
TemperatureMonitoringEnabled: pve.TemperatureMonitoringEnabled, TemperatureMonitoringEnabled: pve.TemperatureMonitoringEnabled,
TemperatureTransport: h.resolveTemperatureTransport(pve.TemperatureMonitoringEnabled, pve.TemperatureProxyURL, pve.TemperatureProxyToken, socketAvailable, containerSSHBlocked),
Status: h.getNodeStatus("pve", pve.Name), Status: h.getNodeStatus("pve", pve.Name),
IsCluster: pve.IsCluster, IsCluster: pve.IsCluster,
ClusterName: pve.ClusterName, ClusterName: pve.ClusterName,
@ -788,6 +838,7 @@ func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse {
Fingerprint: pbs.Fingerprint, Fingerprint: pbs.Fingerprint,
VerifySSL: pbs.VerifySSL, VerifySSL: pbs.VerifySSL,
TemperatureMonitoringEnabled: pbs.TemperatureMonitoringEnabled, TemperatureMonitoringEnabled: pbs.TemperatureMonitoringEnabled,
TemperatureTransport: h.resolveTemperatureTransport(pbs.TemperatureMonitoringEnabled, "", "", socketAvailable, containerSSHBlocked),
MonitorDatastores: pbs.MonitorDatastores, MonitorDatastores: pbs.MonitorDatastores,
MonitorSyncJobs: pbs.MonitorSyncJobs, MonitorSyncJobs: pbs.MonitorSyncJobs,
MonitorVerifyJobs: pbs.MonitorVerifyJobs, MonitorVerifyJobs: pbs.MonitorVerifyJobs,
@ -822,6 +873,7 @@ func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse {
MonitorQuarantine: pmgInst.MonitorQuarantine, MonitorQuarantine: pmgInst.MonitorQuarantine,
MonitorDomainStats: pmgInst.MonitorDomainStats, MonitorDomainStats: pmgInst.MonitorDomainStats,
Status: h.getNodeStatus("pmg", pmgInst.Name), Status: h.getNodeStatus("pmg", pmgInst.Name),
TemperatureTransport: h.resolveTemperatureTransport(pmgInst.TemperatureMonitoringEnabled, "", "", socketAvailable, containerSSHBlocked),
} }
nodes = append(nodes, node) nodes = append(nodes, node)
} }
@ -884,6 +936,7 @@ func (h *ConfigHandlers) HandleGetNodes(w http.ResponseWriter, r *http.Request)
IsCluster: true, IsCluster: true,
ClusterName: "mock-cluster", ClusterName: "mock-cluster",
ClusterEndpoints: clusterEndpoints, // All cluster nodes ClusterEndpoints: clusterEndpoints, // All cluster nodes
TemperatureTransport: temperatureTransportSocketProxy,
} }
mockNodes = append(mockNodes, clusterNode) 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 IsCluster: false, // Not part of a cluster
ClusterName: "", ClusterName: "",
ClusterEndpoints: []config.ClusterEndpoint{}, ClusterEndpoints: []config.ClusterEndpoint{},
TemperatureTransport: temperatureTransportSocketProxy,
} }
mockNodes = append(mockNodes, standaloneNode) mockNodes = append(mockNodes, standaloneNode)
} }
@ -917,22 +971,23 @@ func (h *ConfigHandlers) HandleGetNodes(w http.ResponseWriter, r *http.Request)
// Add mock PBS instances // Add mock PBS instances
for i, pbs := range state.PBSInstances { for i, pbs := range state.PBSInstances {
pbsNode := NodeResponse{ pbsNode := NodeResponse{
ID: generateNodeID("pbs", i), ID: generateNodeID("pbs", i),
Type: "pbs", Type: "pbs",
Name: pbs.Name, Name: pbs.Name,
Host: pbs.Host, Host: pbs.Host,
User: "pulse@pbs", User: "pulse@pbs",
HasPassword: false, HasPassword: false,
TokenName: "pulse", TokenName: "pulse",
HasToken: true, HasToken: true,
Fingerprint: "", Fingerprint: "",
VerifySSL: false, VerifySSL: false,
MonitorDatastores: true, MonitorDatastores: true,
MonitorSyncJobs: true, MonitorSyncJobs: true,
MonitorVerifyJobs: true, MonitorVerifyJobs: true,
MonitorPruneJobs: true, MonitorPruneJobs: true,
MonitorGarbageJobs: true, MonitorGarbageJobs: true,
Status: "connected", // Always connected in mock mode Status: "connected", // Always connected in mock mode
TemperatureTransport: temperatureTransportSocketProxy,
} }
mockNodes = append(mockNodes, pbsNode) mockNodes = append(mockNodes, pbsNode)
} }
@ -940,17 +995,18 @@ func (h *ConfigHandlers) HandleGetNodes(w http.ResponseWriter, r *http.Request)
// Add mock PMG instances // Add mock PMG instances
for i, pmg := range state.PMGInstances { for i, pmg := range state.PMGInstances {
pmgNode := NodeResponse{ pmgNode := NodeResponse{
ID: generateNodeID("pmg", i), ID: generateNodeID("pmg", i),
Type: "pmg", Type: "pmg",
Name: pmg.Name, Name: pmg.Name,
Host: pmg.Host, Host: pmg.Host,
User: "root@pam", User: "root@pam",
HasPassword: true, HasPassword: true,
TokenName: "pulse", TokenName: "pulse",
HasToken: true, HasToken: true,
Fingerprint: "", Fingerprint: "",
VerifySSL: false, VerifySSL: false,
Status: "connected", // Always connected in mock mode Status: "connected", // Always connected in mock mode
TemperatureTransport: temperatureTransportSocketProxy,
} }
mockNodes = append(mockNodes, pmgNode) 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 // TemperatureProxyDiagnostic summarizes proxy detection state
type TemperatureProxyDiagnostic struct { type TemperatureProxyDiagnostic struct {
SocketFound bool `json:"socketFound"` SocketFound bool `json:"socketFound"`
SocketPath string `json:"socketPath,omitempty"` SocketPath string `json:"socketPath,omitempty"`
SocketPermissions string `json:"socketPermissions,omitempty"` SocketPermissions string `json:"socketPermissions,omitempty"`
SocketOwner string `json:"socketOwner,omitempty"` SocketOwner string `json:"socketOwner,omitempty"`
SocketGroup string `json:"socketGroup,omitempty"` SocketGroup string `json:"socketGroup,omitempty"`
ProxyReachable bool `json:"proxyReachable"` ProxyReachable bool `json:"proxyReachable"`
ProxyVersion string `json:"proxyVersion,omitempty"` ProxyVersion string `json:"proxyVersion,omitempty"`
ProxyPublicKeySHA256 string `json:"proxyPublicKeySha256,omitempty"` ProxyPublicKeySHA256 string `json:"proxyPublicKeySha256,omitempty"`
ProxySSHDirectory string `json:"proxySshDirectory,omitempty"` ProxySSHDirectory string `json:"proxySshDirectory,omitempty"`
LegacySSHKeyCount int `json:"legacySshKeyCount,omitempty"` LegacySSHKeyCount int `json:"legacySshKeyCount,omitempty"`
ProxyCapabilities []string `json:"proxyCapabilities,omitempty"` ProxyCapabilities []string `json:"proxyCapabilities,omitempty"`
Notes []string `json:"notes,omitempty"` Notes []string `json:"notes,omitempty"`
HTTPProxies []TemperatureProxyHTTPStatus `json:"httpProxies,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 { type TemperatureProxyHTTPStatus struct {
@ -403,7 +413,12 @@ func (r *Router) computeDiagnostics(ctx context.Context) DiagnosticsInfo {
MemoryMB: memStats.Alloc / 1024 / 1024, 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) diag.APITokens = buildAPITokenDiagnostic(r.config, r.monitor)
// Test each configured node // Test each configured node
@ -659,7 +674,7 @@ func buildDiscoveryDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *
return discovery return discovery
} }
func buildTemperatureProxyDiagnostic(cfg *config.Config) *TemperatureProxyDiagnostic { func buildTemperatureProxyDiagnostic(cfg *config.Config, syncStates map[string]proxySyncState) *TemperatureProxyDiagnostic {
diag := &TemperatureProxyDiagnostic{} diag := &TemperatureProxyDiagnostic{}
appendNote := func(note string) { appendNote := func(note string) {
@ -781,9 +796,75 @@ func buildTemperatureProxyDiagnostic(cfg *config.Config) *TemperatureProxyDiagno
status.Reachable = true status.Reachable = true
} }
} }
diag.HTTPProxies = append(diag.HTTPProxies, status) 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 return diag

View file

@ -107,6 +107,8 @@ func GetRateLimiterForEndpoint(path string, method string) *RateLimiter {
if strings.Contains(path, "/api/health") || if strings.Contains(path, "/api/health") ||
strings.Contains(path, "/api/version") || strings.Contains(path, "/api/version") ||
strings.Contains(path, "/api/security/status") || 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") { strings.Contains(path, "/metrics") {
return globalRateLimitConfig.PublicEndpoints 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/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/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/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/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/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))) 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/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/pulse-sensor-proxy", r.handleDownloadPulseSensorProxy)
r.mux.HandleFunc("/api/install/install-sensor-proxy.sh", r.handleDownloadInstallerScript) 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/install/install-docker.sh", r.handleDownloadDockerInstallerScript)
r.mux.HandleFunc("/api/config", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleConfig))) 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))) 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) 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) // Test node configuration endpoint (for new nodes)
r.mux.HandleFunc("/api/config/nodes/test-config", func(w http.ResponseWriter, req *http.Request) { 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 != "" { if r.config.AllowedOrigins != "" {
w.Header().Set("Access-Control-Allow-Origin", 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-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") 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{ publicPaths := []string{
"/api/health", "/api/health",
"/api/security/status", "/api/security/status",
"/api/security/validate-bootstrap-token",
"/api/version", "/api/version",
"/api/login", // Add login endpoint as public "/api/login", // Add login endpoint as public
"/api/oidc/login", "/api/oidc/login",
config.DefaultOIDCCallbackPath, config.DefaultOIDCCallbackPath,
"/install-docker-agent.sh", // Docker agent bootstrap script must be public "/install-docker-agent.sh", // Docker agent bootstrap script must be public
"/install-container-agent.sh", // Container 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 "/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.sh", // Host agent bootstrap script must be public
"/install-host-agent.ps1", // Host agent PowerShell 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.sh", // Host agent uninstall script must be public
"/uninstall-host-agent.ps1", // 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 "/download/pulse-host-agent", // Host agent binary download should not require auth
"/api/agent/version", // Agent update checks need to work before auth "/api/agent/version", // Agent update checks need to work before auth
"/api/server/info", // Server info for installer script "/api/server/info", // Server info for installer script
"/api/install/install-sensor-proxy.sh", // Temperature proxy installer fallback "/api/install/install-sensor-proxy.sh", // Temperature proxy installer fallback
"/api/install/pulse-sensor-proxy", // Temperature proxy binary fallback "/api/install/pulse-sensor-proxy", // Temperature proxy binary fallback
"/api/install/install-docker.sh", // Docker turnkey installer "/api/install/migrate-sensor-proxy-control-plane.sh", // Proxy migration helper
"/api/system/proxy-public-key", // Temperature proxy public key for setup script "/api/install/install-docker.sh", // Docker turnkey installer
"/api/temperature-proxy/register", // Temperature proxy registration (called by 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) // 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 { func (r *Router) resolvePublicURL(req *http.Request) string {
if publicURL := strings.TrimSpace(r.config.PublicURL); publicURL != "" { if publicURL := strings.TrimSpace(r.config.PublicURL); publicURL != "" {
return strings.TrimRight(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) { func TestQuickSecuritySetupAllowsRecoveryTokenRotation(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "") t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
resetTrustedProxyConfig() resetTrustedProxyConfig()

View file

@ -2,11 +2,18 @@ package api
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"sort"
"strconv"
"strings" "strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/utils" "github.com/rcourtman/pulse-go-rewrite/internal/utils"
@ -16,11 +23,136 @@ import (
// TemperatureProxyHandlers manages temperature proxy registration // TemperatureProxyHandlers manages temperature proxy registration
type TemperatureProxyHandlers struct { type TemperatureProxyHandlers struct {
persistence *config.ConfigPersistence 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 // NewTemperatureProxyHandlers constructs a new handler set for temperature proxy
func NewTemperatureProxyHandlers(persistence *config.ConfigPersistence) *TemperatureProxyHandlers { 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 // HandleRegister handles temperature proxy registration from the installer
@ -39,6 +171,7 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http
var req struct { var req struct {
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
ProxyURL string `json:"proxy_url"` ProxyURL string `json:"proxy_url"`
Mode string `json:"mode"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@ -60,11 +193,23 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http
return return
} }
// Validate proxy URL format mode := strings.ToLower(strings.TrimSpace(req.Mode))
if !strings.HasPrefix(proxyURL, "https://") { 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) writeErrorResponse(w, http.StatusBadRequest, "invalid_proxy_url", "Proxy URL must use HTTPS", nil)
return return
} }
if !isHTTPMode {
proxyURL = ""
}
// Load current config // Load current config
nodesConfig, err := h.persistence.LoadNodesConfig() nodesConfig, err := h.persistence.LoadNodesConfig()
@ -111,8 +256,17 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http
return return
} }
// Generate a secure random token // Generate tokens
token, err := generateSecureToken(32) 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 { if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate authentication token", map[string]string{"error": err.Error()}) writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate authentication token", map[string]string{"error": err.Error()})
return return
@ -120,7 +274,10 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http
// Update the instance with proxy configuration // Update the instance with proxy configuration
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyURL = proxyURL 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 // Save updated configuration
if err := h.persistence.SaveNodesConfig(nodesConfig.PVEInstances, nodesConfig.PBSInstances, nodesConfig.PMGInstances); err != nil { 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(). log.Info().
Str("hostname", hostname). Str("hostname", hostname).
Str("proxy_url", proxyURL). Str("proxy_url", proxyURL).
Str("mode", mode).
Str("pve_instance", matchedInstance.Name). Str("pve_instance", matchedInstance.Name).
Msg("Temperature proxy registered successfully") Msg("Temperature proxy registered successfully")
allowed := buildAuthorizedNodeList(matchedInstance)
resp := map[string]any{ resp := map[string]any{
"success": true, "success": true,
"token": token, "token": authToken,
"pve_instance": matchedInstance.Name, "control_token": ctrlToken,
"message": fmt.Sprintf("Temperature proxy registered for instance '%s'", matchedInstance.Name), "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 { 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 // HandleUnregister removes temperature proxy configuration from a PVE instance
// //
// DELETE /api/temperature-proxy/unregister?hostname=pve1 // DELETE /api/temperature-proxy/unregister?hostname=pve1
@ -200,6 +453,7 @@ func (h *TemperatureProxyHandlers) HandleUnregister(w http.ResponseWriter, r *ht
// Clear proxy configuration // Clear proxy configuration
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyURL = "" nodesConfig.PVEInstances[matchedIndex].TemperatureProxyURL = ""
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyToken = "" nodesConfig.PVEInstances[matchedIndex].TemperatureProxyToken = ""
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyControlToken = ""
// Save updated configuration // Save updated configuration
if err := h.persistence.SaveNodesConfig(nodesConfig.PVEInstances, nodesConfig.PBSInstances, nodesConfig.PMGInstances); err != nil { 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 // PVEInstance represents a Proxmox VE connection
type PVEInstance struct { type PVEInstance struct {
Name string Name string
Host string // Primary endpoint (user-provided) Host string // Primary endpoint (user-provided)
GuestURL string // Optional guest-accessible URL (for navigation) GuestURL string // Optional guest-accessible URL (for navigation)
User string User string
Password string Password string
TokenName string TokenName string
@ -409,6 +409,8 @@ type PVEInstance struct {
// Temperature proxy configuration (for external PVE hosts) // Temperature proxy configuration (for external PVE hosts)
TemperatureProxyURL string // Optional HTTPS URL to pulse-sensor-proxy (e.g., https://pve1.lan:8443) TemperatureProxyURL string // Optional HTTPS URL to pulse-sensor-proxy (e.g., https://pve1.lan:8443)
TemperatureProxyToken string // Bearer token for proxy authentication TemperatureProxyToken string // Bearer token for proxy authentication
// Control-plane token for socket-mode proxies (Pulse -> proxy sync)
TemperatureProxyControlToken string
// Cluster support // Cluster support
IsCluster bool // True if this is a cluster IsCluster bool // True if this is a cluster
@ -418,16 +420,16 @@ type PVEInstance struct {
// ClusterEndpoint represents a single node in a cluster // ClusterEndpoint represents a single node in a cluster
type ClusterEndpoint struct { type ClusterEndpoint struct {
NodeID string // Node ID in cluster NodeID string // Node ID in cluster
NodeName string // Node name NodeName string // Node name
Host string // Full URL (e.g., https://node1.lan:8006) Host string // Full URL (e.g., https://node1.lan:8006)
GuestURL string // Optional guest-accessible URL (for navigation) GuestURL string // Optional guest-accessible URL (for navigation)
IP string // IP address IP string // IP address
Online bool // Current online status from Proxmox Online bool // Current online status from Proxmox
LastSeen time.Time // Last successful connection LastSeen time.Time // Last successful connection
PulseReachable *bool // Pulse's view: can Pulse reach this endpoint? nil = not yet checked PulseReachable *bool // Pulse's view: can Pulse reach this endpoint? nil = not yet checked
LastPulseCheck *time.Time // Last time Pulse checked connectivity LastPulseCheck *time.Time // Last time Pulse checked connectivity
PulseError string // Last error Pulse encountered connecting to this endpoint PulseError string // Last error Pulse encountered connecting to this endpoint
} }
// PBSInstance represents a Proxmox Backup Server connection // PBSInstance represents a Proxmox Backup Server connection
@ -627,21 +629,21 @@ func Load() (*Config, error) {
// Always load DiscoveryEnabled even if false // Always load DiscoveryEnabled even if false
cfg.DiscoveryEnabled = systemSettings.DiscoveryEnabled cfg.DiscoveryEnabled = systemSettings.DiscoveryEnabled
if systemSettings.DiscoverySubnet != "" { if systemSettings.DiscoverySubnet != "" {
cfg.DiscoverySubnet = systemSettings.DiscoverySubnet cfg.DiscoverySubnet = systemSettings.DiscoverySubnet
} }
cfg.Discovery = NormalizeDiscoveryConfig(CloneDiscoveryConfig(systemSettings.DiscoveryConfig)) cfg.Discovery = NormalizeDiscoveryConfig(CloneDiscoveryConfig(systemSettings.DiscoveryConfig))
cfg.TemperatureMonitoringEnabled = systemSettings.TemperatureMonitoringEnabled cfg.TemperatureMonitoringEnabled = systemSettings.TemperatureMonitoringEnabled
// Load DNS cache timeout // Load DNS cache timeout
if systemSettings.DNSCacheTimeout > 0 { if systemSettings.DNSCacheTimeout > 0 {
cfg.DNSCacheTimeout = time.Duration(systemSettings.DNSCacheTimeout) * time.Second cfg.DNSCacheTimeout = time.Duration(systemSettings.DNSCacheTimeout) * time.Second
} }
// Load SSH port // Load SSH port
if systemSettings.SSHPort > 0 { if systemSettings.SSHPort > 0 {
cfg.SSHPort = systemSettings.SSHPort cfg.SSHPort = systemSettings.SSHPort
} else { } else {
cfg.SSHPort = 22 // Default SSH port cfg.SSHPort = 22 // Default SSH port
} }
// APIToken no longer loaded from system.json - only from .env // APIToken no longer loaded from system.json - only from .env
log.Info(). log.Info().
Str("updateChannel", cfg.UpdateChannel). Str("updateChannel", cfg.UpdateChannel).
Str("logLevel", cfg.LogLevel). Str("logLevel", cfg.LogLevel).

View file

@ -3457,6 +3457,21 @@ func (m *Monitor) GetConnectionStatuses() map[string]bool {
return statuses 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 // checkContainerizedTempMonitoring logs a security warning if Pulse is running
// in a container with SSH-based temperature monitoring enabled // in a container with SSH-based temperature monitoring enabled
func checkContainerizedTempMonitoring() { 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, "nct6796") || // Nuvoton NCT6796 SuperIO
strings.Contains(chipLower, "nct6797") || // Nuvoton NCT6797 SuperIO strings.Contains(chipLower, "nct6797") || // Nuvoton NCT6797 SuperIO
strings.Contains(chipLower, "nct6798") || // Nuvoton NCT6798 SuperIO strings.Contains(chipLower, "nct6798") || // Nuvoton NCT6798 SuperIO
strings.Contains(chipLower, "w83627") || // Winbond W83627 SuperIO series strings.Contains(chipLower, "w83627") || // Winbond W83627 SuperIO series
strings.Contains(chipLower, "f71882") || // Fintek F71882 SuperIO strings.Contains(chipLower, "f71882") || // Fintek F71882 SuperIO
strings.Contains(chipLower, "cpu_thermal") || // Raspberry Pi CPU temperature strings.Contains(chipLower, "cpu_thermal") || // Raspberry Pi CPU temperature
strings.Contains(chipLower, "rpitemp") { strings.Contains(chipLower, "rpitemp") {
foundCPUChip = true foundCPUChip = true
@ -817,6 +817,11 @@ func (tc *TemperatureCollector) isProxyEnabled() bool {
return useProxy 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() { func (tc *TemperatureCollector) handleProxySuccess() {
if tc.proxyClient == nil { if tc.proxyClient == nil {
return return

View file

@ -90,15 +90,35 @@ update_allowed_nodes() {
mkdir -p "$(dirname "$config_file")" mkdir -p "$(dirname "$config_file")"
touch "$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 # 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 ""
echo "# ${comment_line}" echo "# ${comment_line}"
echo "# These nodes are allowed to request temperature data when cluster IPC validation is unavailable" echo "# These nodes are allowed to request temperature data when cluster IPC validation is unavailable"
echo "allowed_nodes:" echo "allowed_nodes:"
for node in "${nodes[@]}"; do for node in "${merged_nodes[@]}"; do
echo " - $node" echo " - $node"
done done
} >> "$config_file" } >> "$config_file"
@ -468,6 +488,9 @@ FALLBACK_BASE="${PULSE_SENSOR_PROXY_FALLBACK_URL:-}"
SKIP_RESTART=false SKIP_RESTART=false
UNINSTALL=false UNINSTALL=false
PURGE=false PURGE=false
CONTROL_PLANE_TOKEN=""
CONTROL_PLANE_REFRESH=""
SHORT_HOSTNAME=$(hostname -s 2>/dev/null || hostname | cut -d'.' -f1)
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
@ -981,33 +1004,45 @@ register_with_pulse() {
fi fi
done 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 # 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 # Return full response for caller parsing
local instance_name echo "$response"
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 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 fi
# Return token for caller to use (stdout only) cat >> /etc/pulse-sensor-proxy/config.yaml << EOF
echo "$token"
# 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 # Create base config file if it doesn't exist
@ -1032,6 +1067,25 @@ EOF
chmod 0644 /etc/pulse-sensor-proxy/config.yaml chmod 0644 /etc/pulse-sensor-proxy/config.yaml
fi 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 # HTTP Mode Configuration
if [[ "$HTTP_MODE" == true ]]; then if [[ "$HTTP_MODE" == true ]]; then
echo "" echo ""
@ -1114,16 +1168,31 @@ if [[ "$HTTP_MODE" == true ]]; then
PROXY_URL="https://${PRIMARY_IP}${HTTP_ADDR}" PROXY_URL="https://${PRIMARY_IP}${HTTP_ADDR}"
print_info "Proxy will be accessible at: $PROXY_URL" print_info "Proxy will be accessible at: $PROXY_URL"
# Register with Pulse and get auth token # Register with Pulse and get auth/control tokens
# Use short hostname for registration matching (PVE cluster endpoints use short names) registration_response=$(register_with_pulse "$PULSE_SERVER" "$SHORT_HOSTNAME" "$PROXY_URL" "http")
SHORT_HOSTNAME=$(hostname -s 2>/dev/null || hostname | cut -d'.' -f1) if [[ $? -ne 0 || -z "$registration_response" ]]; then
HTTP_AUTH_TOKEN=$(register_with_pulse "$PULSE_SERVER" "$SHORT_HOSTNAME" "$PROXY_URL")
if [[ $? -ne 0 || -z "$HTTP_AUTH_TOKEN" ]]; then
print_error "Failed to register with Pulse - aborting installation" print_error "Failed to register with Pulse - aborting installation"
print_error "Fix the issue and re-run the installer" print_error "Fix the issue and re-run the installer"
exit 1 exit 1
fi 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 # Backup config before modifying
if [[ -f /etc/pulse-sensor-proxy/config.yaml ]]; then if [[ -f /etc/pulse-sensor-proxy/config.yaml ]]; then
BACKUP_CONFIG="/etc/pulse-sensor-proxy/config.yaml.backup.$(date +%s)" BACKUP_CONFIG="/etc/pulse-sensor-proxy/config.yaml.backup.$(date +%s)"
@ -1176,6 +1245,11 @@ EOF
echo "" echo ""
fi 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) # Stop existing service if running (for upgrades)
if systemctl is-active --quiet pulse-sensor-proxy 2>/dev/null; then if systemctl is-active --quiet pulse-sensor-proxy 2>/dev/null; then
print_info "Stopping existing service for upgrade..." 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."