diff --git a/adapter/directadmin/client.go b/adapter/directadmin/client.go new file mode 100644 index 0000000..60b421f --- /dev/null +++ b/adapter/directadmin/client.go @@ -0,0 +1,89 @@ +package directadmin + +import ( + "io" + "net" + "net/http" + "net/url" + "reflect" + "strings" + + "github.com/gorilla/schema" +) + +type daClient struct { + encoder *schema.Encoder + decoder *schema.Decoder + + addr string + username string + token string +} + +// New creates new DirectAdmin client. +func New(addr, username, token string) DirectAdminClient { + encoder := schema.NewEncoder() + encoder.RegisterEncoder(net.IP{}, func(v reflect.Value) string { + ip := net.IP(v.Bytes()) + return ip.String() + }) + + decoder := schema.NewDecoder() + decoder.IgnoreUnknownKeys(true) + + return &daClient{ + encoder: encoder, + decoder: decoder, + addr: addr, + username: username, + token: token, + } +} + +// DNSControl implements DirectAdminClient. +func (c *daClient) DNSControl(params *DNSControlParams) (*DNSControlResponse, error) { + var ( + formReq = url.Values{} + respObj = &DNSControlResponse{} + ) + + if err := c.encoder.Encode(params, formReq); err != nil { + return nil, err + } + + req, err := http.NewRequest( + http.MethodPost, + c.addr+"/CMD_API_DNS_CONTROL", + strings.NewReader(formReq.Encode()), + ) + if err != nil { + return nil, err + } + + req.SetBasicAuth(c.username, c.token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + respData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + formResp, err := resolveParams(string(respData)) + if err != nil { + return nil, err + } + + if err := c.decoder.Decode(respObj, formResp); err != nil { + return nil, err + } + + return respObj, nil +} + +var ( + _ DirectAdminClient = &daClient{} +) diff --git a/adapter/directadmin/dns_control.go b/adapter/directadmin/dns_control.go new file mode 100644 index 0000000..2b8aec5 --- /dev/null +++ b/adapter/directadmin/dns_control.go @@ -0,0 +1,92 @@ +package directadmin + +import ( + "github.com/rs/zerolog" +) + +// DNSContolActionType represents DNS control action type. +type DNSControlActionType string + +const ( + // DNSControlActionAdd represents "add" action for DNS control. + DNSControlActionAdd DNSControlActionType = "add" + // DNSControlActionEdit represents "edit" action for DNS control. + DNSControlActionEdit DNSControlActionType = "edit" + // DNSControlActionDelete represents "delete" action for DNS control. + DNSControlActionDelete DNSControlActionType = "delete" +) + +// DNSControlRecordType represents DNS control record type. +type DNSControlRecordType string + +const ( + // DNSControlRecordTypeA represents DNS A record type for DNS control. + DNSControlRecordTypeA DNSControlRecordType = "A" + // DNSControlRecordTypeNS represents DNS NS record type for DNS control. + DNSControlRecordTypeNS DNSControlRecordType = "NS" + // DNSControlRecordTypeMX represents DNS MX record type for DNS control. + DNSControlRecordTypeMX DNSControlRecordType = "MX" + // DNSControlRecordTypeCNAME represents DNS CNAME record type for DNS control. + DNSControlRecordTypeCNAME DNSControlRecordType = "CNAME" + // DNSControlRecordTypePTR represents DNS PTR record type for DNS control. + DNSControlRecordTypePTR DNSControlRecordType = "PTR" +) + +// DNSControlParams represents DNS control request params. +type DNSControlParams struct { + // Domain represents domain name from DirectAdmin panel. + Domain string `schema:"domain"` + // Action represents DNS control action type. + Action DNSControlActionType `schema:"action"` + // Type represents DNS record type. + Type DNSControlRecordType `schema:"type"` + // Name represents DNS record name. + Name string `schema:"name"` + // Value represents DNS record value. + Value string `schema:"value"` + // TTL represents DNS record time-to-live value. + TTL *uint16 `schema:"ttl,omitempty"` + // ARECS0 represents aresc0 DNS record query (required for DNS record editing and deleting). + ARECS0 string `schema:"arecs0,omitempty"` +} + +// MarshalZerologObject implements zerolog.LogObjectMarshaler. +func (p *DNSControlParams) MarshalZerologObject(e *zerolog.Event) { + e. + Str("domain", p.Domain). + Str("action", string(p.Action)). + Str("type", string(p.Type)). + Str("name", p.Name). + Str("value", p.Value) + + if p.TTL != nil { + e.Uint16("ttl", *p.TTL) + } + + if p.ARECS0 != "" { + e.Str("arecs0", p.ARECS0) + } +} + +// DNSControlResponse represents DNS control request response. +type DNSControlResponse struct { + // Error determines if error occurred during request processing. + Error bool `schema:"error"` + // Text represents request execution summary. + Text string `schema:"text"` + // Details represents additional information to request execution status. + Details string `schema:"details"` +} + +// MarshalZerologObject implements zerolog.LogObjectMarshaler. +func (r *DNSControlResponse) MarshalZerologObject(e *zerolog.Event) { + e. + Bool("error", r.Error). + Str("text", r.Text). + Str("details", r.Details) +} + +var ( + _ zerolog.LogObjectMarshaler = &DNSControlParams{} + _ zerolog.LogObjectMarshaler = &DNSControlResponse{} +) diff --git a/adapter/directadmin/interface.go b/adapter/directadmin/interface.go new file mode 100644 index 0000000..f27a0e1 --- /dev/null +++ b/adapter/directadmin/interface.go @@ -0,0 +1,7 @@ +package directadmin + +// DirectAdminClient represents DirectAdmin API client. +type DirectAdminClient interface { + // DNSControl represents DNS control request. + DNSControl(params *DNSControlParams) (*DNSControlResponse, error) +} diff --git a/adapter/directadmin/utils.go b/adapter/directadmin/utils.go new file mode 100644 index 0000000..c1a7a63 --- /dev/null +++ b/adapter/directadmin/utils.go @@ -0,0 +1,26 @@ +package directadmin + +import ( + "net/url" + "strings" +) + +func resolveParams(src string) (map[string][]string, error) { + dst := map[string][]string{} + + for _, pair := range strings.Split(src, "&") { + key, val, ok := strings.Cut(pair, "=") + if ok { + if _, ok := dst[key]; !ok { + dst[key] = []string{} + } + unesc, err := url.QueryUnescape(val) + if err != nil { + return nil, err + } + dst[key] = append(dst[key], unesc) + } + } + + return dst, nil +} diff --git a/adapter/dnsresolver/interface.go b/adapter/dnsresolver/interface.go new file mode 100644 index 0000000..c76444d --- /dev/null +++ b/adapter/dnsresolver/interface.go @@ -0,0 +1,9 @@ +package dnsresolver + +import "net" + +// DomainNameResolver represents domain name resolver. +type DomainNameResolver interface { + // ResolveA resolves domain's A records agains given nameserver. + ResolveA(domain, nameserver string) ([]net.IP, error) +} diff --git a/adapter/dnsresolver/resolver.go b/adapter/dnsresolver/resolver.go new file mode 100644 index 0000000..d0ae907 --- /dev/null +++ b/adapter/dnsresolver/resolver.go @@ -0,0 +1,45 @@ +package dnsresolver + +import ( + "errors" + "net" + + "github.com/miekg/dns" +) + +type dnsResolver struct { + resolver dns.Client +} + +// New creates new DomainNameResolver. +func New() DomainNameResolver { + return &dnsResolver{ + resolver: dns.Client{}, + } +} + +// ResolveA implements DomainNameResolver. +func (r *dnsResolver) ResolveA(domain, nameserver string) ([]net.IP, error) { + msg := &dns.Msg{} + msg.SetQuestion(domain+".", dns.TypeA) + + answer, _, err := r.resolver.Exchange(msg, nameserver+":53") + if err != nil { + return nil, err + } + + if len(answer.Answer) == 0 { + return nil, errors.New("empty answer") + } + + result := make([]net.IP, len(answer.Answer)) + for idx, rr := range answer.Answer { + record := rr.(*dns.A) + result[idx] = record.A + } + return result, nil +} + +var ( + _ DomainNameResolver = &dnsResolver{} +) diff --git a/app/service.go b/app/service.go new file mode 100644 index 0000000..ccc46b7 --- /dev/null +++ b/app/service.go @@ -0,0 +1,122 @@ +package app + +import ( + "context" + "fmt" + "time" + + "git.ext.icikowski.pl/icikowski/ip-ddns/adapter/directadmin" + "git.ext.icikowski.pl/icikowski/ip-ddns/adapter/dnsresolver" + "git.ext.icikowski.pl/icikowski/ip-ddns/config" + "github.com/rs/zerolog" +) + +type Service struct { + da directadmin.DirectAdminClient + resolv dnsresolver.DomainNameResolver + + srcDomain string + srcNameserver string + + dstDomain string + dstNameserver string + + entryDomain string + entryName string + entryTTL uint16 + + resyncTime time.Duration + + log zerolog.Logger +} + +func NewService( + directadminClient directadmin.DirectAdminClient, + dnsResolver dnsresolver.DomainNameResolver, + domainConfig config.DomainConfig, + resyncTime time.Duration, + log zerolog.Logger, +) *Service { + return &Service{ + da: directadminClient, + resolv: dnsResolver, + srcDomain: domainConfig.Source.Name, + srcNameserver: domainConfig.Source.Nameserver, + dstDomain: domainConfig.Destination.Name, + dstNameserver: domainConfig.Destination.Nameserver, + entryName: domainConfig.Entry.Name, + entryDomain: domainConfig.Entry.Domain, + entryTTL: domainConfig.Entry.TTL, + resyncTime: resyncTime, + log: log, + } +} + +func (s *Service) update() { + srcIPs, err := s.resolv.ResolveA(s.srcDomain, s.srcNameserver) + if err != nil { + s.log.Warn().Err(err).Msg("unable to get source IP address") + return + } + srcIP := srcIPs[0] + s.log.Info().IPAddr("ip", srcIP).Msg("got source IP address") + + dstIPs, err := s.resolv.ResolveA(s.dstDomain, s.dstNameserver) + if err != nil { + s.log.Warn().Err(err).Msg("unable to get destination IP address") + return + } + dstIP := dstIPs[0] + s.log.Info().IPAddr("ip", dstIP).Msg("got destination IP address") + + if srcIP.Equal(dstIP) { + s.log.Info().Msg("addresses match, no update needed") + return + } + s.log.Info().Msg("addresses mismatch, updating") + + params := &directadmin.DNSControlParams{ + Domain: s.entryDomain, + Action: directadmin.DNSControlActionEdit, + Type: directadmin.DNSControlRecordTypeA, + Name: s.entryName, + Value: srcIP.String(), + TTL: &s.entryTTL, + ARECS0: fmt.Sprintf( + "name=%s&value=%s", + s.entryName, + dstIP.String(), + ), + } + + log := s.log.With().Object("params", params).Logger() + + resp, err := s.da.DNSControl(params) + if err != nil { + log.Error().Err(err).Msg("unable to update domain params") + return + } + + log = log.With().Object("response", resp).Logger() + if resp.Error { + log.Error().Msg("unable to update domain params") + return + } + log.Info().Msg("successfully updated domain params") +} + +func (s *Service) Run(ctx context.Context) error { + ticker := time.NewTicker(s.resyncTime) + defer ticker.Stop() + + for { + s.update() + + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + continue + } + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..7fea445 --- /dev/null +++ b/config/config.go @@ -0,0 +1,25 @@ +package config + +import ( + "time" + + "github.com/rs/zerolog" +) + +// Config represents application's configuration. +type Config struct { + DirectAdmin DirectAdminConfig `envPrefix:"DA_"` + Domain DomainConfig `envPrefix:"DOMAIN_"` + ResyncTime time.Duration `env:"RESYNC_TIME" envDefault:"15m"` +} + +// MarshalZerologObject implements zerolog.LogObjectMarshaler. +func (c *Config) MarshalZerologObject(e *zerolog.Event) { + e. + Object("directAdmin", &c.DirectAdmin). + Object("domain", &c.Domain) +} + +var ( + _ zerolog.LogObjectMarshaler = &Config{} +) diff --git a/config/directadmin.go b/config/directadmin.go new file mode 100644 index 0000000..625a75f --- /dev/null +++ b/config/directadmin.go @@ -0,0 +1,27 @@ +package config + +import "github.com/rs/zerolog" + +// DirectAdminConfig represents DirectAdmin configuration. +type DirectAdminConfig struct { + URL string `env:"URL" envDefault:"https://s149.cyber-folks.pl:2223"` + User string `env:"USER,notEmpty"` + Token string `env:"TOKEN,notEmpty"` +} + +// MarshalZerologObject implements zerolog.LogObjectMarshaler. +func (c *DirectAdminConfig) MarshalZerologObject(e *zerolog.Event) { + token := "[REDACTED]" + if len(c.Token) == 0 { + token = "[EMPTY]" + } + + e. + Str("url", c.URL). + Str("user", c.User). + Str("token", token) +} + +var ( + _ zerolog.LogObjectMarshaler = &DirectAdminConfig{} +) diff --git a/config/domain.go b/config/domain.go new file mode 100644 index 0000000..319eb5f --- /dev/null +++ b/config/domain.go @@ -0,0 +1,52 @@ +package config + +import "github.com/rs/zerolog" + +// DomainDetails represents domain details. +type DomainDetails struct { + Name string `env:"NAME,notEmpty"` + Nameserver string `env:"NAMESERVER,notEmpty"` +} + +// MarshalZerologObject implements zerolog.LogObjectMarshaler. +func (d DomainDetails) MarshalZerologObject(e *zerolog.Event) { + e. + Str("name", d.Name). + Str("nameserver", d.Nameserver) +} + +// EntryDetails represents DNS entry details. +type EntryDetails struct { + Domain string `env:"DN,notEmpty"` + Name string `env:"NAME,notEmpty"` + TTL uint16 `env:"TTL" envDefault:"60"` +} + +// MarshalZerologObject implements zerolog.LogObjectMarshaler. +func (d *EntryDetails) MarshalZerologObject(e *zerolog.Event) { + e. + Str("domain", d.Domain). + Str("name", d.Name). + Uint16("ttl", d.TTL) +} + +// DomainConfig represents domain configuration. +type DomainConfig struct { + Source DomainDetails `envPrefix:"SRC_"` + Destination DomainDetails `envPrefix:"DST_"` + Entry EntryDetails `envPrefix:"ENTRY_"` +} + +// MarshalZerologObject implements zerolog.LogObjectMarshaler. +func (c *DomainConfig) MarshalZerologObject(e *zerolog.Event) { + e. + Object("source", &c.Source). + Object("destination", &c.Destination). + Object("entry", &c.Entry) +} + +var ( + _ zerolog.LogObjectMarshaler = &DomainDetails{} + _ zerolog.LogObjectMarshaler = &EntryDetails{} + _ zerolog.LogObjectMarshaler = &DomainConfig{} +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..93342dd --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module git.ext.icikowski.pl/icikowski/ip-ddns + +go 1.21.4 + +require github.com/miekg/dns v1.1.56 + +require ( + github.com/caarlos0/env/v10 v10.0.0 + github.com/gorilla/schema v1.2.1 + github.com/rs/zerolog v1.31.0 +) + +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/mod v0.12.0 // indirect + golang.org/x/net v0.15.0 // indirect + golang.org/x/sys v0.14.0 // indirect + golang.org/x/tools v0.13.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9871ea5 --- /dev/null +++ b/go.sum @@ -0,0 +1,31 @@ +github.com/caarlos0/env/v10 v10.0.0 h1:yIHUBZGsyqCnpTkbjk8asUlx6RFhhEs+h7TOBdgdzXA= +github.com/caarlos0/env/v10 v10.0.0/go.mod h1:ZfulV76NvVPw3tm591U4SwL3Xx9ldzBP9aGxzeN7G18= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gorilla/schema v1.2.1 h1:tjDxcmdb+siIqkTNoV+qRH2mjYdr2hHe5MKXbp61ziM= +github.com/gorilla/schema v1.2.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/miekg/dns v1.1.56 h1:5imZaSeoRNvpM9SzWNhEcP9QliKiz20/dA2QabIGVnE= +github.com/miekg/dns v1.1.56/go.mod h1:cRm6Oo2C8TY9ZS/TqsSrseAcncm74lfK5G+ikN2SWWY= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A= +github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= diff --git a/main.go b/main.go new file mode 100644 index 0000000..8cf07c3 --- /dev/null +++ b/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "git.ext.icikowski.pl/icikowski/ip-ddns/adapter/directadmin" + "git.ext.icikowski.pl/icikowski/ip-ddns/adapter/dnsresolver" + "git.ext.icikowski.pl/icikowski/ip-ddns/app" + "git.ext.icikowski.pl/icikowski/ip-ddns/config" + "github.com/caarlos0/env/v10" + "github.com/rs/zerolog" +) + +var ( + conf *config.Config = &config.Config{} + mainLog zerolog.Logger = zerolog. + New(zerolog.ConsoleWriter{ + Out: os.Stdout, + NoColor: false, + TimeFormat: "2006-01-02 15:04:05", + }). + Level(zerolog.DebugLevel). + With(). + Timestamp(). + Logger() +) + +func init() { + log := mainLog.With().Str("unit", "init").Logger() + + if err := env.ParseWithOptions(conf, env.Options{ + Prefix: "SYNCER_", + }); err != nil { + log.Fatal().Err(err).Msg("unable to parse config") + } + log.Info().Object("conf", conf).Msg("loaded configuration") +} + +func main() { + log := mainLog.With().Str("unit", "cmd").Logger() + + ctx, cancel := signal.NotifyContext( + context.Background(), + os.Interrupt, + syscall.SIGKILL, + ) + defer cancel() + + resolv := dnsresolver.New() + daClient := directadmin.New( + conf.DirectAdmin.URL, + conf.DirectAdmin.User, + conf.DirectAdmin.Token, + ) + + svc := app.NewService( + daClient, + resolv, + conf.Domain, + conf.ResyncTime, + mainLog.With().Str("unit", "service").Logger(), + ) + done := make(chan error) + + go func() { + log.Info().Msg("starting service") + done <- svc.Run(ctx) + log.Info().Msg("service stopped") + }() + + <-done +}