Files
pangolin-sync/main.go
2026-03-25 22:16:00 -04:00

262 lines
6.1 KiB
Go

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")
}