371 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			371 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
package main
 | 
						|
 | 
						|
import (
 | 
						|
	"flag"
 | 
						|
	"fmt"
 | 
						|
	"os"
 | 
						|
	"path/filepath"
 | 
						|
	"strings"
 | 
						|
	"text/template"
 | 
						|
 | 
						|
	"git.site.quack-lab.dev/dave/cylogger"
 | 
						|
	"github.com/BurntSushi/toml"
 | 
						|
	"github.com/sergi/go-diff/diffmatchpatch"
 | 
						|
)
 | 
						|
 | 
						|
type ServiceConfig struct {
 | 
						|
	Domains  []string `toml:"domains"`
 | 
						|
	Backend  string   `toml:"backend"`
 | 
						|
	IPRange  string   `toml:"ip_range"`
 | 
						|
}
 | 
						|
 | 
						|
type FileConfig struct {
 | 
						|
	Services []ServiceConfig `toml:"services"`
 | 
						|
}
 | 
						|
 | 
						|
type Config struct {
 | 
						|
	IPRanges map[string]string `toml:"ip_ranges"`
 | 
						|
	Files    map[string]FileConfig `toml:"files"`
 | 
						|
}
 | 
						|
 | 
						|
// Template for generating Caddy configuration
 | 
						|
const caddyTemplate = `{{- range $service := .Services -}}
 | 
						|
{{ join $service.Domains " " }} {
 | 
						|
    @lan {
 | 
						|
        remote_ip {{ $service.IPRange }}
 | 
						|
    }
 | 
						|
    handle @lan {
 | 
						|
        reverse_proxy {{ $service.Backend }}
 | 
						|
    }
 | 
						|
    handle {
 | 
						|
        respond "Njet Molotoff" 403
 | 
						|
    }
 | 
						|
}
 | 
						|
{{ end }}`
 | 
						|
 | 
						|
// Global variables
 | 
						|
var (
 | 
						|
	dryRun   = flag.Bool("dry-run", false, "Show what would be done without making changes")
 | 
						|
	force    = flag.Bool("force", false, "Force apply changes")
 | 
						|
	delete   = flag.Bool("d", false, "Delete files not in TOML config")
 | 
						|
	tomlFile = flag.String("config", "services.toml", "Path to TOML configuration file")
 | 
						|
)
 | 
						|
 | 
						|
func main() {
 | 
						|
	flag.Parse()
 | 
						|
	cylogger.InitFlag() // Automatically reads -loglevel flag
 | 
						|
 | 
						|
	logger := cylogger.Default.WithPrefix(fmt.Sprintf("config=%s", *tomlFile))
 | 
						|
	logger.Info("Starting Caddy configuration sync")
 | 
						|
	logger.Debug("Flags: dry-run=%v, force=%v, delete=%v", *dryRun, *force, *delete)
 | 
						|
 | 
						|
	if *delete {
 | 
						|
		logger.Warning("Delete mode enabled - will remove files not in TOML")
 | 
						|
	}
 | 
						|
 | 
						|
	if *dryRun {
 | 
						|
		logger.Info("Dry-run mode enabled - no changes will be made")
 | 
						|
	}
 | 
						|
 | 
						|
	// Parse TOML configuration
 | 
						|
	logger.Debug("Loading configuration from TOML")
 | 
						|
	config, err := loadConfig(*tomlFile)
 | 
						|
	if err != nil {
 | 
						|
		logger.Error("Failed to load configuration: %v", err)
 | 
						|
		os.Exit(1)
 | 
						|
	}
 | 
						|
	logger.Debug("Successfully loaded configuration")
 | 
						|
 | 
						|
	// Validate configuration
 | 
						|
	logger.Debug("Validating configuration")
 | 
						|
	if err := validateConfig(config); err != nil {
 | 
						|
		logger.Error("Configuration validation failed: %v", err)
 | 
						|
		os.Exit(1)
 | 
						|
	}
 | 
						|
	logger.Debug("Configuration validation passed")
 | 
						|
 | 
						|
	// Perform synchronization
 | 
						|
	logger.Debug("Starting synchronization")
 | 
						|
	if err := syncConfigs(config); err != nil {
 | 
						|
		logger.Error("Synchronization failed: %v", err)
 | 
						|
		os.Exit(1)
 | 
						|
	}
 | 
						|
 | 
						|
	logger.Info("Caddy configuration sync completed successfully")
 | 
						|
}
 | 
						|
 | 
						|
func loadConfig(filename string) (*Config, error) {
 | 
						|
	logger := cylogger.Default.WithPrefix(fmt.Sprintf("file=%s", filename))
 | 
						|
	logger.Debug("Loading TOML configuration")
 | 
						|
 | 
						|
	var config Config
 | 
						|
 | 
						|
	_, err := toml.DecodeFile(filename, &config)
 | 
						|
	if err != nil {
 | 
						|
		return nil, fmt.Errorf("failed to decode TOML: %w", err)
 | 
						|
	}
 | 
						|
 | 
						|
	logger.Trace("Loaded %d IP ranges and %d files", len(config.IPRanges), len(config.Files))
 | 
						|
	return &config, nil
 | 
						|
}
 | 
						|
 | 
						|
func validateConfig(config *Config) error {
 | 
						|
	// Validate IP ranges are defined
 | 
						|
	for filename, fileConfig := range config.Files {
 | 
						|
		for i, service := range fileConfig.Services {
 | 
						|
			if service.IPRange == "" {
 | 
						|
				return fmt.Errorf("service %d in file %s has no IP range specified", i+1, filename)
 | 
						|
			}
 | 
						|
 | 
						|
			if _, exists := config.IPRanges[service.IPRange]; !exists {
 | 
						|
				return fmt.Errorf("IP range '%s' not defined for service %d in file %s", service.IPRange, i+1, filename)
 | 
						|
			}
 | 
						|
 | 
						|
			if len(service.Domains) == 0 {
 | 
						|
				return fmt.Errorf("service %d in file %s has no domains specified", i+1, filename)
 | 
						|
			}
 | 
						|
 | 
						|
			if service.Backend == "" {
 | 
						|
				return fmt.Errorf("service %d in file %s has no backend specified", i+1, filename)
 | 
						|
			}
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
func syncConfigs(config *Config) error {
 | 
						|
	// Get current directory
 | 
						|
	currentDir, err := os.Getwd()
 | 
						|
	if err != nil {
 | 
						|
		return fmt.Errorf("failed to get current directory: %w", err)
 | 
						|
	}
 | 
						|
	logger := cylogger.Default
 | 
						|
 | 
						|
	// Get existing .caddy files
 | 
						|
	logger.Debug("Scanning for existing .caddy files")
 | 
						|
	existingFiles, err := getExistingCaddyFiles(currentDir)
 | 
						|
	if err != nil {
 | 
						|
		return fmt.Errorf("failed to get existing .caddy files: %w", err)
 | 
						|
	}
 | 
						|
	logger.Debug("Found %d existing .caddy files", len(existingFiles))
 | 
						|
 | 
						|
	// Track which files we've processed
 | 
						|
	processedFiles := make(map[string]bool)
 | 
						|
	var created, updated, unchanged int
 | 
						|
 | 
						|
	// Process each file in configuration
 | 
						|
	logger.Debug("Processing %d configured files", len(config.Files))
 | 
						|
	for filename, fileConfig := range config.Files {
 | 
						|
		caddyFilename := filename + ".caddy"
 | 
						|
		processedFiles[caddyFilename] = true
 | 
						|
		fileLogger := logger.WithPrefix(fmt.Sprintf("file=%s", caddyFilename))
 | 
						|
		fileLogger.Trace("Processing")
 | 
						|
 | 
						|
		// Generate expected content
 | 
						|
		expectedContent, err := generateCaddyContent(fileConfig, config.IPRanges)
 | 
						|
		if err != nil {
 | 
						|
			return fmt.Errorf("failed to generate content for %s: %w", caddyFilename, err)
 | 
						|
		}
 | 
						|
 | 
						|
		// Check if file exists and compare content
 | 
						|
		existingContent, exists := existingFiles[caddyFilename]
 | 
						|
 | 
						|
		if !exists {
 | 
						|
			// File doesn't exist, create it
 | 
						|
			fileLogger.Info("Creating new file")
 | 
						|
			created++
 | 
						|
			if !*dryRun {
 | 
						|
				if err := writeFile(caddyFilename, expectedContent); err != nil {
 | 
						|
					return fmt.Errorf("failed to create file %s: %w", caddyFilename, err)
 | 
						|
				}
 | 
						|
			}
 | 
						|
		} else if !contentMatches(existingContent, expectedContent) {
 | 
						|
			// File exists but content differs
 | 
						|
			fileLogger.Info("File content differs, updating")
 | 
						|
			updated++
 | 
						|
 | 
						|
			// Show detailed diff of what's changing
 | 
						|
			generateAndLogDiff(fileLogger, caddyFilename, existingContent, expectedContent)
 | 
						|
 | 
						|
			if !*dryRun {
 | 
						|
				if err := writeFile(caddyFilename, expectedContent); err != nil {
 | 
						|
					return fmt.Errorf("failed to update file %s: %w", caddyFilename, err)
 | 
						|
				}
 | 
						|
			}
 | 
						|
		} else {
 | 
						|
			// File exists and content matches
 | 
						|
			fileLogger.Debug("File content matches, no action needed")
 | 
						|
			unchanged++
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	// Handle orphaned files (files that exist but are not in config)
 | 
						|
	var orphanedCount int
 | 
						|
	for filename := range existingFiles {
 | 
						|
		if !processedFiles[filename] {
 | 
						|
			orphanedCount++
 | 
						|
			orphanLogger := logger.WithPrefix(fmt.Sprintf("orphan=%s", filename))
 | 
						|
			if *delete {
 | 
						|
				orphanLogger.Info("Deleting orphaned file")
 | 
						|
				if !*dryRun {
 | 
						|
					if err := os.Remove(filename); err != nil {
 | 
						|
						return fmt.Errorf("failed to delete file %s: %w", filename, err)
 | 
						|
					}
 | 
						|
				}
 | 
						|
			} else {
 | 
						|
				orphanLogger.Warning("Found orphaned file not in configuration (use -d to delete)")
 | 
						|
			}
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	logger.Debug("Sync summary: created=%d, updated=%d, unchanged=%d, orphaned=%d",
 | 
						|
		created, updated, unchanged, orphanedCount)
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
func generateCaddyContent(fileConfig FileConfig, ipRanges map[string]string) (string, error) {
 | 
						|
	logger := cylogger.Default.WithPrefix(fmt.Sprintf("template services=%d", len(fileConfig.Services)))
 | 
						|
	logger.Debug("Generating Caddy content")
 | 
						|
 | 
						|
	// Create template data with resolved IP ranges
 | 
						|
	templateData := struct {
 | 
						|
		Services []ServiceConfig
 | 
						|
	}{
 | 
						|
		Services: make([]ServiceConfig, len(fileConfig.Services)),
 | 
						|
	}
 | 
						|
 | 
						|
	// Resolve IP ranges for each service
 | 
						|
	for i, service := range fileConfig.Services {
 | 
						|
		logger.Trace("Resolving service %d: domains=%v, backend=%s, ip_range=%s",
 | 
						|
			i, service.Domains, service.Backend, service.IPRange)
 | 
						|
 | 
						|
		templateData.Services[i] = ServiceConfig{
 | 
						|
			Domains:  service.Domains,
 | 
						|
			Backend:  service.Backend,
 | 
						|
			IPRange:  ipRanges[service.IPRange],
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	// Create template with join function
 | 
						|
	funcMap := template.FuncMap{
 | 
						|
		"join": strings.Join,
 | 
						|
	}
 | 
						|
 | 
						|
	tmpl, err := template.New("caddy").Funcs(funcMap).Parse(caddyTemplate)
 | 
						|
	if err != nil {
 | 
						|
		return "", fmt.Errorf("failed to parse template: %w", err)
 | 
						|
	}
 | 
						|
 | 
						|
	var result strings.Builder
 | 
						|
	if err := tmpl.Execute(&result, templateData); err != nil {
 | 
						|
		return "", fmt.Errorf("failed to execute template: %w", err)
 | 
						|
	}
 | 
						|
 | 
						|
	content := result.String()
 | 
						|
	logger.Trace("Generated content:\n%s", content)
 | 
						|
	return content, nil
 | 
						|
}
 | 
						|
 | 
						|
func getExistingCaddyFiles(dir string) (map[string]string, error) {
 | 
						|
	logger := cylogger.Default.WithPrefix(fmt.Sprintf("scanner dir=%s", dir))
 | 
						|
	logger.Debug("Scanning directory for .caddy files")
 | 
						|
 | 
						|
	files := make(map[string]string)
 | 
						|
 | 
						|
	entries, err := os.ReadDir(dir)
 | 
						|
	if err != nil {
 | 
						|
		return nil, fmt.Errorf("failed to read directory: %w", err)
 | 
						|
	}
 | 
						|
 | 
						|
	for _, entry := range entries {
 | 
						|
		if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".caddy") {
 | 
						|
			// Skip services.toml and other non-caddy files
 | 
						|
			if entry.Name() == "services.toml" {
 | 
						|
				continue
 | 
						|
			}
 | 
						|
 | 
						|
			logger.Trace("Found .caddy file: %s", entry.Name())
 | 
						|
			content, err := os.ReadFile(filepath.Join(dir, entry.Name()))
 | 
						|
			if err != nil {
 | 
						|
				return nil, fmt.Errorf("failed to read file %s: %w", entry.Name(), err)
 | 
						|
			}
 | 
						|
			files[entry.Name()] = string(content)
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	logger.Debug("Scanned %d files, found %d .caddy files", len(entries), len(files))
 | 
						|
	return files, nil
 | 
						|
}
 | 
						|
 | 
						|
func writeFile(filename, content string) error {
 | 
						|
	return os.WriteFile(filename, []byte(content), 0644)
 | 
						|
}
 | 
						|
 | 
						|
func generateAndLogDiff(logger *cylogger.Logger, filename, existingContent, expectedContent string) {
 | 
						|
	logger.Info("Changes needed for %s:", filename)
 | 
						|
 | 
						|
	// ANSI color codes
 | 
						|
	const (
 | 
						|
		ColorRed     = "\033[31m"
 | 
						|
		ColorGreen   = "\033[32m"
 | 
						|
		ColorReset   = "\033[0m"
 | 
						|
	)
 | 
						|
 | 
						|
	dmp := diffmatchpatch.New()
 | 
						|
	diffs := dmp.DiffMain(existingContent, expectedContent, false)
 | 
						|
 | 
						|
	// Use the library's built-in pretty diff formatting
 | 
						|
	prettyDiff := dmp.DiffPrettyText(diffs)
 | 
						|
 | 
						|
	// Print the pretty diff with colors
 | 
						|
	lines := strings.Split(prettyDiff, "\n")
 | 
						|
	for _, line := range lines {
 | 
						|
		if line == "" {
 | 
						|
			continue
 | 
						|
		}
 | 
						|
 | 
						|
		if strings.HasPrefix(line, "+") {
 | 
						|
			fmt.Printf("%s%s%s\n", ColorGreen, line, ColorReset)
 | 
						|
		} else if strings.HasPrefix(line, "-") {
 | 
						|
			fmt.Printf("%s%s%s\n", ColorRed, line, ColorReset)
 | 
						|
		} else {
 | 
						|
			fmt.Printf("%s\n", line)
 | 
						|
		}
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
func contentMatches(existing, expected string) bool {
 | 
						|
	dmp := diffmatchpatch.New()
 | 
						|
	diffs := dmp.DiffMain(normalize(existing), normalize(expected), false)
 | 
						|
 | 
						|
	// If there are any insertions or deletions, content differs
 | 
						|
	for _, diff := range diffs {
 | 
						|
		if diff.Type == diffmatchpatch.DiffInsert || diff.Type == diffmatchpatch.DiffDelete {
 | 
						|
			cylogger.Trace("Content differs at %q", diff.Text)
 | 
						|
			return false
 | 
						|
		}
 | 
						|
	}
 | 
						|
	return true
 | 
						|
}
 | 
						|
 | 
						|
func normalize(text string) string {
 | 
						|
	// Standardize line endings
 | 
						|
	text = strings.ReplaceAll(text, "\r\n", "\n")
 | 
						|
	text = strings.ReplaceAll(text, "\r", "\n")
 | 
						|
 | 
						|
	// Split into lines and normalize each
 | 
						|
	lines := strings.Split(text, "\n")
 | 
						|
	var normalizedLines []string
 | 
						|
 | 
						|
	for _, line := range lines {
 | 
						|
		trimmed := strings.TrimSpace(line)
 | 
						|
		if trimmed != "" {  // Skip empty lines
 | 
						|
			normalizedLines = append(normalizedLines, trimmed)
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	// Join with single newlines and trim ends
 | 
						|
	result := strings.Join(normalizedLines, "\n")
 | 
						|
	return strings.TrimSpace(result)
 | 
						|
} |