feat(app): add codebase

This commit is contained in:
Piotr Icikowski 2023-11-17 07:20:10 +01:00
parent 833241b870
commit 2c9df62b2a
Signed by: Piotr Icikowski
GPG Key ID: 3931CA47A91F7666
13 changed files with 620 additions and 0 deletions

View File

@ -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{}
)

View File

@ -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{}
)

View File

@ -0,0 +1,7 @@
package directadmin
// DirectAdminClient represents DirectAdmin API client.
type DirectAdminClient interface {
// DNSControl represents DNS control request.
DNSControl(params *DNSControlParams) (*DNSControlResponse, error)
}

View File

@ -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
}

View File

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

View File

@ -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{}
)

122
app/service.go Normal file
View File

@ -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
}
}
}

25
config/config.go Normal file
View File

@ -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{}
)

27
config/directadmin.go Normal file
View File

@ -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{}
)

52
config/domain.go Normal file
View File

@ -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{}
)

20
go.mod Normal file
View File

@ -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
)

31
go.sum Normal file
View File

@ -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=

75
main.go Normal file
View File

@ -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
}