262 lines
6.1 KiB
Go
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")
|
|
}
|