Initial commit.
This commit is contained in:
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
|
||||
}
|
Reference in New Issue
Block a user