From 34054998afe303c02923fc712dcb93d1745e3e05 Mon Sep 17 00:00:00 2001 From: Leon Latsch <50983026+leonlatsch@users.noreply.github.com> Date: Mon, 7 Oct 2024 21:35:13 +0200 Subject: [PATCH] feat: Hetzer Support (#1) * implement hetzner api, service and config * improve logging in get records * fix map error in service * fix error when updating records * update readme * fix json in readme --- README.md | 25 ++++++ internal/api/godaddy_api_fake.go | 30 ------- internal/{api => godaddy}/godaddy_api.go | 26 +++--- internal/godaddy/godaddy_api_fake.go | 28 +++++++ .../godaddy_models.go => godaddy/models.go} | 2 +- internal/hetzner/hetzner_api.go | 59 +++++++++++++ internal/hetzner/hetzner_service.go | 82 +++++++++++++++++++ internal/hetzner/models.go | 16 ++++ internal/models/models.go | 11 +++ internal/service/godaddy_service.go | 5 +- internal/service/gogaddy_service_test.go | 19 +++-- main.go | 22 ++++- 12 files changed, 269 insertions(+), 56 deletions(-) delete mode 100644 internal/api/godaddy_api_fake.go rename internal/{api => godaddy}/godaddy_api.go (75%) create mode 100644 internal/godaddy/godaddy_api_fake.go rename internal/{models/godaddy_models.go => godaddy/models.go} (95%) create mode 100644 internal/hetzner/hetzner_api.go create mode 100644 internal/hetzner/hetzner_service.go create mode 100644 internal/hetzner/models.go diff --git a/README.md b/README.md index 3d761fd..8c36f2a 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,28 @@ docker run -d \ --name go-resolve \ ghcr.io/leonlatsch/go-resolve:latest ``` + +### Config + +```json +{ + "provider": "hetzner", // Available: updateUrl, goDaddy, hetzner + "interval": "1h", + "domain": "yourdomain.dom", + "hosts": [ + "subdomain1", + "subdomain2" + ], + "updateUrlConfig": { + "url": "UPDATE_URL" + }, + "goDaddyConfig": { + "apiKey": "GODADDY_API_KEY", + "apiSecret": "GODADDY_API_SECRET" + }, + "hetznerConfig": { + "zoneId": "HETZNER_ZONE_ID", + "apiToken": "HETZNER_API_TOKEN" + } +} +``` diff --git a/internal/api/godaddy_api_fake.go b/internal/api/godaddy_api_fake.go deleted file mode 100644 index fd4be21..0000000 --- a/internal/api/godaddy_api_fake.go +++ /dev/null @@ -1,30 +0,0 @@ -package api - -import "github.com/leonlatsch/go-resolve/internal/models" - -type GodaddyApiFake struct { - DomainDetail models.DomainDetail - ExistingRecords map[string][]models.DnsRecord - Error error - - CreateRecordCalledWith models.DnsRecord - UpdateRecordCalledWith models.DnsRecord -} - -func (self *GodaddyApiFake) GetDomainDetail() (models.DomainDetail, error) { - return self.DomainDetail, self.Error -} - -func (self *GodaddyApiFake) GetRecords(host string) ([]models.DnsRecord, error) { - return self.ExistingRecords[host], self.Error -} - -func (self *GodaddyApiFake) CreateRecord(record models.DnsRecord) error { - self.CreateRecordCalledWith = record - return self.Error -} - -func (self *GodaddyApiFake) UpdateRecord(record models.DnsRecord) error { - self.UpdateRecordCalledWith = record - return self.Error -} diff --git a/internal/api/godaddy_api.go b/internal/godaddy/godaddy_api.go similarity index 75% rename from internal/api/godaddy_api.go rename to internal/godaddy/godaddy_api.go index 4ce37e2..8c5d6e7 100644 --- a/internal/api/godaddy_api.go +++ b/internal/godaddy/godaddy_api.go @@ -1,4 +1,4 @@ -package api +package godaddy import ( "fmt" @@ -10,10 +10,10 @@ import ( ) type GodaddyApi interface { - GetDomainDetail() (models.DomainDetail, error) - GetRecords(host string) ([]models.DnsRecord, error) - CreateRecord(record models.DnsRecord) error - UpdateRecord(record models.DnsRecord) error + GetDomainDetail() (DomainDetail, error) + GetRecords(host string) ([]DnsRecord, error) + CreateRecord(record DnsRecord) error + UpdateRecord(record DnsRecord) error } type GodaddyApiImpl struct { @@ -23,8 +23,8 @@ type GodaddyApiImpl struct { const BASE_URL = "https://api.godaddy.com/v1" -func (self *GodaddyApiImpl) GetDomainDetail() (models.DomainDetail, error) { - var detail models.DomainDetail +func (self *GodaddyApiImpl) GetDomainDetail() (DomainDetail, error) { + var detail DomainDetail json, err := self.HttpClient.Get(self.endpointDomainDetail(), self.getAuthHeaders()) if err != nil { return detail, err @@ -37,8 +37,8 @@ func (self *GodaddyApiImpl) GetDomainDetail() (models.DomainDetail, error) { return detail, nil } -func (self *GodaddyApiImpl) GetRecords(host string) ([]models.DnsRecord, error) { - var records []models.DnsRecord +func (self *GodaddyApiImpl) GetRecords(host string) ([]DnsRecord, error) { + var records []DnsRecord recordsJson, err := self.HttpClient.Get(self.endpointARecords(host), self.getAuthHeaders()) if err != nil { @@ -52,9 +52,9 @@ func (self *GodaddyApiImpl) GetRecords(host string) ([]models.DnsRecord, error) return records, nil } -func (self *GodaddyApiImpl) CreateRecord(record models.DnsRecord) error { +func (self *GodaddyApiImpl) CreateRecord(record DnsRecord) error { log.Println("Creating " + record.Name + "." + self.Config.Domain + " -> " + record.Data) - records := []models.DnsRecord{record} + records := []DnsRecord{record} if _, err := self.HttpClient.Patch(self.endpointRecords(""), self.getAuthHeaders(), records); err != nil { return err @@ -63,9 +63,9 @@ func (self *GodaddyApiImpl) CreateRecord(record models.DnsRecord) error { return nil } -func (self *GodaddyApiImpl) UpdateRecord(record models.DnsRecord) error { +func (self *GodaddyApiImpl) UpdateRecord(record DnsRecord) error { log.Println("Updating " + record.Name + "." + self.Config.Domain + " -> " + record.Data) - records := []models.DnsRecord{record} + records := []DnsRecord{record} if _, err := self.HttpClient.Put(self.endpointARecords(record.Name), self.getAuthHeaders(), records); err != nil { return err diff --git a/internal/godaddy/godaddy_api_fake.go b/internal/godaddy/godaddy_api_fake.go new file mode 100644 index 0000000..ac20efc --- /dev/null +++ b/internal/godaddy/godaddy_api_fake.go @@ -0,0 +1,28 @@ +package godaddy + +type GodaddyApiFake struct { + DomainDetail DomainDetail + ExistingRecords map[string][]DnsRecord + Error error + + CreateRecordCalledWith DnsRecord + UpdateRecordCalledWith DnsRecord +} + +func (self *GodaddyApiFake) GetDomainDetail() (DomainDetail, error) { + return self.DomainDetail, self.Error +} + +func (self *GodaddyApiFake) GetRecords(host string) ([]DnsRecord, error) { + return self.ExistingRecords[host], self.Error +} + +func (self *GodaddyApiFake) CreateRecord(record DnsRecord) error { + self.CreateRecordCalledWith = record + return self.Error +} + +func (self *GodaddyApiFake) UpdateRecord(record DnsRecord) error { + self.UpdateRecordCalledWith = record + return self.Error +} diff --git a/internal/models/godaddy_models.go b/internal/godaddy/models.go similarity index 95% rename from internal/models/godaddy_models.go rename to internal/godaddy/models.go index a812711..588abcf 100644 --- a/internal/models/godaddy_models.go +++ b/internal/godaddy/models.go @@ -1,4 +1,4 @@ -package models +package godaddy type DnsRecord struct { Data string `json:"data"` diff --git a/internal/hetzner/hetzner_api.go b/internal/hetzner/hetzner_api.go new file mode 100644 index 0000000..004cccd --- /dev/null +++ b/internal/hetzner/hetzner_api.go @@ -0,0 +1,59 @@ +package hetzner + +import ( + "fmt" + + "github.com/leonlatsch/go-resolve/internal/http" + "github.com/leonlatsch/go-resolve/internal/models" + "github.com/leonlatsch/go-resolve/internal/serialization" +) + +type HetznerApi interface { + GetRecords() ([]Record, error) + BulkUpdate(records []Record) error +} + +type HetznerApiImpl struct { + Config *models.Config + HttpClient http.HttpClient +} + +const BASE_URL = "https://dns.hetzner.com/api/v1" + +func (api *HetznerApiImpl) GetRecords() ([]Record, error) { + var records []Record + var recordsWrapper RecordsWrapper + + url := fmt.Sprintf("%v/records?zone_id=%v", BASE_URL, api.Config.HetznerConfig.ZoneId) + recordsJson, err := api.HttpClient.Get(url, api.getHeaders()) + + if err != nil { + return records, err + } + + if err := serialization.FromJson(recordsJson, &recordsWrapper); err != nil { + return records, err + } + + return recordsWrapper.Records, nil +} + +func (api *HetznerApiImpl) BulkUpdate(records []Record) error { + recordsWrapper := RecordsWrapper{ + Records: records, + } + + url := fmt.Sprintf("%v/records/bulk", BASE_URL) + + if _, err := api.HttpClient.Put(url, api.getHeaders(), recordsWrapper); err != nil { + return err + } + + return nil +} + +func (api *HetznerApiImpl) getHeaders() map[string]string { + headers := make(map[string]string) + headers["Auth-API-Token"] = api.Config.HetznerConfig.ApiToken + return headers +} diff --git a/internal/hetzner/hetzner_service.go b/internal/hetzner/hetzner_service.go new file mode 100644 index 0000000..5de88d4 --- /dev/null +++ b/internal/hetzner/hetzner_service.go @@ -0,0 +1,82 @@ +package hetzner + +import ( + "errors" + "fmt" + "log" + + "github.com/leonlatsch/go-resolve/internal/models" + "github.com/leonlatsch/go-resolve/internal/service" +) + +type HetznerService struct { + Config *models.Config + HetznerApi HetznerApi + IpObserverService service.IpObserverService + + RecordIds map[string]RecordId +} + +func (service *HetznerService) PreloadRecordIds() error { + recordIds := make(map[string]RecordId) + + records, err := service.HetznerApi.GetRecords() + if err != nil { + log.Println("Could not preload records ids. Please check your config") + return err + } + + for _, record := range records { + for _, host := range service.Config.Hosts { + if host == record.Name { + log.Printf("Loaded record id %v for %v", record.Id, host) + recordIds[host] = record.Id + } + } + } + + if len(recordIds) <= 0 { + return errors.New("Could not find configured records in dns entries") + } + + service.RecordIds = recordIds + return nil +} + +func (service *HetznerService) ObserveAndUpdateDns() { + log.Println("Running for hetzner") + service.IpObserverService.ObserveIp(func(ip string) { + service.UpdateDns(ip) + }) +} + +func (service *HetznerService) UpdateDns(ip string) { + log.Println("Ip changed: " + ip) + + if len(service.RecordIds) <= 0 { + log.Println("No records ids loaded. Not updating.") + return + } + + records := []Record{} + for _, host := range service.Config.Hosts { + record := Record{ + Id: service.RecordIds[host], + Value: ip, + Type: "A", + Name: host, + ZoneId: ZoneId(service.Config.HetznerConfig.ZoneId), + } + records = append(records, record) + } + + log.Println(fmt.Sprintf("Updating %v records for %v", len(records), service.Config.Domain)) + if err := service.HetznerApi.BulkUpdate(records); err != nil { + log.Println("Bulk update failed. Not caching ip") + log.Println(err) + return + } + + log.Println("Successfully updated all records. Caching " + ip) + service.IpObserverService.LastIp = ip +} diff --git a/internal/hetzner/models.go b/internal/hetzner/models.go new file mode 100644 index 0000000..ad6ae1a --- /dev/null +++ b/internal/hetzner/models.go @@ -0,0 +1,16 @@ +package hetzner + +type ZoneId string +type RecordId string + +type Record struct { + Id RecordId `json:"id"` + Value string `json:"value"` + Type string `json:"type"` + Name string `json:"name"` + ZoneId ZoneId `json:"zone_id"` +} + +type RecordsWrapper struct { + Records []Record `json:"records"` +} diff --git a/internal/models/models.go b/internal/models/models.go index 6dfae02..458ce09 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -3,6 +3,7 @@ package models const ( ProviderUpdateUrl = "updateUrl" ProviderGoDaddy = "goDaddy" + ProviderHetzner = "hetzner" ) type Config struct { @@ -13,6 +14,7 @@ type Config struct { UpdateUrlConfig UpdateUrlConfig `json:"updateUrlConfig"` GoDaddyConfig GoDaddyConfig `json:"goDaddyConfig"` + HetznerConfig HetznerConfig `json:"hetznerConfig"` } type UpdateUrlConfig struct { @@ -24,6 +26,11 @@ type GoDaddyConfig struct { ApiSecret string `json:"apiSecret"` } +type HetznerConfig struct { + ZoneId string `json:"zoneId"` + ApiToken string `json:"apiToken"` +} + // Empty Config. Used for generating file at first launch var EmptyConfig = Config{ @@ -39,4 +46,8 @@ var EmptyConfig = Config{ ApiKey: "GODADDY_API_KEY", ApiSecret: "GODADDY_API_SECRET", }, + HetznerConfig: HetznerConfig{ + ZoneId: "ZONE_ID", + ApiToken: "API_TOKEN", + }, } diff --git a/internal/service/godaddy_service.go b/internal/service/godaddy_service.go index 76fe713..2749db3 100644 --- a/internal/service/godaddy_service.go +++ b/internal/service/godaddy_service.go @@ -5,12 +5,13 @@ import ( "log" "github.com/leonlatsch/go-resolve/internal/api" + "github.com/leonlatsch/go-resolve/internal/godaddy" "github.com/leonlatsch/go-resolve/internal/models" ) type GodaddyService struct { Config *models.Config - GodaddyApi api.GodaddyApi + GodaddyApi godaddy.GodaddyApi IpApi api.IpApi IpObserver IpObserverService } @@ -56,7 +57,7 @@ func (self *GodaddyService) UpdateDns(ip string) { continue } - record := models.DnsRecord{ + record := godaddy.DnsRecord{ Data: ip, Name: host, Type: "A", diff --git a/internal/service/gogaddy_service_test.go b/internal/service/gogaddy_service_test.go index 073547f..9367623 100644 --- a/internal/service/gogaddy_service_test.go +++ b/internal/service/gogaddy_service_test.go @@ -5,12 +5,13 @@ import ( "testing" "github.com/leonlatsch/go-resolve/internal/api" + "github.com/leonlatsch/go-resolve/internal/godaddy" "github.com/leonlatsch/go-resolve/internal/models" "github.com/leonlatsch/go-resolve/internal/service" ) func TestPrintDomainDetails(t *testing.T) { - godaddyApiFake := api.GodaddyApiFake{} + godaddyApiFake := godaddy.GodaddyApiFake{} ipOpserver := service.IpObserverService{} service := service.GodaddyService{ @@ -21,9 +22,9 @@ func TestPrintDomainDetails(t *testing.T) { } t.Run("Get domain details does not crash with correct json response", func(t *testing.T) { - fakeDomainDetail := models.DomainDetail{ + fakeDomainDetail := godaddy.DomainDetail{ Domain: "somedomain.com", - ContactAdmin: models.DomainContact{ + ContactAdmin: godaddy.DomainContact{ Email: "someemail@asdf.com", FirstName: "FirstName", LastName: "LastName", @@ -47,7 +48,7 @@ func TestPrintDomainDetails(t *testing.T) { } func TestOnIpChanged(t *testing.T) { - godaddyApiFake := api.GodaddyApiFake{} + godaddyApiFake := godaddy.GodaddyApiFake{} conf := models.Config{ Domain: "mydomain.com", Hosts: []string{"host1", "host2"}, @@ -60,27 +61,27 @@ func TestOnIpChanged(t *testing.T) { } t.Run("OnIpChanged creates new and updates existing record", func(t *testing.T) { - godaddyApiFake.ExistingRecords = make(map[string][]models.DnsRecord) - godaddyApiFake.ExistingRecords["host1"] = []models.DnsRecord{ + godaddyApiFake.ExistingRecords = make(map[string][]godaddy.DnsRecord) + godaddyApiFake.ExistingRecords["host1"] = []godaddy.DnsRecord{ { Data: "oldIp", Name: "host1", Type: "A", }, } - godaddyApiFake.ExistingRecords["host2"] = []models.DnsRecord{} + godaddyApiFake.ExistingRecords["host2"] = []godaddy.DnsRecord{} // host1 should be updated and host2 should be created newIp := "123.123.123.123" service.UpdateDns(newIp) - expectedUpdatedRecord := models.DnsRecord{ + expectedUpdatedRecord := godaddy.DnsRecord{ Data: newIp, Name: "host1", Type: "A", } - expectedCreatedRecord := models.DnsRecord{ + expectedCreatedRecord := godaddy.DnsRecord{ Data: newIp, Name: "host2", Type: "A", diff --git a/main.go b/main.go index f09fc20..0d6d94f 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,8 @@ import ( "github.com/leonlatsch/go-resolve/internal/api" "github.com/leonlatsch/go-resolve/internal/config" + "github.com/leonlatsch/go-resolve/internal/godaddy" + "github.com/leonlatsch/go-resolve/internal/hetzner" "github.com/leonlatsch/go-resolve/internal/http" "github.com/leonlatsch/go-resolve/internal/models" "github.com/leonlatsch/go-resolve/internal/service" @@ -47,7 +49,7 @@ func createService(conf *models.Config, httpClient http.HttpClient) (service.Dns godaddyService := service.GodaddyService{ Config: conf, - GodaddyApi: &api.GodaddyApiImpl{Config: conf, HttpClient: httpClient}, + GodaddyApi: &godaddy.GodaddyApiImpl{Config: conf, HttpClient: httpClient}, IpObserver: service.IpObserverService{ IpApi: &api.IpApiImpl{HttpClient: httpClient}, Config: conf, @@ -62,5 +64,23 @@ func createService(conf *models.Config, httpClient http.HttpClient) (service.Dns } + if conf.Provider == models.ProviderHetzner { + hetznerService := hetzner.HetznerService{ + Config: conf, + HetznerApi: &hetzner.HetznerApiImpl{Config: conf, HttpClient: httpClient}, + IpObserverService: service.IpObserverService{ + IpApi: &api.IpApiImpl{HttpClient: httpClient}, + Config: conf, + }, + RecordIds: map[string]hetzner.RecordId{}, + } + + if err := hetznerService.PreloadRecordIds(); err != nil { + log.Fatalln(err) + } + + return &hetznerService, nil + } + return nil, errors.New("No service for configured provider") }