diff --git a/coolify/proxy/caddy/dynamic/caddy-sync.go b/coolify/proxy/caddy/dynamic/caddy-sync.go new file mode 100644 index 0000000..77e1f58 --- /dev/null +++ b/coolify/proxy/caddy/dynamic/caddy-sync.go @@ -0,0 +1,348 @@ +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") +} \ No newline at end of file diff --git a/coolify/proxy/caddy/dynamic/go.mod b/coolify/proxy/caddy/dynamic/go.mod new file mode 100644 index 0000000..f1381f4 --- /dev/null +++ b/coolify/proxy/caddy/dynamic/go.mod @@ -0,0 +1,20 @@ +module caddy-sync + +go 1.23 + +toolchain go1.23.6 + +require ( + git.site.quack-lab.dev/dave/cylogger v1.5.0 + github.com/BurntSushi/toml v1.3.2 + github.com/sergi/go-diff v1.4.0 +) + +require ( + github.com/google/go-cmp v0.5.9 // indirect + github.com/hexops/valast v1.5.0 // indirect + golang.org/x/mod v0.7.0 // indirect + golang.org/x/sys v0.3.0 // indirect + golang.org/x/tools v0.4.0 // indirect + mvdan.cc/gofumpt v0.4.0 // indirect +) diff --git a/coolify/proxy/caddy/dynamic/go.sum b/coolify/proxy/caddy/dynamic/go.sum new file mode 100644 index 0000000..e66e8bb --- /dev/null +++ b/coolify/proxy/caddy/dynamic/go.sum @@ -0,0 +1,48 @@ +git.site.quack-lab.dev/dave/cylogger v1.5.0 h1:9H/eEMD1dqJ9hEudwbszxrzE9lN0P0iCeYOzYRPMWOA= +git.site.quack-lab.dev/dave/cylogger v1.5.0/go.mod h1:wctgZplMvroA4X6p8f4B/LaCKtiBcT1Pp+L14kcS8jk= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= +github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hexops/autogold v0.8.1 h1:wvyd/bAJ+Dy+DcE09BoLk6r4Fa5R5W+O+GUzmR985WM= +github.com/hexops/autogold v0.8.1/go.mod h1:97HLDXyG23akzAoRYJh/2OBs3kd80eHyKPvZw0S5ZBY= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hexops/valast v1.5.0 h1:FBTuvVi0wjTngtXJRZXMbkN/Dn6DgsUsBwch2DUJU8Y= +github.com/hexops/valast v1.5.0/go.mod h1:Jcy1pNH7LNraVaAZDLyv21hHg2WBv9Nf9FL6fGxU7o4= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/tools v0.4.0 h1:7mTAgkunk3fr4GAloyyCasadO6h9zSsQZbwvcaIciV4= +golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +mvdan.cc/gofumpt v0.4.0 h1:JVf4NN1mIpHogBj7ABpgOyZc65/UUOkKQFkoURsz4MM= +mvdan.cc/gofumpt v0.4.0/go.mod h1:PljLOHDeZqgS8opHRKLzp2It2VBuSdteAgqUfzMTxlQ= diff --git a/coolify/proxy/caddy/dynamic/services.toml b/coolify/proxy/caddy/dynamic/services.toml new file mode 100644 index 0000000..fbf6908 --- /dev/null +++ b/coolify/proxy/caddy/dynamic/services.toml @@ -0,0 +1,61 @@ +# IP ranges that can be referenced by name +[ip_ranges] +lan = "192.168.0.0/16 127.0.0.0/8 10.0.0.0/8 172.16.0.0/12" + +# Caddy configuration files - each key creates a separate .caddy file +[files.actual] +services = [ + { domains = ["actual.site.quack-lab.dev"], backend = "actual_server:5006", ip_range = "lan" } +] + +[files.meilisearch] +services = [ + { domains = ["meili.site.quack-lab.dev"], backend = "meilisearch:7700", ip_range = "lan" } +] + +[files.meili-web] +services = [ + { domains = ["meili-web.site.quack-lab.dev"], backend = "meili-web:24900", ip_range = "lan" } +] + +[files.grist] +services = [ + { domains = ["grist.site.quack-lab.dev"], backend = "grist:8484", ip_range = "lan" } +] + +[files.nsq] +services = [ + { domains = ["nsq.site.quack-lab.dev", "nsq-http.site.quack-lab.dev"], backend = "nsqd:4151", ip_range = "lan" }, + { domains = ["nsqadmin.site.quack-lab.dev"], backend = "nsqadmin:4171", ip_range = "lan" } +] + +[files.monitoring] +services = [ + { domains = ["prometheus.site.quack-lab.dev", "vmagent.site.quack-lab.dev"], backend = "host.docker.internal:43261", ip_range = "lan" }, + { domains = ["victoria.site.quack-lab.dev"], backend = "host.docker.internal:8428", ip_range = "lan" }, + { domains = ["grafana.site.quack-lab.dev"], backend = "grafana-jococcw004848ck4k0owwww0:43433", ip_range = "lan" }, + { domains = ["nodeexporter-sparky.site.quack-lab.dev"], backend = "host.docker.internal:56546", ip_range = "lan" }, + { domains = ["libre-metrics-exporter-dave.site.quack-lab.dev"], backend = "192.168.1.64:9646", ip_range = "lan" }, + { domains = ["libre-metrics-exporter-jana.site.quack-lab.dev"], backend = "192.168.1.68:9646", ip_range = "lan" }, + { domains = ["power-meter-reader.site.quack-lab.dev"], backend = "host.docker.internal:9646", ip_range = "lan" } +] + +[files.pdf] +services = [ + { domains = ["pdf.site.quack-lab.dev"], backend = "stirling-pdf:8080", ip_range = "lan" } +] + +[files.torrent] +services = [ + { domains = ["torrent.site.quack-lab.dev"], backend = "qbit:8080", ip_range = "lan" } +] + +[files.portainer] +services = [ + { domains = ["portainer.site.quack-lab.dev"], backend = "portainer:9000", ip_range = "lan" } +] + +[files.webtop] +services = [ + { domains = ["webtop.site.quack-lab.dev"], backend = "webtop:3000", ip_range = "lan" } +] \ No newline at end of file