mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
Improve temperature proxy control-plane flow
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
120
docs/temperature-proxy-control-plane.md
Normal file
120
docs/temperature-proxy-control-plane.md
Normal 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).
|
||||
|
||||
@@ -30,6 +30,7 @@ export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: bool
|
||||
const [lxcCtid, setLxcCtid] = createSignal<string>('');
|
||||
const [dockerContainerName, setDockerContainerName] = createSignal<string>('');
|
||||
const [showAlternatives, setShowAlternatives] = createSignal(false);
|
||||
const [isValidatingToken, setIsValidatingToken] = createSignal(false);
|
||||
|
||||
const applyTheme = (mode: 'system' | 'light' | 'dark') => {
|
||||
if (mode === 'light') {
|
||||
@@ -102,13 +103,39 @@ export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: bool
|
||||
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
|
||||
const handleUnlock = () => {
|
||||
const handleUnlock = async () => {
|
||||
if (!bootstrapToken().trim()) {
|
||||
showError('Please enter the bootstrap token');
|
||||
return;
|
||||
}
|
||||
// Simple client-side unlock - actual validation happens during setup
|
||||
setIsUnlocked(true);
|
||||
|
||||
setIsValidatingToken(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/security/validate-bootstrap-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: bootstrapToken().trim() }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(error || 'Invalid bootstrap setup token');
|
||||
}
|
||||
|
||||
setIsUnlocked(true);
|
||||
showSuccess('Bootstrap token verified. Continue with setup.');
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
showError(error.message || 'Failed to validate bootstrap token');
|
||||
} else {
|
||||
showError('Failed to validate bootstrap token');
|
||||
}
|
||||
} finally {
|
||||
setIsValidatingToken(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetup = async () => {
|
||||
@@ -399,7 +426,7 @@ IMPORTANT: Keep these credentials secure!
|
||||
type="text"
|
||||
value={bootstrapToken()}
|
||||
onInput={(e) => setBootstrapToken(e.currentTarget.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleUnlock()}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleUnlock()}
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
|
||||
placeholder="Paste the token from your host"
|
||||
autofocus
|
||||
@@ -423,10 +450,10 @@ IMPORTANT: Keep these credentials secure!
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUnlock}
|
||||
disabled={!bootstrapToken().trim()}
|
||||
disabled={isValidatingToken() || !bootstrapToken().trim()}
|
||||
class="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white rounded-lg font-medium transition-colors disabled:cursor-not-allowed"
|
||||
>
|
||||
Unlock Wizard
|
||||
{isValidatingToken() ? 'Validating...' : 'Unlock Wizard'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,14 @@ interface TemperatureProxyHTTPStatus {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface TemperatureProxyControlPlaneState {
|
||||
instance: string;
|
||||
lastSync?: string;
|
||||
refreshIntervalSeconds?: number;
|
||||
secondsBehind?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface TemperatureProxyDiagnostic {
|
||||
legacySSHDetected: boolean;
|
||||
recommendProxyUpgrade: boolean;
|
||||
@@ -155,6 +163,8 @@ interface TemperatureProxyDiagnostic {
|
||||
proxyCapabilities?: string[];
|
||||
notes?: string[];
|
||||
httpProxies?: TemperatureProxyHTTPStatus[];
|
||||
controlPlaneEnabled?: boolean;
|
||||
controlPlaneStates?: TemperatureProxyControlPlaneState[];
|
||||
}
|
||||
|
||||
interface APITokenSummary {
|
||||
@@ -792,6 +802,33 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||
return formatRelativeTime(timestamp);
|
||||
};
|
||||
|
||||
const controlPlaneStatusLabel = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return 'Healthy';
|
||||
case 'stale':
|
||||
return 'Behind';
|
||||
case 'offline':
|
||||
return 'Offline';
|
||||
case 'pending':
|
||||
default:
|
||||
return 'Pending';
|
||||
}
|
||||
};
|
||||
|
||||
const controlPlaneStatusClass = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return 'bg-green-500';
|
||||
case 'stale':
|
||||
return 'bg-yellow-500';
|
||||
case 'offline':
|
||||
return 'bg-red-500';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const formatUptime = (seconds: number) => {
|
||||
if (!seconds || seconds <= 0) {
|
||||
return 'Unknown';
|
||||
@@ -814,15 +851,26 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||
};
|
||||
|
||||
const emitTemperatureProxyWarnings = (diag: DiagnosticsData | null) => {
|
||||
if (!diag?.temperatureProxy?.httpProxies) {
|
||||
if (!diag?.temperatureProxy) {
|
||||
return;
|
||||
}
|
||||
const failing = (diag.temperatureProxy.httpProxies as TemperatureProxyHTTPStatus[]).filter(
|
||||
(proxy) => proxy && proxy.node && !proxy.reachable,
|
||||
);
|
||||
if (failing.length > 0) {
|
||||
const nodes = failing.map((proxy) => proxy.node || 'Unknown').join(', ');
|
||||
showWarning(`Pulse cannot reach HTTPS temperature proxy on: ${nodes}`);
|
||||
if (diag.temperatureProxy.httpProxies) {
|
||||
const failing = (diag.temperatureProxy.httpProxies as TemperatureProxyHTTPStatus[]).filter(
|
||||
(proxy) => proxy && proxy.node && !proxy.reachable,
|
||||
);
|
||||
if (failing.length > 0) {
|
||||
const nodes = failing.map((proxy) => proxy.node || 'Unknown').join(', ');
|
||||
showWarning(`Pulse cannot reach HTTPS temperature proxy on: ${nodes}`);
|
||||
}
|
||||
}
|
||||
if (diag.temperatureProxy.controlPlaneStates) {
|
||||
const stale = (diag.temperatureProxy.controlPlaneStates as TemperatureProxyControlPlaneState[]).filter(
|
||||
(state) => state && (state.status === 'stale' || state.status === 'offline'),
|
||||
);
|
||||
if (stale.length > 0) {
|
||||
const names = stale.map((state) => state.instance || 'Proxy').join(', ');
|
||||
showWarning(`Temperature proxy control plane is behind on: ${names}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5210,17 +5258,71 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||
<Show when={typeof temp().legacySshKeyCount === 'number'}>
|
||||
<div>Legacy SSH keys: {temp().legacySshKeyCount ?? 0}</div>
|
||||
</Show>
|
||||
<Show when={temp().legacySSHDetected}>
|
||||
<div class="text-red-500">
|
||||
Legacy SSH temperature collection detected
|
||||
<Show when={temp().legacySSHDetected}>
|
||||
<div class="text-red-500">
|
||||
Legacy SSH temperature collection detected
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={
|
||||
temp().controlPlaneStates &&
|
||||
(temp().controlPlaneStates as TemperatureProxyControlPlaneState[]).length > 0
|
||||
}
|
||||
>
|
||||
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold text-gray-700 dark:text-gray-200">
|
||||
Control plane sync
|
||||
</div>
|
||||
</Show>
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-white text-xs ${
|
||||
temp().controlPlaneEnabled ? 'bg-green-500' : 'bg-gray-500'
|
||||
}`}
|
||||
>
|
||||
{temp().controlPlaneEnabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<For each={temp().controlPlaneStates || []}>
|
||||
{(state) => (
|
||||
<div class="rounded border border-gray-200 dark:border-gray-700 px-2 py-1.5 space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-medium text-gray-700 dark:text-gray-200">
|
||||
{state.instance || 'Proxy'}
|
||||
</div>
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-white text-xs ${controlPlaneStatusClass(
|
||||
state.status,
|
||||
)}`}
|
||||
>
|
||||
{controlPlaneStatusLabel(state.status)}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={state.lastSync}>
|
||||
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
|
||||
Last sync: {formatIsoRelativeTime(state.lastSync)}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={typeof state.secondsBehind === 'number' && (state.secondsBehind || 0) > 0}>
|
||||
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
|
||||
Behind by ~{formatUptime(state.secondsBehind || 0)}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={state.refreshIntervalSeconds}>
|
||||
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
|
||||
Target interval: {formatUptime(state.refreshIntervalSeconds || 0)}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show
|
||||
when={
|
||||
temp().httpProxies && (temp().httpProxies as TemperatureProxyHTTPStatus[]).length > 0
|
||||
}
|
||||
>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
temp().httpProxies && (temp().httpProxies as TemperatureProxyHTTPStatus[]).length > 0
|
||||
}
|
||||
>
|
||||
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-2">
|
||||
<div class="font-semibold text-gray-700 dark:text-gray-200">
|
||||
HTTPS proxies
|
||||
|
||||
@@ -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 {
|
||||
|
||||
22
install.sh
22
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
58
internal/api/config_handlers_temperature_test.go
Normal file
58
internal/api/config_handlers_temperature_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "/")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -2,11 +2,18 @@ package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||
@@ -16,11 +23,136 @@ import (
|
||||
// TemperatureProxyHandlers manages temperature proxy registration
|
||||
type TemperatureProxyHandlers struct {
|
||||
persistence *config.ConfigPersistence
|
||||
syncMu sync.RWMutex
|
||||
syncStatus map[string]proxySyncState
|
||||
}
|
||||
|
||||
type proxySyncState struct {
|
||||
Instance string
|
||||
LastPull time.Time
|
||||
RefreshInterval int
|
||||
}
|
||||
|
||||
const defaultProxyAllowlistRefreshSeconds = 60
|
||||
|
||||
type authorizedNode struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
}
|
||||
|
||||
// NewTemperatureProxyHandlers constructs a new handler set for temperature proxy
|
||||
func NewTemperatureProxyHandlers(persistence *config.ConfigPersistence) *TemperatureProxyHandlers {
|
||||
return &TemperatureProxyHandlers{persistence: persistence}
|
||||
return &TemperatureProxyHandlers{
|
||||
persistence: persistence,
|
||||
syncStatus: make(map[string]proxySyncState),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *TemperatureProxyHandlers) recordSync(instance string, refreshSeconds int) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
|
||||
instance = strings.TrimSpace(instance)
|
||||
if instance == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if refreshSeconds <= 0 {
|
||||
refreshSeconds = defaultProxyAllowlistRefreshSeconds
|
||||
}
|
||||
|
||||
h.syncMu.Lock()
|
||||
defer h.syncMu.Unlock()
|
||||
|
||||
key := strings.ToLower(instance)
|
||||
h.syncStatus[key] = proxySyncState{
|
||||
Instance: instance,
|
||||
LastPull: time.Now(),
|
||||
RefreshInterval: refreshSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *TemperatureProxyHandlers) SnapshotSyncStatus() map[string]proxySyncState {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
h.syncMu.RLock()
|
||||
defer h.syncMu.RUnlock()
|
||||
|
||||
if len(h.syncStatus) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
copy := make(map[string]proxySyncState, len(h.syncStatus))
|
||||
for key, state := range h.syncStatus {
|
||||
copy[key] = state
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
func extractHostPart(raw string) string {
|
||||
host := strings.TrimSpace(raw)
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.HasPrefix(host, "http://") || strings.HasPrefix(host, "https://") {
|
||||
if parsed, err := url.Parse(host); err == nil {
|
||||
return parsed.Hostname()
|
||||
}
|
||||
}
|
||||
|
||||
if idx := strings.Index(host, "/"); idx != -1 {
|
||||
host = host[:idx]
|
||||
}
|
||||
if idx := strings.Index(host, ":"); idx != -1 {
|
||||
host = host[:idx]
|
||||
}
|
||||
return strings.TrimSpace(host)
|
||||
}
|
||||
|
||||
func buildAuthorizedNodeList(instance *config.PVEInstance) []authorizedNode {
|
||||
if instance == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
nodes := make([]authorizedNode, 0)
|
||||
seen := make(map[string]struct{})
|
||||
add := func(name, ip string) {
|
||||
name = strings.TrimSpace(name)
|
||||
ip = strings.TrimSpace(ip)
|
||||
if name == "" && ip == "" {
|
||||
return
|
||||
}
|
||||
key := name + "|" + ip
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
nodes = append(nodes, authorizedNode{Name: name, IP: ip})
|
||||
}
|
||||
|
||||
// Base instance host/name
|
||||
add(instance.Name, extractHostPart(instance.Host))
|
||||
add(instance.Name, extractHostPart(instance.GuestURL))
|
||||
|
||||
if instance.ClusterEndpoints != nil {
|
||||
for _, ep := range instance.ClusterEndpoints {
|
||||
name := ep.NodeName
|
||||
ip := ep.IP
|
||||
if ip == "" {
|
||||
ip = extractHostPart(ep.Host)
|
||||
}
|
||||
if name == "" {
|
||||
name = ep.Host
|
||||
}
|
||||
add(name, ip)
|
||||
}
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
// HandleRegister handles temperature proxy registration from the installer
|
||||
@@ -39,6 +171,7 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http
|
||||
var req struct {
|
||||
Hostname string `json:"hostname"`
|
||||
ProxyURL string `json:"proxy_url"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -60,11 +193,23 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http
|
||||
return
|
||||
}
|
||||
|
||||
// Validate proxy URL format
|
||||
if !strings.HasPrefix(proxyURL, "https://") {
|
||||
mode := strings.ToLower(strings.TrimSpace(req.Mode))
|
||||
if mode == "" {
|
||||
if proxyURL != "" {
|
||||
mode = "http"
|
||||
} else {
|
||||
mode = "socket"
|
||||
}
|
||||
}
|
||||
|
||||
isHTTPMode := mode == "http"
|
||||
if isHTTPMode && !strings.HasPrefix(proxyURL, "https://") {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "invalid_proxy_url", "Proxy URL must use HTTPS", nil)
|
||||
return
|
||||
}
|
||||
if !isHTTPMode {
|
||||
proxyURL = ""
|
||||
}
|
||||
|
||||
// Load current config
|
||||
nodesConfig, err := h.persistence.LoadNodesConfig()
|
||||
@@ -111,8 +256,17 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a secure random token
|
||||
token, err := generateSecureToken(32)
|
||||
// Generate tokens
|
||||
authToken := ""
|
||||
if isHTTPMode {
|
||||
authToken, err = generateSecureToken(32)
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate authentication token", map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctrlToken, err := generateSecureToken(32)
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate authentication token", map[string]string{"error": err.Error()})
|
||||
return
|
||||
@@ -120,7 +274,10 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http
|
||||
|
||||
// Update the instance with proxy configuration
|
||||
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyURL = proxyURL
|
||||
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyToken = token
|
||||
if isHTTPMode {
|
||||
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyToken = authToken
|
||||
}
|
||||
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyControlToken = ctrlToken
|
||||
|
||||
// Save updated configuration
|
||||
if err := h.persistence.SaveNodesConfig(nodesConfig.PVEInstances, nodesConfig.PBSInstances, nodesConfig.PMGInstances); err != nil {
|
||||
@@ -131,14 +288,19 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http
|
||||
log.Info().
|
||||
Str("hostname", hostname).
|
||||
Str("proxy_url", proxyURL).
|
||||
Str("mode", mode).
|
||||
Str("pve_instance", matchedInstance.Name).
|
||||
Msg("Temperature proxy registered successfully")
|
||||
|
||||
allowed := buildAuthorizedNodeList(matchedInstance)
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"token": token,
|
||||
"pve_instance": matchedInstance.Name,
|
||||
"message": fmt.Sprintf("Temperature proxy registered for instance '%s'", matchedInstance.Name),
|
||||
"success": true,
|
||||
"token": authToken,
|
||||
"control_token": ctrlToken,
|
||||
"pve_instance": matchedInstance.Name,
|
||||
"allowed_nodes": allowed,
|
||||
"refresh_interval": defaultProxyAllowlistRefreshSeconds,
|
||||
"message": fmt.Sprintf("Temperature proxy registered for instance '%s'", matchedInstance.Name),
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, resp); err != nil {
|
||||
@@ -146,6 +308,97 @@ func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAuthorizedNodes returns the list of nodes Pulse has authorized for a proxy.
|
||||
//
|
||||
// GET /api/temperature-proxy/authorized-nodes
|
||||
// Headers:
|
||||
//
|
||||
// X-Proxy-Token: <control-plane token>
|
||||
func (h *TemperatureProxyHandlers) HandleAuthorizedNodes(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil)
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(r.Header.Get("X-Proxy-Token"))
|
||||
if token == "" {
|
||||
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
token = strings.TrimSpace(authHeader[7:])
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
writeErrorResponse(w, http.StatusUnauthorized, "missing_token", "X-Proxy-Token header required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
nodesConfig, err := h.persistence.LoadNodesConfig()
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "config_load_failed", "Failed to load configuration", map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var matched *config.PVEInstance
|
||||
for i := range nodesConfig.PVEInstances {
|
||||
inst := &nodesConfig.PVEInstances[i]
|
||||
switch {
|
||||
case strings.TrimSpace(inst.TemperatureProxyControlToken) == token:
|
||||
matched = inst
|
||||
case inst.TemperatureProxyControlToken == "" && strings.TrimSpace(inst.TemperatureProxyToken) == token:
|
||||
// Legacy HTTP-mode proxies reuse TemperatureProxyToken
|
||||
matched = inst
|
||||
}
|
||||
if matched != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
refreshFromProxy := 0
|
||||
if hdr := strings.TrimSpace(r.Header.Get("X-Proxy-Refresh")); hdr != "" {
|
||||
if val, err := strconv.Atoi(hdr); err == nil && val > 0 {
|
||||
refreshFromProxy = val
|
||||
}
|
||||
}
|
||||
|
||||
if matched == nil {
|
||||
writeErrorResponse(w, http.StatusUnauthorized, "invalid_token", "Proxy token not recognized", nil)
|
||||
return
|
||||
}
|
||||
|
||||
refreshInterval := defaultProxyAllowlistRefreshSeconds
|
||||
if refreshFromProxy > 0 {
|
||||
refreshInterval = refreshFromProxy
|
||||
}
|
||||
|
||||
nodes := buildAuthorizedNodeList(matched)
|
||||
if len(nodes) == 0 {
|
||||
// Always include at least the base instance name/host
|
||||
nodes = append(nodes, authorizedNode{Name: matched.Name, IP: extractHostPart(matched.Host)})
|
||||
}
|
||||
|
||||
hashMaterial := make([]string, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
hashMaterial = append(hashMaterial, fmt.Sprintf("%s|%s", node.Name, node.IP))
|
||||
}
|
||||
sort.Strings(hashMaterial)
|
||||
hashBytes := sha256.Sum256([]byte(strings.Join(hashMaterial, "\n")))
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"instance": matched.Name,
|
||||
"nodes": nodes,
|
||||
"hash": hex.EncodeToString(hashBytes[:]),
|
||||
"refresh_interval": refreshInterval,
|
||||
"generated_at": time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, resp); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write authorized-nodes response")
|
||||
} else {
|
||||
h.recordSync(matched.Name, refreshInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUnregister removes temperature proxy configuration from a PVE instance
|
||||
//
|
||||
// DELETE /api/temperature-proxy/unregister?hostname=pve1
|
||||
@@ -200,6 +453,7 @@ func (h *TemperatureProxyHandlers) HandleUnregister(w http.ResponseWriter, r *ht
|
||||
// Clear proxy configuration
|
||||
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyURL = ""
|
||||
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyToken = ""
|
||||
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyControlToken = ""
|
||||
|
||||
// Save updated configuration
|
||||
if err := h.persistence.SaveNodesConfig(nodesConfig.PVEInstances, nodesConfig.PBSInstances, nodesConfig.PMGInstances); err != nil {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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..."
|
||||
|
||||
141
scripts/migrate-sensor-proxy-control-plane.sh
Normal file
141
scripts/migrate-sensor-proxy-control-plane.sh
Normal 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."
|
||||
Reference in New Issue
Block a user