Initial commit.

This commit is contained in:
2021-06-06 23:13:55 +05:00
commit 462d09cee2
16 changed files with 631 additions and 0 deletions

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
}