From 6b8c340dcfd63093a7a39303cee59c18381aaef3 Mon Sep 17 00:00:00 2001 From: Henrik Hautakoski Date: Sun, 12 Oct 2025 23:55:53 +0200 Subject: [PATCH] adding vultr provider --- config.example.yml | 8 ++ go.mod | 10 +- go.sum | 10 ++ provider/manager/manager.go | 2 + provider/vultr/mock_test.go | 44 ++++++ provider/vultr/provider.go | 95 +++++++++++++ provider/vultr/provider_test.go | 241 ++++++++++++++++++++++++++++++++ 7 files changed, 407 insertions(+), 3 deletions(-) create mode 100644 provider/vultr/mock_test.go create mode 100644 provider/vultr/provider.go create mode 100644 provider/vultr/provider_test.go diff --git a/config.example.yml b/config.example.yml index cb6243d..7471468 100644 --- a/config.example.yml +++ b/config.example.yml @@ -5,6 +5,8 @@ services: providers: digitalocean: token: xxxx + vultr: + token: xxxx updates: digitalocean: @@ -15,4 +17,10 @@ updates: www: wan mail: wan static: 84.24.254.21 + vultr: + example1.com: + www: wan + example2.com: + www: wan + ftp: 88.212.99.90 diff --git a/go.mod b/go.mod index 389fb00..e6a84d8 100644 --- a/go.mod +++ b/go.mod @@ -1,24 +1,28 @@ module dnsupdater -go 1.19 +go 1.23 + +toolchain go1.24.1 require ( github.com/digitalocean/godo v1.99.0 github.com/rs/zerolog v1.30.0 github.com/stretchr/testify v1.8.2 + github.com/vultr/govultr/v3 v3.24.0 + golang.org/x/oauth2 v0.7.0 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-cmp v0.5.9 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // 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/sys v0.20.0 // indirect golang.org/x/time v0.3.0 // indirect google.golang.org/appengine v1.6.7 // indirect diff --git a/go.sum b/go.sum index e11432e..7a3c34f 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ 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/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 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= @@ -15,6 +17,12 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -35,6 +43,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ 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= +github.com/vultr/govultr/v3 v3.24.0 h1:fTTTj0VBve+Miy+wGhlb90M2NMDfpGFi6Frlj3HVy6M= +github.com/vultr/govultr/v3 v3.24.0/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY= 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= diff --git a/provider/manager/manager.go b/provider/manager/manager.go index 48778da..5cd609b 100644 --- a/provider/manager/manager.go +++ b/provider/manager/manager.go @@ -5,10 +5,12 @@ import ( "dnsupdater/provider" "dnsupdater/provider/digitalocean" + "dnsupdater/provider/vultr" ) var factories = map[string]provider.ProviderFactory{ "digitalocean": digitalocean.Factory, + "vultr": vultr.Factory, } type Manager struct { diff --git a/provider/vultr/mock_test.go b/provider/vultr/mock_test.go new file mode 100644 index 0000000..77ddf7a --- /dev/null +++ b/provider/vultr/mock_test.go @@ -0,0 +1,44 @@ +package vultr + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/vultr/govultr/v3" +) + +type mock struct { + t *testing.T + + ListReturn map[string][]govultr.DomainRecord + updateError error +} + +func (m mock) Create(ctx context.Context, domain string, domainRecordReq *govultr.DomainRecordReq) (*govultr.DomainRecord, *http.Response, error) { + m.t.Error("Create called when it should not have been") + return nil, nil, nil +} + +func (m mock) List(ctx context.Context, domain string, options *govultr.ListOptions) ([]govultr.DomainRecord, *govultr.Meta, *http.Response, error) { + records, ok := m.ListReturn[domain] + if !ok { + return nil, nil, nil, errors.New("not found") + } + return records, nil, nil, nil +} + +func (m mock) Get(ctx context.Context, domain, recordID string) (*govultr.DomainRecord, *http.Response, error) { + m.t.Error("Get called when it should not have been") + return nil, nil, nil +} + +func (m mock) Update(ctx context.Context, domain, recordID string, domainRecordReq *govultr.DomainRecordReq) error { + return m.updateError +} + +func (m mock) Delete(ctx context.Context, domain, recordID string) error { + m.t.Error("Delete called when it should not have been") + return nil +} diff --git a/provider/vultr/provider.go b/provider/vultr/provider.go new file mode 100644 index 0000000..bb8753f --- /dev/null +++ b/provider/vultr/provider.go @@ -0,0 +1,95 @@ +package vultr + +import ( + "context" + "errors" + "net" + + "dnsupdater/provider" + + "github.com/vultr/govultr/v3" + "golang.org/x/oauth2" +) + +type Provider struct { + service govultr.DomainRecordService + cache map[string][]govultr.DomainRecord +} + +func New(token string) Provider { + ctx := context.Background() + config := &oauth2.Config{} + ts := config.TokenSource(ctx, &oauth2.Token{AccessToken: token}) + client := govultr.NewClient(oauth2.NewClient(ctx, ts)) + + return Provider{ + service: client.DomainRecord, + cache: make(map[string][]govultr.DomainRecord), + } +} + +func Factory(args map[string]any) (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) ([]govultr.DomainRecord, error) { + records, ok := d.cache[domain] + if !ok { + fetchedRecords, _, _, err := d.service.List(context.Background(), domain, nil) + if err != nil { + return nil, err + } + + for _, rec := range fetchedRecords { + if rec.Type != "A" { + continue + } + + records = append(records, rec) + } + d.cache[domain] = records + } + return records, nil +} + +func (d Provider) find(domain, name string) (govultr.DomainRecord, error) { + records, err := d.fetch(domain) + if err != nil { + return govultr.DomainRecord{}, err + } + + for _, rec := range records { + if rec.Name == name { + return rec, nil + } + } + return govultr.DomainRecord{}, errors.New("not found") +} + +func (d Provider) Update(domain string, name string, ip net.IP) error { + ctx := context.Background() + + record, err := d.find(domain, name) + if err != nil { + return err + } + + recordIP := net.ParseIP(record.Data) + if recordIP == nil || !recordIP.Equal(ip) { + updateData := govultr.DomainRecordReq{ + Data: ip.String(), + } + return d.service.Update(ctx, domain, record.ID, &updateData) + } + return nil +} diff --git a/provider/vultr/provider_test.go b/provider/vultr/provider_test.go new file mode 100644 index 0000000..764cc25 --- /dev/null +++ b/provider/vultr/provider_test.go @@ -0,0 +1,241 @@ +package vultr + +import ( + "errors" + "net" + "testing" + + "github.com/vultr/govultr/v3" + + "github.com/stretchr/testify/assert" +) + +func TestProvider_fetch(t *testing.T) { + expected := []govultr.DomainRecord{ + { + ID: "656939ee-f942-4ce2-af1d-3bd68c764e96", + Type: "A", + Name: "sub1", + Data: "201.110.66.72", + Priority: 2, + TTL: 1800, + }, + { + ID: "c80118f4-f04c-4ad2-8ec2-16eb15cc8aca", + Type: "A", + Name: "sub2", + Data: "242.124.218.187", + Priority: 1, + TTL: 1800, + }, + } + + provider := Provider{ + service: mock{ + t: t, + ListReturn: map[string][]govultr.DomainRecord{ + "example.com": expected, + }, + }, + cache: make(map[string][]govultr.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 := []govultr.DomainRecord{ + { + ID: "884edc0a-295c-418a-97ac-b7b67d85efc1", + Type: "A", + Name: "sub1", + Data: "107.218.197.189", + Priority: 2, + TTL: 1800, + }, + { + ID: "43608e90-8917-45f9-8300-1a40b13886b7", + Type: "A", + Name: "sub2", + Data: "254.221.12.160", + Priority: 1, + TTL: 1800, + }, + } + + another_com_records := []govultr.DomainRecord{ + { + ID: "43608e90-8917-45f9-8300-1a40b13886b7", + Type: "A", + Name: "box", + Data: "108.151.98.62", + Priority: 2, + TTL: 1800, + }, + { + ID: "43608e90-8917-45f9-8300-1a40b13886b7", + Type: "A", + Name: "ntp", + Data: "190.255.140.208", + Priority: 10, + TTL: 300, + }, + } + + mockService := mock{ + t: t, + ListReturn: map[string][]govultr.DomainRecord{ + "example.com": example_com_records, + "another.com": another_com_records, + }, + } + + provider := Provider{ + service: mockService, + cache: make(map[string][]govultr.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.ListReturn) +} + +func TestProvider_fetch_from_cache(t *testing.T) { + expected := []govultr.DomainRecord{ + { + ID: "43608e90-8917-45f9-8300-1a40b13886b7", + Type: "A", + Name: "sub1", + Data: "42.170.152.94", + Priority: 10, + TTL: 1800, + }, + } + + provider := Provider{ + service: mock{t: t}, + cache: map[string][]govultr.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 := []govultr.DomainRecord{ + { + ID: "7d53d1b4-8264-40f7-b379-536b5caff54c", + Type: "A", + Name: "sub1", + Data: "161.125.137.64", + Priority: 10, + TTL: 1800, + }, + { + ID: "309f6dda-4aef-453d-ae05-71f8274cdd76", + Type: "A", + Name: "sub2", + Data: "154.63.46.159", + Priority: 5, + TTL: 1800, + }, + } + + expected_cache := []govultr.DomainRecord{ + { + ID: "2216c435-0405-49aa-96bc-1d177146ee4e", + Type: "A", + Name: "mail", + Data: "176.151.152.10", + Priority: 10, + TTL: 3600, + }, + } + + provider := Provider{ + service: mock{ + t: t, + ListReturn: map[string][]govultr.DomainRecord{ + "example.com": expected, + }, + }, + cache: map[string][]govultr.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 := []govultr.DomainRecord{ + { + ID: "6cabe6ba-1ea1-405d-b66d-cd56ecac45ce", + Type: "A", + Name: "www", + Data: "80.17.42.157", + Priority: 10, + TTL: 360, + }, + } + + mockService := mock{ + t: t, + ListReturn: map[string][]govultr.DomainRecord{ + "example.com": expected, + }, + } + + provider := Provider{ + service: &mockService, + cache: map[string][]govultr.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.updateError = errors.New("Error") + + err = provider.Update("example.com", "www", net.IPv4(221, 135, 170, 186)) + assert.Error(t, err) +}