commit f57d35563160683b1c11a2ed653b115c913d9d9a Author: Henrik Hautakoski Date: Fri Apr 28 16:41:44 2023 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9abc7f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +config.yml \ No newline at end of file diff --git a/app/app.go b/app/app.go new file mode 100644 index 0000000..0e499bc --- /dev/null +++ b/app/app.go @@ -0,0 +1,41 @@ +package app + +import ( + "net" + + "dnsupdater/provider/manager" + + "dnsupdater/ip" + resolver "dnsupdater/ip/resolver" +) + +type App struct { + iplookup ip.NetInterfaceIPResolver + + // Ip lookup service + IPLookupService resolver.Service + + // Updater manager + ProviderManager *manager.Manager +} + +func NewApp(config *Config) (*App, error) { + providerMgr := manager.New() + // providerMgr.Register("digitalocean", digitalocean.New(config.Services.DigitalOcean.Token)) + err := providerMgr.RegisterFromConfig(config.Providers) + if err != nil { + return nil, err + } + + l := resolver.Get(config.Services.IPLookup) + + return &App{ + ProviderManager: providerMgr, + IPLookupService: resolver.Get(config.Services.IPLookup), + iplookup: ip.NewCache(resolver.LookupWrapper(l)).Get, + }, nil +} + +func (a App) GetIP(iface_name string) (net.IP, error) { + return a.iplookup(iface_name) +} diff --git a/app/config.go b/app/config.go new file mode 100644 index 0000000..7346c95 --- /dev/null +++ b/app/config.go @@ -0,0 +1,47 @@ +package app + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +type ( + DomainRecords map[string]string + Domain map[string]DomainRecords +) + +type DigitalOceanService struct { + Token string `yaml:"token"` + Domains map[string]DomainRecords `yaml:"domains"` +} + +type Providers struct { + Token string `yaml:"token"` + Domains map[string]DomainRecords `yaml:"domains"` +} + +type Services struct { + IPLookup string `yaml:"IPLookup"` + // DigitalOcean DigitalOceanService `yaml:"digitalocean"` +} + +type Config struct { + Services Services `yaml:"services"` + Providers map[string]map[string]interface{} + Updates map[string]Domain +} + +func LoadConfig(filename string) (*Config, error) { + cfg := Config{ + Services: Services{ + IPLookup: "ipecho", + }, + } + + data, err := os.ReadFile(filename) + if err == nil { + err = yaml.Unmarshal(data, &cfg) + } + return &cfg, err +} diff --git a/cmd/dnsupdater/main.go b/cmd/dnsupdater/main.go new file mode 100644 index 0000000..107c3ff --- /dev/null +++ b/cmd/dnsupdater/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "os" + + "dnsupdater/app" +) + +func main() { + config, err := app.LoadConfig("config.yml") + if err != nil { + fmt.Println("Failed to load config:", err) + os.Exit(1) + } + + app, err := app.NewApp(config) + if err != nil { + fmt.Println("Failed to initialize application:", err) + os.Exit(1) + } + + for service, domains := range config.Updates { + fmt.Println("Service", service) + + // Get service + service := app.ProviderManager.Get(service) + + for domain, records := range domains { + fmt.Println(" ", "Domain", domain) + fmt.Println(" ", "Records") + for name, data := range records { + + fmt.Println(" Update: ", name, data) + + ip, err := app.GetIP(data) + if err != nil { + fmt.Println(err) + continue + } + + err = service.Update(domain, name, ip) + if err != nil { + fmt.Println("Error", err) + } + } + } + + } +} diff --git a/config.example.yml b/config.example.yml new file mode 100644 index 0000000..093ff0b --- /dev/null +++ b/config.example.yml @@ -0,0 +1,18 @@ + +services: + IPLookup: ipecho + +providers: + digitalocean: + token: xxxx + +updates: + digitalocean: + domain1.com: + www: wan + box: 10.140.14.2 + domain2.com: + www: wan + mail: wan + static: 84.24.254.21 + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5de7dac --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module dnsupdater + +go 1.19 + +require ( + github.com/digitalocean/godo v1.99.0 + github.com/stretchr/testify v1.8.2 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/net v0.9.0 // indirect + golang.org/x/oauth2 v0.7.0 // indirect + golang.org/x/time v0.3.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.30.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..babe8d8 --- /dev/null +++ b/go.sum @@ -0,0 +1,47 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/digitalocean/godo v1.99.0 h1:gUHO7n9bDaZFWvbzOum4bXE0/09ZuYA9yA8idQHX57E= +github.com/digitalocean/godo v1.99.0/go.mod h1:SsS2oXo2rznfM/nORlZ/6JaUJZFhmKTib1YhopUc8NA= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g= +golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ip/cache.go b/ip/cache.go new file mode 100644 index 0000000..4fa130b --- /dev/null +++ b/ip/cache.go @@ -0,0 +1,34 @@ +package ip + +import ( + "net" +) + +type Cache struct { + resolver NetInterfaceIPResolver + items map[string]net.IP +} + +func NewCache(resolver NetInterfaceIPResolver) *Cache { + return &Cache{ + resolver: resolver, + items: make(map[string]net.IP), + } +} + +func (c Cache) Get(name string) (net.IP, error) { + // Return cached entry. + if cached, ok := c.items[name]; ok { + return cached, nil + } + + ip, err := c.resolver(name) + if err == nil { + c.Set(name, ip) + } + return ip, err +} + +func (c *Cache) Set(name string, ip net.IP) { + c.items[name] = ip +} diff --git a/ip/cache_test.go b/ip/cache_test.go new file mode 100644 index 0000000..a66e949 --- /dev/null +++ b/ip/cache_test.go @@ -0,0 +1,43 @@ +package ip + +import ( + "errors" + "net" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +func mockResolver(t *testing.T, expected_name string, ip net.IP, err error) NetInterfaceIPResolver { + return func(name string) (net.IP, error) { + assert.Equal(t, expected_name, name) + return ip, err + } +} + +func TestCache_Get(t *testing.T) { + tests := []struct { + name string + c *Cache + iface string + want net.IP + wantErr bool + }{ + {"FromCache", &Cache{resolver: nil, items: map[string]net.IP{"eth0": net.IPv4(10, 4, 0, 1)}}, "eth0", net.IPv4(10, 4, 0, 1), false}, + {"FromResolver", NewCache(mockResolver(t, "eth1", net.IPv4(192, 172, 44, 25), nil)), "eth1", net.IPv4(192, 172, 44, 25), false}, + {"NoInterface", NewCache(mockResolver(t, "eth2", nil, errors.New("Invalid interface"))), "eth2", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.c.Get(tt.iface) + if (err != nil) != tt.wantErr { + t.Errorf("Cache.Get() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Cache.Get() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/ip/helpers.go b/ip/helpers.go new file mode 100644 index 0000000..c7384d6 --- /dev/null +++ b/ip/helpers.go @@ -0,0 +1,49 @@ +package ip + +import ( + "errors" + "net" +) + +// Resolver is a function that gets the ip from a interface name +type NetInterfaceIPResolver func(iface string) (net.IP, error) + +func GetInterfaceIP(iface_name string) (net.IP, error) { + ip := net.IP{} + iface, err := net.InterfaceByName(iface_name) + if err != nil { + return ip, err + } + + addrs, err := iface.Addrs() + if err != nil { + return ip, err + } + + return GetPublicIp(addrs) +} + +func GetPublicIp(list []net.Addr) (net.IP, error) { + for _, addr := range list { + ip, err := AddrToIP(addr) + if err == nil && !ip.IsPrivate() { + return ip, nil + } + } + + return nil, errors.New("no public ip found on interface") +} + +func AddrToIP(addr net.Addr) (net.IP, error) { + switch v := addr.(type) { + case *net.IPNet: + return v.IP, nil + case *net.IPAddr: + return v.IP, nil + case *net.UDPAddr: + return v.IP, nil + case *net.TCPAddr: + return v.IP, nil + } + return nil, errors.New("could not find ip") +} diff --git a/ip/helpers_test.go b/ip/helpers_test.go new file mode 100644 index 0000000..436eb96 --- /dev/null +++ b/ip/helpers_test.go @@ -0,0 +1,69 @@ +package ip + +import ( + "net" + "reflect" + "testing" +) + +func TestGetPublicIp(t *testing.T) { + tests := []struct { + name string + list []string + want string + wantErr bool + }{ + {"empty", []string{}, "", true}, + {"find", []string{"99.140.96.132"}, "99.140.96.132", false}, + {"findfirst", []string{"23.114.115.197", "251.78.128.148"}, "23.114.115.197", false}, + {"dontfindprivate", []string{"192.168.0.22", "88.12.32.44"}, "88.12.32.44", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + list := []net.Addr{} + for _, item := range tt.list { + list = append(list, &net.IPAddr{IP: net.ParseIP(item)}) + } + + want := net.ParseIP(tt.want) + + got, err := GetPublicIp(list) + if (err != nil) != tt.wantErr { + t.Errorf("GetPublicIp() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, want) { + t.Errorf("GetPublicIp() = %v, want %v", got, want) + } + }) + } +} + +func TestAddrToIP(t *testing.T) { + tests := []struct { + name string + addr net.Addr + want net.IP + wantErr bool + }{ + {"IPNet", &net.IPNet{IP: net.IPv4(177, 171, 44, 1)}, net.IPv4(177, 171, 44, 1), false}, + {"IPAddr", &net.IPAddr{IP: net.IPv4(240, 23, 119, 171)}, net.IPv4(240, 23, 119, 171), false}, + {"TCPAddr", &net.TCPAddr{IP: net.IPv4(139, 231, 35, 221)}, net.IPv4(139, 231, 35, 221), false}, + {"UDPAddr", &net.UDPAddr{IP: net.IPv4(167, 147, 140, 119)}, net.IPv4(167, 147, 140, 119), false}, + {"UnixAddr", &net.UnixAddr{}, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := AddrToIP(tt.addr) + + if (err != nil) != tt.wantErr { + t.Errorf("AddrToIP() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("AddrToIP() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/ip/resolver/ifconfigme/service.go b/ip/resolver/ifconfigme/service.go new file mode 100644 index 0000000..679a35e --- /dev/null +++ b/ip/resolver/ifconfigme/service.go @@ -0,0 +1,39 @@ +package ifconfigme + +import ( + "io" + "net" + "net/http" + "strings" +) + +type Service struct { + url string +} + +func New() *Service { + return &Service{ + url: "https://ifconfig.me/ip", + } +} + +func (s Service) Name() string { + return "ifconfig.me" +} + +func (s Service) Lookup() (net.IP, error) { + resp, err := http.DefaultClient.Get(s.url) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // Trim spaces and stuff. + ip_str := strings.TrimSpace(string(body)) + + return net.ParseIP(ip_str), err +} diff --git a/ip/resolver/ifconfigme/service_test.go b/ip/resolver/ifconfigme/service_test.go new file mode 100644 index 0000000..9ebcc12 --- /dev/null +++ b/ip/resolver/ifconfigme/service_test.go @@ -0,0 +1,31 @@ +package ifconfigme + +import ( + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestService_Name(t *testing.T) { + s := Service{} + + assert.Equal(t, "ifconfig.me", s.Name()) +} + +func TestService_Lookup(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte("113.145.244.129")) + assert.NoError(t, err) + })) + defer server.Close() + + s := Service{url: server.URL} + + ip, err := s.Lookup() + assert.NoError(t, err) + + assert.Equal(t, net.IPv4(113, 145, 244, 129), ip) +} diff --git a/ip/resolver/ipecho/service.go b/ip/resolver/ipecho/service.go new file mode 100644 index 0000000..c08cab8 --- /dev/null +++ b/ip/resolver/ipecho/service.go @@ -0,0 +1,35 @@ +package ipecho + +import ( + "io" + "net" + "net/http" +) + +type Service struct { + url string +} + +func New() *Service { + return &Service{ + url: "http://ipecho.net/plain", + } +} + +func (s Service) Name() string { + return "ipecho" +} + +func (s Service) Lookup() (net.IP, error) { + resp, err := http.DefaultClient.Get(s.url) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return net.ParseIP(string(body)), nil +} diff --git a/ip/resolver/ipecho/service_test.go b/ip/resolver/ipecho/service_test.go new file mode 100644 index 0000000..97b2eab --- /dev/null +++ b/ip/resolver/ipecho/service_test.go @@ -0,0 +1,31 @@ +package ipecho + +import ( + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestService_Name(t *testing.T) { + s := Service{} + + assert.Equal(t, "ipecho", s.Name()) +} + +func TestService_Lookup(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte("188.242.103.22")) + assert.NoError(t, err) + })) + defer server.Close() + + s := Service{url: server.URL} + + ip, err := s.Lookup() + assert.NoError(t, err) + + assert.Equal(t, net.IPv4(188, 242, 103, 22), ip) +} diff --git a/ip/resolver/ipme/service.go b/ip/resolver/ipme/service.go new file mode 100644 index 0000000..9b70a0f --- /dev/null +++ b/ip/resolver/ipme/service.go @@ -0,0 +1,46 @@ +package ipme + +import ( + "io" + "net" + "net/http" + "strings" +) + +type Service struct { + url string +} + +func New() *Service { + return &Service{ + url: "https://ip.me", + } +} + +func (s Service) Name() string { + return "ip.me" +} + +func (s Service) Lookup() (net.IP, error) { + req, err := http.NewRequest("GET", s.url, nil) + if err != nil { + return nil, err + } + + req.Header.Add("User-Agent", "curl") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // Trim spaces and stuff. + ip_str := strings.TrimSpace(string(body)) + + return net.ParseIP(ip_str), err +} diff --git a/ip/resolver/ipme/service_test.go b/ip/resolver/ipme/service_test.go new file mode 100644 index 0000000..0b3908c --- /dev/null +++ b/ip/resolver/ipme/service_test.go @@ -0,0 +1,33 @@ +package ipme + +import ( + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestService_Name(t *testing.T) { + s := Service{} + + assert.Equal(t, "ip.me", s.Name()) +} + +func TestService_Lookup(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "curl", r.Header.Get("User-Agent")) + + _, err := w.Write([]byte("255.240.85.2")) + assert.NoError(t, err) + })) + defer server.Close() + + s := Service{url: server.URL} + + ip, err := s.Lookup() + assert.NoError(t, err) + + assert.Equal(t, net.IPv4(255, 240, 85, 2), ip) +} diff --git a/ip/resolver/jsonip/service.go b/ip/resolver/jsonip/service.go new file mode 100644 index 0000000..960ec01 --- /dev/null +++ b/ip/resolver/jsonip/service.go @@ -0,0 +1,39 @@ +package jsonip + +import ( + "encoding/json" + "net" + "net/http" +) + +type Service struct { + url string +} + +func New() *Service { + return &Service{ + url: "https://jsonip.com", + } +} + +func (s Service) Name() string { + return "jsonip" +} + +func (s Service) Lookup() (net.IP, error) { + resp, err := http.DefaultClient.Get(s.url) + if err != nil { + return nil, err + } + + var v struct { + Ip string `json:"ip"` + Location string `json:"geo-ip"` + Help string `json:"API Help"` + } + if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { + return nil, err + } + + return net.ParseIP(v.Ip), err +} diff --git a/ip/resolver/jsonip/service_test.go b/ip/resolver/jsonip/service_test.go new file mode 100644 index 0000000..1087b3e --- /dev/null +++ b/ip/resolver/jsonip/service_test.go @@ -0,0 +1,31 @@ +package jsonip + +import ( + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestService_Name(t *testing.T) { + s := Service{} + + assert.Equal(t, "jsonip", s.Name()) +} + +func TestService_Lookup(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte(`{"ip":"211.46.32.214","geo-ip":"https://getjsonip.com/#plus","API Help":"https://getjsonip.com/#docs"}`)) + assert.NoError(t, err) + })) + defer server.Close() + + s := Service{url: server.URL} + + ip, err := s.Lookup() + assert.NoError(t, err) + + assert.Equal(t, net.IPv4(211, 46, 32, 214), ip) +} diff --git a/ip/resolver/manager.go b/ip/resolver/manager.go new file mode 100644 index 0000000..e6e474f --- /dev/null +++ b/ip/resolver/manager.go @@ -0,0 +1,45 @@ +package lookup + +import ( + "errors" + "net" + + "dnsupdater/ip/resolver/ifconfigme" + "dnsupdater/ip/resolver/ipecho" + "dnsupdater/ip/resolver/ipme" + "dnsupdater/ip/resolver/jsonip" +) + +var services []Service + +func Provide(service Service) { + services = append(services, service) +} + +func Get(name string) Service { + for _, service := range services { + if service.Name() == name { + return service + } + } + return nil +} + +func Lookup() (net.IP, error) { + for _, service := range services { + ip, err := service.Lookup() + if err != nil { + continue + } + return ip, err + } + + return nil, errors.New("failed to get ip") +} + +func init() { + Provide(jsonip.New()) + Provide(ifconfigme.New()) + Provide(ipme.New()) + Provide(ipecho.New()) +} diff --git a/ip/resolver/mock/service.go b/ip/resolver/mock/service.go new file mode 100644 index 0000000..b27f0c6 --- /dev/null +++ b/ip/resolver/mock/service.go @@ -0,0 +1,16 @@ +package mock + +import "net" + +type Service struct { + IP net.IP + Error error +} + +func (s Service) Name() string { + return "mock" +} + +func (s Service) Lookup() (net.IP, error) { + return s.IP, s.Error +} diff --git a/ip/resolver/service.go b/ip/resolver/service.go new file mode 100644 index 0000000..60951d4 --- /dev/null +++ b/ip/resolver/service.go @@ -0,0 +1,24 @@ +package lookup + +import ( + "net" + + "dnsupdater/ip" +) + +type Service interface { + Name() string + + Lookup() (net.IP, error) +} + +const WAN_IFACE = "wan" + +func LookupWrapper(service Service) ip.NetInterfaceIPResolver { + return func(iface_name string) (net.IP, error) { + if iface_name == WAN_IFACE { + return service.Lookup() + } + return ip.GetInterfaceIP(iface_name) + } +} diff --git a/provider/digitalocean/mock_test.go b/provider/digitalocean/mock_test.go new file mode 100644 index 0000000..1f9ab0b --- /dev/null +++ b/provider/digitalocean/mock_test.go @@ -0,0 +1,105 @@ +package digitalocean + +import ( + "context" + "errors" + "testing" + + "github.com/digitalocean/godo" + "github.com/stretchr/testify/assert" +) + +type mock struct { + t *testing.T + + records_by_type map[string][]godo.DomainRecord + + edit_record_request *godo.DomainRecordEditRequest + edit_record_error error +} + +func (m mock) List(context.Context, *godo.ListOptions) ([]godo.Domain, *godo.Response, error) { + m.t.Error("List called when it should not have been") + return nil, nil, nil +} + +func (m mock) Get(context.Context, string) (*godo.Domain, *godo.Response, error) { + m.t.Error("Get called when it should not have been") + return nil, nil, nil +} + +func (m mock) Create(context.Context, *godo.DomainCreateRequest) (*godo.Domain, *godo.Response, error) { + m.t.Error("Create called when it should not have been") + return nil, nil, nil +} + +func (m mock) Delete(context.Context, string) (*godo.Response, error) { + m.t.Error("Delete called when it should not have been") + return nil, nil +} + +func (m mock) Records(context.Context, string, *godo.ListOptions) ([]godo.DomainRecord, *godo.Response, error) { + m.t.Error("Records called when it should not have been") + return nil, nil, nil +} + +func (m mock) RecordsByType(_ context.Context, name string, t string, opt *godo.ListOptions) ([]godo.DomainRecord, *godo.Response, error) { + var err error + + // Only care about "A" records + assert.Equal(m.t, "A", t) + + r, ok := m.records_by_type[name] + if !ok { + err = errors.New("Record not found") + } + return r, nil, err +} + +func (m mock) RecordsByName(context.Context, string, string, *godo.ListOptions) ([]godo.DomainRecord, *godo.Response, error) { + m.t.Error("RecordsByName called when it should not have been") + return nil, nil, nil +} + +func (m mock) RecordsByTypeAndName(context.Context, string, string, string, *godo.ListOptions) ([]godo.DomainRecord, *godo.Response, error) { + m.t.Error("RecordsByTypeAndName called when it should not have been") + return nil, nil, nil +} + +func (m mock) Record(context.Context, string, int) (*godo.DomainRecord, *godo.Response, error) { + m.t.Error("Record called when it should not have been") + return nil, nil, nil +} + +func (m mock) DeleteRecord(context.Context, string, int) (*godo.Response, error) { + m.t.Error("DeleteRecord called when it should not have been") + return nil, nil +} + +func (m mock) EditRecord(_ context.Context, domain string, id int, req *godo.DomainRecordEditRequest) (*godo.DomainRecord, *godo.Response, error) { + if m.edit_record_request == nil { + m.t.Error("EditRecord called with empty request") + } + + assert.Equal(m.t, m.edit_record_request, req) + + record := godo.DomainRecord{ + ID: id, + Type: req.Type, + Name: req.Name, + Data: req.Data, + Priority: req.Priority, + Port: req.Port, + TTL: req.TTL, + Weight: req.Weight, + Flags: req.Flags, + Tag: req.Tag, + } + + return &record, nil, m.edit_record_error +} + +func (m mock) CreateRecord(context.Context, string, *godo.DomainRecordEditRequest) (*godo.DomainRecord, *godo.Response, error) { + m.t.Error("CreateRecord called when it should not have been") + return nil, nil, nil +} diff --git a/provider/digitalocean/provider.go b/provider/digitalocean/provider.go new file mode 100644 index 0000000..cb4acf2 --- /dev/null +++ b/provider/digitalocean/provider.go @@ -0,0 +1,98 @@ +package digitalocean + +import ( + "context" + "errors" + "fmt" + "net" + + "dnsupdater/provider" + + "github.com/digitalocean/godo" +) + +type Provider struct { + service godo.DomainsService + cache map[string][]godo.DomainRecord +} + +func New(token string) Provider { + return Provider{ + service: godo.NewFromToken(token).Domains, + cache: make(map[string][]godo.DomainRecord), + } +} + +func Factory(args map[string]interface{}) (provider.Provider, error) { + t, ok := args["token"] + if !ok { + return nil, errors.New("did not find token") + } + + token, ok := t.(string) + if !ok { + return nil, errors.New("token must be a string") + } + + return New(token), nil +} + +func (d *Provider) fetch(domain string) ([]godo.DomainRecord, error) { + domains, ok := d.cache[domain] + if !ok { + var err error + options := &godo.ListOptions{ + PerPage: 50, + } + + domains, _, err = d.service.RecordsByType(context.Background(), domain, "A", options) + if err != nil { + return nil, err + } + d.cache[domain] = domains + } + return domains, nil +} + +func (d *Provider) find(domain string, record string) (*godo.DomainRecord, error) { + records, err := d.fetch(domain) + if err != nil { + return nil, err + } + for _, r := range records { + if r.Name == record { + return &r, nil + } + } + + return nil, fmt.Errorf("could not find record %s", record) +} + +func (d Provider) Update(domain string, record string, ip net.IP) error { + r, err := d.find(domain, record) + if err != nil { + return err + } + + if r.Data != ip.String() { + // Update + req := godo.DomainRecordEditRequest{ + // Type: r.Type, + // Name: r.Name, + Data: ip.String(), + // Priority: r.Priority, + // Port: r.Port, + // TTL: r.TTL, + // Weight: r.Weight, + // Flags: r.Flags, + // Tag: r.Tag, + } + + _, _, err := d.service.EditRecord(context.Background(), domain, r.ID, &req) + if err != nil { + return err + } + } + + return nil +} diff --git a/provider/digitalocean/provider_test.go b/provider/digitalocean/provider_test.go new file mode 100644 index 0000000..bf4a771 --- /dev/null +++ b/provider/digitalocean/provider_test.go @@ -0,0 +1,263 @@ +package digitalocean + +import ( + "errors" + "net" + "testing" + + "github.com/digitalocean/godo" + + "github.com/stretchr/testify/assert" +) + +func TestProvider_New(t *testing.T) { + assert.Equal(t, Provider{ + service: godo.NewFromToken("token").Domains, + cache: make(map[string][]godo.DomainRecord), + }, New("token")) +} + +func TestProvider_fetch(t *testing.T) { + expected := []godo.DomainRecord{ + { + ID: 28448429, + Type: "A", + Name: "sub1", + Data: "201.110.66.72", + Priority: 2, + TTL: 1800, + }, + { + ID: 28448430, + Type: "A", + Name: "sub2", + Data: "242.124.218.187", + Priority: 1, + TTL: 1800, + }, + } + + provider := Provider{ + service: mock{ + t: t, + records_by_type: map[string][]godo.DomainRecord{ + "example.com": expected, + }, + }, + cache: make(map[string][]godo.DomainRecord), + } + + records, err := provider.fetch("example.com") + assert.NoError(t, err) + + assert.Equal(t, expected, records) + + // Fetch invalid + _, err = provider.fetch("noexists.com") + assert.Error(t, err) +} + +func TestProvider_fetch_caches_records(t *testing.T) { + example_com_records := []godo.DomainRecord{ + { + ID: 28448429, + Type: "A", + Name: "sub1", + Data: "107.218.197.189", + Priority: 2, + TTL: 1800, + }, + { + ID: 28448430, + Type: "A", + Name: "sub2", + Data: "254.221.12.160", + Priority: 1, + TTL: 1800, + }, + } + + another_com_records := []godo.DomainRecord{ + { + ID: 237823, + Type: "A", + Name: "box", + Data: "108.151.98.62", + Priority: 2, + TTL: 1800, + }, + { + ID: 237824, + Type: "A", + Name: "ntp", + Data: "190.255.140.208", + Priority: 10, + TTL: 300, + }, + } + + mockService := mock{ + t: t, + records_by_type: map[string][]godo.DomainRecord{ + "example.com": example_com_records, + "another.com": another_com_records, + }, + } + + provider := Provider{ + service: mockService, + cache: make(map[string][]godo.DomainRecord), + } + + records, err := provider.fetch("example.com") + assert.NoError(t, err) + assert.Equal(t, example_com_records, records) + + records, err = provider.fetch("another.com") + assert.NoError(t, err) + assert.Equal(t, another_com_records, records) + + // Check cache, should be equal to the map in mock service. + assert.Equal(t, provider.cache, mockService.records_by_type) +} + +func TestProvider_fetch_from_cache(t *testing.T) { + expected := []godo.DomainRecord{ + { + ID: 273671823, + Type: "A", + Name: "sub1", + Data: "42.170.152.94", + Priority: 10, + TTL: 1800, + }, + } + + provider := Provider{ + service: mock{t: t}, + cache: map[string][]godo.DomainRecord{ + "example.com": expected, + }, + } + + records, err := provider.fetch("example.com") + assert.NoError(t, err) + + assert.Equal(t, expected, records) + + _, err = provider.fetch("noexists.com") + assert.Error(t, err) +} + +func TestProvider_find(t *testing.T) { + expected := []godo.DomainRecord{ + { + ID: 236718, + Type: "A", + Name: "sub1", + Data: "161.125.137.64", + Priority: 10, + TTL: 1800, + }, + { + ID: 23123131, + Type: "A", + Name: "sub2", + Data: "154.63.46.159", + Priority: 5, + TTL: 1800, + }, + } + + expected_cache := []godo.DomainRecord{ + { + ID: 23713762, + Type: "A", + Name: "mail", + Data: "176.151.152.10", + Priority: 10, + TTL: 3600, + }, + } + + provider := Provider{ + service: mock{ + t: t, + records_by_type: map[string][]godo.DomainRecord{ + "example.com": expected, + }, + }, + cache: map[string][]godo.DomainRecord{ + "cached.com": expected_cache, + }, + } + + // Test fetch. + record, err := provider.find("example.com", "sub2") + assert.NoError(t, err) + assert.Equal(t, expected[1], *record) + + // Test cached record + record, err = provider.find("cached.com", "mail") + assert.NoError(t, err) + assert.Equal(t, expected_cache[0], *record) + + // Test not found (domain) + _, err = provider.find("noexists.com", "www") + assert.Error(t, err) + + // Test not found (subdomain) + _, err = provider.find("cached.com", "nosub") + assert.Error(t, err) +} + +func TestProvider_Update(t *testing.T) { + expected := []godo.DomainRecord{ + { + ID: 1337, + Type: "A", + Name: "www", + Data: "80.17.42.157", + Priority: 10, + TTL: 360, + Port: 22, + Weight: 100, + Flags: 0xf1, + Tag: "some_tag", + }, + } + + mockService := mock{ + t: t, + records_by_type: map[string][]godo.DomainRecord{ + "example.com": expected, + }, + edit_record_request: &godo.DomainRecordEditRequest{ + // Type: "A", + // Name: "www", + Data: "221.135.170.186", + // Priority: 10, + // Port: 22, + // TTL: 360, + // Weight: 100, + // Flags: 0xf1, + // Tag: "some_tag", + }, + } + + provider := Provider{ + service: &mockService, + cache: map[string][]godo.DomainRecord{}, + } + + err := provider.Update("example.com", "www", net.IPv4(221, 135, 170, 186)) + assert.NoError(t, err) + + err = provider.Update("invalid.com", "www", net.IPv4(72, 82, 118, 186)) + assert.Error(t, err) + + mockService.edit_record_error = errors.New("Error") + + err = provider.Update("example.com", "www", net.IPv4(221, 135, 170, 186)) + assert.Error(t, err) +} diff --git a/provider/interface.go b/provider/interface.go new file mode 100644 index 0000000..1d7927a --- /dev/null +++ b/provider/interface.go @@ -0,0 +1,11 @@ +package provider + +import ( + "net" +) + +type Provider interface { + Update(domain string, record string, ip net.IP) error +} + +type ProviderFactory func(map[string]interface{}) (Provider, error) diff --git a/provider/manager/manager.go b/provider/manager/manager.go new file mode 100644 index 0000000..9f54f79 --- /dev/null +++ b/provider/manager/manager.go @@ -0,0 +1,48 @@ +package manager + +import ( + "fmt" + + "dnsupdater/provider" + "dnsupdater/provider/digitalocean" +) + +var factories = map[string]provider.ProviderFactory{ + "digitalocean": digitalocean.Factory, +} + +type Manager struct { + services map[string]provider.Provider +} + +func New() *Manager { + return &Manager{ + services: make(map[string]provider.Provider), + } +} + +func (m Manager) Get(name string) provider.Provider { + if service, ok := m.services[name]; ok { + return service + } + return nil +} + +func (m Manager) RegisterFromConfig(providers map[string]map[string]interface{}) error { + for name, args := range providers { + if factory, ok := factories[name]; ok { + + provider, err := factory(args) + if err != nil { + return fmt.Errorf("could not create provider '%s': %v", name, err) + } + + m.Register(name, provider) + } + } + return nil +} + +func (m Manager) Register(name string, provider provider.Provider) { + m.services[name] = provider +}