commit 4b1861fa9bf10188bad38211b76250ee07d6eb5a Author: William Bouzourène Date: Sun Mar 16 19:38:58 2025 +0100 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..412023a --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +DRY_RUN=true +HETZNER_API_TOKEN=token-goes-here diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d25d62e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__debug_bin* +gestion-dns +.env +zones/*.toml \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..83f87db --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceRoot}", + "console": "integratedTerminal", + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fcc558f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "editor.tabSize": 2, + "editor.detectIndentation": false, + "editor.insertSpaces": true, + "[go]": { + "editor.insertSpaces": false + } +} diff --git a/dnsconfig/zones.go b/dnsconfig/zones.go new file mode 100644 index 0000000..4a9699e --- /dev/null +++ b/dnsconfig/zones.go @@ -0,0 +1,75 @@ +package dnsconfig + +import ( + "fmt" + "os" + "strings" + + "github.com/BurntSushi/toml" +) + +type Record struct { + Name string `toml:"name"` + Type string `toml:"type"` + Value string `toml:"value"` + Flat bool `toml:"flat"` + TTL int `toml:"ttl"` +} + +type Zone struct { + Domain string `toml:"domain"` + DefaultTTL int `toml:"default_ttl"` + Records []Record `toml:"record"` +} + +func GetZones() ([]Zone, error) { + var zones []Zone + + files, err := os.ReadDir("./zones/") + if err != nil { + return zones, err + } + + for _, file := range files { + name := file.Name() + path := fmt.Sprintf("./zones/%s", name) + + if !strings.HasSuffix(strings.ToLower(name), ".toml") { + continue + } + + var zone Zone + _, err = toml.DecodeFile(path, &zone) + if err != nil { + continue + } + + if len(zone.Domain) == 0 { + continue + } + + if zone.DefaultTTL <= 0 { + zone.DefaultTTL = 3600 + } + + zones = append(zones, zone) + } + + return zones, nil +} + +func CreateZone(zone Zone) error { + if len(zone.Domain) == 0 { + return fmt.Errorf("zone name cannot be empty") + } + + name := strings.ReplaceAll(strings.ToLower(zone.Domain), ".", "_") + path := fmt.Sprintf("./zones/%s.toml", name) + + file, err := os.Create(path) + if err != nil { + return err + } + + return toml.NewEncoder(file).Encode(&zone) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b90ab16 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module git.readonly.ch/bouzoure/gestion-dns + +go 1.24.1 + +require ( + github.com/BurntSushi/toml v1.4.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/lipgloss v1.0.0 // indirect + github.com/charmbracelet/log v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.4.2 // indirect + github.com/domainr/dnsr v0.0.0-20250314081958-26623e3d15cb // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/golobby/cast v1.3.3 // indirect + github.com/golobby/dotenv v1.3.2 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/miekg/dns v1.1.63 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/mod v0.18.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/tools v0.22.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..78c4152 --- /dev/null +++ b/go.sum @@ -0,0 +1,62 @@ +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= +github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk= +github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I= +github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk= +github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/domainr/dnsr v0.0.0-20250314081958-26623e3d15cb h1:JrBQZ2g3k3DsBpyJwXDc37JgAAxJOc+tbF4TBWqT6uw= +github.com/domainr/dnsr v0.0.0-20250314081958-26623e3d15cb/go.mod h1:CoRD9PKaV6dU3S7hQpccTRzZjQpBgBnlnO0h1NHpVV8= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/golobby/cast v1.3.3 h1:s2Lawb9RMz7YyYf8IrfMQY4IFmA1R/lgfmj97Vc6fig= +github.com/golobby/cast v1.3.3/go.mod h1:0oDO5IT84HTXcbLDf1YXuk0xtg/cRDrxhbpWKxwtJCY= +github.com/golobby/dotenv v1.3.2 h1:9vA8XqXXIB3cX/5xQ1CTbOCPegioHtHXIxeFng+uOqQ= +github.com/golobby/dotenv v1.3.2/go.mod h1:9MMVXqzLNluhVxCv3X/DLYBNUb289f05tr+df1+7278= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +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.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= +github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= +golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/helpers/config.go b/helpers/config.go new file mode 100644 index 0000000..6a775e6 --- /dev/null +++ b/helpers/config.go @@ -0,0 +1,41 @@ +package helpers + +import ( + "os" + + "github.com/golobby/dotenv" +) + +type Config struct { + DryRun bool `env:"DRY_RUN"` + Hetzner struct { + ApiToken string `env:"HETZNER_API_TOKEN"` + } +} + +var configParsed bool +var config Config + +func GetConfig() (Config, error) { + if configParsed { + return config, nil + } + + return parseConfig() +} + +func parseConfig() (Config, error) { + file, err := os.Open(".env") + if err != nil { + return config, err + } + + err = dotenv.NewDecoder(file).Decode(&config) + if err != nil { + return config, err + } + + configParsed = true + + return config, err +} diff --git a/helpers/logger.go b/helpers/logger.go new file mode 100644 index 0000000..d396b86 --- /dev/null +++ b/helpers/logger.go @@ -0,0 +1,26 @@ +package helpers + +import ( + "os" + "time" + + "github.com/charmbracelet/log" +) + +var loggerCreated bool +var logger *log.Logger + +func GetLogger() *log.Logger { + if !loggerCreated { + logger = log.NewWithOptions(os.Stderr, log.Options{ + Level: log.DebugLevel, + ReportCaller: true, + ReportTimestamp: true, + TimeFormat: time.RFC3339, + }) + + loggerCreated = true + } + + return logger +} diff --git a/helpers/resolver.go b/helpers/resolver.go new file mode 100644 index 0000000..8e18015 --- /dev/null +++ b/helpers/resolver.go @@ -0,0 +1,14 @@ +package helpers + +import ( + "github.com/domainr/dnsr" +) + +func ResolveRecord(recordName, recordType string) string { + r := dnsr.NewResolver(dnsr.WithCache(0)) + for _, rr := range r.Resolve(recordName, recordType) { + return rr.Value + } + + return "" +} diff --git a/hetzner/records.go b/hetzner/records.go new file mode 100644 index 0000000..849e0f5 --- /dev/null +++ b/hetzner/records.go @@ -0,0 +1,217 @@ +package hetzner + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "git.readonly.ch/bouzoure/gestion-dns/helpers" +) + +type Record struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Value string `json:"value"` + TTL int `json:"ttl"` + ZoneID string `json:"zone_id"` + Created string `json:"created"` + Modified string `json:"modified"` +} + +type Records struct { + Records []Record `json:"records"` +} + +type CreateRecordResponse struct { + Record Record `json:"record"` +} + +type UpdateRecordResponse struct { + Record Record `json:"record"` +} + +func GetRecords(zone *Zone) error { + config, err := helpers.GetConfig() + if err != nil { + return err + } + + // Create client + client := &http.Client{} + + // Create request + url := fmt.Sprintf("https://dns.hetzner.com/api/v1/records?zone_id=%s", zone.ID) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + + // Headers + req.Header.Add("Auth-API-Token", config.Hetzner.ApiToken) + + // Fetch Request + resp, err := client.Do(req) + if err != nil { + return err + } + + // Read Response Body + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var records Records + err = json.Unmarshal(respBody, &records) + if err != nil { + return err + } + + var recordsToKeep []Record + for _, record := range records.Records { + if strings.EqualFold(record.Type, "NS") { + continue + } + + if strings.EqualFold(record.Type, "SOA") { + continue + } + + record.Value = strings.TrimPrefix(record.Value, "\"") + record.Value = strings.TrimSuffix(record.Value, "\"") + + recordsToKeep = append(recordsToKeep, record) + } + + zone.Records = recordsToKeep + return nil +} + +func CreateRecord(record *Record) (Record, error) { + var newRecord Record + + config, err := helpers.GetConfig() + if err != nil { + return newRecord, err + } + + payload, err := json.Marshal(record) + if err != nil { + return newRecord, err + } + body := bytes.NewBuffer(payload) + + // Create client + client := &http.Client{} + + // Create request + req, err := http.NewRequest("POST", "https://dns.hetzner.com/api/v1/records", body) + if err != nil { + return newRecord, err + } + + // Headers + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Auth-API-Token", config.Hetzner.ApiToken) + + // Fetch Request + resp, err := client.Do(req) + if err != nil { + return newRecord, err + } + + // Read Response Body + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return newRecord, err + } + + var createRecordResponse CreateRecordResponse + err = json.Unmarshal(respBody, &createRecordResponse) + if err != nil { + return newRecord, err + } + + return createRecordResponse.Record, nil +} + +func UpdateRecord(record *Record) (Record, error) { + var newRecord Record + + config, err := helpers.GetConfig() + if err != nil { + return newRecord, err + } + + payload, err := json.Marshal(record) + if err != nil { + return newRecord, err + } + body := bytes.NewBuffer(payload) + + // Create client + client := &http.Client{} + + // Create request + url := fmt.Sprintf("https://dns.hetzner.com/api/v1/records/%s", record.ID) + req, err := http.NewRequest("PUT", url, body) + if err != nil { + return newRecord, err + } + + // Headers + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Auth-API-Token", config.Hetzner.ApiToken) + + // Fetch Request + resp, err := client.Do(req) + if err != nil { + return newRecord, err + } + + // Read Response Body + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return newRecord, err + } + + var updateRecordResponse UpdateRecordResponse + err = json.Unmarshal(respBody, &updateRecordResponse) + if err != nil { + return newRecord, err + } + + return updateRecordResponse.Record, nil +} + +func DeleteRecord(record *Record) error { + config, err := helpers.GetConfig() + if err != nil { + return err + } + + // Create client + client := &http.Client{} + + // Create request + url := fmt.Sprintf("https://dns.hetzner.com/api/v1/records/%s", record.ID) + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return err + } + + // Headers + req.Header.Add("Auth-API-Token", config.Hetzner.ApiToken) + + // Fetch Request + _, err = client.Do(req) + if err != nil { + return err + } + + return nil +} diff --git a/hetzner/zones.go b/hetzner/zones.go new file mode 100644 index 0000000..e2418d5 --- /dev/null +++ b/hetzner/zones.go @@ -0,0 +1,62 @@ +package hetzner + +import ( + "encoding/json" + "io" + "net/http" + + "git.readonly.ch/bouzoure/gestion-dns/helpers" +) + +type Zone struct { + ID string `json:"id"` + Name string `json:"name"` + TTL int `json:"ttl"` + NS []string `json:"ns"` + Records []Record `json:"-"` +} + +type Zones struct { + Zones []Zone `json:"zones"` +} + +func GetZones() ([]Zone, error) { + var zones Zones + + config, err := helpers.GetConfig() + if err != nil { + return zones.Zones, err + } + + // Create client + client := &http.Client{} + + // Create request + req, err := http.NewRequest( + "GET", + "https://dns.hetzner.com/api/v1/zones", + nil, + ) + if err != nil { + return zones.Zones, err + } + + // Headers + req.Header.Add("Auth-API-Token", config.Hetzner.ApiToken) + + // Fetch Request + resp, err := client.Do(req) + if err != nil { + return zones.Zones, err + } + + // Read Response Body + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return zones.Zones, err + } + + err = json.Unmarshal(respBody, &zones) + + return zones.Zones, err +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f179731 --- /dev/null +++ b/main.go @@ -0,0 +1,261 @@ +package main + +import ( + "slices" + "strings" + + "git.readonly.ch/bouzoure/gestion-dns/dnsconfig" + "git.readonly.ch/bouzoure/gestion-dns/helpers" + "git.readonly.ch/bouzoure/gestion-dns/hetzner" +) + +func main() { + log := helpers.GetLogger() + + config, err := helpers.GetConfig() + if err != nil { + log.Fatal(err) + } + + if config.DryRun { + log.Warn("Dry run is enabled, changes will not be applied") + } else { + log.Warn("Dry run is disabled, changes will be applied") + } + + // Fetch zones from Hetzner API + log.Info("Fetching existing zones from Hetzner API") + hZonesTmp, err := hetzner.GetZones() + if err != nil { + log.Fatal(err) + } + + // Fetch records from Hetzner API + log.Info("Fetching existing records from Hetzner API") + var hZones []hetzner.Zone + for _, zone := range hZonesTmp { + err = hetzner.GetRecords(&zone) + if err != nil { + log.Error(err) + continue + } + + hZones = append(hZones, zone) + } + + // Fetch zones from config files + log.Info("Fetching zones from DNS config files") + zones, err := dnsconfig.GetZones() + if err != nil { + log.Fatal(err) + } + + // Create missing config files from existing zones + log.Info("Checking if we need to create DNS config files from existing zones") + for _, hZone := range hZones { + found := false + for _, zone := range zones { + if strings.EqualFold(hZone.Name, zone.Domain) { + found = true + break + } + } + + if found { + continue + } + + log.Info("Creating DNS config file for existing zone", "zone", hZone.Name) + + zone := dnsconfig.Zone{ + Domain: strings.ToLower(hZone.Name), + DefaultTTL: 3600, + } + + for _, record := range hZone.Records { + zone.Records = append(zone.Records, dnsconfig.Record{ + Name: record.Name, + Type: record.Type, + Value: record.Value, + TTL: record.TTL, + Flat: false, + }) + } + + err = dnsconfig.CreateZone(zone) + if err != nil { + log.Fatal(err) + } + } + + // Fetch zones from config files again + // This is because the previous step might have created new config files + log.Info("Fetching zones from DNS config files") + zones, err = dnsconfig.GetZones() + if err != nil { + log.Fatal(err) + } + + log.Info("Starting sync (step 1: create/update)", "dry_run", config.DryRun) + var keepTheseIds []string + for _, zone := range zones { + if zone.DefaultTTL <= 0 { + zone.DefaultTTL = 3600 + } + + for _, hZone := range hZones { + if strings.EqualFold(zone.Domain, hZone.Name) { + log.Info("Syncing zone", "step", 1, "name", zone.Domain) + + var alreadyFoundIds []string + for _, record := range zone.Records { + if record.Flat { + record.Value = helpers.ResolveRecord( + record.Value, record.Type, + ) + + if len(record.Value) == 0 { + log.Error("Could not flatten record, skipping", "record", record) + continue + } + } + + if record.TTL <= 0 { + record.TTL = zone.DefaultTTL + } + + var id string + for _, hRecord := range hZone.Records { + if slices.Contains(alreadyFoundIds, hRecord.ID) { + continue + } + + if !strings.EqualFold(record.Name, hRecord.Name) { + continue + } + + if !strings.EqualFold(record.Type, hRecord.Type) { + continue + } + + id = hRecord.ID + alreadyFoundIds = append(alreadyFoundIds, id) + break + } + + if len(id) > 0 { + keepTheseIds = append(keepTheseIds, id) + updateNeeded := false + for _, hRecord := range hZone.Records { + if hRecord.ID == id { + if record.TTL != hRecord.TTL { + updateNeeded = true + } + + if record.Value != hRecord.Value { + updateNeeded = true + } + + break + } + } + + if updateNeeded { + log.Info( + "Updating record", + "name", record.Name, + "type", record.Type, + "id", id, + ) + + if !config.DryRun { + newRecord, err := hetzner.UpdateRecord(&hetzner.Record{ + ID: id, + Type: record.Type, + Name: record.Name, + Value: record.Value, + TTL: record.TTL, + ZoneID: hZone.ID, + }) + if err != nil { + log.Error(err) + } + + log.Info( + "Record updated", + "ID", newRecord.ID, + ) + } + } + } else { + log.Info( + "Creating record", + "name", record.Name, + "type", record.Type, + "value", record.Value, + "ttl", record.TTL, + ) + + if !config.DryRun { + newRecord, err := hetzner.CreateRecord(&hetzner.Record{ + Type: record.Type, + Name: record.Name, + Value: record.Value, + TTL: record.TTL, + ZoneID: hZone.ID, + }) + if err != nil { + log.Error(err) + } + + log.Info( + "Record created", + "ID", newRecord.ID, + ) + } + } + } + + break + } + } + } + + log.Info("Starting sync (step 2: delete)", "dry_run", config.DryRun) + for _, zone := range zones { + for _, hZone := range hZones { + if strings.EqualFold(zone.Domain, hZone.Name) { + log.Info("Syncing zone", "setp", 2, "name", zone.Domain) + + for _, hRecord := range hZone.Records { + if !slices.Contains(keepTheseIds, hRecord.ID) { + log.Info( + "Deleting record", + "name", hRecord.Name, + "type", hRecord.Type, + "id", hRecord.ID, + ) + + if !config.DryRun { + hetzner.DeleteRecord(&hetzner.Record{ + ID: hRecord.ID, + }) + if err != nil { + log.Error(err) + } + + log.Info( + "Record deleted", + "ID", hRecord.ID, + ) + } + } + } + + break + } + } + } + + log.Info("Sync is finished, all done!") +} diff --git a/zones/.gitkeep b/zones/.gitkeep new file mode 100644 index 0000000..e69de29