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.WithPrefix(fmt.Sprintf("dir=%s", currentDir)) // 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 normalizeContent(existingContent) != normalizeContent(expectedContent) { // File exists but content differs fileLogger.Warning("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.Warning("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 normalizeContent(content string) string { // Normalize content for comparison by trimming whitespace and standardizing line endings lines := strings.Split(content, "\n") var normalizedLines []string for _, line := range lines { normalizedLines = append(normalizedLines, strings.TrimSpace(line)) } return strings.Join(normalizedLines, "\n") }