From 47d5c14aeff70f2c391e5f0daebed1da37280076 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 15 Nov 2025 21:49:51 +0000 Subject: [PATCH] Improve temperature proxy control-plane flow --- cmd/pulse-sensor-proxy/config.go | 33 ++- cmd/pulse-sensor-proxy/main.go | 151 +++++++++- cmd/pulse-sensor-proxy/validation.go | 106 +++++-- docs/TEMPERATURE_MONITORING.md | 42 +++ docs/temperature-proxy-control-plane.md | 120 ++++++++ .../src/components/FirstRunSetup.tsx | 39 ++- .../Settings/ConfiguredNodeTables.tsx | 83 +++++- .../src/components/Settings/Settings.tsx | 134 ++++++++- frontend-modern/src/types/nodes.ts | 8 + install.sh | 22 +- internal/api/bootstrap_token.go | 42 +++ internal/api/config_handlers.go | 110 +++++-- .../api/config_handlers_temperature_test.go | 58 ++++ internal/api/diagnostics.go | 113 +++++++- internal/api/rate_limit_config.go | 2 + internal/api/router.go | 63 +++- internal/api/security_setup_fix_test.go | 72 +++++ internal/api/temperature_proxy.go | 274 +++++++++++++++++- internal/config/config.go | 56 ++-- internal/monitoring/monitor.go | 15 + internal/monitoring/temperature.go | 9 +- scripts/install-sensor-proxy.sh | 134 +++++++-- scripts/migrate-sensor-proxy-control-plane.sh | 141 +++++++++ 23 files changed, 1617 insertions(+), 210 deletions(-) create mode 100644 docs/temperature-proxy-control-plane.md create mode 100644 internal/api/config_handlers_temperature_test.go create mode 100644 scripts/migrate-sensor-proxy-control-plane.sh diff --git a/cmd/pulse-sensor-proxy/config.go b/cmd/pulse-sensor-proxy/config.go index 54eeb9207..874461a34 100644 --- a/cmd/pulse-sensor-proxy/config.go +++ b/cmd/pulse-sensor-proxy/config.go @@ -39,11 +39,20 @@ type Config struct { RateLimit *RateLimitConfig `yaml:"rate_limit,omitempty"` // HTTP mode configuration - HTTPEnabled bool `yaml:"http_enabled"` // Enable HTTP server mode - HTTPListenAddr string `yaml:"http_listen_addr"` // Address to listen on (e.g., ":8443") - HTTPTLSCertFile string `yaml:"http_tls_cert"` // Path to TLS certificate - HTTPTLSKeyFile string `yaml:"http_tls_key"` // Path to TLS private key - HTTPAuthToken string `yaml:"http_auth_token"` // Bearer token for authentication + HTTPEnabled bool `yaml:"http_enabled"` // Enable HTTP server mode + HTTPListenAddr string `yaml:"http_listen_addr"` // Address to listen on (e.g., ":8443") + HTTPTLSCertFile string `yaml:"http_tls_cert"` // Path to TLS certificate + HTTPTLSKeyFile string `yaml:"http_tls_key"` // Path to TLS private key + HTTPAuthToken string `yaml:"http_auth_token"` // Bearer token for authentication + + PulseControlPlane *ControlPlaneConfig `yaml:"pulse_control_plane"` +} + +type ControlPlaneConfig struct { + URL string `yaml:"url"` + TokenFile string `yaml:"token_file"` + RefreshIntervalSec int `yaml:"refresh_interval"` // seconds + InsecureSkipVerify bool `yaml:"insecure_skip_verify"` } // PeerConfig represents a peer entry with capabilities. @@ -323,6 +332,15 @@ func loadConfig(configPath string) (*Config, error) { } } + if cfg.PulseControlPlane != nil { + if cfg.PulseControlPlane.TokenFile == "" { + cfg.PulseControlPlane.TokenFile = defaultControlPlaneTokenPath + } + if cfg.PulseControlPlane.RefreshIntervalSec <= 0 { + cfg.PulseControlPlane.RefreshIntervalSec = defaultControlPlaneRefreshSecs + } + } + return cfg, nil } @@ -453,3 +471,8 @@ func parseAllowedSubnets(cfg []string) ([]string, error) { return normalized, nil } + +const ( + defaultControlPlaneTokenPath = "/etc/pulse-sensor-proxy/.pulse-control-token" + defaultControlPlaneRefreshSecs = 60 +) diff --git a/cmd/pulse-sensor-proxy/main.go b/cmd/pulse-sensor-proxy/main.go index e011e1d0d..5408ddfc6 100644 --- a/cmd/pulse-sensor-proxy/main.go +++ b/cmd/pulse-sensor-proxy/main.go @@ -4,11 +4,13 @@ import ( "bufio" "bytes" "context" + "crypto/tls" "encoding/json" "errors" "fmt" "io" "net" + "net/http" "os" "os/signal" "os/user" @@ -249,21 +251,24 @@ func lookupUserFromPasswd(username string) (*userSpec, error) { // Proxy manages the temperature monitoring proxy type Proxy struct { - socketPath string - sshKeyPath string - workDir string - knownHosts knownhosts.Manager - listener net.Listener - rateLimiter *rateLimiter - nodeGate *nodeGate - router map[string]handlerFunc - config *Config - metrics *ProxyMetrics - audit *auditLogger - nodeValidator *nodeValidator - readTimeout time.Duration - writeTimeout time.Duration - maxSSHOutputBytes int64 + socketPath string + sshKeyPath string + workDir string + knownHosts knownhosts.Manager + listener net.Listener + rateLimiter *rateLimiter + nodeGate *nodeGate + router map[string]handlerFunc + config *Config + metrics *ProxyMetrics + audit *auditLogger + nodeValidator *nodeValidator + readTimeout time.Duration + writeTimeout time.Duration + maxSSHOutputBytes int64 + controlPlaneCfg *ControlPlaneConfig + controlPlaneToken string + controlPlaneCancel context.CancelFunc allowedPeerUIDs map[uint32]struct{} allowedPeerGIDs map[uint32]struct{} @@ -426,6 +431,7 @@ func runProxy() { readTimeout: cfg.ReadTimeout, writeTimeout: cfg.WriteTimeout, maxSSHOutputBytes: cfg.MaxSSHOutputBytes, + controlPlaneCfg: cfg.PulseControlPlane, } if wd, err := os.Getwd(); err == nil { @@ -453,6 +459,7 @@ func runProxy() { if err := proxy.Start(); err != nil { log.Fatal().Err(err).Msg("Failed to start proxy") } + proxy.startControlPlaneSync() // Start HTTP server if enabled var httpServer *HTTPServer @@ -537,12 +544,126 @@ func (p *Proxy) Start() error { // Stop shuts down the proxy func (p *Proxy) Stop() { + if p.controlPlaneCancel != nil { + p.controlPlaneCancel() + } if p.listener != nil { p.listener.Close() os.Remove(p.socketPath) } } +func (p *Proxy) startControlPlaneSync() { + if p.controlPlaneCfg == nil || strings.TrimSpace(p.controlPlaneCfg.URL) == "" { + return + } + + tokenBytes, err := os.ReadFile(p.controlPlaneCfg.TokenFile) + if err != nil { + log.Warn().Err(err).Str("token_file", p.controlPlaneCfg.TokenFile).Msg("Control plane token unavailable; skipping sync") + return + } + token := strings.TrimSpace(string(tokenBytes)) + if token == "" { + log.Warn().Str("token_file", p.controlPlaneCfg.TokenFile).Msg("Control plane token is empty; skipping sync") + return + } + + ctx, cancel := context.WithCancel(context.Background()) + p.controlPlaneToken = token + p.controlPlaneCancel = cancel + go p.controlPlaneLoop(ctx) + log.Info(). + Str("url", p.controlPlaneCfg.URL). + Int("refresh_interval", p.controlPlaneCfg.RefreshIntervalSec). + Msg("Control plane synchronization enabled") +} + +func (p *Proxy) controlPlaneLoop(ctx context.Context) { + refresh := time.Duration(p.controlPlaneCfg.RefreshIntervalSec) * time.Second + if refresh <= 0 { + refresh = time.Duration(defaultControlPlaneRefreshSecs) * time.Second + } + + client := &http.Client{Timeout: 10 * time.Second} + if p.controlPlaneCfg.InsecureSkipVerify { + client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec + } + } + + for { + if err := p.fetchAuthorizedNodes(client); err != nil { + log.Warn().Err(err).Msg("Control plane sync failed") + } + + select { + case <-ctx.Done(): + return + case <-time.After(refresh): + } + } +} + +func (p *Proxy) fetchAuthorizedNodes(client *http.Client) error { + cfg := p.controlPlaneCfg + if cfg == nil { + return nil + } + + req, err := http.NewRequest(http.MethodGet, strings.TrimRight(cfg.URL, "/")+"/api/temperature-proxy/authorized-nodes", nil) + if err != nil { + return err + } + req.Header.Set("X-Proxy-Token", p.controlPlaneToken) + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("control plane responded %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var payload struct { + Nodes []struct { + Name string `json:"name"` + IP string `json:"ip"` + } `json:"nodes"` + Hash string `json:"hash"` + RefreshInterval int `json:"refresh_interval"` + GeneratedAt time.Time `json:"generated_at"` + } + + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return fmt.Errorf("decode authorized-nodes response: %w", err) + } + + if payload.RefreshInterval > 0 && cfg.RefreshIntervalSec != payload.RefreshInterval { + cfg.RefreshIntervalSec = payload.RefreshInterval + } + + var entries []string + for _, node := range payload.Nodes { + if node.Name != "" { + entries = append(entries, node.Name) + } + if node.IP != "" { + entries = append(entries, node.IP) + } + } + + if len(entries) == 0 { + return errors.New("authorized-nodes response empty") + } + + p.nodeValidator.UpdateAllowlist(entries) + return nil +} + // acceptConnections handles incoming socket connections func (p *Proxy) acceptConnections() { for { diff --git a/cmd/pulse-sensor-proxy/validation.go b/cmd/pulse-sensor-proxy/validation.go index fb294c347..6cb3ca034 100644 --- a/cmd/pulse-sensor-proxy/validation.go +++ b/cmd/pulse-sensor-proxy/validation.go @@ -221,6 +221,7 @@ const ( // nodeValidator enforces node allow-list and cluster membership checks type nodeValidator struct { + mu sync.RWMutex allowHosts map[string]struct{} allowCIDRs []*net.IPNet hasAllowlist bool @@ -286,23 +287,7 @@ func newNodeValidator(cfg *Config, metrics *ProxyMetrics) (*nodeValidator, error clock: time.Now, } - for _, raw := range cfg.AllowedNodes { - entry := strings.TrimSpace(raw) - if entry == "" { - continue - } - - if _, network, err := net.ParseCIDR(entry); err == nil { - v.allowCIDRs = append(v.allowCIDRs, network) - continue - } - - if normalized := normalizeAllowlistEntry(entry); normalized != "" { - v.allowHosts[normalized] = struct{}{} - } - } - - v.hasAllowlist = len(v.allowHosts) > 0 || len(v.allowCIDRs) > 0 + v.setAllowlist(cfg.AllowedNodes) if v.hasAllowlist { log.Info(). @@ -331,17 +316,67 @@ func newNodeValidator(cfg *Config, metrics *ProxyMetrics) (*nodeValidator, error return v, nil } +func (v *nodeValidator) setAllowlist(entries []string) { + v.allowHosts = make(map[string]struct{}) + v.allowCIDRs = nil + + for _, raw := range entries { + entry := strings.TrimSpace(raw) + if entry == "" { + continue + } + + if _, network, err := net.ParseCIDR(entry); err == nil { + v.allowCIDRs = append(v.allowCIDRs, network) + continue + } + + if normalized := normalizeAllowlistEntry(entry); normalized != "" { + v.allowHosts[normalized] = struct{}{} + } + } + + v.hasAllowlist = len(v.allowHosts) > 0 || len(v.allowCIDRs) > 0 +} + +func (v *nodeValidator) UpdateAllowlist(entries []string) { + if v == nil { + return + } + + v.mu.Lock() + v.setAllowlist(entries) + hasAllowlist := v.hasAllowlist + hosts := len(v.allowHosts) + cidrs := len(v.allowCIDRs) + v.mu.Unlock() + + if hasAllowlist { + log.Info(). + Int("allowed_node_count", hosts). + Int("allowed_cidr_count", cidrs). + Msg("Updated node allow-list from control plane") + } else { + log.Warn().Msg("Control plane allow-list update produced empty set") + } +} + // Validate ensures the provided node is authorized before any SSH is attempted. func (v *nodeValidator) Validate(ctx context.Context, node string) error { if v == nil { return nil } + v.mu.RLock() + hasAllowlist := v.hasAllowlist + clusterEnabled := v.clusterEnabled + v.mu.RUnlock() + if ctx == nil { ctx = context.Background() } - if v.hasAllowlist { + if hasAllowlist { allowed, err := v.matchesAllowlist(ctx, node) if err != nil { v.recordFailure(validationReasonResolutionFailed) @@ -354,7 +389,7 @@ func (v *nodeValidator) Validate(ctx context.Context, node string) error { return nil } - if v.clusterEnabled { + if clusterEnabled { allowed, err := v.matchesCluster(ctx, node) if err != nil { // Cluster query failed (e.g., IPC permission denied, running in LXC) @@ -416,33 +451,39 @@ func (v *nodeValidator) validateAsLocalhost(ctx context.Context, node string) er } func (v *nodeValidator) matchesAllowlist(ctx context.Context, node string) (bool, error) { + v.mu.RLock() + allowHosts := v.allowHosts + allowCIDRs := v.allowCIDRs + resolver := v.resolver + v.mu.RUnlock() + normalized := normalizeAllowlistEntry(node) if normalized != "" { - if _, ok := v.allowHosts[normalized]; ok { + if _, ok := allowHosts[normalized]; ok { return true, nil } } if ip := parseNodeIP(node); ip != nil { - if v.ipAllowed(ip) { + if ipAllowed(ip, allowHosts, allowCIDRs) { return true, nil } // If the node itself is an IP and it didn't match, no need to resolve again. return false, nil } - if len(v.allowCIDRs) == 0 { + if len(allowCIDRs) == 0 { return false, nil } host := stripNodeDelimiters(node) - ips, err := v.resolver.LookupIP(ctx, host) + ips, err := resolver.LookupIP(ctx, host) if err != nil { return false, fmt.Errorf("resolve node %q: %w", host, err) } for _, ip := range ips { - if v.ipAllowed(ip) { + if ipAllowed(ip, allowHosts, allowCIDRs) { return true, nil } } @@ -565,6 +606,23 @@ func (v *nodeValidator) ipAllowed(ip net.IP) bool { return false } +func ipAllowed(ip net.IP, hosts map[string]struct{}, cidrs []*net.IPNet) bool { + if ip == nil { + return false + } + if hosts != nil { + if _, ok := hosts[ip.String()]; ok { + return true + } + } + for _, network := range cidrs { + if network.Contains(ip) { + return true + } + } + return false +} + func (v *nodeValidator) recordFailure(reason string) { if v.metrics != nil { v.metrics.recordNodeValidationFailure(reason) diff --git a/docs/TEMPERATURE_MONITORING.md b/docs/TEMPERATURE_MONITORING.md index f2956c8d7..4420864e4 100644 --- a/docs/TEMPERATURE_MONITORING.md +++ b/docs/TEMPERATURE_MONITORING.md @@ -43,6 +43,16 @@ If a node has an HTTPS proxy configured, Pulse does **not** fall back to socket Use the socket path wherever Pulse is containerised. Use HTTP mode when the sensors live on machines Pulse cannot mount directly. +### Monitoring proxy health + +Pulse surfaces the current transport status under **Settings → Diagnostics → Temperature proxy**. + +- The **Control plane sync** table lists every proxy registered with the new control-plane channel (`install-sensor-proxy.sh` now configures this automatically). Each entry shows the last time the proxy fetched its authorized node list, the expected refresh interval, and whether it is healthy, stale, or offline. +- If a proxy falls behind more than one refresh interval you will see a yellow “Behind” badge; Pulse also adds a diagnostic note explaining which host is lagging. After four consecutive missed polls the badge turns red (“Offline”). +- HTTPS-mode proxies still appear under the **HTTPS proxies** section with reachability/error information, so you can see socket/HTTP transport issues side-by-side. + +If a proxy never completes its first sync the diagnostics card will call that out explicitly (status “Pending”). Rerun the host installer or check the proxy journal (`journalctl -u pulse-sensor-proxy`) to resolve any startup problems, then refresh Diagnostics to confirm the sync is healthy. + ## Docker in VM Setup **Running Pulse in Docker inside a VM on Proxmox?** The proxy socket cannot cross VM boundaries, so use pulse-host-agent instead. @@ -1337,6 +1347,38 @@ test -S /run/pulse-sensor-proxy/pulse-sensor-proxy.sock && echo "Socket OK" || e **Contributions Welcome:** If any of these improvements interest you, open a GitHub issue to discuss implementation! +## Control-Plane Sync & Migration + +As of v4.32 the sensor proxy registers with Pulse and syncs its authorized node list via `/api/temperature-proxy/authorized-nodes`. No more manual `allowed_nodes` maintenance or `/etc/pve` access is required. + +### New installs + +Always pass the Pulse URL when installing: + +```bash +curl -sSL https://pulse.example.com/api/install/install-sensor-proxy.sh \ + | sudo bash -s -- --ctid 108 --pulse-server http://192.168.0.149:7655 +``` + +The installer now: + +- Registers the proxy with Pulse (even for socket-only mode) +- Saves `/etc/pulse-sensor-proxy/.pulse-control-token` +- Appends a `pulse_control_plane` block to `/etc/pulse-sensor-proxy/config.yaml` + +### Migrating existing hosts + +If you installed before v4.32, run the migration helper on each host: + +```bash +curl -sSL https://pulse.example.com/api/install/migrate-sensor-proxy-control-plane.sh \ + | sudo bash -s -- --pulse-server http://192.168.0.149:7655 +``` + +The script registers the existing proxy, writes the control token, updates the config, and restarts the service (use `--skip-restart` if you prefer to bounce it yourself). Once migrated, temperatures for every node defined in Pulse will continue working even if the proxy can’t reach `/etc/pve` or Corosync IPC. + +After migration you should see `Temperature data fetched successfully` entries for each node in `journalctl -u pulse-sensor-proxy`, and Settings → Diagnostics will show the last control-plane sync time. + ### Getting Help If temperature monitoring isn't working: diff --git a/docs/temperature-proxy-control-plane.md b/docs/temperature-proxy-control-plane.md new file mode 100644 index 000000000..ce7bbd4b4 --- /dev/null +++ b/docs/temperature-proxy-control-plane.md @@ -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: ` 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://:`. + - 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). + diff --git a/frontend-modern/src/components/FirstRunSetup.tsx b/frontend-modern/src/components/FirstRunSetup.tsx index 93b0a8a6a..b77740ed5 100644 --- a/frontend-modern/src/components/FirstRunSetup.tsx +++ b/frontend-modern/src/components/FirstRunSetup.tsx @@ -30,6 +30,7 @@ export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: bool const [lxcCtid, setLxcCtid] = createSignal(''); const [dockerContainerName, setDockerContainerName] = createSignal(''); const [showAlternatives, setShowAlternatives] = createSignal(false); + const [isValidatingToken, setIsValidatingToken] = createSignal(false); const applyTheme = (mode: 'system' | 'light' | 'dark') => { if (mode === 'light') { @@ -102,13 +103,39 @@ export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: bool return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); }; - const handleUnlock = () => { + const handleUnlock = async () => { if (!bootstrapToken().trim()) { showError('Please enter the bootstrap token'); return; } - // Simple client-side unlock - actual validation happens during setup - setIsUnlocked(true); + + setIsValidatingToken(true); + + try { + const response = await fetch('/api/security/validate-bootstrap-token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token: bootstrapToken().trim() }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error || 'Invalid bootstrap setup token'); + } + + setIsUnlocked(true); + showSuccess('Bootstrap token verified. Continue with setup.'); + } catch (error) { + if (error instanceof Error) { + showError(error.message || 'Failed to validate bootstrap token'); + } else { + showError('Failed to validate bootstrap token'); + } + } finally { + setIsValidatingToken(false); + } }; const handleSetup = async () => { @@ -399,7 +426,7 @@ IMPORTANT: Keep these credentials secure! type="text" value={bootstrapToken()} onInput={(e) => setBootstrapToken(e.currentTarget.value)} - onKeyPress={(e) => e.key === 'Enter' && handleUnlock()} + onKeyPress={(e) => e.key === 'Enter' && handleUnlock()} class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm" placeholder="Paste the token from your host" autofocus @@ -423,10 +450,10 @@ IMPORTANT: Keep these credentials secure! diff --git a/frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx b/frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx index d3a71a939..be656f25b 100644 --- a/frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx +++ b/frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx @@ -64,15 +64,84 @@ const resolveTemperatureTransport = ( globalEnabled: boolean, ): TemperatureTransportBadge => { const monitoringEnabled = isTemperatureMonitoringEnabled(node, globalEnabled); + const normalizedTransport = (node.temperatureTransport || '').toLowerCase(); if (!monitoringEnabled) { return { label: 'Temp disabled', badgeClass: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300', }; } + if (normalizedTransport === 'disabled') { + return { + label: 'Temp disabled', + badgeClass: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300', + }; + } const key = (node.name || '').toLowerCase(); const httpEntry = info?.httpMap?.[key]; + const socketStatus = info?.socketStatus; + + const buildSocketBadge = (): TemperatureTransportBadge => { + if (socketStatus === 'error') { + return { + label: 'Socket error', + badgeClass: 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300', + description: 'Proxy socket not responding', + }; + } + if (socketStatus === 'missing') { + return { + label: 'Socket missing', + badgeClass: 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300', + description: 'Mount /mnt/pulse-proxy inside the container', + }; + } + return { + label: 'Socket proxy', + badgeClass: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300', + }; + }; + + if (normalizedTransport) { + switch (normalizedTransport) { + case 'https-proxy': + if (httpEntry) { + if (httpEntry.reachable) { + return { + label: 'HTTPS proxy', + badgeClass: 'bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300', + description: httpEntry.url, + }; + } + return { + label: 'HTTPS error', + badgeClass: 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300', + description: httpEntry.error || 'Proxy unreachable', + }; + } + return { + label: 'HTTPS proxy', + badgeClass: 'bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300', + }; + case 'socket-proxy': + return buildSocketBadge(); + case 'ssh-blocked': + return { + label: 'Proxy required', + badgeClass: 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300', + description: 'Containerized Pulse requires pulse-sensor-proxy', + }; + case 'ssh': + return { + label: 'SSH fallback', + badgeClass: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300', + }; + default: + break; + } + } + if (httpEntry) { if (httpEntry.reachable) { return { @@ -89,18 +158,8 @@ const resolveTemperatureTransport = ( } if (info) { - if (info.socketStatus === 'healthy') { - return { - label: 'Socket proxy', - badgeClass: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300', - }; - } - if (info.socketStatus === 'error') { - return { - label: 'Socket error', - badgeClass: 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300', - description: 'Proxy socket not responding', - }; + if (info.socketStatus === 'healthy' || info.socketStatus === 'error' || info.socketStatus === 'missing') { + return buildSocketBadge(); } } diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index eaed10dfa..fb9ef2d40 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -139,6 +139,14 @@ interface TemperatureProxyHTTPStatus { error?: string; } +interface TemperatureProxyControlPlaneState { + instance: string; + lastSync?: string; + refreshIntervalSeconds?: number; + secondsBehind?: number; + status?: string; +} + interface TemperatureProxyDiagnostic { legacySSHDetected: boolean; recommendProxyUpgrade: boolean; @@ -155,6 +163,8 @@ interface TemperatureProxyDiagnostic { proxyCapabilities?: string[]; notes?: string[]; httpProxies?: TemperatureProxyHTTPStatus[]; + controlPlaneEnabled?: boolean; + controlPlaneStates?: TemperatureProxyControlPlaneState[]; } interface APITokenSummary { @@ -792,6 +802,33 @@ const Settings: Component = (props) => { return formatRelativeTime(timestamp); }; + const controlPlaneStatusLabel = (status?: string) => { + switch (status) { + case 'healthy': + return 'Healthy'; + case 'stale': + return 'Behind'; + case 'offline': + return 'Offline'; + case 'pending': + default: + return 'Pending'; + } + }; + + const controlPlaneStatusClass = (status?: string) => { + switch (status) { + case 'healthy': + return 'bg-green-500'; + case 'stale': + return 'bg-yellow-500'; + case 'offline': + return 'bg-red-500'; + default: + return 'bg-gray-500'; + } + }; + const formatUptime = (seconds: number) => { if (!seconds || seconds <= 0) { return 'Unknown'; @@ -814,15 +851,26 @@ const Settings: Component = (props) => { }; const emitTemperatureProxyWarnings = (diag: DiagnosticsData | null) => { - if (!diag?.temperatureProxy?.httpProxies) { + if (!diag?.temperatureProxy) { return; } - const failing = (diag.temperatureProxy.httpProxies as TemperatureProxyHTTPStatus[]).filter( - (proxy) => proxy && proxy.node && !proxy.reachable, - ); - if (failing.length > 0) { - const nodes = failing.map((proxy) => proxy.node || 'Unknown').join(', '); - showWarning(`Pulse cannot reach HTTPS temperature proxy on: ${nodes}`); + if (diag.temperatureProxy.httpProxies) { + const failing = (diag.temperatureProxy.httpProxies as TemperatureProxyHTTPStatus[]).filter( + (proxy) => proxy && proxy.node && !proxy.reachable, + ); + if (failing.length > 0) { + const nodes = failing.map((proxy) => proxy.node || 'Unknown').join(', '); + showWarning(`Pulse cannot reach HTTPS temperature proxy on: ${nodes}`); + } + } + if (diag.temperatureProxy.controlPlaneStates) { + const stale = (diag.temperatureProxy.controlPlaneStates as TemperatureProxyControlPlaneState[]).filter( + (state) => state && (state.status === 'stale' || state.status === 'offline'), + ); + if (stale.length > 0) { + const names = stale.map((state) => state.instance || 'Proxy').join(', '); + showWarning(`Temperature proxy control plane is behind on: ${names}`); + } } }; @@ -5210,17 +5258,71 @@ const Settings: Component = (props) => {
Legacy SSH keys: {temp().legacySshKeyCount ?? 0}
- -
- Legacy SSH temperature collection detected + +
+ Legacy SSH temperature collection detected +
+
+
+ 0 + } + > +
+
+
+ Control plane sync
- + + {temp().controlPlaneEnabled ? 'Enabled' : 'Disabled'} + +
+ + {(state) => ( +
+
+
+ {state.instance || 'Proxy'} +
+ + {controlPlaneStatusLabel(state.status)} + +
+ +
+ Last sync: {formatIsoRelativeTime(state.lastSync)} +
+
+ 0}> +
+ Behind by ~{formatUptime(state.secondsBehind || 0)} +
+
+ +
+ Target interval: {formatUptime(state.refreshIntervalSeconds || 0)} +
+
+
+ )} +
- 0 - } - > + + 0 + } + >
HTTPS proxies diff --git a/frontend-modern/src/types/nodes.ts b/frontend-modern/src/types/nodes.ts index 2642d36c1..82efd1694 100644 --- a/frontend-modern/src/types/nodes.ts +++ b/frontend-modern/src/types/nodes.ts @@ -1,5 +1,12 @@ import type { Temperature } from '@/types/api'; +export type TemperatureTransport = + | 'disabled' + | 'socket-proxy' + | 'https-proxy' + | 'ssh' + | 'ssh-blocked'; + // Node configuration types export interface ClusterEndpoint { @@ -83,6 +90,7 @@ export type NodeConfig = (PVENodeConfig | PBSNodeConfig | PMGNodeConfig) & { status?: 'connected' | 'disconnected' | 'offline' | 'error' | 'pending'; temperature?: Temperature; displayName?: string; + temperatureTransport?: TemperatureTransport; }; export interface NodesResponse { diff --git a/install.sh b/install.sh index 53547c6cf..bd86d4c29 100755 --- a/install.sh +++ b/install.sh @@ -185,6 +185,11 @@ safe_systemctl() { # Detect existing service name (pulse or pulse-backend) detect_service_name() { + if ! command -v systemctl >/dev/null 2>&1; then + echo "pulse" + return + fi + if systemctl list-unit-files --no-legend | grep -q "^pulse-backend.service"; then echo "pulse-backend" elif systemctl list-unit-files --no-legend | grep -q "^pulse.service"; then @@ -1565,7 +1570,7 @@ fi'; then fi # If building from source, copy the binary from the LXC instead of downloading - local proxy_install_args=(--ctid "$CTID" --skip-restart) + local proxy_install_args=(--ctid "$CTID" --skip-restart --pulse-server "http://${IP}:${frontend_port}") local local_proxy_binary="" if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then local_proxy_binary="/tmp/pulse-sensor-proxy-$CTID" @@ -1770,20 +1775,29 @@ compare_versions() { check_existing_installation() { CURRENT_VERSION="" # Make it global so we can use it later local BINARY_PATH="" - + local detected_service="$SERVICE_NAME" + local service_available=false + # Check for the binary in expected locations if [[ -f "$INSTALL_DIR/bin/pulse" ]]; then BINARY_PATH="$INSTALL_DIR/bin/pulse" elif [[ -f "$INSTALL_DIR/pulse" ]]; then BINARY_PATH="$INSTALL_DIR/pulse" fi - + + # Detect actual service name if systemd is available + if command -v systemctl >/dev/null 2>&1; then + detected_service=$(detect_service_name) + SERVICE_NAME="$detected_service" + service_available=true + fi + # Try to get version if binary exists if [[ -n "$BINARY_PATH" ]]; then CURRENT_VERSION=$($BINARY_PATH --version 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.]+)?' | head -1 || echo "unknown") fi - if systemctl is-active --quiet $SERVICE_NAME 2>/dev/null; then + if [[ "$service_available" == true ]] && systemctl is-active --quiet "$detected_service" 2>/dev/null; then if [[ -n "$CURRENT_VERSION" && "$CURRENT_VERSION" != "unknown" ]]; then print_info "Pulse $CURRENT_VERSION is currently running" else diff --git a/internal/api/bootstrap_token.go b/internal/api/bootstrap_token.go index 28f447a64..0ce6cfbb3 100644 --- a/internal/api/bootstrap_token.go +++ b/internal/api/bootstrap_token.go @@ -3,8 +3,10 @@ package api import ( "crypto/rand" "encoding/hex" + "encoding/json" "errors" "fmt" + "net/http" "os" "path/filepath" "strings" @@ -136,3 +138,43 @@ func (r *Router) clearBootstrapToken() { r.bootstrapTokenHash = "" r.bootstrapTokenPath = "" } + +func (r *Router) handleValidateBootstrapToken(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if r.bootstrapTokenHash == "" { + http.Error(w, "Bootstrap token unavailable. Reload the page or restart Pulse.", http.StatusConflict) + return + } + + token := strings.TrimSpace(req.Header.Get(bootstrapTokenHeader)) + + if token == "" { + var payload struct { + Token string `json:"token"` + } + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + http.Error(w, "Invalid request payload", http.StatusBadRequest) + return + } + token = strings.TrimSpace(payload.Token) + } + + if token == "" { + http.Error(w, "Bootstrap token is required", http.StatusBadRequest) + return + } + + if !r.bootstrapTokenValid(token) { + log.Warn(). + Str("ip", GetClientIP(req)). + Msg("Rejected invalid bootstrap token validation request") + http.Error(w, "Invalid bootstrap setup token", http.StatusUnauthorized) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go index b647f12d3..17067d3b2 100644 --- a/internal/api/config_handlers.go +++ b/internal/api/config_handlers.go @@ -44,6 +44,14 @@ var ( setupAuthTokenPattern = regexp.MustCompile(`^[A-Fa-f0-9]{32,128}$`) ) +const ( + temperatureTransportDisabled = "disabled" + temperatureTransportSocketProxy = "socket-proxy" + temperatureTransportHTTPSProxy = "https-proxy" + temperatureTransportSSHFallback = "ssh" + temperatureTransportSSHBlocked = "ssh-blocked" +) + func sanitizeInstallerURL(raw string) (string, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { @@ -409,6 +417,7 @@ type NodeResponse struct { MonitorBackups bool `json:"monitorBackups,omitempty"` MonitorPhysicalDisks *bool `json:"monitorPhysicalDisks,omitempty"` TemperatureMonitoringEnabled *bool `json:"temperatureMonitoringEnabled,omitempty"` + TemperatureTransport string `json:"temperatureTransport,omitempty"` MonitorDatastores bool `json:"monitorDatastores,omitempty"` MonitorSyncJobs bool `json:"monitorSyncJobs,omitempty"` MonitorVerifyJobs bool `json:"monitorVerifyJobs,omitempty"` @@ -424,6 +433,44 @@ type NodeResponse struct { ClusterEndpoints []config.ClusterEndpoint `json:"clusterEndpoints,omitempty"` } +func determineTemperatureTransport(enabled bool, proxyURL, proxyToken string, socketAvailable bool, containerSSHBlocked bool) string { + if !enabled { + return temperatureTransportDisabled + } + + proxyURL = strings.TrimSpace(proxyURL) + proxyToken = strings.TrimSpace(proxyToken) + if proxyURL != "" && proxyToken != "" { + return temperatureTransportHTTPSProxy + } + + if socketAvailable { + return temperatureTransportSocketProxy + } + + if containerSSHBlocked { + return temperatureTransportSSHBlocked + } + + return temperatureTransportSSHFallback +} + +func isContainerSSHRestricted() bool { + isContainer := os.Getenv("PULSE_DOCKER") == "true" || system.InContainer() + if !isContainer { + return false + } + return strings.ToLower(strings.TrimSpace(os.Getenv("PULSE_DEV_ALLOW_CONTAINER_SSH"))) != "true" +} + +func (h *ConfigHandlers) resolveTemperatureTransport(enabledOverride *bool, proxyURL, proxyToken string, socketAvailable bool, containerSSHBlocked bool) string { + enabled := h.config.TemperatureMonitoringEnabled + if enabledOverride != nil { + enabled = *enabledOverride + } + return determineTemperatureTransport(enabled, proxyURL, proxyToken, socketAvailable, containerSSHBlocked) +} + // deriveSchemeAndPort infers the scheme (without ://) and port from a base host URL. // Defaults align with Proxmox expectations when details are omitted. func deriveSchemeAndPort(baseHost string) (scheme string, port string) { @@ -742,6 +789,8 @@ func detectPVECluster(clientConfig proxmox.ClientConfig, nodeName string, existi // GetAllNodesForAPI returns all configured nodes for API responses func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse { nodes := []NodeResponse{} + socketAvailable := h.monitor != nil && h.monitor.HasSocketTemperatureProxy() + containerSSHBlocked := isContainerSSHRestricted() // Add PVE nodes for i, pve := range h.config.PVEInstances { @@ -766,6 +815,7 @@ func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse { MonitorBackups: pve.MonitorBackups, MonitorPhysicalDisks: pve.MonitorPhysicalDisks, TemperatureMonitoringEnabled: pve.TemperatureMonitoringEnabled, + TemperatureTransport: h.resolveTemperatureTransport(pve.TemperatureMonitoringEnabled, pve.TemperatureProxyURL, pve.TemperatureProxyToken, socketAvailable, containerSSHBlocked), Status: h.getNodeStatus("pve", pve.Name), IsCluster: pve.IsCluster, ClusterName: pve.ClusterName, @@ -788,6 +838,7 @@ func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse { Fingerprint: pbs.Fingerprint, VerifySSL: pbs.VerifySSL, TemperatureMonitoringEnabled: pbs.TemperatureMonitoringEnabled, + TemperatureTransport: h.resolveTemperatureTransport(pbs.TemperatureMonitoringEnabled, "", "", socketAvailable, containerSSHBlocked), MonitorDatastores: pbs.MonitorDatastores, MonitorSyncJobs: pbs.MonitorSyncJobs, MonitorVerifyJobs: pbs.MonitorVerifyJobs, @@ -822,6 +873,7 @@ func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse { MonitorQuarantine: pmgInst.MonitorQuarantine, MonitorDomainStats: pmgInst.MonitorDomainStats, Status: h.getNodeStatus("pmg", pmgInst.Name), + TemperatureTransport: h.resolveTemperatureTransport(pmgInst.TemperatureMonitoringEnabled, "", "", socketAvailable, containerSSHBlocked), } nodes = append(nodes, node) } @@ -884,6 +936,7 @@ func (h *ConfigHandlers) HandleGetNodes(w http.ResponseWriter, r *http.Request) IsCluster: true, ClusterName: "mock-cluster", ClusterEndpoints: clusterEndpoints, // All cluster nodes + TemperatureTransport: temperatureTransportSocketProxy, } mockNodes = append(mockNodes, clusterNode) } @@ -910,6 +963,7 @@ func (h *ConfigHandlers) HandleGetNodes(w http.ResponseWriter, r *http.Request) IsCluster: false, // Not part of a cluster ClusterName: "", ClusterEndpoints: []config.ClusterEndpoint{}, + TemperatureTransport: temperatureTransportSocketProxy, } mockNodes = append(mockNodes, standaloneNode) } @@ -917,22 +971,23 @@ func (h *ConfigHandlers) HandleGetNodes(w http.ResponseWriter, r *http.Request) // Add mock PBS instances for i, pbs := range state.PBSInstances { pbsNode := NodeResponse{ - ID: generateNodeID("pbs", i), - Type: "pbs", - Name: pbs.Name, - Host: pbs.Host, - User: "pulse@pbs", - HasPassword: false, - TokenName: "pulse", - HasToken: true, - Fingerprint: "", - VerifySSL: false, - MonitorDatastores: true, - MonitorSyncJobs: true, - MonitorVerifyJobs: true, - MonitorPruneJobs: true, - MonitorGarbageJobs: true, - Status: "connected", // Always connected in mock mode + ID: generateNodeID("pbs", i), + Type: "pbs", + Name: pbs.Name, + Host: pbs.Host, + User: "pulse@pbs", + HasPassword: false, + TokenName: "pulse", + HasToken: true, + Fingerprint: "", + VerifySSL: false, + MonitorDatastores: true, + MonitorSyncJobs: true, + MonitorVerifyJobs: true, + MonitorPruneJobs: true, + MonitorGarbageJobs: true, + Status: "connected", // Always connected in mock mode + TemperatureTransport: temperatureTransportSocketProxy, } mockNodes = append(mockNodes, pbsNode) } @@ -940,17 +995,18 @@ func (h *ConfigHandlers) HandleGetNodes(w http.ResponseWriter, r *http.Request) // Add mock PMG instances for i, pmg := range state.PMGInstances { pmgNode := NodeResponse{ - ID: generateNodeID("pmg", i), - Type: "pmg", - Name: pmg.Name, - Host: pmg.Host, - User: "root@pam", - HasPassword: true, - TokenName: "pulse", - HasToken: true, - Fingerprint: "", - VerifySSL: false, - Status: "connected", // Always connected in mock mode + ID: generateNodeID("pmg", i), + Type: "pmg", + Name: pmg.Name, + Host: pmg.Host, + User: "root@pam", + HasPassword: true, + TokenName: "pulse", + HasToken: true, + Fingerprint: "", + VerifySSL: false, + Status: "connected", // Always connected in mock mode + TemperatureTransport: temperatureTransportSocketProxy, } mockNodes = append(mockNodes, pmgNode) } diff --git a/internal/api/config_handlers_temperature_test.go b/internal/api/config_handlers_temperature_test.go new file mode 100644 index 000000000..b15a847e5 --- /dev/null +++ b/internal/api/config_handlers_temperature_test.go @@ -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) + } + }) + } +} diff --git a/internal/api/diagnostics.go b/internal/api/diagnostics.go index a3a26b5c7..8d7ce66b9 100644 --- a/internal/api/diagnostics.go +++ b/internal/api/diagnostics.go @@ -234,19 +234,29 @@ type SystemDiagnostic struct { // TemperatureProxyDiagnostic summarizes proxy detection state type TemperatureProxyDiagnostic struct { - SocketFound bool `json:"socketFound"` - SocketPath string `json:"socketPath,omitempty"` - SocketPermissions string `json:"socketPermissions,omitempty"` - SocketOwner string `json:"socketOwner,omitempty"` - SocketGroup string `json:"socketGroup,omitempty"` - ProxyReachable bool `json:"proxyReachable"` - ProxyVersion string `json:"proxyVersion,omitempty"` - ProxyPublicKeySHA256 string `json:"proxyPublicKeySha256,omitempty"` - ProxySSHDirectory string `json:"proxySshDirectory,omitempty"` - LegacySSHKeyCount int `json:"legacySshKeyCount,omitempty"` - ProxyCapabilities []string `json:"proxyCapabilities,omitempty"` - Notes []string `json:"notes,omitempty"` - HTTPProxies []TemperatureProxyHTTPStatus `json:"httpProxies,omitempty"` + SocketFound bool `json:"socketFound"` + SocketPath string `json:"socketPath,omitempty"` + SocketPermissions string `json:"socketPermissions,omitempty"` + SocketOwner string `json:"socketOwner,omitempty"` + SocketGroup string `json:"socketGroup,omitempty"` + ProxyReachable bool `json:"proxyReachable"` + ProxyVersion string `json:"proxyVersion,omitempty"` + ProxyPublicKeySHA256 string `json:"proxyPublicKeySha256,omitempty"` + ProxySSHDirectory string `json:"proxySshDirectory,omitempty"` + LegacySSHKeyCount int `json:"legacySshKeyCount,omitempty"` + ProxyCapabilities []string `json:"proxyCapabilities,omitempty"` + Notes []string `json:"notes,omitempty"` + HTTPProxies []TemperatureProxyHTTPStatus `json:"httpProxies,omitempty"` + ControlPlaneEnabled bool `json:"controlPlaneEnabled"` + ControlPlaneStates []TemperatureProxyControlPlaneState `json:"controlPlaneStates,omitempty"` +} + +type TemperatureProxyControlPlaneState struct { + Instance string `json:"instance"` + LastSync string `json:"lastSync,omitempty"` + RefreshIntervalSeconds int `json:"refreshIntervalSeconds,omitempty"` + SecondsBehind int `json:"secondsBehind,omitempty"` + Status string `json:"status,omitempty"` } type TemperatureProxyHTTPStatus struct { @@ -403,7 +413,12 @@ func (r *Router) computeDiagnostics(ctx context.Context) DiagnosticsInfo { MemoryMB: memStats.Alloc / 1024 / 1024, } - diag.TemperatureProxy = buildTemperatureProxyDiagnostic(r.config) + var proxySync map[string]proxySyncState + if r.temperatureProxyHandlers != nil { + proxySync = r.temperatureProxyHandlers.SnapshotSyncStatus() + } + + diag.TemperatureProxy = buildTemperatureProxyDiagnostic(r.config, proxySync) diag.APITokens = buildAPITokenDiagnostic(r.config, r.monitor) // Test each configured node @@ -659,7 +674,7 @@ func buildDiscoveryDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) * return discovery } -func buildTemperatureProxyDiagnostic(cfg *config.Config) *TemperatureProxyDiagnostic { +func buildTemperatureProxyDiagnostic(cfg *config.Config, syncStates map[string]proxySyncState) *TemperatureProxyDiagnostic { diag := &TemperatureProxyDiagnostic{} appendNote := func(note string) { @@ -781,9 +796,75 @@ func buildTemperatureProxyDiagnostic(cfg *config.Config) *TemperatureProxyDiagno status.Reachable = true } } - diag.HTTPProxies = append(diag.HTTPProxies, status) } + + controlStates := make([]TemperatureProxyControlPlaneState, 0) + now := time.Now() + + lookupState := func(name string) (proxySyncState, bool) { + if len(syncStates) == 0 { + return proxySyncState{}, false + } + key := strings.ToLower(strings.TrimSpace(name)) + if state, ok := syncStates[key]; ok { + return state, true + } + for _, state := range syncStates { + if strings.EqualFold(state.Instance, name) { + return state, true + } + } + return proxySyncState{}, false + } + + for _, inst := range cfg.PVEInstances { + if strings.TrimSpace(inst.TemperatureProxyControlToken) == "" { + continue + } + + state := TemperatureProxyControlPlaneState{ + Instance: strings.TrimSpace(inst.Name), + Status: "pending", + RefreshIntervalSeconds: defaultProxyAllowlistRefreshSeconds, + } + diag.ControlPlaneEnabled = true + + if syncState, ok := lookupState(inst.Name); ok { + if syncState.RefreshInterval > 0 { + state.RefreshIntervalSeconds = syncState.RefreshInterval + } + if !syncState.LastPull.IsZero() { + state.LastSync = syncState.LastPull.UTC().Format(time.RFC3339) + behind := int(now.Sub(syncState.LastPull).Seconds()) + if behind < 0 { + behind = 0 + } + state.SecondsBehind = behind + + switch { + case behind <= state.RefreshIntervalSeconds+15: + state.Status = "healthy" + case behind <= state.RefreshIntervalSeconds*4: + state.Status = "stale" + appendNote(fmt.Sprintf("Proxy '%s' has not refreshed its authorized nodes for %d seconds (target %d). Verify pulse-sensor-proxy is running.", state.Instance, behind, state.RefreshIntervalSeconds)) + default: + state.Status = "offline" + appendNote(fmt.Sprintf("Proxy '%s' missed the control plane for %d seconds. Check connectivity and restart pulse-sensor-proxy.", state.Instance, behind)) + } + } else { + appendNote(fmt.Sprintf("Proxy '%s' registered for control plane sync but has not completed its first pull yet.", state.Instance)) + } + } else { + appendNote(fmt.Sprintf("Proxy '%s' has control-plane sync enabled but has not contacted Pulse. Confirm the installer wrote the control token and the host has connectivity.", state.Instance)) + } + + controlStates = append(controlStates, state) + } + + if len(controlStates) > 0 { + diag.ControlPlaneStates = controlStates + } } return diag diff --git a/internal/api/rate_limit_config.go b/internal/api/rate_limit_config.go index fe2eec77f..21cabb59d 100644 --- a/internal/api/rate_limit_config.go +++ b/internal/api/rate_limit_config.go @@ -107,6 +107,8 @@ func GetRateLimiterForEndpoint(path string, method string) *RateLimiter { if strings.Contains(path, "/api/health") || strings.Contains(path, "/api/version") || strings.Contains(path, "/api/security/status") || + strings.Contains(path, "/api/security/validate-bootstrap-token") || + strings.Contains(path, "/api/temperature-proxy/authorized-nodes") || strings.Contains(path, "/metrics") { return globalRateLimitConfig.PublicEndpoints } diff --git a/internal/api/router.go b/internal/api/router.go index 9c0b28c3e..379a0c76d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -184,6 +184,7 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/agents/host/lookup", RequireAuth(r.config, RequireScope(config.ScopeHostReport, r.hostAgentHandlers.HandleLookup))) r.mux.HandleFunc("/api/agents/host/", RequireAdmin(r.config, RequireScope(config.ScopeHostManage, r.hostAgentHandlers.HandleDeleteHost))) r.mux.HandleFunc("/api/temperature-proxy/register", r.temperatureProxyHandlers.HandleRegister) + r.mux.HandleFunc("/api/temperature-proxy/authorized-nodes", r.temperatureProxyHandlers.HandleAuthorizedNodes) r.mux.HandleFunc("/api/temperature-proxy/unregister", RequireAdmin(r.config, r.temperatureProxyHandlers.HandleUnregister)) r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerReport, r.dockerAgentHandlers.HandleCommandAck))) r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions))) @@ -196,6 +197,7 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/diagnostics/docker/prepare-token", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleDiagnosticsDockerPrepareToken))) r.mux.HandleFunc("/api/install/pulse-sensor-proxy", r.handleDownloadPulseSensorProxy) r.mux.HandleFunc("/api/install/install-sensor-proxy.sh", r.handleDownloadInstallerScript) + r.mux.HandleFunc("/api/install/migrate-sensor-proxy-control-plane.sh", r.handleDownloadMigrationScript) r.mux.HandleFunc("/api/install/install-docker.sh", r.handleDownloadDockerInstallerScript) r.mux.HandleFunc("/api/config", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleConfig))) r.mux.HandleFunc("/api/backups", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleBackups))) @@ -273,6 +275,7 @@ func (r *Router) setupRoutes() { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } }) + r.mux.HandleFunc("/api/security/validate-bootstrap-token", r.handleValidateBootstrapToken) // Test node configuration endpoint (for new nodes) r.mux.HandleFunc("/api/config/nodes/test-config", func(w http.ResponseWriter, req *http.Request) { @@ -1262,7 +1265,7 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { if r.config.AllowedOrigins != "" { w.Header().Set("Access-Control-Allow-Origin", r.config.AllowedOrigins) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Token, X-CSRF-Token") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Token, X-CSRF-Token, X-Setup-Token") w.Header().Set("Access-Control-Expose-Headers", "X-CSRF-Token, X-Authenticated-User, X-Auth-Method") } @@ -1309,25 +1312,28 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { publicPaths := []string{ "/api/health", "/api/security/status", + "/api/security/validate-bootstrap-token", "/api/version", "/api/login", // Add login endpoint as public "/api/oidc/login", config.DefaultOIDCCallbackPath, - "/install-docker-agent.sh", // Docker agent bootstrap script must be public - "/install-container-agent.sh", // Container agent bootstrap script must be public - "/download/pulse-docker-agent", // Agent binary download should not require auth - "/install-host-agent.sh", // Host agent bootstrap script must be public - "/install-host-agent.ps1", // Host agent PowerShell script must be public - "/uninstall-host-agent.sh", // Host agent uninstall script must be public - "/uninstall-host-agent.ps1", // Host agent uninstall script must be public - "/download/pulse-host-agent", // Host agent binary download should not require auth - "/api/agent/version", // Agent update checks need to work before auth - "/api/server/info", // Server info for installer script - "/api/install/install-sensor-proxy.sh", // Temperature proxy installer fallback - "/api/install/pulse-sensor-proxy", // Temperature proxy binary fallback - "/api/install/install-docker.sh", // Docker turnkey installer - "/api/system/proxy-public-key", // Temperature proxy public key for setup script - "/api/temperature-proxy/register", // Temperature proxy registration (called by installer) + "/install-docker-agent.sh", // Docker agent bootstrap script must be public + "/install-container-agent.sh", // Container agent bootstrap script must be public + "/download/pulse-docker-agent", // Agent binary download should not require auth + "/install-host-agent.sh", // Host agent bootstrap script must be public + "/install-host-agent.ps1", // Host agent PowerShell script must be public + "/uninstall-host-agent.sh", // Host agent uninstall script must be public + "/uninstall-host-agent.ps1", // Host agent uninstall script must be public + "/download/pulse-host-agent", // Host agent binary download should not require auth + "/api/agent/version", // Agent update checks need to work before auth + "/api/server/info", // Server info for installer script + "/api/install/install-sensor-proxy.sh", // Temperature proxy installer fallback + "/api/install/pulse-sensor-proxy", // Temperature proxy binary fallback + "/api/install/migrate-sensor-proxy-control-plane.sh", // Proxy migration helper + "/api/install/install-docker.sh", // Docker turnkey installer + "/api/system/proxy-public-key", // Temperature proxy public key for setup script + "/api/temperature-proxy/register", // Temperature proxy registration (called by installer) + "/api/temperature-proxy/authorized-nodes", // Proxy control-plane sync } // Also allow static assets without auth (JS, CSS, etc) @@ -3765,6 +3771,31 @@ func (r *Router) handleDownloadDockerInstallerScript(w http.ResponseWriter, req } } +func (r *Router) handleDownloadMigrationScript(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil) + return + } + + scriptPath := "/opt/pulse/scripts/migrate-sensor-proxy-control-plane.sh" + content, err := os.ReadFile(scriptPath) + if err != nil { + scriptPath = filepath.Join(r.projectRoot, "scripts", "migrate-sensor-proxy-control-plane.sh") + content, err = os.ReadFile(scriptPath) + if err != nil { + log.Error().Err(err).Str("path", scriptPath).Msg("Failed to read migration script") + writeErrorResponse(w, http.StatusInternalServerError, "read_error", "Failed to read migration script", nil) + return + } + } + + w.Header().Set("Content-Type", "text/x-shellscript") + w.Header().Set("Content-Disposition", "attachment; filename=migrate-sensor-proxy-control-plane.sh") + if _, err := w.Write(content); err != nil { + log.Error().Err(err).Msg("Failed to write migration script to client") + } +} + func (r *Router) resolvePublicURL(req *http.Request) string { if publicURL := strings.TrimSpace(r.config.PublicURL); publicURL != "" { return strings.TrimRight(publicURL, "/") diff --git a/internal/api/security_setup_fix_test.go b/internal/api/security_setup_fix_test.go index 19bae1963..b1a7cd923 100644 --- a/internal/api/security_setup_fix_test.go +++ b/internal/api/security_setup_fix_test.go @@ -115,6 +115,78 @@ func TestQuickSecuritySetupRequiresBootstrapToken(t *testing.T) { } } +func TestValidateBootstrapTokenEndpoint(t *testing.T) { + t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "") + resetTrustedProxyConfig() + + dataDir := t.TempDir() + cfg := &config.Config{ + DataPath: dataDir, + ConfigPath: dataDir, + } + + router := &Router{config: cfg} + router.initializeBootstrapToken() + + tokenPath := filepath.Join(cfg.DataPath, bootstrapTokenFilename) + content, err := os.ReadFile(tokenPath) + if err != nil { + t.Fatalf("read bootstrap token: %v", err) + } + token := strings.TrimSpace(string(content)) + if token == "" { + t.Fatalf("bootstrap token should not be empty") + } + + handler := http.HandlerFunc(router.handleValidateBootstrapToken) + + // GET not allowed + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/security/validate-bootstrap-token", nil) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405 for GET, got %d", rr.Code) + } + + // Missing token payload + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader("{}")) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for missing token, got %d (%s)", rr.Code, rr.Body.String()) + } + + // Invalid token + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{"token":"deadbeef"}`)) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for invalid token, got %d (%s)", rr.Code, rr.Body.String()) + } + + // Valid token + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{"token":"`+token+`"}`)) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusNoContent { + t.Fatalf("expected 204 for valid token, got %d (%s)", rr.Code, rr.Body.String()) + } + + // Bootstrap token should remain on disk after validation + if _, err := os.Stat(tokenPath); err != nil { + t.Fatalf("bootstrap token should remain after validation, got err=%v", err) + } + + // Once token removed, endpoint should report conflict + router.clearBootstrapToken() + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{"token":"`+token+`"}`)) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusConflict { + t.Fatalf("expected 409 when bootstrap token unavailable, got %d (%s)", rr.Code, rr.Body.String()) + } +} + func TestQuickSecuritySetupAllowsRecoveryTokenRotation(t *testing.T) { t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "") resetTrustedProxyConfig() diff --git a/internal/api/temperature_proxy.go b/internal/api/temperature_proxy.go index d99580a04..da5fb7a1e 100644 --- a/internal/api/temperature_proxy.go +++ b/internal/api/temperature_proxy.go @@ -2,11 +2,18 @@ package api import ( "crypto/rand" + "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "net/http" + "net/url" + "sort" + "strconv" "strings" + "sync" + "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/utils" @@ -16,11 +23,136 @@ import ( // TemperatureProxyHandlers manages temperature proxy registration type TemperatureProxyHandlers struct { persistence *config.ConfigPersistence + syncMu sync.RWMutex + syncStatus map[string]proxySyncState +} + +type proxySyncState struct { + Instance string + LastPull time.Time + RefreshInterval int +} + +const defaultProxyAllowlistRefreshSeconds = 60 + +type authorizedNode struct { + Name string `json:"name,omitempty"` + IP string `json:"ip,omitempty"` } // NewTemperatureProxyHandlers constructs a new handler set for temperature proxy func NewTemperatureProxyHandlers(persistence *config.ConfigPersistence) *TemperatureProxyHandlers { - return &TemperatureProxyHandlers{persistence: persistence} + return &TemperatureProxyHandlers{ + persistence: persistence, + syncStatus: make(map[string]proxySyncState), + } +} + +func (h *TemperatureProxyHandlers) recordSync(instance string, refreshSeconds int) { + if h == nil { + return + } + + instance = strings.TrimSpace(instance) + if instance == "" { + return + } + + if refreshSeconds <= 0 { + refreshSeconds = defaultProxyAllowlistRefreshSeconds + } + + h.syncMu.Lock() + defer h.syncMu.Unlock() + + key := strings.ToLower(instance) + h.syncStatus[key] = proxySyncState{ + Instance: instance, + LastPull: time.Now(), + RefreshInterval: refreshSeconds, + } +} + +func (h *TemperatureProxyHandlers) SnapshotSyncStatus() map[string]proxySyncState { + if h == nil { + return nil + } + + h.syncMu.RLock() + defer h.syncMu.RUnlock() + + if len(h.syncStatus) == 0 { + return nil + } + + copy := make(map[string]proxySyncState, len(h.syncStatus)) + for key, state := range h.syncStatus { + copy[key] = state + } + return copy +} + +func extractHostPart(raw string) string { + host := strings.TrimSpace(raw) + if host == "" { + return "" + } + + if strings.HasPrefix(host, "http://") || strings.HasPrefix(host, "https://") { + if parsed, err := url.Parse(host); err == nil { + return parsed.Hostname() + } + } + + if idx := strings.Index(host, "/"); idx != -1 { + host = host[:idx] + } + if idx := strings.Index(host, ":"); idx != -1 { + host = host[:idx] + } + return strings.TrimSpace(host) +} + +func buildAuthorizedNodeList(instance *config.PVEInstance) []authorizedNode { + if instance == nil { + return nil + } + + nodes := make([]authorizedNode, 0) + seen := make(map[string]struct{}) + add := func(name, ip string) { + name = strings.TrimSpace(name) + ip = strings.TrimSpace(ip) + if name == "" && ip == "" { + return + } + key := name + "|" + ip + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + nodes = append(nodes, authorizedNode{Name: name, IP: ip}) + } + + // Base instance host/name + add(instance.Name, extractHostPart(instance.Host)) + add(instance.Name, extractHostPart(instance.GuestURL)) + + if instance.ClusterEndpoints != nil { + for _, ep := range instance.ClusterEndpoints { + name := ep.NodeName + ip := ep.IP + if ip == "" { + ip = extractHostPart(ep.Host) + } + if name == "" { + name = ep.Host + } + add(name, ip) + } + } + + return nodes } // HandleRegister handles temperature proxy registration from the installer @@ -39,6 +171,7 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http var req struct { Hostname string `json:"hostname"` ProxyURL string `json:"proxy_url"` + Mode string `json:"mode"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -60,11 +193,23 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http return } - // Validate proxy URL format - if !strings.HasPrefix(proxyURL, "https://") { + mode := strings.ToLower(strings.TrimSpace(req.Mode)) + if mode == "" { + if proxyURL != "" { + mode = "http" + } else { + mode = "socket" + } + } + + isHTTPMode := mode == "http" + if isHTTPMode && !strings.HasPrefix(proxyURL, "https://") { writeErrorResponse(w, http.StatusBadRequest, "invalid_proxy_url", "Proxy URL must use HTTPS", nil) return } + if !isHTTPMode { + proxyURL = "" + } // Load current config nodesConfig, err := h.persistence.LoadNodesConfig() @@ -111,8 +256,17 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http return } - // Generate a secure random token - token, err := generateSecureToken(32) + // Generate tokens + authToken := "" + if isHTTPMode { + authToken, err = generateSecureToken(32) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate authentication token", map[string]string{"error": err.Error()}) + return + } + } + + ctrlToken, err := generateSecureToken(32) if err != nil { writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate authentication token", map[string]string{"error": err.Error()}) return @@ -120,7 +274,10 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http // Update the instance with proxy configuration nodesConfig.PVEInstances[matchedIndex].TemperatureProxyURL = proxyURL - nodesConfig.PVEInstances[matchedIndex].TemperatureProxyToken = token + if isHTTPMode { + nodesConfig.PVEInstances[matchedIndex].TemperatureProxyToken = authToken + } + nodesConfig.PVEInstances[matchedIndex].TemperatureProxyControlToken = ctrlToken // Save updated configuration if err := h.persistence.SaveNodesConfig(nodesConfig.PVEInstances, nodesConfig.PBSInstances, nodesConfig.PMGInstances); err != nil { @@ -131,14 +288,19 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http log.Info(). Str("hostname", hostname). Str("proxy_url", proxyURL). + Str("mode", mode). Str("pve_instance", matchedInstance.Name). Msg("Temperature proxy registered successfully") + allowed := buildAuthorizedNodeList(matchedInstance) resp := map[string]any{ - "success": true, - "token": token, - "pve_instance": matchedInstance.Name, - "message": fmt.Sprintf("Temperature proxy registered for instance '%s'", matchedInstance.Name), + "success": true, + "token": authToken, + "control_token": ctrlToken, + "pve_instance": matchedInstance.Name, + "allowed_nodes": allowed, + "refresh_interval": defaultProxyAllowlistRefreshSeconds, + "message": fmt.Sprintf("Temperature proxy registered for instance '%s'", matchedInstance.Name), } if err := utils.WriteJSONResponse(w, resp); err != nil { @@ -146,6 +308,97 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http } } +// HandleAuthorizedNodes returns the list of nodes Pulse has authorized for a proxy. +// +// GET /api/temperature-proxy/authorized-nodes +// Headers: +// +// X-Proxy-Token: +func (h *TemperatureProxyHandlers) HandleAuthorizedNodes(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil) + return + } + + token := strings.TrimSpace(r.Header.Get("X-Proxy-Token")) + if token == "" { + authHeader := strings.TrimSpace(r.Header.Get("Authorization")) + if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { + token = strings.TrimSpace(authHeader[7:]) + } + } + + if token == "" { + writeErrorResponse(w, http.StatusUnauthorized, "missing_token", "X-Proxy-Token header required", nil) + return + } + + nodesConfig, err := h.persistence.LoadNodesConfig() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "config_load_failed", "Failed to load configuration", map[string]string{"error": err.Error()}) + return + } + + var matched *config.PVEInstance + for i := range nodesConfig.PVEInstances { + inst := &nodesConfig.PVEInstances[i] + switch { + case strings.TrimSpace(inst.TemperatureProxyControlToken) == token: + matched = inst + case inst.TemperatureProxyControlToken == "" && strings.TrimSpace(inst.TemperatureProxyToken) == token: + // Legacy HTTP-mode proxies reuse TemperatureProxyToken + matched = inst + } + if matched != nil { + break + } + } + + refreshFromProxy := 0 + if hdr := strings.TrimSpace(r.Header.Get("X-Proxy-Refresh")); hdr != "" { + if val, err := strconv.Atoi(hdr); err == nil && val > 0 { + refreshFromProxy = val + } + } + + if matched == nil { + writeErrorResponse(w, http.StatusUnauthorized, "invalid_token", "Proxy token not recognized", nil) + return + } + + refreshInterval := defaultProxyAllowlistRefreshSeconds + if refreshFromProxy > 0 { + refreshInterval = refreshFromProxy + } + + nodes := buildAuthorizedNodeList(matched) + if len(nodes) == 0 { + // Always include at least the base instance name/host + nodes = append(nodes, authorizedNode{Name: matched.Name, IP: extractHostPart(matched.Host)}) + } + + hashMaterial := make([]string, 0, len(nodes)) + for _, node := range nodes { + hashMaterial = append(hashMaterial, fmt.Sprintf("%s|%s", node.Name, node.IP)) + } + sort.Strings(hashMaterial) + hashBytes := sha256.Sum256([]byte(strings.Join(hashMaterial, "\n"))) + + resp := map[string]interface{}{ + "instance": matched.Name, + "nodes": nodes, + "hash": hex.EncodeToString(hashBytes[:]), + "refresh_interval": refreshInterval, + "generated_at": time.Now().UTC(), + } + + if err := utils.WriteJSONResponse(w, resp); err != nil { + log.Error().Err(err).Msg("Failed to write authorized-nodes response") + } else { + h.recordSync(matched.Name, refreshInterval) + } +} + // HandleUnregister removes temperature proxy configuration from a PVE instance // // DELETE /api/temperature-proxy/unregister?hostname=pve1 @@ -200,6 +453,7 @@ func (h *TemperatureProxyHandlers) HandleUnregister(w http.ResponseWriter, r *ht // Clear proxy configuration nodesConfig.PVEInstances[matchedIndex].TemperatureProxyURL = "" nodesConfig.PVEInstances[matchedIndex].TemperatureProxyToken = "" + nodesConfig.PVEInstances[matchedIndex].TemperatureProxyControlToken = "" // Save updated configuration if err := h.persistence.SaveNodesConfig(nodesConfig.PVEInstances, nodesConfig.PBSInstances, nodesConfig.PMGInstances); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 58ddd6f05..ff499c3c3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -389,8 +389,8 @@ func splitAndTrim(value string) []string { // PVEInstance represents a Proxmox VE connection type PVEInstance struct { Name string - Host string // Primary endpoint (user-provided) - GuestURL string // Optional guest-accessible URL (for navigation) + Host string // Primary endpoint (user-provided) + GuestURL string // Optional guest-accessible URL (for navigation) User string Password string TokenName string @@ -409,6 +409,8 @@ type PVEInstance struct { // Temperature proxy configuration (for external PVE hosts) TemperatureProxyURL string // Optional HTTPS URL to pulse-sensor-proxy (e.g., https://pve1.lan:8443) TemperatureProxyToken string // Bearer token for proxy authentication + // Control-plane token for socket-mode proxies (Pulse -> proxy sync) + TemperatureProxyControlToken string // Cluster support IsCluster bool // True if this is a cluster @@ -418,16 +420,16 @@ type PVEInstance struct { // ClusterEndpoint represents a single node in a cluster type ClusterEndpoint struct { - NodeID string // Node ID in cluster - NodeName string // Node name - Host string // Full URL (e.g., https://node1.lan:8006) - GuestURL string // Optional guest-accessible URL (for navigation) - IP string // IP address - Online bool // Current online status from Proxmox - LastSeen time.Time // Last successful connection - PulseReachable *bool // Pulse's view: can Pulse reach this endpoint? nil = not yet checked - LastPulseCheck *time.Time // Last time Pulse checked connectivity - PulseError string // Last error Pulse encountered connecting to this endpoint + NodeID string // Node ID in cluster + NodeName string // Node name + Host string // Full URL (e.g., https://node1.lan:8006) + GuestURL string // Optional guest-accessible URL (for navigation) + IP string // IP address + Online bool // Current online status from Proxmox + LastSeen time.Time // Last successful connection + PulseReachable *bool // Pulse's view: can Pulse reach this endpoint? nil = not yet checked + LastPulseCheck *time.Time // Last time Pulse checked connectivity + PulseError string // Last error Pulse encountered connecting to this endpoint } // PBSInstance represents a Proxmox Backup Server connection @@ -627,21 +629,21 @@ func Load() (*Config, error) { // Always load DiscoveryEnabled even if false cfg.DiscoveryEnabled = systemSettings.DiscoveryEnabled if systemSettings.DiscoverySubnet != "" { - cfg.DiscoverySubnet = systemSettings.DiscoverySubnet - } - cfg.Discovery = NormalizeDiscoveryConfig(CloneDiscoveryConfig(systemSettings.DiscoveryConfig)) - cfg.TemperatureMonitoringEnabled = systemSettings.TemperatureMonitoringEnabled - // Load DNS cache timeout - if systemSettings.DNSCacheTimeout > 0 { - cfg.DNSCacheTimeout = time.Duration(systemSettings.DNSCacheTimeout) * time.Second - } - // Load SSH port - if systemSettings.SSHPort > 0 { - cfg.SSHPort = systemSettings.SSHPort - } else { - cfg.SSHPort = 22 // Default SSH port - } - // APIToken no longer loaded from system.json - only from .env + cfg.DiscoverySubnet = systemSettings.DiscoverySubnet + } + cfg.Discovery = NormalizeDiscoveryConfig(CloneDiscoveryConfig(systemSettings.DiscoveryConfig)) + cfg.TemperatureMonitoringEnabled = systemSettings.TemperatureMonitoringEnabled + // Load DNS cache timeout + if systemSettings.DNSCacheTimeout > 0 { + cfg.DNSCacheTimeout = time.Duration(systemSettings.DNSCacheTimeout) * time.Second + } + // Load SSH port + if systemSettings.SSHPort > 0 { + cfg.SSHPort = systemSettings.SSHPort + } else { + cfg.SSHPort = 22 // Default SSH port + } + // APIToken no longer loaded from system.json - only from .env log.Info(). Str("updateChannel", cfg.UpdateChannel). Str("logLevel", cfg.LogLevel). diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 3b95747cc..cfb32e4ac 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -3457,6 +3457,21 @@ func (m *Monitor) GetConnectionStatuses() map[string]bool { return statuses } +// HasSocketTemperatureProxy reports whether the local unix socket proxy is available. +func (m *Monitor) HasSocketTemperatureProxy() bool { + if m == nil { + return false + } + + m.mu.RLock() + defer m.mu.RUnlock() + + if m.tempCollector == nil { + return false + } + return m.tempCollector.SocketProxyAvailable() +} + // checkContainerizedTempMonitoring logs a security warning if Pulse is running // in a container with SSH-based temperature monitoring enabled func checkContainerizedTempMonitoring() { diff --git a/internal/monitoring/temperature.go b/internal/monitoring/temperature.go index ff64b56cf..86b7ab278 100644 --- a/internal/monitoring/temperature.go +++ b/internal/monitoring/temperature.go @@ -396,8 +396,8 @@ func (tc *TemperatureCollector) parseSensorsJSON(jsonStr string) (*models.Temper strings.Contains(chipLower, "nct6796") || // Nuvoton NCT6796 SuperIO strings.Contains(chipLower, "nct6797") || // Nuvoton NCT6797 SuperIO strings.Contains(chipLower, "nct6798") || // Nuvoton NCT6798 SuperIO - strings.Contains(chipLower, "w83627") || // Winbond W83627 SuperIO series - strings.Contains(chipLower, "f71882") || // Fintek F71882 SuperIO + strings.Contains(chipLower, "w83627") || // Winbond W83627 SuperIO series + strings.Contains(chipLower, "f71882") || // Fintek F71882 SuperIO strings.Contains(chipLower, "cpu_thermal") || // Raspberry Pi CPU temperature strings.Contains(chipLower, "rpitemp") { foundCPUChip = true @@ -817,6 +817,11 @@ func (tc *TemperatureCollector) isProxyEnabled() bool { return useProxy } +// SocketProxyAvailable reports whether the unix socket proxy can currently be used. +func (tc *TemperatureCollector) SocketProxyAvailable() bool { + return tc != nil && tc.isProxyEnabled() +} + func (tc *TemperatureCollector) handleProxySuccess() { if tc.proxyClient == nil { return diff --git a/scripts/install-sensor-proxy.sh b/scripts/install-sensor-proxy.sh index 29e0762e8..26a4117a3 100755 --- a/scripts/install-sensor-proxy.sh +++ b/scripts/install-sensor-proxy.sh @@ -90,15 +90,35 @@ update_allowed_nodes() { mkdir -p "$(dirname "$config_file")" touch "$config_file" + # Gather existing nodes (if any) to preserve manual edits + local existing_nodes=() + if command -v perl >/dev/null 2>&1; then + mapfile -t existing_nodes < <(perl -0ne 'if (m/allowed_nodes:\n((?:[ \t]+-[^\n]*\n)+)/m) { @lines = split /\n/, $1; for (@lines) { s/^[ \t-]+//; s/\s+$//; next unless $_; print "$_\n"; } }' "$config_file" 2>/dev/null || true) + fi + + # Merge and de-duplicate nodes + local merged_nodes=() + declare -A seen_nodes=() + for node in "${existing_nodes[@]}" "${nodes[@]}"; do + node=$(echo "$node" | awk '{$1=$1;print}') + if [[ -z "$node" ]]; then + continue + fi + if [[ -z "${seen_nodes[$node]+set}" ]]; then + merged_nodes+=("$node") + seen_nodes["$node"]=1 + fi + done + # Remove any existing allowed_nodes block (including descriptive comments) to prevent duplicates - perl -0pi -e 's/\n(?:[ ]*#[^\n]*\n)*allowed_nodes:\n(?:(?:[ ]+-[^\n]*|[ ]*#[^\n]*)\n)*//g' "$config_file" 2>/dev/null || true + perl -0pi -e 's/(?:^[ \t]*#[^\n]*\n)*allowed_nodes:\n(?:(?:[ \t]+-[^\n]*|[ \t]*#[^\n]*)\n)*//mg' "$config_file" 2>/dev/null || true { echo "" echo "# ${comment_line}" echo "# These nodes are allowed to request temperature data when cluster IPC validation is unavailable" echo "allowed_nodes:" - for node in "${nodes[@]}"; do + for node in "${merged_nodes[@]}"; do echo " - $node" done } >> "$config_file" @@ -468,6 +488,9 @@ FALLBACK_BASE="${PULSE_SENSOR_PROXY_FALLBACK_URL:-}" SKIP_RESTART=false UNINSTALL=false PURGE=false +CONTROL_PLANE_TOKEN="" +CONTROL_PLANE_REFRESH="" +SHORT_HOSTNAME=$(hostname -s 2>/dev/null || hostname | cut -d'.' -f1) while [[ $# -gt 0 ]]; do case $1 in @@ -981,33 +1004,45 @@ register_with_pulse() { fi done - # Parse token from response - local token - token=$(echo "$response" | grep -o '"token":"[^"]*"' | cut -d'"' -f4) - - if [[ -z "$token" ]]; then - print_error "Registration succeeded but no token received" - print_error "Response: $response" - return 1 - fi - - # Store token - echo "$token" > /etc/pulse-sensor-proxy/.http-auth-token - chmod 600 /etc/pulse-sensor-proxy/.http-auth-token - chown pulse-sensor-proxy:pulse-sensor-proxy /etc/pulse-sensor-proxy/.http-auth-token - # Output to stderr so it doesn't interfere with command substitution - print_success "Registered successfully - token received" >&2 + print_success "Registered successfully" >&2 - # Parse instance name from response for logging - local instance_name - instance_name=$(echo "$response" | grep -o '"pve_instance":"[^"]*"' | cut -d'"' -f4) - if [[ -n "$instance_name" ]]; then - print_info "Linked to PVE instance: $instance_name" >&2 + # Return full response for caller parsing + echo "$response" +} + +write_control_plane_token() { + local token="$1" + if [[ -z "$token" ]]; then + return + fi + print_info "Writing control plane token..." + echo "$token" > /etc/pulse-sensor-proxy/.pulse-control-token + chmod 600 /etc/pulse-sensor-proxy/.pulse-control-token + chown pulse-sensor-proxy:pulse-sensor-proxy /etc/pulse-sensor-proxy/.pulse-control-token +} + +ensure_control_plane_config() { + local pulse_url="$1" + local refresh="$2" + if [[ -z "$pulse_url" ]]; then + return + fi + if [[ -z "$refresh" ]]; then + refresh=60 + fi + if grep -q "^pulse_control_plane:" /etc/pulse-sensor-proxy/config.yaml 2>/dev/null; then + return fi - # Return token for caller to use (stdout only) - echo "$token" + cat >> /etc/pulse-sensor-proxy/config.yaml << EOF + +# Pulse control plane configuration (auto-generated) +pulse_control_plane: + url: "$pulse_url" + token_file: "/etc/pulse-sensor-proxy/.pulse-control-token" + refresh_interval: $refresh +EOF } # Create base config file if it doesn't exist @@ -1032,6 +1067,25 @@ EOF chmod 0644 /etc/pulse-sensor-proxy/config.yaml fi +# Register socket-mode proxy with Pulse if server provided +if [[ "$HTTP_MODE" != true ]]; then + if [[ -z "$PULSE_SERVER" ]]; then + print_warn "PULSE_SERVER not provided; control plane sync disabled. Temperatures will only work on this host." + else + print_info "Registering socket proxy with Pulse server ${PULSE_SERVER}..." + registration_response=$(register_with_pulse "$PULSE_SERVER" "$SHORT_HOSTNAME" "" "socket") + if [[ $? -eq 0 && -n "$registration_response" ]]; then + CONTROL_PLANE_TOKEN=$(echo "$registration_response" | grep -o '"control_token":"[^"]*"' | head -1 | cut -d'"' -f4) + CONTROL_PLANE_REFRESH=$(echo "$registration_response" | grep -o '"refresh_interval":[0-9]*' | head -1 | awk -F: '{print $2}') + if [[ -z "$CONTROL_PLANE_REFRESH" ]]; then + CONTROL_PLANE_REFRESH="60" + fi + else + print_warn "Failed to register socket proxy with Pulse; continuing without control plane sync" + fi + fi +fi + # HTTP Mode Configuration if [[ "$HTTP_MODE" == true ]]; then echo "" @@ -1114,16 +1168,31 @@ if [[ "$HTTP_MODE" == true ]]; then PROXY_URL="https://${PRIMARY_IP}${HTTP_ADDR}" print_info "Proxy will be accessible at: $PROXY_URL" - # Register with Pulse and get auth token - # Use short hostname for registration matching (PVE cluster endpoints use short names) - SHORT_HOSTNAME=$(hostname -s 2>/dev/null || hostname | cut -d'.' -f1) - HTTP_AUTH_TOKEN=$(register_with_pulse "$PULSE_SERVER" "$SHORT_HOSTNAME" "$PROXY_URL") - if [[ $? -ne 0 || -z "$HTTP_AUTH_TOKEN" ]]; then + # Register with Pulse and get auth/control tokens + registration_response=$(register_with_pulse "$PULSE_SERVER" "$SHORT_HOSTNAME" "$PROXY_URL" "http") + if [[ $? -ne 0 || -z "$registration_response" ]]; then print_error "Failed to register with Pulse - aborting installation" print_error "Fix the issue and re-run the installer" exit 1 fi + HTTP_AUTH_TOKEN=$(echo "$registration_response" | grep -o '"token":"[^"]*"' | head -1 | cut -d'"' -f4) + CONTROL_PLANE_TOKEN=$(echo "$registration_response" | grep -o '"control_token":"[^"]*"' | head -1 | cut -d'"' -f4) + CONTROL_PLANE_REFRESH=$(echo "$registration_response" | grep -o '"refresh_interval":[0-9]*' | head -1 | awk -F: '{print $2}') + if [[ -z "$CONTROL_PLANE_REFRESH" ]]; then + CONTROL_PLANE_REFRESH="60" + fi + + if [[ -z "$HTTP_AUTH_TOKEN" ]]; then + print_error "Registration succeeded but Pulse did not return an auth token" + print_error "Response: $registration_response" + exit 1 + fi + + echo "$HTTP_AUTH_TOKEN" > /etc/pulse-sensor-proxy/.http-auth-token + chmod 600 /etc/pulse-sensor-proxy/.http-auth-token + chown pulse-sensor-proxy:pulse-sensor-proxy /etc/pulse-sensor-proxy/.http-auth-token + # Backup config before modifying if [[ -f /etc/pulse-sensor-proxy/config.yaml ]]; then BACKUP_CONFIG="/etc/pulse-sensor-proxy/config.yaml.backup.$(date +%s)" @@ -1176,6 +1245,11 @@ EOF echo "" fi +if [[ -n "$CONTROL_PLANE_TOKEN" && -n "$PULSE_SERVER" ]]; then + write_control_plane_token "$CONTROL_PLANE_TOKEN" + ensure_control_plane_config "$PULSE_SERVER" "$CONTROL_PLANE_REFRESH" +fi + # Stop existing service if running (for upgrades) if systemctl is-active --quiet pulse-sensor-proxy 2>/dev/null; then print_info "Stopping existing service for upgrade..." diff --git a/scripts/migrate-sensor-proxy-control-plane.sh b/scripts/migrate-sensor-proxy-control-plane.sh new file mode 100644 index 000000000..335ea30bd --- /dev/null +++ b/scripts/migrate-sensor-proxy-control-plane.sh @@ -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" <