diff --git a/cmd/pulse-sensor-proxy/config_cmd.go b/cmd/pulse-sensor-proxy/config_cmd.go index e46e00a..d525a1a 100644 --- a/cmd/pulse-sensor-proxy/config_cmd.go +++ b/cmd/pulse-sensor-proxy/config_cmd.go @@ -12,10 +12,21 @@ import ( var ( // Config command flags - configPathFlag string - allowedNodesPathFlag string - mergeNodesFlag []string - replaceMode bool + 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{ @@ -139,11 +150,114 @@ Safe to run multiple times (idempotent). }, } +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)") @@ -158,10 +272,72 @@ func init() { 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) + }) +} + // validateConfigFile parses and validates the main config file func validateConfigFile(path string) error { data, err := os.ReadFile(path) diff --git a/scripts/install-sensor-proxy.sh b/scripts/install-sensor-proxy.sh index 33f63e3..189dc7f 100755 --- a/scripts/install-sensor-proxy.sh +++ b/scripts/install-sensor-proxy.sh @@ -154,6 +154,16 @@ ensure_allowed_source_subnet() { return fi + # Use robust binary config management if available + if [[ -x "$BINARY_PATH" ]] && "$BINARY_PATH" config add-subnet --help >/dev/null 2>&1; then + if "$BINARY_PATH" config add-subnet "$subnet" --config "$CONFIG_FILE"; then + print_info "Added allowed_source_subnets entry ${subnet}" + return + else + print_warn "Failed to add subnet using binary; falling back to legacy method" + fi + fi + local escaped_subnet="${subnet//\//\\/}" if grep -Eq "^[[:space:]]+-[[:space:]]*${escaped_subnet}([[:space:]]|$)" "$CONFIG_FILE"; then return @@ -163,16 +173,24 @@ ensure_allowed_source_subnet() { tmp=$(mktemp) if grep -Eq "^[[:space:]]*allowed_source_subnets:" "$CONFIG_FILE"; then - awk -v subnet="$subnet" ' -/^allowed_source_subnets:/ {print; in_block=1; next} + awk -v subnet="$subnet" ' +/^allowed_source_subnets:/ {print; in_block=1; indent=" "; next} +in_block && /^[[:space:]]+-/ { + # Capture indentation from existing items + if (!captured) { + match($0, /^[[:space:]]+/) + indent = substr($0, RSTART, RLENGTH) + captured = 1 + } +} in_block && /^[^[:space:]]/ { - if (!added) { printf(" - %s\n", subnet); added=1 } + if (!added) { printf("%s- %s\n", indent, subnet); added=1 } in_block=0 } {print} END { if (in_block && !added) { - printf(" - %s\n", subnet) + printf("%s- %s\n", indent, subnet) } } ' "$CONFIG_FILE" > "$tmp" @@ -181,7 +199,7 @@ END { { echo "" echo "allowed_source_subnets:" - echo " - $subnet" + echo " - $subnet" } >> "$tmp" fi @@ -1659,6 +1677,17 @@ ensure_control_plane_config() { refresh=60 fi + # Use robust binary config management if available + if [[ -x "$BINARY_PATH" ]] && "$BINARY_PATH" config set-control-plane --help >/dev/null 2>&1; then + if "$BINARY_PATH" config set-control-plane --url "$pulse_url" --token-file "/etc/pulse-sensor-proxy/.pulse-control-token" --refresh "$refresh" --config "$config_file"; then + chown pulse-sensor-proxy:pulse-sensor-proxy "$config_file" + chmod 0644 "$config_file" + return + else + print_warn "Failed to set control plane using binary; falling back to legacy method" + fi + fi + if grep -q "^pulse_control_plane:" "$config_file" 2>/dev/null; then # Re-write the existing control-plane block with the latest URL/token path. local tmp @@ -1970,16 +1999,30 @@ if [[ "$HTTP_MODE" == true ]]; then # Configure HTTP mode - check if already configured to avoid duplicates print_info "Configuring HTTP mode..." - if grep -q "^http_enabled:" "$CONFIG_FILE" 2>/dev/null; then - # HTTP mode already configured - only update the token (avoid duplicates) - sed -i "s|^http_auth_token:.*|http_auth_token: $HTTP_AUTH_TOKEN|" "$CONFIG_FILE" - for subnet in "${HTTP_ALLOWED_SUBNETS[@]}"; do - ensure_allowed_source_subnet "$subnet" - done - print_info "Updated HTTP auth token (existing HTTP mode configuration kept)" - else - # Fresh HTTP mode configuration - append to file - cat >> "$CONFIG_FILE" << EOF + if [[ -x "$BINARY_PATH" ]] && "$BINARY_PATH" config set-http --help >/dev/null 2>&1; then + if "$BINARY_PATH" config set-http \ + --enabled=true \ + --listen-addr="$HTTP_ADDR" \ + --auth-token="$HTTP_AUTH_TOKEN" \ + --tls-cert="/etc/pulse-sensor-proxy/tls/server.crt" \ + --tls-key="/etc/pulse-sensor-proxy/tls/server.key" \ + --config="$CONFIG_FILE"; then + + for subnet in "${HTTP_ALLOWED_SUBNETS[@]}"; do + ensure_allowed_source_subnet "$subnet" + done + print_info "HTTP mode configured successfully (using binary)" + else + print_warn "Failed to set HTTP config using binary; falling back to legacy method" + # Fallback to legacy logic + if grep -q "^http_enabled:" "$CONFIG_FILE" 2>/dev/null; then + sed -i "s|^http_auth_token:.*|http_auth_token: $HTTP_AUTH_TOKEN|" "$CONFIG_FILE" + for subnet in "${HTTP_ALLOWED_SUBNETS[@]}"; do + ensure_allowed_source_subnet "$subnet" + done + print_info "Updated HTTP auth token (existing HTTP mode configuration kept)" + else + cat >> "$CONFIG_FILE" << EOF # HTTP Mode Configuration (External PVE Host) http_enabled: true @@ -1991,9 +2034,37 @@ http_auth_token: "$HTTP_AUTH_TOKEN" # Allow HTTP connections from Pulse server, localhost, and this host allowed_source_subnets: EOF - for subnet in "${HTTP_ALLOWED_SUBNETS[@]}"; do - echo " - $subnet" >> "$CONFIG_FILE" - done + for subnet in "${HTTP_ALLOWED_SUBNETS[@]}"; do + echo " - $subnet" >> "$CONFIG_FILE" + done + fi + fi + else + if grep -q "^http_enabled:" "$CONFIG_FILE" 2>/dev/null; then + # HTTP mode already configured - only update the token (avoid duplicates) + sed -i "s|^http_auth_token:.*|http_auth_token: $HTTP_AUTH_TOKEN|" "$CONFIG_FILE" + for subnet in "${HTTP_ALLOWED_SUBNETS[@]}"; do + ensure_allowed_source_subnet "$subnet" + done + print_info "Updated HTTP auth token (existing HTTP mode configuration kept)" + else + # Fresh HTTP mode configuration - append to file + cat >> "$CONFIG_FILE" << EOF + +# HTTP Mode Configuration (External PVE Host) +http_enabled: true +http_listen_addr: "$HTTP_ADDR" +http_tls_cert: /etc/pulse-sensor-proxy/tls/server.crt +http_tls_key: /etc/pulse-sensor-proxy/tls/server.key +http_auth_token: "$HTTP_AUTH_TOKEN" + +# Allow HTTP connections from Pulse server, localhost, and this host +allowed_source_subnets: +EOF + for subnet in "${HTTP_ALLOWED_SUBNETS[@]}"; do + echo " - $subnet" >> "$CONFIG_FILE" + done + fi fi chown pulse-sensor-proxy:pulse-sensor-proxy "$CONFIG_FILE" chmod 0644 "$CONFIG_FILE"