Initial commit.

This commit is contained in:
Stanislav Nikitin 2021-06-06 23:13:55 +05:00
commit 462d09cee2
Signed by: pztrn
GPG Key ID: 1E944A0F0568B550
16 changed files with 631 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.vscode

13
.gitlab-ci.yml Normal file
View File

@ -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

28
.golangci.yml Normal file
View File

@ -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"

25
README.md Normal file
View File

@ -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

View File

@ -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)
}
}

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module go.dev.pztrn.name/hosts-translator
go 1.16

View File

@ -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
}

View File

@ -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
}

7
internal/models/host.go Normal file
View File

@ -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
}

136
internal/parser/parser.go Normal file
View File

@ -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
}

View File

@ -0,0 +1,6 @@
package storage
// Interface describes storage interface.
type Interface interface {
Process() error
}

View File

@ -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
}

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}