package main import ( "PangolinDNS/pangolin" "context" "fmt" "net/netip" "os" "reflect" "time" "github.com/go-playground/validator/v10" _ "github.com/joho/godotenv/autoload" "github.com/libdns/cloudflare" "github.com/libdns/libdns" "github.com/libdns/route53" "github.com/samber/lo" log "github.com/sirupsen/logrus" "github.com/spf13/viper" ) const ( defaultTTL = 60 * 24 * time.Minute ) type Config struct { Interval time.Duration `mapstructure:"interval" validate:"required"` DNS DNSConfig `mapstructure:"dns" validate:"required"` Pangolin PangolinConfig `mapstructure:"pangolin" validate:"required"` Cloudflare CloudflareConfig `mapstructure:"cloudflare" validate:"-"` Route53 Route53Config `mapstructure:"route53" validate:"-"` } type PangolinConfig struct { URL string `mapstructure:"url" validate:"required,url"` Key string `mapstructure:"key" validate:"required"` Organizations []string `mapstructure:"orgs" validate:"required"` } type CloudflareConfig struct { Token string `mapstructure:"token" validate:"required"` } type Route53Config struct { Region string `mapstructure:"region" validate:"required"` AccessKey string `mapstructure:"access_key" validate:"required"` SecretKey string `mapstructure:"secret_key" validate:"required"` } type DNSConfig struct { Provider string `mapstructure:"provider" validate:"required"` Default DNSTarget `mapstructure:"default" validate:"required"` Domains map[string]DNSTarget `mapstructure:"domains" validate:"dive"` } type DNSTarget struct { CNAME string `mapstructure:"cname" validate:"required_without=Address,omitempty,hostname|fqdn"` Address string `mapstructure:"address" validate:"required_without=CNAME,omitempty,ip"` TTL time.Duration `mapstructure:"ttl"` } type DNSSync struct { cfg Config pangolin *pangolin.Client provider libdns.RecordSetter resourceCache map[string]bool } func main() { var provider libdns.RecordSetter v := viper.NewWithOptions(viper.KeyDelimiter("::")) v.SetConfigFile("config.yaml") if err := v.ReadInConfig(); err != nil { log.WithError(err).Fatal("Error reading config") } var cfg Config if err := v.Unmarshal(&cfg); err != nil { log.WithError(err).Fatal("Error unmarshalling config") } validate := validator.New() validate.RegisterStructValidation(func(sl validator.StructLevel) { cfg := sl.Current().Interface().(Config) t := reflect.TypeOf(cfg) cfgValue := reflect.ValueOf(cfg) for i := 0; i < t.NumField(); i++ { field := t.Field(i) fieldName := field.Tag.Get("mapstructure") if fieldName != cfg.DNS.Provider { continue } if err := validate.Struct(cfgValue.Field(i).Interface()); err != nil { for _, e := range err.(validator.ValidationErrors) { sl.ReportError( e.Value(), fieldName+"."+e.Field(), e.StructField(), e.Tag(), e.Param(), ) } } break } }, Config{}) if err := validate.Struct(cfg); err != nil { fmt.Println(err) os.Exit(1) } switch cfg.DNS.Provider { case "cloudflare": provider = &cloudflare.Provider{ APIToken: cfg.Cloudflare.Token, } case "route53": provider = &route53.Provider{ Region: cfg.Route53.Region, AccessKeyId: cfg.Route53.AccessKey, SecretAccessKey: cfg.Route53.SecretKey, } case "example": provider = &ExampleSetter{} default: log.Fatalf("Unsupported provider %s", cfg.DNS.Provider) } p := pangolin.New(cfg.Pangolin.URL, cfg.Pangolin.Key) s := &DNSSync{ cfg: cfg, provider: provider, pangolin: p, resourceCache: make(map[string]bool), } // On initial startup, always push domains s.checkResources(true) if cfg.Interval <= 0 { return } t := time.NewTicker(cfg.Interval) for { <-t.C s.checkResources(false) } } func (d *DNSSync) checkResources(force bool) { for _, org := range d.cfg.Pangolin.Organizations { log.Infof("Reading domains from org %s", org) domains, err := d.pangolin.Domains(org) if err != nil { log.WithFields(log.Fields{ "error": err, "org": org, }).Fatal("Unable to fetch domains for org") } resources, err := d.pangolin.Resources(org) if err != nil { log.WithFields(log.Fields{ "error": err, "org": org, }).Fatal("Unable to fetch resources for org") } log.Info("Mapping domains to keys") domainKeys := lo.Associate(domains, func(domain pangolin.Domain) (string, string) { return domain.ID, domain.BaseDomain }) log.Info("Mapping records to domains") recordMap := lo.GroupBy(resources, func(record pangolin.Resource) string { return domainKeys[record.DomainID] }) for domain, res := range recordMap { log.Infof("Building domain list for %s", domain) zone := domain + "." if !force { res = lo.Filter(res, func(record pangolin.Resource, _ int) bool { _, ok := d.resourceCache[record.FullDomain] return !ok }) } records := lo.Map(res, func(resource pangolin.Resource, _ int) libdns.Record { if target, ok := d.cfg.DNS.Domains[domain]; ok { return recordFromTarget(resource.FullDomain, target) } return recordFromTarget(resource.FullDomain, d.cfg.DNS.Default) }) changed, err := d.provider.SetRecords(context.Background(), zone, records) if err != nil { log.WithFields(log.Fields{ "error": err, "org": org, "domain": domain, }).Fatal("Unable to set records for domain") } for _, changed := range changed { log.Infof("Updated record %s", changed.RR()) } // Ensure all resources we checked are set in the cache for future runs for _, res := range resources { d.resourceCache[res.FullDomain] = true } } } } func recordFromTarget(name string, target DNSTarget) libdns.Record { ttl := defaultTTL if target.TTL > 0 { ttl = target.TTL } if target.CNAME != "" { return libdns.CNAME{ Name: name, TTL: ttl, Target: target.CNAME, } } if target.Address != "" { return libdns.Address{ Name: name, TTL: ttl, IP: netip.MustParseAddr(target.Address), } } panic("no target defined for domain") }