[feature] Initial commit
This commit is contained in:
261
main.go
Normal file
261
main.go
Normal file
@@ -0,0 +1,261 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user