Initial commit.
This commit is contained in:
commit
462d09cee2
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
.vscode
|
13
.gitlab-ci.yml
Normal file
13
.gitlab-ci.yml
Normal 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
28
.golangci.yml
Normal 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
25
README.md
Normal 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
|
43
cmd/hoststranslator/main.go
Normal file
43
cmd/hoststranslator/main.go
Normal 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)
|
||||
}
|
||||
}
|
54
internal/configuration/configuration.go
Normal file
54
internal/configuration/configuration.go
Normal 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
|
||||
}
|
43
internal/configuration/powerdns.go
Normal file
43
internal/configuration/powerdns.go
Normal 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
7
internal/models/host.go
Normal 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
136
internal/parser/parser.go
Normal 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
|
||||
}
|
6
internal/storage/interface.go
Normal file
6
internal/storage/interface.go
Normal file
@ -0,0 +1,6 @@
|
||||
package storage
|
||||
|
||||
// Interface describes storage interface.
|
||||
type Interface interface {
|
||||
Process() error
|
||||
}
|
25
internal/storage/powerdns/client.go
Normal file
25
internal/storage/powerdns/client.go
Normal 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
|
||||
}
|
21
internal/storage/powerdns/data.go
Normal file
21
internal/storage/powerdns/data.go
Normal 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"`
|
||||
}
|
144
internal/storage/powerdns/powerdns.go
Normal file
144
internal/storage/powerdns/powerdns.go
Normal 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
|
||||
}
|
37
internal/storage/powerdns/request_get_zone_data.go
Normal file
37
internal/storage/powerdns/request_get_zone_data.go
Normal 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
|
||||
}
|
45
internal/storage/powerdns/request_update_zone_data.go
Normal file
45
internal/storage/powerdns/request_update_zone_data.go
Normal 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
|
||||
}
|
Loading…
Reference in New Issue
Block a user