From 462d09cee2b40eab58d69830d7ab3fe77f810faa Mon Sep 17 00:00:00 2001 From: "Stanislav N. aka pztrn" Date: Sun, 6 Jun 2021 23:13:55 +0500 Subject: [PATCH] Initial commit. --- .gitignore | 1 + .gitlab-ci.yml | 13 ++ .golangci.yml | 28 ++++ README.md | 25 +++ cmd/hoststranslator/main.go | 43 ++++++ go.mod | 3 + internal/configuration/configuration.go | 54 +++++++ internal/configuration/powerdns.go | 43 ++++++ internal/models/host.go | 7 + internal/parser/parser.go | 136 +++++++++++++++++ internal/storage/interface.go | 6 + internal/storage/powerdns/client.go | 25 +++ internal/storage/powerdns/data.go | 21 +++ internal/storage/powerdns/powerdns.go | 144 ++++++++++++++++++ .../storage/powerdns/request_get_zone_data.go | 37 +++++ .../powerdns/request_update_zone_data.go | 45 ++++++ 16 files changed, 631 insertions(+) create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 .golangci.yml create mode 100644 README.md create mode 100644 cmd/hoststranslator/main.go create mode 100644 go.mod create mode 100644 internal/configuration/configuration.go create mode 100644 internal/configuration/powerdns.go create mode 100644 internal/models/host.go create mode 100644 internal/parser/parser.go create mode 100644 internal/storage/interface.go create mode 100644 internal/storage/powerdns/client.go create mode 100644 internal/storage/powerdns/data.go create mode 100644 internal/storage/powerdns/powerdns.go create mode 100644 internal/storage/powerdns/request_get_zone_data.go create mode 100644 internal/storage/powerdns/request_update_zone_data.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..722d5e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.vscode diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..93f74f6 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,13 @@ +variables: + LINTTEST_IMAGE: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/golangci/golangci-lint:v1.33.0 + +stages: + - test + +lint: + stage: test + tags: + - docker + image: $LINTTEST_IMAGE + script: + - golangci-lint run diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..5194d96 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,28 @@ +run: + deadline: 5m +linters: + enable-all: true + disable: + # While it might be useful it'll create more problems that will solve. + - gocritic + # Magic numbers might be everywhere. Disabled for now. + - gomnd + # Why? WHY? WHY _test??? + - testpackage +linters-settings: + lll: + line-length: 120 + gocyclo: + min-complexity: 40 + gocognit: + min-complexity: 40 + funlen: + lines: 200 + statements: 100 + +issues: + exclude-rules: + # There will be some ToDos. + - linters: + - godox + text: "TODO" diff --git a/README.md b/README.md new file mode 100644 index 0000000..74be165 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Hosts translator + +This repository contains hosts file to different DNS systems translator. + +## Why + +While it may sound crazy - why do anyone need to translate hosts file? - it has own specific use case. + +I have some internal DNS servers powered by PowerDNS and actually do not want to tinker with hosts file for adding ability to use systems and servers at work. So instead of modifying hosts file on every system I have I just launching hosts translator and it seamlessly (more or less) updating DNS server data with provided hosts file. + +## Caveats + +### PowerDNS storage provider + +1. It assumes that server ID is 'localhost'. It is 'kind-of-default', especially if native database replication is used for zones distribution. +2. It assumes that every domain has only one address. +3. It assumes IPv4 usage. Some additional love is required for proper AAAA support. + +## Installation + +TBW + +## Configuration + +TBW diff --git a/cmd/hoststranslator/main.go b/cmd/hoststranslator/main.go new file mode 100644 index 0000000..fbab795 --- /dev/null +++ b/cmd/hoststranslator/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "flag" + "log" + "os" + "strings" + + "go.dev.pztrn.name/hosts-translator/internal/configuration" + "go.dev.pztrn.name/hosts-translator/internal/parser" + "go.dev.pztrn.name/hosts-translator/internal/storage" + "go.dev.pztrn.name/hosts-translator/internal/storage/powerdns" +) + +func main() { + log.Println("Starting hosts file translator...") + + configuration.Initialize() + configuration.Parse() + + if err := configuration.Validate(); err != nil { + log.Println(err) + flag.PrintDefaults() + os.Exit(1) + } + + p := parser.NewParser() + + var s storage.Interface + + switch strings.ToLower(configuration.StorageToUse) { + case "powerdns": + s = powerdns.NewPowerDNS(p) + } + + if err := p.Parse(); err != nil { + panic(err) + } + + if err := s.Process(); err != nil { + panic(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0c7a7e0 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module go.dev.pztrn.name/hosts-translator + +go 1.16 diff --git a/internal/configuration/configuration.go b/internal/configuration/configuration.go new file mode 100644 index 0000000..f9744c7 --- /dev/null +++ b/internal/configuration/configuration.go @@ -0,0 +1,54 @@ +package configuration + +import ( + "errors" + "flag" + "fmt" +) + +var ( + // ErrConfigurationError can be used to determine if received error was + // about invalid configuration. + ErrConfigurationError = errors.New("configuration") + + // DomainPostfix is a string that will be added to parsed domain if not already + // present. E.g. with DomainPostfix = "example.com" for parsed domain "host-1" + // it will be added and parsed domain will be in form "host-1.example.com", but + // for parsed domain "host-2.example.com" it won't be added as it is already + // present in the very end. + DomainPostfix string + // HostsFilePath defines a path from which hosts file will be used for parsing. + HostsFilePath string + // StorageToUse defines storage translator will use for updating data. + StorageToUse string +) + +// Initialize initializes configuration subsystem. +func Initialize() { + flag.StringVar(&DomainPostfix, "domain-postfix", "", "Postfix to append to domain. Some storages requires this parameter to be filled.") + flag.StringVar(&HostsFilePath, "hosts-file", "", "Path to hosts file to parse.") + flag.StringVar(&StorageToUse, "storage", "", "Storage to use. Currently supported: 'powerdns'.") + + initializePowerDNS() +} + +func Parse() { + flag.Parse() +} + +// Validate validates configuration data and returns error is something isn't right. +func Validate() error { + if HostsFilePath == "" { + return fmt.Errorf("%w: empty hosts file path", ErrConfigurationError) + } + + if StorageToUse == "" { + return fmt.Errorf("%w: no storage name was provided", ErrConfigurationError) + } + + if err := validatePowerDNS(); err != nil { + return err + } + + return nil +} diff --git a/internal/configuration/powerdns.go b/internal/configuration/powerdns.go new file mode 100644 index 0000000..0e950ab --- /dev/null +++ b/internal/configuration/powerdns.go @@ -0,0 +1,43 @@ +package configuration + +import ( + "flag" + "fmt" + "strings" +) + +var ( + // PowerDNSAPIKey defines API key for PowerDNS HTTP API. + PowerDNSAPIKey string + // PowerDNSURI defines URL for PowerDNS HTTP API. + PowerDNSURI string +) + +func initializePowerDNS() { + flag.StringVar(&PowerDNSAPIKey, "powerdns-api-key", "", "API key for PowerDNS HTTP API.") + flag.StringVar(&PowerDNSURI, "powerdns-uri", "", "URI for PowerDNS API. Should be in 'proto://ADDR:PORT' form.") +} + +func validatePowerDNS() error { + if strings.ToLower(StorageToUse) != "powerdns" { + return nil + } + + // PowerDNS storage requires DomainSuffix to determine zone name to update. + if DomainPostfix == "" { + return fmt.Errorf("%w: domain postfix isn't filled which is required by PowerDNS storage", ErrConfigurationError) + } + + if PowerDNSAPIKey == "" { + return fmt.Errorf("%w: no PowerDNS API key was provided", ErrConfigurationError) + } + + if PowerDNSURI == "" { + return fmt.Errorf("%w: no PowerDNS HTTP API server URI provided", ErrConfigurationError) + } + + // Hack: trim slashes in end. + PowerDNSURI = strings.TrimRight(PowerDNSURI, "/") + + return nil +} diff --git a/internal/models/host.go b/internal/models/host.go new file mode 100644 index 0000000..e04653a --- /dev/null +++ b/internal/models/host.go @@ -0,0 +1,7 @@ +package models + +// Host represents structure of single host that was parsed from hosts file. +type Host struct { + Domain string + Address string +} diff --git a/internal/parser/parser.go b/internal/parser/parser.go new file mode 100644 index 0000000..8183fb4 --- /dev/null +++ b/internal/parser/parser.go @@ -0,0 +1,136 @@ +package parser + +import ( + "errors" + "log" + "os" + "path/filepath" + "strings" + + "go.dev.pztrn.name/hosts-translator/internal/configuration" + "go.dev.pztrn.name/hosts-translator/internal/models" +) + +var ErrParserError = errors.New("hosts parser") + +// Parser is a controlling structure for hosts file (both parsed and unparsed data). +type Parser struct { + parsedHosts []models.Host +} + +// NewParser creates new hosts file parsing controlling structure. +func NewParser() *Parser { + p := &Parser{} + p.initialize() + + return p +} + +// Returns file data as bytes. +func (p *Parser) getFileData() ([]string, error) { + // Before everything we should normalize file path. + filePath := configuration.HostsFilePath + + // Replace possible "~" in the beginning as file reading function unable + // to expand it. Also we should check only beginning because tilde is actually + // a very valid directory or file name. + if strings.HasPrefix(filePath, "~") { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + + filePath = strings.Replace(filePath, "~", homeDir, 1) + } + + // Get absolute file path. + absolutePath, err := filepath.Abs(filePath) + if err != nil { + return nil, err + } + + log.Println("Reading file data from", absolutePath) + + // Read file data. + rawData, err := os.ReadFile(absolutePath) + if err != nil { + return nil, err + } + + data := strings.Split(string(rawData), "\n") + + return data, nil +} + +// GetParsedData returns parsed data slice. +func (p *Parser) GetParsedData() []models.Host { + return p.parsedHosts +} + +// Initializes internal state of parser as well as CLI flags. +func (p *Parser) initialize() { + p.parsedHosts = make([]models.Host, 0) +} + +// Parse parses hosts file into internal representation. +func (p *Parser) Parse() error { + log.Println("Starting hosts file parsing. File located at", configuration.HostsFilePath) + + data, err := p.getFileData() + if err != nil { + return err + } + + for _, line := range data { + // We should skip commented lines. + if strings.HasPrefix(line, "#") { + continue + } + + // Every line is a two-or-more-not-empty-strings. First string is always + // an IP address. + // Also there are a non-zero possibility that line will contain tabs, so as + // very first action we should replace them with spaces. + if strings.Contains(line, "\t") { + line = strings.Replace(line, "\t", " ", -1) + } + + lineSplitted := strings.Split(line, " ") + + // As one IP address can be bound to multiple domains we should take care + // of that situation by creating multiple Host structures. + var address string + + for _, lineData := range lineSplitted { + // Also there might be a case when address placed first in line but + // line itself has spaces in the beginning. + if address == "" && lineData != "" { + address = lineData + + continue + } + + if lineData == "" { + continue + } + + domainToAdd := lineData + + if configuration.DomainPostfix != "" && !strings.HasSuffix(domainToAdd, configuration.DomainPostfix) { + domainToAdd += "." + configuration.DomainPostfix + } + + p.parsedHosts = append(p.parsedHosts, models.Host{ + Domain: domainToAdd, + Address: address, + }) + } + } + + log.Println("Got", len(p.parsedHosts), "domains from hosts file") + + // ToDo: hide under CLI parameter like '-debug'? + // log.Printf("%+v\n", p.parsedHosts) + + return nil +} diff --git a/internal/storage/interface.go b/internal/storage/interface.go new file mode 100644 index 0000000..db26624 --- /dev/null +++ b/internal/storage/interface.go @@ -0,0 +1,6 @@ +package storage + +// Interface describes storage interface. +type Interface interface { + Process() error +} diff --git a/internal/storage/powerdns/client.go b/internal/storage/powerdns/client.go new file mode 100644 index 0000000..8f22bc7 --- /dev/null +++ b/internal/storage/powerdns/client.go @@ -0,0 +1,25 @@ +package powerdns + +import ( + "io" + "net/http" + + "go.dev.pztrn.name/hosts-translator/internal/configuration" +) + +// Executes request to PowerDNS server and returns data or error. +func (s *PowerDNS) request(req *http.Request) ([]byte, error) { + req.Header.Add("X-API-Key", configuration.PowerDNSAPIKey) + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return data, nil +} diff --git a/internal/storage/powerdns/data.go b/internal/storage/powerdns/data.go new file mode 100644 index 0000000..ed322e7 --- /dev/null +++ b/internal/storage/powerdns/data.go @@ -0,0 +1,21 @@ +package powerdns + +type zoneData struct { + Name string `json:"name"` + RRSets []RRSet `json:"rrsets"` + Serial int64 `json:"serial"` + Type string `json:"type"` +} + +type RRSet struct { + ChangeType string `json:"changetype"` + Name string `json:"name"` + Records []Record `json:"records"` + TTL int64 `json:"ttl"` + Type string `json:"type"` +} + +type Record struct { + Content string `json:"content"` + Disabled bool `json:"disabled"` +} diff --git a/internal/storage/powerdns/powerdns.go b/internal/storage/powerdns/powerdns.go new file mode 100644 index 0000000..eae8a72 --- /dev/null +++ b/internal/storage/powerdns/powerdns.go @@ -0,0 +1,144 @@ +package powerdns + +import ( + "errors" + "log" + "net/http" + "strings" + "time" + + "go.dev.pztrn.name/hosts-translator/internal/configuration" + "go.dev.pztrn.name/hosts-translator/internal/parser" +) + +var ErrPowerDNSStorageError = errors.New("powerdns storage") + +// PowerDNS is a controlling structure for PowerDNS storage. +type PowerDNS struct { + parser *parser.Parser + + httpClient *http.Client +} + +// NewPowerDNS creates new PowerDNS storage. +func NewPowerDNS(p *parser.Parser) *PowerDNS { + s := &PowerDNS{ + parser: p, + } + + s.initialize() + + return s +} + +// Initializes storage internal state. +func (s *PowerDNS) initialize() { + s.httpClient = &http.Client{ + Timeout: time.Second * 5, + } +} + +// Process processes parsed data. +func (s *PowerDNS) Process() error { + log.Println("Processing parsed data into PowerDNS storage...") + + zoneData, err := s.getZoneData(configuration.DomainPostfix) + if err != nil { + return err + } + + hostsParsedData := s.parser.GetParsedData() + + // Check what we should to create, update or delete. + recordsToCreate := make([]RRSet, 0) + recordsToDelete := make([]RRSet, 0) + recordsToUpdate := make([]RRSet, 0) + + // First iteration - figure out what to create or update. + for _, hostData := range hostsParsedData { + var found bool + + for _, rrset := range zoneData.RRSets { + // We're only for A or AAAA things here. + if strings.ToUpper(rrset.Type) != "A" && strings.ToUpper(rrset.Type) != "AAAA" { + continue + } + + // ToDo: multiple addresses support somehow? + if strings.TrimSuffix(rrset.Name, ".") == hostData.Domain { + found = true + + if rrset.Records[0].Content != hostData.Address { + recordsToUpdate = append(recordsToUpdate, RRSet{ + ChangeType: "REPLACE", + Name: rrset.Name, + Records: []Record{ + { + Content: hostData.Address, + }, + }, + TTL: 300, + Type: rrset.Type, + }) + } + + break + } + } + + if !found { + recordsToCreate = append(recordsToCreate, RRSet{ + ChangeType: "REPLACE", + Name: hostData.Domain + ".", + Records: []Record{ + { + Content: hostData.Address, + }, + }, + TTL: 300, + // ToDo: support for AAAA? + Type: "A", + }) + } + } + + // Second iteration - figure out what to delete. + for _, rrset := range zoneData.RRSets { + // We're only for A or AAAA things here. + if strings.ToUpper(rrset.Type) != "A" || strings.ToUpper(rrset.Type) != "AAAA" { + continue + } + + var found bool + + for _, hostData := range hostsParsedData { + if strings.TrimSuffix(rrset.Name, ".") == hostData.Domain+"." { + found = true + + break + } + } + + if !found { + rrset.ChangeType = "DELETE" + recordsToDelete = append(recordsToDelete, rrset) + } + } + + log.Println("Got", len(zoneData.RRSets), "RRSets in NS") + log.Println("Got", len(recordsToCreate), "RRSets to create") + log.Println("Got", len(recordsToUpdate), "RRSets to update") + log.Println("Got", len(recordsToDelete), "RRSets to delete") + + recordsUnchanged := len(zoneData.RRSets) - len(recordsToDelete) - len(recordsToUpdate) + log.Println("Got", recordsUnchanged, "RRSets unchanged") + + // ToDo: '-debug'? + log.Printf("Got RRSets to create: %+v\n", recordsToCreate) + // log.Printf("Got RRSets to update: %+v\n", recordsToUpdate) + // log.Printf("Got RRSets to delete: %+v\n", recordsToDelete) + + s.updateZoneData(zoneData.Name, append(recordsToCreate, append(recordsToUpdate, recordsToDelete...)...)) + + return nil +} diff --git a/internal/storage/powerdns/request_get_zone_data.go b/internal/storage/powerdns/request_get_zone_data.go new file mode 100644 index 0000000..3c3aa78 --- /dev/null +++ b/internal/storage/powerdns/request_get_zone_data.go @@ -0,0 +1,37 @@ +package powerdns + +import ( + "encoding/json" + "log" + "net/http" + "strings" + + "go.dev.pztrn.name/hosts-translator/internal/configuration" +) + +// Gets zone data from PowerDNS. +func (s *PowerDNS) getZoneData(zoneName string) (*zoneData, error) { + log.Println("Getting zone data for domain", zoneName) + + url := strings.Join([]string{configuration.PowerDNSURI, "api", "v1", "servers", "localhost", "zones", zoneName}, "/") + + log.Println("URL:", url) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + bytesData, err := s.request(req) + if err != nil { + return nil, err + } + + zd := &zoneData{} + + if err := json.Unmarshal(bytesData, zd); err != nil { + return nil, err + } + + return zd, nil +} diff --git a/internal/storage/powerdns/request_update_zone_data.go b/internal/storage/powerdns/request_update_zone_data.go new file mode 100644 index 0000000..1fec5cd --- /dev/null +++ b/internal/storage/powerdns/request_update_zone_data.go @@ -0,0 +1,45 @@ +package powerdns + +import ( + "bytes" + "encoding/json" + "log" + "net/http" + "strings" + + "go.dev.pztrn.name/hosts-translator/internal/configuration" +) + +// Updates zone data from PowerDNS. +func (s *PowerDNS) updateZoneData(zoneName string, RRSets []RRSet) error { + log.Println("Updating zone data for domain", zoneName) + + zd := &zoneData{ + Name: zoneName, + RRSets: RRSets, + Type: "Zone", + } + + url := strings.Join([]string{configuration.PowerDNSURI, "api", "v1", "servers", "localhost", "zones", strings.TrimSuffix(zd.Name, ".")}, "/") + + log.Println("URL:", url) + + zoneBytes, err := json.Marshal(zd) + if err != nil { + return err + } + + req, err := http.NewRequest("PATCH", url, bytes.NewReader(zoneBytes)) + if err != nil { + return err + } + + bytesData, err := s.request(req) + if err != nil { + return err + } + + log.Println("Got response:", string(bytesData)) + + return nil +}