mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
Remove legacy V1 handlers replaced by V2 versions: - sendError (replaced by sendErrorV2) - handleGetStatus (replaced by handleGetStatusV2) - handleEnsureClusterKeys (replaced by handleEnsureClusterKeysV2) - handleRegisterNodes (replaced by handleRegisterNodesV2) - handleGetTemperature (replaced by handleGetTemperatureV2) Also remove related unused functions: - getPublicKey wrapper (only getPublicKeyFrom is used) - pushSSHKey wrapper (only pushSSHKeyFrom is used) - nodeValidator.ipAllowed method (standalone ipAllowed is used) - validateConfigFile (never called) - runServiceDebug (Windows debug mode, never called)
620 lines
18 KiB
Go
620 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/spf13/cobra"
|
|
"golang.org/x/sys/unix"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
var (
|
|
// Config command flags
|
|
configPathFlag string
|
|
allowedNodesPathFlag string
|
|
mergeNodesFlag []string
|
|
replaceMode bool
|
|
|
|
// New flags for extended commands
|
|
controlPlaneURL string
|
|
controlPlaneToken string
|
|
controlPlaneRefresh int
|
|
|
|
httpEnabled bool
|
|
httpAddr string
|
|
httpAuthToken string
|
|
httpTLSCert string
|
|
httpTLSKey string
|
|
)
|
|
|
|
var configCmd = &cobra.Command{
|
|
Use: "config",
|
|
Short: "Manage sensor proxy configuration",
|
|
Long: `Atomic configuration management for pulse-sensor-proxy`,
|
|
}
|
|
|
|
var validateCmd = &cobra.Command{
|
|
Use: "validate",
|
|
Short: "Validate configuration files",
|
|
Long: `Parse and validate config.yaml and allowed_nodes.yaml files`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cfgPath := configPathFlag
|
|
if cfgPath == "" {
|
|
cfgPath = defaultConfigPath
|
|
}
|
|
|
|
// Use loadConfig to parse config properly and get the actual allowed_nodes_file path
|
|
cfg, err := loadConfig(cfgPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Config validation failed: %v\n", err)
|
|
return err
|
|
}
|
|
|
|
// Determine allowed_nodes path from config (honors allowed_nodes_file setting)
|
|
allowedNodesPath := allowedNodesPathFlag
|
|
if allowedNodesPath == "" {
|
|
if cfg.AllowedNodesFile != "" {
|
|
allowedNodesPath = cfg.AllowedNodesFile
|
|
} else {
|
|
// Default fallback
|
|
allowedNodesPath = filepath.Join(filepath.Dir(cfgPath), "allowed_nodes.yaml")
|
|
}
|
|
}
|
|
|
|
// Check if allowed_nodes.yaml exists and validate it
|
|
// Empty lists are allowed (admin may clear for security or cluster relies on IPC validation)
|
|
if _, err := os.Stat(allowedNodesPath); err == nil {
|
|
if err := validateAllowedNodesFile(allowedNodesPath); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Allowed nodes validation failed: %v\n", err)
|
|
return err
|
|
}
|
|
}
|
|
|
|
fmt.Println("Configuration valid")
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var setAllowedNodesCmd = &cobra.Command{
|
|
Use: "set-allowed-nodes",
|
|
Short: "Atomically update allowed_nodes.yaml",
|
|
Long: `Merge or replace allowed nodes with atomic writes and file locking.
|
|
|
|
Examples:
|
|
# Merge new nodes into existing list
|
|
pulse-sensor-proxy config set-allowed-nodes --merge 192.168.0.1 --merge node1.local
|
|
|
|
# Replace entire list
|
|
pulse-sensor-proxy config set-allowed-nodes --replace --merge 192.168.0.1 --merge 192.168.0.2
|
|
`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
allowedNodesPath := allowedNodesPathFlag
|
|
if allowedNodesPath == "" {
|
|
// Default to /etc/pulse-sensor-proxy/allowed_nodes.yaml
|
|
allowedNodesPath = "/etc/pulse-sensor-proxy/allowed_nodes.yaml"
|
|
}
|
|
|
|
// Allow zero nodes when using --replace (admin may want to clear the list)
|
|
if len(mergeNodesFlag) == 0 && !replaceMode {
|
|
return fmt.Errorf("no nodes specified (use --merge flag, or --replace to clear)")
|
|
}
|
|
|
|
if err := setAllowedNodes(allowedNodesPath, mergeNodesFlag, replaceMode); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Failed to update allowed nodes: %v\n", err)
|
|
return err
|
|
}
|
|
|
|
if replaceMode {
|
|
fmt.Printf("Replaced allowed nodes with %d entries\n", len(mergeNodesFlag))
|
|
} else {
|
|
fmt.Printf("Merged %d nodes into allowed nodes list\n", len(mergeNodesFlag))
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var migrateToFileCmd = &cobra.Command{
|
|
Use: "migrate-to-file",
|
|
Short: "Migrate inline allowed_nodes to file-based configuration",
|
|
Long: `Atomically migrates inline allowed_nodes block from config.yaml to allowed_nodes.yaml.
|
|
|
|
This command:
|
|
- Extracts nodes from inline allowed_nodes block in config.yaml
|
|
- Removes the inline block from config.yaml
|
|
- Ensures allowed_nodes_file is set in config.yaml
|
|
- Writes nodes to allowed_nodes.yaml
|
|
- All operations are atomic with file locking
|
|
|
|
Safe to run multiple times (idempotent).
|
|
`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cfgPath := configPathFlag
|
|
if cfgPath == "" {
|
|
cfgPath = defaultConfigPath
|
|
}
|
|
|
|
allowedNodesPath := allowedNodesPathFlag
|
|
if allowedNodesPath == "" {
|
|
allowedNodesPath = filepath.Join(filepath.Dir(cfgPath), "allowed_nodes.yaml")
|
|
}
|
|
|
|
if err := migrateInlineToFile(cfgPath, allowedNodesPath); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Migration failed: %v\n", err)
|
|
return err
|
|
}
|
|
|
|
fmt.Println("Migration complete: inline allowed_nodes moved to file")
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var addSubnetCmd = &cobra.Command{
|
|
Use: "add-subnet [subnet]",
|
|
Short: "Add an allowed source subnet",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
subnet := args[0]
|
|
cfgPath := configPathFlag
|
|
if cfgPath == "" {
|
|
cfgPath = defaultConfigPath
|
|
}
|
|
|
|
return updateConfigMap(cfgPath, func(config map[string]interface{}) error {
|
|
var subnets []string
|
|
if existing, ok := config["allowed_source_subnets"]; ok {
|
|
if list, ok := existing.([]interface{}); ok {
|
|
for _, item := range list {
|
|
if s, ok := item.(string); ok {
|
|
subnets = append(subnets, s)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if already exists
|
|
for _, s := range subnets {
|
|
if s == subnet {
|
|
return nil // Already exists
|
|
}
|
|
}
|
|
|
|
subnets = append(subnets, subnet)
|
|
config["allowed_source_subnets"] = subnets
|
|
return nil
|
|
})
|
|
},
|
|
}
|
|
|
|
var setControlPlaneCmd = &cobra.Command{
|
|
Use: "set-control-plane",
|
|
Short: "Configure Pulse control plane connection",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cfgPath := configPathFlag
|
|
if cfgPath == "" {
|
|
cfgPath = defaultConfigPath
|
|
}
|
|
|
|
return updateConfigMap(cfgPath, func(config map[string]interface{}) error {
|
|
cp := make(map[string]interface{})
|
|
if existing, ok := config["pulse_control_plane"]; ok {
|
|
if m, ok := existing.(map[string]interface{}); ok {
|
|
cp = m
|
|
}
|
|
}
|
|
|
|
if controlPlaneURL != "" {
|
|
cp["url"] = controlPlaneURL
|
|
}
|
|
if controlPlaneToken != "" {
|
|
cp["token_file"] = controlPlaneToken
|
|
}
|
|
if controlPlaneRefresh > 0 {
|
|
cp["refresh_interval"] = controlPlaneRefresh
|
|
}
|
|
|
|
config["pulse_control_plane"] = cp
|
|
return nil
|
|
})
|
|
},
|
|
}
|
|
|
|
var setHTTPCmd = &cobra.Command{
|
|
Use: "set-http",
|
|
Short: "Configure HTTP mode settings",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cfgPath := configPathFlag
|
|
if cfgPath == "" {
|
|
cfgPath = defaultConfigPath
|
|
}
|
|
|
|
return updateConfigMap(cfgPath, func(config map[string]interface{}) error {
|
|
if cmd.Flags().Changed("enabled") {
|
|
config["http_enabled"] = httpEnabled
|
|
}
|
|
if httpAddr != "" {
|
|
config["http_listen_addr"] = httpAddr
|
|
}
|
|
if httpAuthToken != "" {
|
|
config["http_auth_token"] = httpAuthToken
|
|
}
|
|
if httpTLSCert != "" {
|
|
config["http_tls_cert"] = httpTLSCert
|
|
}
|
|
if httpTLSKey != "" {
|
|
config["http_tls_key"] = httpTLSKey
|
|
}
|
|
return nil
|
|
})
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
// Add subcommands to config command
|
|
configCmd.AddCommand(validateCmd)
|
|
configCmd.AddCommand(setAllowedNodesCmd)
|
|
configCmd.AddCommand(migrateToFileCmd)
|
|
configCmd.AddCommand(addSubnetCmd)
|
|
configCmd.AddCommand(setControlPlaneCmd)
|
|
configCmd.AddCommand(setHTTPCmd)
|
|
|
|
// Validate command flags
|
|
validateCmd.Flags().StringVar(&configPathFlag, "config", "", "Path to config.yaml (default: /etc/pulse-sensor-proxy/config.yaml)")
|
|
validateCmd.Flags().StringVar(&allowedNodesPathFlag, "allowed-nodes", "", "Path to allowed_nodes.yaml (default: same dir as config)")
|
|
|
|
// Set-allowed-nodes command flags
|
|
setAllowedNodesCmd.Flags().StringVar(&allowedNodesPathFlag, "allowed-nodes", "", "Path to allowed_nodes.yaml (default: /etc/pulse-sensor-proxy/allowed_nodes.yaml)")
|
|
setAllowedNodesCmd.Flags().StringSliceVar(&mergeNodesFlag, "merge", []string{}, "Node to merge (can be specified multiple times)")
|
|
setAllowedNodesCmd.Flags().BoolVar(&replaceMode, "replace", false, "Replace entire list instead of merging")
|
|
|
|
// Migrate-to-file command flags
|
|
migrateToFileCmd.Flags().StringVar(&configPathFlag, "config", "", "Path to config.yaml (default: /etc/pulse-sensor-proxy/config.yaml)")
|
|
migrateToFileCmd.Flags().StringVar(&allowedNodesPathFlag, "allowed-nodes", "", "Path to allowed_nodes.yaml (default: same dir as config)")
|
|
|
|
// Add-subnet command flags
|
|
addSubnetCmd.Flags().StringVar(&configPathFlag, "config", "", "Path to config.yaml")
|
|
|
|
// Set-control-plane command flags
|
|
setControlPlaneCmd.Flags().StringVar(&configPathFlag, "config", "", "Path to config.yaml")
|
|
setControlPlaneCmd.Flags().StringVar(&controlPlaneURL, "url", "", "Control plane URL")
|
|
setControlPlaneCmd.Flags().StringVar(&controlPlaneToken, "token-file", "", "Path to token file")
|
|
setControlPlaneCmd.Flags().IntVar(&controlPlaneRefresh, "refresh", 0, "Refresh interval in seconds")
|
|
|
|
// Set-http command flags
|
|
setHTTPCmd.Flags().StringVar(&configPathFlag, "config", "", "Path to config.yaml")
|
|
setHTTPCmd.Flags().BoolVar(&httpEnabled, "enabled", false, "Enable HTTP mode")
|
|
setHTTPCmd.Flags().StringVar(&httpAddr, "listen-addr", "", "HTTP listen address")
|
|
setHTTPCmd.Flags().StringVar(&httpAuthToken, "auth-token", "", "HTTP auth token")
|
|
setHTTPCmd.Flags().StringVar(&httpTLSCert, "tls-cert", "", "TLS certificate path")
|
|
setHTTPCmd.Flags().StringVar(&httpTLSKey, "tls-key", "", "TLS key path")
|
|
|
|
// Add config command to root
|
|
rootCmd.AddCommand(configCmd)
|
|
}
|
|
|
|
// updateConfigMap safely updates the config file using a map representation
|
|
func updateConfigMap(path string, updateFn func(map[string]interface{}) error) error {
|
|
lockPath := path + ".lock"
|
|
return withLockedFile(lockPath, func(f *os.File) error {
|
|
// Read current config
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
data = []byte("{}\n")
|
|
} else {
|
|
return fmt.Errorf("failed to read config: %w", err)
|
|
}
|
|
}
|
|
|
|
// Sanitize duplicate blocks before parsing
|
|
_, data = sanitizeDuplicateAllowedNodesBlocks(path, data)
|
|
|
|
var config map[string]interface{}
|
|
if err := yaml.Unmarshal(data, &config); err != nil {
|
|
return fmt.Errorf("failed to parse config: %w", err)
|
|
}
|
|
|
|
if config == nil {
|
|
config = make(map[string]interface{})
|
|
}
|
|
|
|
// Apply updates
|
|
if err := updateFn(config); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write back
|
|
newData, err := yaml.Marshal(config)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal config: %w", err)
|
|
}
|
|
|
|
// Preserve header comment if possible (simple heuristic)
|
|
header := "# Managed by pulse-sensor-proxy config CLI\n"
|
|
finalData := []byte(header + string(newData))
|
|
|
|
return atomicWriteFile(path, finalData, 0644)
|
|
})
|
|
}
|
|
|
|
// validateAllowedNodesFile parses and validates the allowed_nodes.yaml file
|
|
func validateAllowedNodesFile(path string) error {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read allowed_nodes file: %w", err)
|
|
}
|
|
|
|
// Parse YAML - can be either a dict with allowed_nodes key or a list
|
|
var result interface{}
|
|
if err := yaml.Unmarshal(data, &result); err != nil {
|
|
return fmt.Errorf("failed to parse allowed_nodes YAML: %w", err)
|
|
}
|
|
|
|
// Extract nodes (empty list is valid - admin may clear for security)
|
|
// Clusters can also rely on IPC-based validation instead of static allowlists
|
|
var nodes []string
|
|
switch v := result.(type) {
|
|
case map[string]interface{}:
|
|
if nodeList, ok := v["allowed_nodes"]; ok {
|
|
if list, ok := nodeList.([]interface{}); ok {
|
|
for _, item := range list {
|
|
if s, ok := item.(string); ok && s != "" {
|
|
nodes = append(nodes, s)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
case []interface{}:
|
|
for _, item := range v {
|
|
if s, ok := item.(string); ok && s != "" {
|
|
nodes = append(nodes, s)
|
|
}
|
|
}
|
|
case nil:
|
|
// Empty file is valid
|
|
return nil
|
|
}
|
|
|
|
// Empty list is allowed - don't enforce minimum
|
|
return nil
|
|
}
|
|
|
|
// setAllowedNodes atomically updates the allowed_nodes.yaml file with file locking
|
|
func setAllowedNodes(path string, newNodes []string, replace bool) error {
|
|
// Create directory if it doesn't exist
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create config directory: %w", err)
|
|
}
|
|
|
|
// Use a separate lock file that persists across renames
|
|
lockPath := path + ".lock"
|
|
return withLockedFile(lockPath, func(f *os.File) error {
|
|
var existing []string
|
|
|
|
// Read existing nodes if not in replace mode
|
|
if !replace {
|
|
if data, err := os.ReadFile(path); err == nil {
|
|
existing = extractNodesFromYAML(data)
|
|
}
|
|
}
|
|
|
|
// Merge and deduplicate
|
|
merged := normalizeNodes(append(existing, newNodes...))
|
|
|
|
// Allow empty lists (admin may want to clear the file)
|
|
// Serialize to YAML
|
|
output := map[string]interface{}{
|
|
"allowed_nodes": merged,
|
|
}
|
|
data, err := yaml.Marshal(output)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal YAML: %w", err)
|
|
}
|
|
|
|
// Add header comment
|
|
header := "# Managed by pulse-sensor-proxy config CLI\n# Do not edit manually while service is running\n"
|
|
finalData := []byte(header + string(data))
|
|
|
|
// Write atomically while holding lock
|
|
return atomicWriteFile(path, finalData, 0644)
|
|
})
|
|
}
|
|
|
|
// withLockedFile opens a lock file with exclusive locking and runs a callback
|
|
//
|
|
// IMPORTANT: If future commands need to modify multiple files, use consistent lock ordering
|
|
// to avoid deadlocks (e.g., always lock config.yaml.lock before allowed_nodes.yaml.lock)
|
|
func withLockedFile(lockPath string, fn func(f *os.File) error) error {
|
|
// Open or create the lock file (never deleted, persists across renames)
|
|
// Use 0600 to prevent unprivileged users from holding LOCK_EX and DoS'ing the installer
|
|
f, err := os.OpenFile(lockPath, os.O_RDWR|os.O_CREATE, 0600)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open lock file: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
// Ensure correct permissions even if file already exists with broader perms
|
|
if err := f.Chmod(0600); err != nil {
|
|
return fmt.Errorf("failed to set lock file permissions: %w", err)
|
|
}
|
|
|
|
// Acquire exclusive lock
|
|
if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil {
|
|
return fmt.Errorf("failed to acquire file lock: %w", err)
|
|
}
|
|
defer unix.Flock(int(f.Fd()), unix.LOCK_UN) //nolint:errcheck
|
|
|
|
// Run callback while holding lock
|
|
return fn(f)
|
|
}
|
|
|
|
// atomicWriteFile writes data to a file atomically using temp file + rename
|
|
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
|
dir := filepath.Dir(path)
|
|
|
|
// Create temp file in same directory
|
|
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
tmpPath := tmp.Name()
|
|
|
|
// Clean up temp file on error
|
|
defer func() {
|
|
if tmpPath != "" {
|
|
os.Remove(tmpPath)
|
|
}
|
|
}()
|
|
|
|
// Write data
|
|
if _, err := tmp.Write(data); err != nil {
|
|
tmp.Close()
|
|
return fmt.Errorf("failed to write temp file: %w", err)
|
|
}
|
|
|
|
// Sync to disk
|
|
if err := tmp.Sync(); err != nil {
|
|
tmp.Close()
|
|
return fmt.Errorf("failed to sync temp file: %w", err)
|
|
}
|
|
|
|
// Close temp file
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("failed to close temp file: %w", err)
|
|
}
|
|
|
|
// Set permissions
|
|
if err := os.Chmod(tmpPath, perm); err != nil {
|
|
return fmt.Errorf("failed to set permissions: %w", err)
|
|
}
|
|
|
|
// Atomic rename
|
|
if err := os.Rename(tmpPath, path); err != nil {
|
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
|
}
|
|
|
|
// Mark temp file as successfully moved (don't delete in defer)
|
|
tmpPath = ""
|
|
|
|
// Sync directory to ensure rename is persisted
|
|
dirFile, err := os.Open(dir)
|
|
if err == nil {
|
|
dirFile.Sync() //nolint:errcheck
|
|
dirFile.Close()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// extractNodesFromYAML extracts node list from YAML data
|
|
func extractNodesFromYAML(data []byte) []string {
|
|
var result interface{}
|
|
if err := yaml.Unmarshal(data, &result); err != nil {
|
|
return nil
|
|
}
|
|
|
|
var nodes []string
|
|
switch v := result.(type) {
|
|
case map[string]interface{}:
|
|
if nodeList, ok := v["allowed_nodes"]; ok {
|
|
if list, ok := nodeList.([]interface{}); ok {
|
|
for _, item := range list {
|
|
if s, ok := item.(string); ok && s != "" {
|
|
nodes = append(nodes, s)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
case []interface{}:
|
|
for _, item := range v {
|
|
if s, ok := item.(string); ok && s != "" {
|
|
nodes = append(nodes, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nodes
|
|
}
|
|
|
|
// migrateInlineToFile atomically migrates inline allowed_nodes from config.yaml to allowed_nodes.yaml
|
|
func migrateInlineToFile(configPath, allowedNodesPath string) error {
|
|
configLockPath := configPath + ".lock"
|
|
allowedNodesLockPath := allowedNodesPath + ".lock"
|
|
|
|
// Lock both files in consistent order to prevent deadlocks
|
|
// Always lock config.yaml before allowed_nodes.yaml
|
|
return withLockedFile(configLockPath, func(configLock *os.File) error {
|
|
return withLockedFile(allowedNodesLockPath, func(allowedNodesLock *os.File) error {
|
|
// Read current config
|
|
configData, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read config: %w", err)
|
|
}
|
|
|
|
// Sanitize duplicate blocks before parsing
|
|
_, configData = sanitizeDuplicateAllowedNodesBlocks(configPath, configData)
|
|
|
|
// Parse config to extract inline nodes
|
|
var config map[string]interface{}
|
|
if err := yaml.Unmarshal(configData, &config); err != nil {
|
|
return fmt.Errorf("failed to parse config: %w", err)
|
|
}
|
|
|
|
// Extract inline nodes (if any)
|
|
var inlineNodes []string
|
|
if allowedNodes, ok := config["allowed_nodes"]; ok {
|
|
if nodeList, ok := allowedNodes.([]interface{}); ok {
|
|
for _, node := range nodeList {
|
|
if s, ok := node.(string); ok && s != "" {
|
|
inlineNodes = append(inlineNodes, s)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove inline allowed_nodes block from config
|
|
delete(config, "allowed_nodes")
|
|
|
|
// Ensure allowed_nodes_file is set
|
|
if _, exists := config["allowed_nodes_file"]; !exists {
|
|
config["allowed_nodes_file"] = allowedNodesPath
|
|
}
|
|
|
|
// Write updated config atomically
|
|
newConfigData, err := yaml.Marshal(config)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal config: %w", err)
|
|
}
|
|
|
|
if err := atomicWriteFile(configPath, newConfigData, 0644); err != nil {
|
|
return fmt.Errorf("failed to write config: %w", err)
|
|
}
|
|
|
|
// Merge inline nodes with existing file nodes (if any)
|
|
var existingNodes []string
|
|
if data, err := os.ReadFile(allowedNodesPath); err == nil {
|
|
existingNodes = extractNodesFromYAML(data)
|
|
}
|
|
|
|
// Combine and deduplicate
|
|
allNodes := normalizeNodes(append(existingNodes, inlineNodes...))
|
|
|
|
// Write allowed_nodes.yaml atomically
|
|
output := map[string]interface{}{
|
|
"allowed_nodes": allNodes,
|
|
}
|
|
allowedNodesData, err := yaml.Marshal(output)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal allowed_nodes: %w", err)
|
|
}
|
|
|
|
header := "# Managed by pulse-sensor-proxy config CLI\n# Do not edit manually while service is running\n"
|
|
finalData := []byte(header + string(allowedNodesData))
|
|
|
|
return atomicWriteFile(allowedNodesPath, finalData, 0644)
|
|
})
|
|
})
|
|
}
|