├── .gitignore ├── pkg ├── dnsprovider │ ├── ttl.go │ ├── ttl_test.go │ ├── dnsprovider.go │ └── powerdns │ │ ├── powerdns.go │ │ ├── delete.go │ │ └── add.go └── ingest │ ├── ingest.go │ ├── remoteaddress │ ├── remoteaddress_test.go │ └── remoteaddress.go │ ├── ipset.go │ ├── getparameter │ └── getparameter.go │ └── ipset_test.go ├── internal ├── genericerror │ ├── genericerror_test.go │ └── genericerror.go ├── ginresponse │ ├── httperror_test.go │ ├── httperror.go │ ├── ginjsonerror.go │ └── ginjsonerror_test.go ├── ginmiddleware │ ├── middleware_test.go │ └── middleware.go ├── yamlconfig │ ├── dnsprovider.go │ └── config.go └── auth │ ├── auth.go │ └── auth_test.go ├── init └── dyndns-pdns.service ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── go-licenses.yml │ ├── govulncheck.yml │ ├── lint.yml │ ├── test.yml │ └── golangci-lint.yml ├── scripts └── init_docker_fixtures.sh ├── cmd └── dyndns-pdns │ ├── health_test.go │ ├── ginengine.go │ ├── health.go │ ├── main.go │ ├── records.go │ ├── hostsync_test.go │ └── hostsync.go ├── configs ├── config.test.yml └── config.dist.yml ├── Makefile ├── LICENSE ├── docker-compose.yml ├── go.mod ├── README.md ├── api └── v1.yml └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | /configs/config.yml 2 | /configs/server.crt 3 | /configs/server.csr 4 | /configs/server.key 5 | /coverage.out 6 | /dyndns-pdns 7 | /dyndns-pdns_linux_amd64 8 | /vendor/ 9 | -------------------------------------------------------------------------------- /pkg/dnsprovider/ttl.go: -------------------------------------------------------------------------------- 1 | package dnsprovider 2 | 3 | // GetTTL selects the proper value of two choices 4 | func GetTTL(keyItemTTL uint32, defaultTTL uint32) uint32 { 5 | if keyItemTTL != 0 { 6 | return keyItemTTL 7 | } 8 | return defaultTTL 9 | } 10 | -------------------------------------------------------------------------------- /pkg/ingest/ingest.go: -------------------------------------------------------------------------------- 1 | package ingest 2 | 3 | // ModeType sets the IP address ingest mode 4 | type ModeType string 5 | 6 | // Mode defines the interface for IP address ingest mode processing 7 | type Mode interface { 8 | Process() (*IPSet, error) 9 | } 10 | -------------------------------------------------------------------------------- /internal/genericerror/genericerror_test.go: -------------------------------------------------------------------------------- 1 | package genericerror 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestError(t *testing.T) { 8 | e := &GenericError{"Foo"} 9 | if e.Error() != "Foo" { 10 | t.Error("GenericError method call returns invalid value") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /internal/ginresponse/httperror_test.go: -------------------------------------------------------------------------------- 1 | package ginresponse 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestError(t *testing.T) { 8 | e := &HTTPError{Message: "Foo"} 9 | if e.Error() != "Foo" { 10 | t.Error("HTTPError method call returns invalid value") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /internal/ginmiddleware/middleware_test.go: -------------------------------------------------------------------------------- 1 | package ginmiddleware 2 | 3 | import "testing" 4 | 5 | func TestGenerateRequestID(t *testing.T) { 6 | requestID, err := generateRequestID() 7 | if requestID == "" && err == nil { 8 | t.Error("Request ID was not generated properly, but error is nil") 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /internal/genericerror/genericerror.go: -------------------------------------------------------------------------------- 1 | package genericerror 2 | 3 | // GenericError contains information regarding a certain error 4 | type GenericError struct { 5 | Message string 6 | } 7 | 8 | // GenericError returns an error message string 9 | func (e *GenericError) Error() string { 10 | return e.Message 11 | } 12 | -------------------------------------------------------------------------------- /internal/ginresponse/httperror.go: -------------------------------------------------------------------------------- 1 | package ginresponse 2 | 3 | // HTTPError contains information regarding a certain error 4 | type HTTPError struct { 5 | Message string 6 | HTTPErrorCode int 7 | } 8 | 9 | // HTTPError returns an error message string 10 | func (e *HTTPError) Error() string { 11 | return e.Message 12 | } 13 | -------------------------------------------------------------------------------- /init/dyndns-pdns.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Dynamic DNS Collector 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | User=dyndns 8 | Group=dyndns 9 | WorkingDirectory=/opt/dyndns-pdns/ 10 | ExecStart=dyndns-pdns -config=config.yml 11 | RestartSec=15 12 | Restart=always 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /pkg/ingest/remoteaddress/remoteaddress_test.go: -------------------------------------------------------------------------------- 1 | package remoteaddress 2 | 3 | import "testing" 4 | 5 | func TestIsolateHostAddress(t *testing.T) { 6 | if isolateHostAddress("foo") != "foo" { 7 | t.Error("Invalid host address returned") 8 | } 9 | if isolateHostAddress("foo:80") != "foo" { 10 | t.Error("Port was not stripped successfully") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | cooldown: 9 | default-days: 7 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: "weekly" 14 | cooldown: 15 | default-days: 7 16 | -------------------------------------------------------------------------------- /scripts/init_docker_fixtures.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | apk --no-cache add boost-program_options 4 | 5 | DOM="dyn.example.com" 6 | 7 | pdnsutil create-zone "${DOM}" 8 | pdnsutil set-kind "${DOM}" master 9 | pdnsutil set-meta "${DOM}" SOA-EDIT-API INCEPTION-INCREMENT 10 | pdnsutil secure-zone "${DOM}" 11 | pdnsutil set-nsec3 "${DOM}" "1 0 10 0123456789ABCDEF" 12 | pdnsutil rectify-zone "${DOM}" 13 | pdnsutil show-zone "${DOM}" 14 | -------------------------------------------------------------------------------- /cmd/dyndns-pdns/health_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | ) 8 | 9 | func TestHealth(t *testing.T) { 10 | router := setupGinEngine() 11 | res := httptest.NewRecorder() 12 | 13 | req, _ := http.NewRequest(http.MethodGet, "/v1/health", nil) 14 | router.ServeHTTP(res, req) 15 | if res.Code != http.StatusOK { 16 | t.Errorf("HTTP request does not return %v", http.StatusOK) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /pkg/dnsprovider/ttl_test.go: -------------------------------------------------------------------------------- 1 | package dnsprovider 2 | 3 | import "testing" 4 | 5 | func TestGetTTL(t *testing.T) { 6 | t.Run("TestGetTTLNotDefault", func(t *testing.T) { 7 | if GetTTL(0, 10) != 10 { 8 | t.Error("Zero key item TTL returns invalid default TTL") 9 | } 10 | }) 11 | t.Run("TestGetTTLInvalid", func(t *testing.T) { 12 | if GetTTL(1337, 10) != 1337 { 13 | t.Error("Test key item TTL returns invalid TTL") 14 | } 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /pkg/dnsprovider/dnsprovider.go: -------------------------------------------------------------------------------- 1 | package dnsprovider 2 | 3 | // Type sets the DNS provider type 4 | type Type string 5 | 6 | // DNSProvider is an interface for basic DNS operations 7 | type DNSProvider interface { 8 | AddIPv4ResourceRecord(hostname string, ipv4 string, ttl uint32) error 9 | AddIPv6ResourceRecord(hostname string, ipv6 string, ttl uint32) error 10 | DeleteIPv4ResourceRecord(hostname string) error 11 | DeleteIPv6ResourceRecord(hostname string) error 12 | } 13 | -------------------------------------------------------------------------------- /cmd/dyndns-pdns/ginengine.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/joeig/dyndns-pdns/internal/ginmiddleware" 6 | ) 7 | 8 | // Initializes the Gin engine 9 | func setupGinEngine() *gin.Engine { 10 | router := gin.Default() 11 | router.Use(ginmiddleware.RequestIDMiddleware()) 12 | 13 | v1 := router.Group("/v1") 14 | { 15 | v1.GET("/health", Health) 16 | v1.GET("/host/:name/sync", HostSync) 17 | } 18 | 19 | return router 20 | } 21 | -------------------------------------------------------------------------------- /cmd/dyndns-pdns/health.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "net/http" 6 | ) 7 | 8 | // HealthStatus contains information regarding the healthiness of the application 9 | type HealthStatus struct { 10 | ApplicationRunning bool `json:"applicationRunning"` 11 | } 12 | 13 | // Health Gin route 14 | func Health(ctx *gin.Context) { 15 | hs := &HealthStatus{ 16 | ApplicationRunning: true, 17 | } 18 | 19 | ctx.JSON(http.StatusOK, hs) 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: build 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | concurrency: 12 | group: "${{ github.workflow }}-$${{ github.pull_request.number || github.run_id }}" 13 | cancel-in-progress: true 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v6 19 | - uses: actions/setup-go@v6 20 | with: 21 | go-version-file: "go.mod" 22 | check-latest: true 23 | - run: go build ./cmd/dyndns-pdns 24 | -------------------------------------------------------------------------------- /.github/workflows/go-licenses.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: go-licenses 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | concurrency: 12 | group: "${{ github.workflow }}-$${{ github.pull_request.number || github.run_id }}" 13 | cancel-in-progress: true 14 | permissions: 15 | contents: read 16 | jobs: 17 | go-licenses: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v6 21 | - uses: joeig/go-licenses-action@v1 22 | with: 23 | disallowed-types: restricted,forbidden,unknown 24 | -------------------------------------------------------------------------------- /.github/workflows/govulncheck.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: govulncheck 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | schedule: 11 | - cron: "21 7 * * 5" 12 | workflow_dispatch: 13 | concurrency: 14 | group: "${{ github.workflow }}-$${{ github.pull_request.number || github.run_id }}" 15 | cancel-in-progress: true 16 | jobs: 17 | govulncheck: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: golang/govulncheck-action@v1 21 | with: 22 | go-version-file: "go.mod" 23 | go-package: ./... 24 | check-latest: true 25 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: lint 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | concurrency: 12 | group: "${{ github.workflow }}-$${{ github.pull_request.number || github.run_id }}" 13 | cancel-in-progress: true 14 | jobs: 15 | lint: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v6 19 | - uses: actions/setup-go@v6 20 | with: 21 | go-version-file: "go.mod" 22 | check-latest: true 23 | - run: | 24 | gofmt -d . 25 | test -z $(gofmt -l .) 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: test 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | concurrency: 12 | group: "${{ github.workflow }}-$${{ github.pull_request.number || github.run_id }}" 13 | cancel-in-progress: true 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v6 19 | - uses: actions/setup-go@v6 20 | with: 21 | go-version-file: "go.mod" 22 | check-latest: true 23 | - run: go test -v -coverprofile="coverage.out" ./... 24 | - run: go tool cover -func="coverage.out" 25 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: golangci-lint 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | concurrency: 12 | group: "${{ github.workflow }}-$${{ github.pull_request.number || github.run_id }}" 13 | cancel-in-progress: true 14 | jobs: 15 | golangci-lint: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v6 19 | - uses: actions/setup-go@v6 20 | with: 21 | go-version-file: "go.mod" 22 | check-latest: true 23 | - uses: golangci/golangci-lint-action@v9 24 | with: 25 | version: v2.1.5 26 | -------------------------------------------------------------------------------- /configs/config.test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | server: 3 | listenAddress: "127.0.0.1:8000" 4 | tls: 5 | enable: true 6 | certFile: "server.crt" 7 | keyFile: "server.key" 8 | 9 | dnsProviderType: "powerDNS" 10 | 11 | powerDNS: 12 | baseURL: "http://127.0.0.1:8080/" 13 | vhost: "localhost" 14 | apiKey: "secret" 15 | zone: "dyn.example.com" 16 | defaultTTL: 10 17 | 18 | keyTable: 19 | - name: "homeRouter" 20 | enable: true 21 | key: "secret" 22 | hostName: "home-router" 23 | ingestMode: "getParameter" 24 | cleanUpMode: "any" 25 | ttl: 5 26 | - name: "officeRouter" 27 | enable: true 28 | key: "topSecret" 29 | hostName: "office-router" 30 | ingestMode: "remoteAddress" 31 | -------------------------------------------------------------------------------- /internal/yamlconfig/dnsprovider.go: -------------------------------------------------------------------------------- 1 | package yamlconfig 2 | 3 | import ( 4 | "fmt" 5 | "github.com/joeig/dyndns-pdns/pkg/dnsprovider" 6 | ) 7 | 8 | // DNSProviderPowerDNS sets the DNS provider to PowerDNS. 9 | // 10 | // This setting uses a PowerDNS backend. 11 | const DNSProviderPowerDNS dnsprovider.Type = "powerDNS" 12 | 13 | // ActiveDNSProvider contains the currently activated DNS provider 14 | var ActiveDNSProvider dnsprovider.DNSProvider 15 | 16 | // SetDNSProvider configures the DNS provider set by the configuration 17 | func SetDNSProvider(d *dnsprovider.DNSProvider) { 18 | switch C.DNSProviderType { 19 | case DNSProviderPowerDNS: 20 | *d = &C.PowerDNS 21 | default: 22 | panic(fmt.Errorf("invalid dnsProviderType \"%s\"", C.DNSProviderType)) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pkg/ingest/ipset.go: -------------------------------------------------------------------------------- 1 | package ingest 2 | 3 | import "github.com/asaskevich/govalidator" 4 | 5 | // IPSet is a combination of one IPv4 address and one IPv6 address 6 | type IPSet struct { 7 | IPv4 string `json:"ipv4"` 8 | IPv6 string `json:"ipv6"` 9 | } 10 | 11 | // HasIPv4 evaluates whether an IPSet contains a IPv4 address 12 | func (ipSet *IPSet) HasIPv4() bool { 13 | return ipSet.IPv4 != "" 14 | } 15 | 16 | // IsIPv4 evaluates whether an IPSet contains a valid IPv4 address 17 | func (ipSet *IPSet) IsIPv4() bool { 18 | return govalidator.IsIPv4(ipSet.IPv4) 19 | } 20 | 21 | // HasIPv6 evaluates whether an IPSet contains a IPv6 address 22 | func (ipSet *IPSet) HasIPv6() bool { 23 | return ipSet.IPv6 != "" 24 | } 25 | 26 | // IsIPv6 evaluates whether an IPSet contains a valid IPv6 address 27 | func (ipSet *IPSet) IsIPv6() bool { 28 | return govalidator.IsIPv6(ipSet.IPv6) 29 | } 30 | -------------------------------------------------------------------------------- /internal/ginresponse/ginjsonerror.go: -------------------------------------------------------------------------------- 1 | package ginresponse 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/google/jsonapi" 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | const DefaultHTTPErrorCode = http.StatusInternalServerError 11 | 12 | func gatherHTTPErrorCode(myError *error) int { 13 | httpErrorCode := DefaultHTTPErrorCode 14 | if errVal, ok := (*myError).(*HTTPError); ok { 15 | if errVal.HTTPErrorCode != 0 { 16 | httpErrorCode = errVal.HTTPErrorCode 17 | } 18 | } 19 | return httpErrorCode 20 | } 21 | 22 | // GinJSONError returns a JSON API formatted HTTP error through a Gin context 23 | func GinJSONError(ctx *gin.Context, myError error) { 24 | httpErrorCode := gatherHTTPErrorCode(&myError) 25 | errPayload := jsonapi.ErrorsPayload{Errors: []*jsonapi.ErrorObject{{Title: myError.Error()}}} 26 | log.Printf("%+v", errPayload) 27 | ctx.JSON(httpErrorCode, errPayload) 28 | } 29 | -------------------------------------------------------------------------------- /internal/ginmiddleware/middleware.go: -------------------------------------------------------------------------------- 1 | package ginmiddleware 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gin-gonic/gin" 6 | "github.com/google/uuid" 7 | "github.com/joeig/dyndns-pdns/internal/genericerror" 8 | "log" 9 | ) 10 | 11 | func generateRequestID() (string, error) { 12 | uuid4, err := uuid.NewRandom() 13 | if err != nil { 14 | log.Fatal("Unable to generate request Id") 15 | return "", &genericerror.GenericError{} 16 | } 17 | 18 | return uuid4.String(), nil 19 | } 20 | 21 | // RequestIDMiddleware adds a unique request ID to each HTTP response 22 | func RequestIDMiddleware() gin.HandlerFunc { 23 | return func(ctx *gin.Context) { 24 | requestID, err := generateRequestID() 25 | if err != nil { 26 | return 27 | } 28 | 29 | ctx.Set("RequestId", requestID) 30 | ctx.Header("X-Request-Id", requestID) 31 | 32 | log.SetPrefix(fmt.Sprintf("[%s] ", requestID)) 33 | log.Printf("Set request Id to \"%s\"", requestID) 34 | 35 | ctx.Next() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pkg/ingest/getparameter/getparameter.go: -------------------------------------------------------------------------------- 1 | package getparameter 2 | 3 | import ( 4 | "github.com/joeig/dyndns-pdns/internal/genericerror" 5 | "github.com/joeig/dyndns-pdns/pkg/ingest" 6 | ) 7 | 8 | // GetParameter defines the input values for this ingest mode 9 | type GetParameter struct { 10 | IPv4 string 11 | IPv6 string 12 | } 13 | 14 | // Process turns the input values into proper IPSet output values 15 | func (g *GetParameter) Process() (*ingest.IPSet, error) { 16 | ipSet := &ingest.IPSet{ 17 | IPv4: g.IPv4, 18 | IPv6: g.IPv6, 19 | } 20 | 21 | if !ipSet.HasIPv4() && !ipSet.HasIPv6() { 22 | return ipSet, &genericerror.GenericError{Message: "IPv4 as well as IPv6 parameter missing"} 23 | } 24 | 25 | if ipSet.HasIPv4() && !ipSet.IsIPv4() { 26 | return ipSet, &genericerror.GenericError{Message: "IPv4 address invalid"} 27 | } 28 | 29 | if ipSet.HasIPv6() && !ipSet.IsIPv6() { 30 | return ipSet, &genericerror.GenericError{Message: "IPv6 address invalid"} 31 | } 32 | 33 | return ipSet, nil 34 | } 35 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOCMD=go 2 | GOBUILD=$(GOCMD) build 3 | GOCLEAN=$(GOCMD) clean 4 | GOTEST=$(GOCMD) test 5 | GOCOVER=$(GOCMD) tool cover 6 | GOGET=$(GOCMD) get 7 | GOFMT=gofmt 8 | BINARY_NAME=dyndns-pdns 9 | GOFILES=$(shell find . -type f -name '*.go' -not -path "./vendor/*") 10 | 11 | .DEFAULT_GOAL := all 12 | .PHONY: all build build-linux-amd64 test coverage check-fmt fmt clean run 13 | 14 | all: check-fmt test coverage build 15 | 16 | build: 17 | $(GOBUILD) -o $(BINARY_NAME) -v ./cmd/dyndns-pdns 18 | 19 | build-linux-amd64: 20 | GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BINARY_NAME)_linux_amd64 -v ./cmd/dyndns-pdns 21 | 22 | test: 23 | $(GOTEST) -v ./... -covermode=count -coverprofile=coverage.out 24 | 25 | coverage: 26 | $(GOCOVER) -func=coverage.out 27 | 28 | check-fmt: 29 | $(GOFMT) -d ${GOFILES} 30 | 31 | fmt: 32 | $(GOFMT) -w ${GOFILES} 33 | 34 | clean: 35 | $(GOCLEAN) 36 | rm -f $(BINARY_NAME) 37 | rm -f $(BINARY_NAME)_linux_amd64 38 | 39 | run: 40 | $(GOBUILD) -o $(BINARY_NAME) -v ./... 41 | ./$(BINARY_NAME) 42 | -------------------------------------------------------------------------------- /internal/ginresponse/ginjsonerror_test.go: -------------------------------------------------------------------------------- 1 | package ginresponse 2 | 3 | import ( 4 | "github.com/joeig/dyndns-pdns/internal/genericerror" 5 | "net/http" 6 | "testing" 7 | ) 8 | 9 | func generateTestHTTPError(message string, httpErrorCode int) error { 10 | return &HTTPError{Message: message, HTTPErrorCode: httpErrorCode} 11 | } 12 | 13 | func generateTestGenericError(message string) error { 14 | return &genericerror.GenericError{Message: message} 15 | } 16 | 17 | func TestGatherHTTPErrorCode(t *testing.T) { 18 | testError := generateTestHTTPError("", 0) 19 | if gatherHTTPErrorCode(&testError) != DefaultHTTPErrorCode { 20 | t.Error("Error code is not 500, even though input error code is zero") 21 | } 22 | 23 | testError = generateTestHTTPError("", http.StatusBadRequest) 24 | if gatherHTTPErrorCode(&testError) != http.StatusBadRequest { 25 | t.Error("Specified error code is not returned") 26 | } 27 | 28 | testError = generateTestGenericError("") 29 | if gatherHTTPErrorCode(&testError) != DefaultHTTPErrorCode { 30 | t.Error("Generic error code is not returned") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Johannes Eiglsperger 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pkg/ingest/remoteaddress/remoteaddress.go: -------------------------------------------------------------------------------- 1 | package remoteaddress 2 | 3 | import ( 4 | "github.com/asaskevich/govalidator" 5 | "github.com/joeig/dyndns-pdns/internal/genericerror" 6 | "github.com/joeig/dyndns-pdns/pkg/ingest" 7 | "net" 8 | ) 9 | 10 | // RemoteAddress defines the input values for this ingest mode 11 | type RemoteAddress struct { 12 | Address string 13 | } 14 | 15 | func isolateHostAddress(remoteAddress string) string { 16 | // Under certain circumstances, RemoteAddr contains also the port number 17 | address, _, err := net.SplitHostPort(remoteAddress) 18 | if err != nil { 19 | return remoteAddress 20 | } 21 | return address 22 | } 23 | 24 | // Process turns the input values into proper IPSet output values 25 | func (r *RemoteAddress) Process() (*ingest.IPSet, error) { 26 | address := isolateHostAddress(r.Address) 27 | 28 | ipSet := &ingest.IPSet{} 29 | 30 | if govalidator.IsIPv4(address) { 31 | ipSet.IPv4 = address 32 | } else if govalidator.IsIPv6(address) { 33 | ipSet.IPv6 = address 34 | } else { 35 | return ipSet, &genericerror.GenericError{Message: "Invalid remote address"} 36 | } 37 | 38 | return ipSet, nil 39 | } 40 | -------------------------------------------------------------------------------- /pkg/dnsprovider/powerdns/powerdns.go: -------------------------------------------------------------------------------- 1 | package powerdns 2 | 3 | import "github.com/joeig/go-powerdns/v2" 4 | 5 | // PowerDNS defines the configuration for the PowerDNS authoritative server. 6 | type PowerDNS struct { 7 | // BaseURL contains the URL to the PowerDNS API. 8 | // 9 | // Example: http://127.0.0.1:8080/ 10 | BaseURL string `mapstructure:"baseURL"` 11 | 12 | // VHost contains the PowerDNS virtual host. 13 | // 14 | // Example: localhost 15 | VHost string `mapstructure:"vhost"` 16 | 17 | // APIKey contains the API key. 18 | APIKey string `mapstructure:"apiKey"` 19 | 20 | // Zone contains the DNS zone, which contains all maintained resource records. 21 | // 22 | // Example: dyn.example.com 23 | Zone string `mapstructure:"zone"` 24 | 25 | // DefaultTTL sets a default TTL value in seconds. 26 | // 27 | // Example: 10 28 | DefaultTTL uint32 `mapstructure:"defaultTTL"` 29 | 30 | // Dry toggles the simulation mode off and on. 31 | // 32 | // If this is enabled, no actual API calls are made. 33 | Dry bool `mapstructure:"dry"` 34 | } 35 | 36 | func (p *PowerDNS) setupPowerDNSClient() *powerdns.Client { 37 | headers := map[string]string{"X-API-Key": p.APIKey} 38 | return powerdns.NewClient(p.BaseURL, p.VHost, headers, nil) 39 | } 40 | -------------------------------------------------------------------------------- /configs/config.dist.yml: -------------------------------------------------------------------------------- 1 | --- 2 | server: 3 | # Proxy API listener: 4 | listenAddress: "127.0.0.1:8000" 5 | tls: 6 | # Let dyndns-pdns terminate the TLS session: 7 | enable: true 8 | # TLS certificate file: 9 | certFile: "server.crt" 10 | # TLS key file: 11 | keyFile: "server.key" 12 | 13 | # Use PowerDNS provider: 14 | dnsProviderType: "powerDNS" 15 | 16 | powerDNS: 17 | # PowerDNS API base URL: 18 | baseURL: "http://127.0.0.1:8080/" 19 | # PowerDNS vHost (usually "localhost"): 20 | vhost: "localhost" 21 | # PowerDNS API key: 22 | apiKey: "secret" 23 | # DNS zone that is containing the dynamic resource records: 24 | zone: "dyn.example.com" 25 | # Default TTL for TXT records (might be overwritten by the key table): 26 | defaultTTL: 10 27 | 28 | keyTable: 29 | - name: "homeRouter" 30 | enable: true 31 | key: "secret" 32 | # DNS hostname (will be concatenated to "${hostName}.${miscellaneous.zone}"): 33 | hostName: "home-router" 34 | # Choose ingestMode between "getParameter" and "remoteAddress" (see README.md for further information): 35 | ingestMode: "getParameter" 36 | cleanUpMode: "any" 37 | # Overwrite miscellaneous.defaultTTL: 38 | ttl: 5 39 | - name: "officeRouter" 40 | enable: true 41 | key: "topSecret" 42 | hostName: "office-router" 43 | ingestMode: "remoteAddress" 44 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '3.3' 3 | 4 | services: 5 | mariadb: 6 | image: mariadb:10.3 7 | volumes: 8 | - mariadb_data:/var/lib/mysql 9 | restart: always 10 | environment: 11 | MYSQL_ROOT_PASSWORD: root 12 | MYSQL_DATABASE: powerdns 13 | MYSQL_USER: powerdns 14 | MYSQL_PASSWORD: powerdns 15 | 16 | powerdns: 17 | depends_on: 18 | - mariadb 19 | image: psitrax/powerdns:4.1 20 | ports: 21 | - "8053:53" 22 | - "8053:53/udp" 23 | - "8080:80" 24 | links: 25 | - mariadb 26 | volumes: 27 | - ./scripts/init_docker_fixtures.sh:/init_docker_fixtures.sh:ro 28 | restart: always 29 | environment: 30 | MYSQL_HOST: mariadb 31 | MYSQL_DB: powerdns 32 | MYSQL_USER: powerdns 33 | MYSQL_PASS: powerdns 34 | command: [ 35 | "--webserver=yes", 36 | "--webserver-address=0.0.0.0", 37 | "--webserver-port=80", 38 | "--webserver-password=webserverpw", 39 | "--webserver-allow-from=0.0.0.0/0", 40 | "--api=yes", 41 | "--api-key=apipw", 42 | "--api-readonly=no", 43 | "--disable-syslog=yes", 44 | "--loglevel=9", 45 | "--log-dns-queries=yes", 46 | "--log-dns-details=yes", 47 | "--query-logging=yes", 48 | "--default-soa-edit=INCEPTION-INCREMENT", 49 | "--gmysql-dnssec=yes" 50 | ] 51 | 52 | volumes: 53 | mariadb_data: 54 | -------------------------------------------------------------------------------- /pkg/ingest/ipset_test.go: -------------------------------------------------------------------------------- 1 | package ingest 2 | 3 | import "testing" 4 | 5 | func TestIPSetHasIPv4(t *testing.T) { 6 | ipSet1 := &IPSet{ 7 | IPv4: "foo", 8 | IPv6: "bar", 9 | } 10 | if !ipSet1.HasIPv4() { 11 | t.Error("IPSet has IPv4, but returns false") 12 | } 13 | ipSet2 := &IPSet{ 14 | IPv6: "bar", 15 | } 16 | if ipSet2.HasIPv4() { 17 | t.Error("IPSet has no IPv4, but returns true") 18 | } 19 | } 20 | 21 | func TestIPSetIsIPv4(t *testing.T) { 22 | ipSet1 := &IPSet{IPv4: "1.2.3.4"} 23 | if !ipSet1.IsIPv4() { 24 | t.Error("IPSet contains a valid IPv4 address, but returns false") 25 | } 26 | ipSet2 := &IPSet{IPv4: "bar"} 27 | if ipSet2.IsIPv4() { 28 | t.Error("IPSet contains an invalid IPv4 address, but returns true") 29 | } 30 | } 31 | 32 | func TestIPSetHasIPv6(t *testing.T) { 33 | ipSet1 := &IPSet{ 34 | IPv4: "foo", 35 | IPv6: "bar", 36 | } 37 | if !ipSet1.HasIPv6() { 38 | t.Error("IPSet has IPv6, but returns false") 39 | } 40 | ipSet2 := &IPSet{ 41 | IPv4: "bar", 42 | } 43 | if ipSet2.HasIPv6() { 44 | t.Error("IPSet has no IPv6, but returns true") 45 | } 46 | } 47 | 48 | func TestIPSetIsIPv6(t *testing.T) { 49 | ipSet1 := &IPSet{IPv6: "::1"} 50 | if !ipSet1.IsIPv6() { 51 | t.Error("IPSet contains a valid IPv6 address, but returns false") 52 | } 53 | ipSet2 := &IPSet{IPv6: "bar"} 54 | if ipSet2.IsIPv6() { 55 | t.Error("IPSet contains an invalid IPv6 address, but returns true") 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /pkg/dnsprovider/powerdns/delete.go: -------------------------------------------------------------------------------- 1 | package powerdns 2 | 3 | import ( 4 | "fmt" 5 | "github.com/asaskevich/govalidator" 6 | "github.com/joeig/go-powerdns/v2" 7 | "log" 8 | ) 9 | 10 | // DeleteIPv4ResourceRecord deletes an IPv4 resource record 11 | func (p *PowerDNS) DeleteIPv4ResourceRecord(hostname string) error { 12 | return p.deletePowerDNSResourceRecord(hostname, powerdns.RRTypeA) 13 | } 14 | 15 | // DeleteIPv6ResourceRecord deletes an IPv6 resource record 16 | func (p *PowerDNS) DeleteIPv6ResourceRecord(hostname string) error { 17 | return p.deletePowerDNSResourceRecord(hostname, powerdns.RRTypeAAAA) 18 | } 19 | 20 | func (p *PowerDNS) deletePowerDNSResourceRecord(hostname string, recordType powerdns.RRType) error { 21 | if !govalidator.IsDNSName(hostname) { 22 | return &powerdns.Error{} 23 | } 24 | 25 | log.Printf("Calling PowerDNS (delete) with domain='%s' hostname='%s' recordType='%s'", p.Zone, hostname, recordType) 26 | 27 | if p.Dry { 28 | log.Print("Dry run is enabled: Skipping calls to PowerDNS") 29 | return nil 30 | } 31 | 32 | pdns := p.setupPowerDNSClient() 33 | 34 | name := fmt.Sprintf("%s.%s", hostname, p.Zone) 35 | log.Printf("Generated name='%s'", name) 36 | 37 | if err := pdns.Records.Delete(p.Zone, name, recordType); err != nil { 38 | log.Printf("Error deleting %s record: %+v", recordType, err) 39 | return err 40 | } 41 | 42 | log.Print("Successfully deleted resource record") 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /pkg/dnsprovider/powerdns/add.go: -------------------------------------------------------------------------------- 1 | package powerdns 2 | 3 | import ( 4 | "fmt" 5 | "github.com/asaskevich/govalidator" 6 | "github.com/joeig/dyndns-pdns/pkg/dnsprovider" 7 | "github.com/joeig/go-powerdns/v2" 8 | "log" 9 | ) 10 | 11 | // AddIPv4ResourceRecord adds a new IPv4 resource record 12 | func (p *PowerDNS) AddIPv4ResourceRecord(hostname string, ipv4 string, ttl uint32) error { 13 | if !govalidator.IsIPv4(ipv4) { 14 | return &powerdns.Error{} 15 | } 16 | return p.addPowerDNSResourceRecord(hostname, powerdns.RRTypeA, ipv4, ttl) 17 | } 18 | 19 | // AddIPv6ResourceRecord adds a new IPv6 resource record 20 | func (p *PowerDNS) AddIPv6ResourceRecord(hostname string, ipv6 string, ttl uint32) error { 21 | if !govalidator.IsIPv6(ipv6) { 22 | return &powerdns.Error{} 23 | } 24 | return p.addPowerDNSResourceRecord(hostname, powerdns.RRTypeAAAA, ipv6, ttl) 25 | } 26 | 27 | func (p *PowerDNS) addPowerDNSResourceRecord(hostname string, recordType powerdns.RRType, content string, ttl uint32) error { 28 | if !govalidator.IsDNSName(hostname) { 29 | return &powerdns.Error{} 30 | } 31 | 32 | log.Printf("Calling PowerDNS (add) with domain='%s' hostname='%s' recordType='%s' content='%s' ttl=%d", p.Zone, hostname, recordType, content, ttl) 33 | 34 | if p.Dry { 35 | log.Print("Dry run is enabled: Skipping calls to PowerDNS") 36 | return nil 37 | } 38 | 39 | pdns := p.setupPowerDNSClient() 40 | 41 | name := fmt.Sprintf("%s.%s", hostname, p.Zone) 42 | thisTTL := dnsprovider.GetTTL(ttl, p.DefaultTTL) 43 | log.Printf("Generated name='%s' ttl=%d", name, thisTTL) 44 | 45 | if err := pdns.Records.Add(p.Zone, name, recordType, thisTTL, []string{content}); err != nil { 46 | log.Printf("Error changing %s record: %+v", recordType, err) 47 | return err 48 | } 49 | 50 | log.Print("Successfully created resource record") 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /cmd/dyndns-pdns/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "github.com/gin-gonic/gin" 7 | "github.com/joeig/dyndns-pdns/internal/yamlconfig" 8 | "log" 9 | "os" 10 | ) 11 | 12 | // BuildVersion is set at linking time 13 | var BuildVersion string 14 | 15 | // BuildGitCommit is set at linking time 16 | var BuildGitCommit string 17 | 18 | func printVersionAndExit() { 19 | fmt.Printf("Build Version: %s\n", BuildVersion) 20 | fmt.Printf("Build Git Commit: %s\n", BuildGitCommit) 21 | os.Exit(0) 22 | } 23 | 24 | // Dry prohibits calling any backend services 25 | var Dry = false 26 | 27 | func toggleDryMode() { 28 | if Dry { 29 | log.Print("Dry run enabled") 30 | } 31 | yamlconfig.C.PowerDNS.Dry = Dry 32 | } 33 | 34 | // Debug enables verbose log output 35 | var Debug = false 36 | 37 | func toggleDebugMode() { 38 | if Debug { 39 | gin.SetMode("debug") 40 | } else { 41 | gin.SetMode("release") 42 | } 43 | } 44 | 45 | func runServer(router *gin.Engine) { 46 | if yamlconfig.C.Server.TLS.Enable { 47 | log.Fatal(router.RunTLS(yamlconfig.C.Server.ListenAddress, yamlconfig.C.Server.TLS.CertFile, yamlconfig.C.Server.TLS.KeyFile)) 48 | } 49 | log.Fatal(router.Run(yamlconfig.C.Server.ListenAddress)) 50 | } 51 | 52 | func main() { 53 | configFile := flag.String("config", "config.yml", "Configuration file") 54 | dryFlag := flag.Bool("dry", false, "Dry run (do not call any backend services)") 55 | debugFlag := flag.Bool("debug", false, "Debug mode") 56 | version := flag.Bool("version", false, "Prints the version name") 57 | flag.Parse() 58 | 59 | if *version { 60 | printVersionAndExit() 61 | } 62 | 63 | yamlconfig.ParseConfig(&yamlconfig.C, configFile) 64 | yamlconfig.SetDNSProvider(&yamlconfig.ActiveDNSProvider) 65 | 66 | Dry = *dryFlag 67 | toggleDryMode() 68 | 69 | Debug = *debugFlag 70 | toggleDebugMode() 71 | 72 | router := setupGinEngine() 73 | runServer(router) 74 | } 75 | -------------------------------------------------------------------------------- /internal/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "github.com/joeig/dyndns-pdns/internal/genericerror" 5 | "github.com/joeig/dyndns-pdns/internal/ginresponse" 6 | "github.com/joeig/dyndns-pdns/internal/yamlconfig" 7 | "log" 8 | "net/http" 9 | ) 10 | 11 | func checkHost(host string) (string, error) { 12 | if host == "" { 13 | return "", &genericerror.GenericError{Message: "Host parameter missing"} 14 | } 15 | return host, nil 16 | } 17 | 18 | // GetName validates the given host name 19 | func GetName(host string) (string, error) { 20 | name, err := checkHost(host) 21 | if err != nil { 22 | return name, &ginresponse.HTTPError{Message: err.Error(), HTTPErrorCode: http.StatusUnauthorized} 23 | } 24 | 25 | log.Printf("Received name=\"%s\"", name) 26 | return name, nil 27 | } 28 | 29 | func checkKey(key string) (string, error) { 30 | if key == "" { 31 | return "", &genericerror.GenericError{Message: "Key parameter missing"} 32 | } 33 | return key, nil 34 | } 35 | 36 | // GetKey validates the given key 37 | func GetKey(key string) (string, error) { 38 | key, err := checkKey(key) 39 | if err != nil { 40 | return key, &ginresponse.HTTPError{Message: err.Error(), HTTPErrorCode: http.StatusUnauthorized} 41 | } 42 | 43 | log.Printf("Received key=\"%s\"", key) 44 | return key, nil 45 | } 46 | 47 | func checkAuthorization(keyTable []yamlconfig.Key, name string, key string) (*yamlconfig.Key, error) { 48 | for _, keyItem := range keyTable { 49 | if keyItem.Enable && keyItem.Name == name && keyItem.Key == key { 50 | return &keyItem, nil 51 | } 52 | } 53 | return &yamlconfig.Key{}, &genericerror.GenericError{Message: "Permission denied"} 54 | } 55 | 56 | // GetKeyItem validates the given host name and key and returns a proper key item from the configuration 57 | func GetKeyItem(name string, key string) (*yamlconfig.Key, error) { 58 | keyItem, err := checkAuthorization(yamlconfig.C.KeyTable, name, key) 59 | if err != nil { 60 | return keyItem, &ginresponse.HTTPError{Message: err.Error(), HTTPErrorCode: http.StatusForbidden} 61 | } 62 | 63 | log.Printf("Found key item: %+v", keyItem) 64 | return keyItem, nil 65 | } 66 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/joeig/dyndns-pdns 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/asaskevich/govalidator v0.0.0-20180315120708-ccb8e960c48f 7 | github.com/gin-gonic/gin v1.10.1 8 | github.com/google/jsonapi v1.0.0 9 | github.com/google/uuid v1.6.0 10 | github.com/joeig/go-powerdns/v2 v2.5.0 11 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 12 | github.com/spf13/viper v1.19.0 13 | ) 14 | 15 | require ( 16 | github.com/bytedance/sonic v1.11.6 // indirect 17 | github.com/bytedance/sonic/loader v0.1.1 // indirect 18 | github.com/cloudwego/base64x v0.1.4 // indirect 19 | github.com/cloudwego/iasm v0.2.0 // indirect 20 | github.com/fsnotify/fsnotify v1.7.0 // indirect 21 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 22 | github.com/gin-contrib/sse v0.1.0 // indirect 23 | github.com/go-playground/locales v0.14.1 // indirect 24 | github.com/go-playground/universal-translator v0.18.1 // indirect 25 | github.com/go-playground/validator/v10 v10.20.0 // indirect 26 | github.com/goccy/go-json v0.10.2 // indirect 27 | github.com/hashicorp/hcl v1.0.0 // indirect 28 | github.com/json-iterator/go v1.1.12 // indirect 29 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 30 | github.com/leodido/go-urn v1.4.0 // indirect 31 | github.com/magiconair/properties v1.8.7 // indirect 32 | github.com/mattn/go-isatty v0.0.20 // indirect 33 | github.com/mitchellh/mapstructure v1.5.0 // indirect 34 | github.com/modern-go/reflect2 v1.0.2 // indirect 35 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 36 | github.com/sagikazarmark/locafero v0.4.0 // indirect 37 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 38 | github.com/sourcegraph/conc v0.3.0 // indirect 39 | github.com/spf13/afero v1.11.0 // indirect 40 | github.com/spf13/cast v1.6.0 // indirect 41 | github.com/spf13/pflag v1.0.5 // indirect 42 | github.com/subosito/gotenv v1.6.0 // indirect 43 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 44 | github.com/ugorji/go/codec v1.2.12 // indirect 45 | go.uber.org/atomic v1.9.0 // indirect 46 | go.uber.org/multierr v1.9.0 // indirect 47 | golang.org/x/arch v0.8.0 // indirect 48 | golang.org/x/crypto v0.45.0 // indirect 49 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 50 | golang.org/x/net v0.47.0 // indirect 51 | golang.org/x/sys v0.38.0 // indirect 52 | golang.org/x/text v0.31.0 // indirect 53 | google.golang.org/protobuf v1.34.1 // indirect 54 | gopkg.in/ini.v1 v1.67.0 // indirect 55 | gopkg.in/yaml.v3 v3.0.1 // indirect 56 | ) 57 | -------------------------------------------------------------------------------- /cmd/dyndns-pdns/records.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/joeig/dyndns-pdns/internal/ginresponse" 5 | "github.com/joeig/dyndns-pdns/internal/yamlconfig" 6 | "github.com/joeig/dyndns-pdns/pkg/ingest" 7 | "log" 8 | "net/http" 9 | ) 10 | 11 | func cleanUpOutdatedResourceRecords(ipSet *ingest.IPSet, keyItem *yamlconfig.Key) error { 12 | if keyItem.CleanUpMode == yamlconfig.CleanUpModeAny || (keyItem.CleanUpMode == yamlconfig.CleanUpModeRequestBased && ipSet.HasIPv4()) { 13 | log.Print("Cleaning up any previously created IPv4 resource records") 14 | 15 | if err := yamlconfig.ActiveDNSProvider.DeleteIPv4ResourceRecord(keyItem.HostName); err != nil { 16 | log.Printf("%+v", err) 17 | return &ginresponse.HTTPError{Message: "IPv4 record deletion failed", HTTPErrorCode: http.StatusInternalServerError} 18 | } 19 | } else { 20 | log.Print("Skipping clean up of previously created IPv4 resource records") 21 | } 22 | 23 | if keyItem.CleanUpMode == yamlconfig.CleanUpModeAny || (keyItem.CleanUpMode == yamlconfig.CleanUpModeRequestBased && ipSet.HasIPv6()) { 24 | log.Print("Cleaning up any previously created IPv6 resource records") 25 | 26 | if err := yamlconfig.ActiveDNSProvider.DeleteIPv6ResourceRecord(keyItem.HostName); err != nil { 27 | log.Printf("%+v", err) 28 | return &ginresponse.HTTPError{Message: "IPv6 record deletion failed", HTTPErrorCode: http.StatusInternalServerError} 29 | } 30 | } else { 31 | log.Print("Skipping clean up of previously created IPv6 resource records") 32 | } 33 | 34 | return nil 35 | } 36 | 37 | func createNewIPv4ResourceRecord(ipSet *ingest.IPSet, keyItem *yamlconfig.Key) error { 38 | log.Print("Creating IPv4 resource records") 39 | 40 | if err := yamlconfig.ActiveDNSProvider.AddIPv4ResourceRecord(keyItem.HostName, ipSet.IPv4, keyItem.TTL); err != nil { 41 | log.Printf("%+v", err) 42 | return &ginresponse.HTTPError{Message: "IPv4 record creation failed", HTTPErrorCode: http.StatusInternalServerError} 43 | } 44 | 45 | return nil 46 | } 47 | 48 | func createNewIPv6ResourceRecord(ipSet *ingest.IPSet, keyItem *yamlconfig.Key) error { 49 | log.Print("Creating IPv6 resource records") 50 | 51 | if err := yamlconfig.ActiveDNSProvider.AddIPv6ResourceRecord(keyItem.HostName, ipSet.IPv6, keyItem.TTL); err != nil { 52 | log.Printf("%+v", err) 53 | return &ginresponse.HTTPError{Message: "IPv6 record creation failed", HTTPErrorCode: http.StatusInternalServerError} 54 | } 55 | 56 | return nil 57 | } 58 | 59 | func createNewResourceRecords(ipSet *ingest.IPSet, keyItem *yamlconfig.Key) error { 60 | if ipSet.HasIPv4() { 61 | if err := createNewIPv4ResourceRecord(ipSet, keyItem); err != nil { 62 | return err 63 | } 64 | } 65 | 66 | if ipSet.HasIPv6() { 67 | if err := createNewIPv6ResourceRecord(ipSet, keyItem); err != nil { 68 | return err 69 | } 70 | } 71 | 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /internal/auth/auth_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "github.com/joeig/dyndns-pdns/internal/yamlconfig" 5 | "testing" 6 | ) 7 | 8 | func TestCheckHost(t *testing.T) { 9 | t.Run("TestCheckHostEmptyReturnNotEmpty", func(t *testing.T) { 10 | if host, _ := checkHost(""); host != "" { 11 | t.Error("Empty host returns invalid host string") 12 | } 13 | }) 14 | t.Run("TestCheckHostEmptyErrorNil", func(t *testing.T) { 15 | if _, err := checkHost(""); err == nil { 16 | t.Error("Empty host does not return error") 17 | } 18 | }) 19 | t.Run("TestCheckTestHostReturnInvalid", func(t *testing.T) { 20 | if host, _ := checkHost("foo"); host != "foo" { 21 | t.Error("Test host returns invalid host string") 22 | } 23 | }) 24 | t.Run("TestCheckTestHostReturnNotNil", func(t *testing.T) { 25 | if _, err := checkHost("foo"); err != nil { 26 | t.Error("Test host returns error") 27 | } 28 | }) 29 | } 30 | 31 | func TestCheckKey(t *testing.T) { 32 | t.Run("TestCheckKeyEmptyReturnNotEmpty", func(t *testing.T) { 33 | if key, _ := checkKey(""); key != "" { 34 | t.Error("Empty key returns invalid key string") 35 | } 36 | }) 37 | t.Run("TestCheckKeyEmptyErrorNil", func(t *testing.T) { 38 | if _, err := checkKey(""); err == nil { 39 | t.Error("Empty key does not return error") 40 | } 41 | }) 42 | t.Run("TestCheckTestKeyReturnInvalid", func(t *testing.T) { 43 | if key, _ := checkKey("foo"); key != "foo" { 44 | t.Error("Test key returns invalid key string") 45 | } 46 | }) 47 | t.Run("TestCheckTestKeyReturnNotNil", func(t *testing.T) { 48 | if _, err := checkKey("foo"); err != nil { 49 | t.Error("Test key returns error") 50 | } 51 | }) 52 | } 53 | 54 | func TestCheckAuthorization(t *testing.T) { 55 | keyTable := []yamlconfig.Key{ 56 | { 57 | Name: "homeRouter", 58 | Enable: true, 59 | Key: "secret", 60 | HostName: "home-router", 61 | IngestMode: yamlconfig.IngestModeGetParameter, 62 | TTL: 1337, 63 | }, 64 | } 65 | 66 | t.Run("TestCheckAuthorizationEmptyReturnNotEmpty", func(t *testing.T) { 67 | if keyItem, _ := checkAuthorization(keyTable, "foo", "bar"); keyItem.Name != "" { 68 | t.Error("Invalid key name returns invalid key item") 69 | } 70 | }) 71 | t.Run("TestCheckAuthorizationEmptyErrorNil", func(t *testing.T) { 72 | if _, err := checkAuthorization(keyTable, "foo", "bar"); err == nil { 73 | t.Error("Invalid key name does not return error") 74 | } 75 | }) 76 | t.Run("TestCheckTestAuthorizationReturnInvalid", func(t *testing.T) { 77 | if keyItem, _ := checkAuthorization(keyTable, "homeRouter", "secret"); keyItem.Name != "homeRouter" { 78 | t.Error("Test key name returns invalid key item") 79 | } 80 | }) 81 | t.Run("TestCheckTestAuthorizationReturnNotNil", func(t *testing.T) { 82 | if _, err := checkAuthorization(keyTable, "homeRouter", "secret"); err != nil { 83 | t.Error("Test key name returns error") 84 | } 85 | }) 86 | } 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dynamic DNS Collector for PowerDNS 2 | 3 | Collects IPv4/IPv6 addresses of network devices (routers, firewalls etc.) and writes the corresponding PowerDNS resource records. 4 | 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/joeig/dyndns-pdns)](https://goreportcard.com/report/github.com/joeig/dyndns-pdns) 6 | 7 | ## Setup 8 | 9 | ### Install from source 10 | 11 | You need `go` and `GOBIN` in your `PATH`. Once that is done, install dyndns-pdns using the following command: 12 | 13 | ~~~ bash 14 | go install github.com/joeig/dyndns-pdns/cmd/dyndns-pdns@latest 15 | ~~~ 16 | 17 | After that, copy [`config.dist.yml`](configs/config.dist.yml) to `config.yml`, replace the default settings and run the binary: 18 | 19 | ~~~ bash 20 | dyndns-pdns -config=/path/to/config.yml 21 | ~~~ 22 | 23 | If you're intending to add the application to your systemd runlevel, you may want to take a look at [`init/dyndns-pdns.service`](init/dyndns-pdns.service). 24 | 25 | ## Usage 26 | 27 | ### Update IP addresses of a certain host 28 | 29 | In order to update the IP address of a certain host, you can choose between to ingest modes: 30 | 31 | - Use the value provided by a HTTP GET parameter (IPv4 and/or IPv6) 32 | - Use the value provided by the TCP remote address field (either IPv4 or IPv6, depending on the client's capabilities) 33 | 34 | This tool does not support the DDNS protocol (RFC2136), which is supported by PowerDNS out of the box. 35 | 36 | #### HTTP GET parameter 37 | 38 | Suitable for all common network devices. 39 | 40 | ~~~ bash 41 | http "https://dyn-ingest.example.com/v1/host//sync?key=&ipv4=&ipv6=" 42 | ~~~ 43 | 44 | You have to provide at least one IP address family. 45 | 46 | #### TCP remote address 47 | 48 | If your router doesn't know its own egress IP address (might be the most promising solution for people that have to work behind NAT gateways or proxies). 49 | 50 | ~~~ bash 51 | http "https://dyn-ingest.example.com/v1/host//sync?key=" 52 | ~~~ 53 | 54 | This option takes the IP address (IPv4 or IPv6) used by the client during the TCP handshake. 55 | 56 | ### Comment regarding the update process 57 | 58 | The PowerDNS API is not transaction safe. Existing resource record entries, which belong to a specific host, are dropped, before the new IP addresses are inserted for that host. Due to potential delays between the "delete" step and the "add" step, there is a risk that the dynamic DNS hostname might become unavailable. In order to lower the impact caused by this behaviour, it is recommended to set your negative caching TTL to a low value. 59 | 60 | ## Examples 61 | 62 | ### Fritzbox (FritzOS) 63 | 64 | Your Fritzbox configuration might look like this: 65 | 66 | | Key | Value | 67 | | --- | ----- | 68 | | Provider | Custom | 69 | | Update URL | https://dyn-ingest.example.com/v1/host/\/sync?key=\&ipv4=\&ipv6=\ | 70 | | Domain name | fb-home.dyn.example.com | 71 | | Username | fb-home | 72 | | Password | my secret password | 73 | 74 | You have to copy the update URL as it is, including all the placeholders and \<\> brackets. They will be substituted by FritzOS internally with the corresponding values. 75 | 76 | The key table item should be as following: 77 | 78 | ~~~ yaml 79 | keyTable: 80 | - name: "fb-home" 81 | enable: true 82 | key: "my secret password" 83 | hostName: "fb-home" 84 | ingestMode: "getParameter" 85 | ~~~ 86 | -------------------------------------------------------------------------------- /api/v1.yml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | description: "Collects IPv4/IPv6 addresses of network devices (routers, firewalls etc.) and writes the corresponding PowerDNS resource records." 4 | version: "v1" 5 | title: "Dynamic DNS Collector for PowerDNS" 6 | basePath: "/v1" 7 | tags: 8 | - name: "host" 9 | - name: "internals" 10 | paths: 11 | /host/{name}/sync: 12 | get: 13 | tags: 14 | - "host" 15 | summary: "Updates IP addresses of a certain host" 16 | operationId: "syncHost" 17 | produces: 18 | - "application/json" 19 | parameters: 20 | - name: "name" 21 | in: "path" 22 | description: "Name of the host" 23 | required: true 24 | type: "string" 25 | - name: "key" 26 | in: "query" 27 | description: "Key of the corresponding host item" 28 | required: true 29 | type: "string" 30 | - name: "ipv4" 31 | in: "query" 32 | description: "IPv4 address (required if ingestMode is \"getParameter\")" 33 | required: false 34 | type: "string" 35 | - name: "ipv6" 36 | in: "query" 37 | description: "IPv6 address (required if ingestMode is \"getParameter\")" 38 | required: false 39 | type: "string" 40 | responses: 41 | 200: 42 | description: "OK" 43 | schema: 44 | $ref: '#/definitions/HostSyncObjects' 45 | 400: 46 | description: "Bad Request" 47 | schema: 48 | $ref: '#/definitions/Errors' 49 | 401: 50 | description: "Unauthorized" 51 | schema: 52 | $ref: '#/definitions/Errors' 53 | 403: 54 | description: "Forbidden" 55 | schema: 56 | $ref: '#/definitions/Errors' 57 | 500: 58 | description: "Internal Server Error" 59 | schema: 60 | $ref: '#/definitions/Errors' 61 | /health: 62 | get: 63 | tags: 64 | - "internals" 65 | summary: "Health check endpoint" 66 | operationId: "health" 67 | produces: 68 | - "application/json" 69 | responses: 70 | 200: 71 | description: "OK" 72 | schema: 73 | $ref: '#/definitions/HealthCheckObject' 74 | 500: 75 | description: "Internal Server Error" 76 | schema: 77 | $ref: '#/definitions/Errors' 78 | definitions: 79 | HostSyncObjects: 80 | type: "object" 81 | properties: 82 | hostSyncObjects: 83 | type: "array" 84 | items: 85 | $ref: "#/definitions/HostSyncObject" 86 | HostSyncObject: 87 | type: "object" 88 | properties: 89 | hostName: 90 | type: "string" 91 | ingestMode: 92 | type: "string" 93 | ttl: 94 | type: "integer" 95 | ipv4: 96 | type: "string" 97 | ipv6: 98 | type: "string" 99 | HealthCheckObject: 100 | type: "object" 101 | properties: 102 | applicationRunning: 103 | type: "boolean" 104 | Errors: 105 | type: "object" 106 | properties: 107 | errors: 108 | type: "array" 109 | items: 110 | $ref: '#/definitions/Error' 111 | Error: 112 | type: "object" 113 | properties: 114 | title: 115 | type: "string" 116 | externalDocs: 117 | description: "Find out more on GitHub" 118 | url: "https://github.com/joeig/dyndns-pdns" -------------------------------------------------------------------------------- /cmd/dyndns-pdns/hostsync_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/joeig/dyndns-pdns/internal/yamlconfig" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | ) 10 | 11 | func assertHostSyncComponent(t *testing.T, router *gin.Engine, method string, url string, remoteAddr string, assertedCode int) *httptest.ResponseRecorder { 12 | req, _ := http.NewRequest(method, url, nil) 13 | req.RemoteAddr = remoteAddr 14 | res := httptest.NewRecorder() 15 | router.ServeHTTP(res, req) 16 | 17 | if res.Code != assertedCode { 18 | t.Errorf("HTTP request to \"%s\" returned %d instead of %d", url, res.Code, assertedCode) 19 | } 20 | 21 | return res 22 | } 23 | 24 | func TestHostSync(t *testing.T) { 25 | configFile := "../../configs/config.test.yml" 26 | yamlconfig.ParseConfig(&yamlconfig.C, &configFile) 27 | yamlconfig.SetDNSProvider(&yamlconfig.ActiveDNSProvider) 28 | Dry = true 29 | yamlconfig.C.PowerDNS.Dry = Dry 30 | router := setupGinEngine() 31 | 32 | // OK 33 | t.Run("TestGetParameterIPv4IPv6OK", func(t *testing.T) { 34 | assertHostSyncComponent(t, router, http.MethodGet, "/v1/host/homeRouter/sync?key=secret&ipv4=127.0.0.1&ipv6=::1", "127.0.0.1", http.StatusOK) 35 | }) 36 | t.Run("TestGetParameterIPv4OK", func(t *testing.T) { 37 | assertHostSyncComponent(t, router, http.MethodGet, "/v1/host/homeRouter/sync?key=secret&ipv4=127.0.0.1", "127.0.0.1", http.StatusOK) 38 | }) 39 | t.Run("TestGetParameterIPv6OK", func(t *testing.T) { 40 | assertHostSyncComponent(t, router, http.MethodGet, "/v1/host/homeRouter/sync?key=secret&ipv6=::1", "127.0.0.1", http.StatusOK) 41 | }) 42 | t.Run("TestRemoteAddressIPv4OK", func(t *testing.T) { 43 | assertHostSyncComponent(t, router, http.MethodGet, "/v1/host/officeRouter/sync?key=topSecret", "127.0.0.1", http.StatusOK) 44 | }) 45 | t.Run("TestRemoteAddressIPv4PortOK", func(t *testing.T) { 46 | assertHostSyncComponent(t, router, http.MethodGet, "/v1/host/officeRouter/sync?key=topSecret", "127.0.0.1:1337", http.StatusOK) 47 | }) 48 | t.Run("TestRemoteAddressIPv6OK", func(t *testing.T) { 49 | assertHostSyncComponent(t, router, http.MethodGet, "/v1/host/officeRouter/sync?key=topSecret", "::1", http.StatusOK) 50 | }) 51 | t.Run("TestRemoteAddressIPv6PortOK", func(t *testing.T) { 52 | assertHostSyncComponent(t, router, http.MethodGet, "/v1/host/officeRouter/sync?key=topSecret", "[::1]:1337", http.StatusOK) 53 | }) 54 | 55 | // Forbidden 56 | t.Run("TestUnknownDeviceNameForbidden", func(t *testing.T) { 57 | assertHostSyncComponent(t, router, http.MethodGet, "/v1/host/unknownDevice/sync?key=secret&ipv6=::1", "127.0.0.1", http.StatusForbidden) 58 | }) 59 | t.Run("TestInvalidKeyForbidden", func(t *testing.T) { 60 | assertHostSyncComponent(t, router, http.MethodGet, "/v1/host/homeRouter/sync?key=wrongKey&ipv6=::1", "127.0.0.1", http.StatusForbidden) 61 | }) 62 | 63 | // Unauthorized 64 | t.Run("TestMissingDeviceNameUnauthorized", func(t *testing.T) { 65 | assertHostSyncComponent(t, router, http.MethodGet, "/v1/host//sync?key=secret&ipv6=::1", "127.0.0.1", http.StatusUnauthorized) 66 | }) 67 | t.Run("TestMissingKeyUnauthorized", func(t *testing.T) { 68 | assertHostSyncComponent(t, router, http.MethodGet, "/v1/host/homeRouter/sync", "127.0.0.1", http.StatusUnauthorized) 69 | }) 70 | 71 | // BadRequest 72 | t.Run("TestGetParameterMissingBadRequest", func(t *testing.T) { 73 | assertHostSyncComponent(t, router, http.MethodGet, "/v1/host/homeRouter/sync?key=secret", "127.0.0.1", http.StatusBadRequest) 74 | }) 75 | t.Run("TestInvalidIPv4BadRequest", func(t *testing.T) { 76 | assertHostSyncComponent(t, router, http.MethodGet, "/v1/host/homeRouter/sync?key=secret&ipv4=foo", "127.0.0.1", http.StatusBadRequest) 77 | }) 78 | t.Run("TestInvalidIPv6BadRequest", func(t *testing.T) { 79 | assertHostSyncComponent(t, router, http.MethodGet, "/v1/host/homeRouter/sync?key=secret&ipv6=foo", "127.0.0.1", http.StatusBadRequest) 80 | }) 81 | 82 | // Response headers 83 | t.Run("TestCacheControl", func(t *testing.T) { 84 | res := assertHostSyncComponent(t, router, http.MethodGet, "/v1/host/homeRouter/sync?key=secret&ipv4=127.0.0.1&ipv6=::1", "127.0.0.1", http.StatusOK) 85 | if res.Header().Get("Cache-Control") == "" { 86 | t.Errorf("Cache-Control is missing") 87 | } 88 | }) 89 | t.Run("TestRequestID", func(t *testing.T) { 90 | res := assertHostSyncComponent(t, router, http.MethodGet, "/v1/host/homeRouter/sync?key=secret&ipv4=127.0.0.1&ipv6=::1", "127.0.0.1", http.StatusOK) 91 | if res.Header().Get("X-Request-ID") == "" { 92 | t.Errorf("X-Request-ID is missing") 93 | } 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /cmd/dyndns-pdns/hostsync.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/joeig/dyndns-pdns/internal/auth" 6 | "github.com/joeig/dyndns-pdns/internal/ginresponse" 7 | "github.com/joeig/dyndns-pdns/internal/yamlconfig" 8 | "github.com/joeig/dyndns-pdns/pkg/ingest" 9 | "github.com/joeig/dyndns-pdns/pkg/ingest/getparameter" 10 | "github.com/joeig/dyndns-pdns/pkg/ingest/remoteaddress" 11 | "log" 12 | "net/http" 13 | ) 14 | 15 | // HostSyncPayload returns a payload containing HostSyncObjects 16 | type HostSyncPayload struct { 17 | HostSyncObjects []*HostSyncObject `json:"hostSyncObjects"` 18 | } 19 | 20 | // HostSyncObject contains a payload for the requester in order to identify the values that have been stored 21 | type HostSyncObject struct { 22 | HostName string `json:"hostName"` 23 | IngestMode ingest.ModeType `json:"ingestMode"` 24 | CleanUpMode yamlconfig.CleanUpModeType `json:"cleanUpMode"` 25 | TTL int `json:"ttl"` 26 | IPv4 string `json:"ipv4"` 27 | IPv6 string `json:"ipv6"` 28 | } 29 | 30 | // HostSync Gin route 31 | func HostSync(ctx *gin.Context) { 32 | ctx.Header("Cache-Control", "no-cache") 33 | 34 | name, err := auth.GetName(ctx.Param("name")) 35 | if err != nil { 36 | ginresponse.GinJSONError(ctx, err) 37 | return 38 | } 39 | 40 | key, err := auth.GetKey(ctx.Query("key")) 41 | if err != nil { 42 | ginresponse.GinJSONError(ctx, err) 43 | return 44 | } 45 | 46 | keyItem, err := auth.GetKeyItem(name, key) 47 | if err != nil { 48 | ginresponse.GinJSONError(ctx, err) 49 | return 50 | } 51 | 52 | ipSet, err := getIPAddresses(ctx, keyItem) 53 | if err != nil { 54 | ginresponse.GinJSONError(ctx, err) 55 | return 56 | } 57 | 58 | if ipSet.HasIPv4() || ipSet.HasIPv6() { 59 | if err := cleanUpOutdatedResourceRecords(ipSet, keyItem); err != nil { 60 | ginresponse.GinJSONError(ctx, err) 61 | return 62 | } 63 | } 64 | 65 | if err := createNewResourceRecords(ipSet, keyItem); err != nil { 66 | ginresponse.GinJSONError(ctx, err) 67 | return 68 | } 69 | 70 | buildResponsePayload(ctx, keyItem, ipSet) 71 | } 72 | 73 | func getIngestModeHandler(ctx *gin.Context, desiredIngestModeType ingest.ModeType) (ingest.Mode, error) { 74 | var activeIngestMode ingest.Mode 75 | 76 | switch desiredIngestModeType { 77 | case yamlconfig.IngestModeGetParameter: 78 | ipv4 := ctx.Query("ipv4") 79 | ipv6 := ctx.Query("ipv6") 80 | log.Printf("Received ipv4=\"%s\" ipv6=\"%s\"", ipv4, ipv6) 81 | 82 | activeIngestMode = &getparameter.GetParameter{IPv4: ipv4, IPv6: ipv6} 83 | 84 | case yamlconfig.IngestModeRemoteAddress: 85 | address := ctx.Request.RemoteAddr 86 | log.Printf("Received address=\"%s\"", address) 87 | 88 | activeIngestMode = &remoteaddress.RemoteAddress{Address: ctx.Request.RemoteAddr} 89 | 90 | default: 91 | return activeIngestMode, &ginresponse.HTTPError{Message: "Server configuration error: Invalid ingest mode", HTTPErrorCode: http.StatusBadRequest} 92 | } 93 | 94 | return activeIngestMode, nil 95 | } 96 | 97 | func getIPAddresses(ctx *gin.Context, keyItem *yamlconfig.Key) (*ingest.IPSet, error) { 98 | activeIngestMode, err := getIngestModeHandler(ctx, keyItem.IngestMode) 99 | if err != nil { 100 | log.Printf("Unable to initialise ingests mode for \"%s\": %s", keyItem.Name, err.Error()) 101 | return &ingest.IPSet{}, err 102 | } 103 | 104 | log.Printf("Processing ingest for %+v mode", keyItem.IngestMode) 105 | ipSet, err := activeIngestMode.Process() 106 | if err != nil { 107 | return ipSet, &ginresponse.HTTPError{Message: err.Error(), HTTPErrorCode: http.StatusBadRequest} 108 | } 109 | 110 | log.Printf("Gathered ipSet: %+v", ipSet) 111 | return ipSet, nil 112 | } 113 | 114 | func buildResponsePayload(ctx *gin.Context, keyItem *yamlconfig.Key, ipSet *ingest.IPSet) { 115 | if keyItem.HostName != "" && keyItem.IngestMode != "" && (ipSet.HasIPv4() || ipSet.HasIPv6()) { 116 | payload := HostSyncPayload{HostSyncObjects: []*HostSyncObject{{ 117 | HostName: keyItem.HostName, 118 | IngestMode: keyItem.IngestMode, 119 | CleanUpMode: keyItem.CleanUpMode, 120 | IPv4: ipSet.IPv4, 121 | IPv6: ipSet.IPv6, 122 | }}} 123 | log.Printf("Updated \"%s\" successfully", keyItem.Name) 124 | ctx.JSON(http.StatusOK, payload) 125 | return 126 | } 127 | 128 | ginresponse.GinJSONError(ctx, &ginresponse.HTTPError{Message: "HostSync request processing error", HTTPErrorCode: http.StatusInternalServerError}) 129 | } 130 | -------------------------------------------------------------------------------- /internal/yamlconfig/config.go: -------------------------------------------------------------------------------- 1 | package yamlconfig 2 | 3 | import ( 4 | "fmt" 5 | "github.com/joeig/dyndns-pdns/pkg/dnsprovider" 6 | "github.com/joeig/dyndns-pdns/pkg/dnsprovider/powerdns" 7 | "github.com/joeig/dyndns-pdns/pkg/ingest" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | const ( 12 | // IngestModeGetParameter sets the ingest mode to "GET parameter". 13 | // 14 | // The IP addresses can be updated by providing the new values within the query string. 15 | // Endpoint: /v1/host//sync?key=&ipv4=&ipv6= 16 | IngestModeGetParameter ingest.ModeType = "getParameter" 17 | 18 | // IngestModeRemoteAddress sets the ingest mode to "remote address". 19 | // 20 | // Instead of using values from the query string, this mode uses the remote address which was provided by the operating system's TCP/IP stack. 21 | // This might cause issues if you're running dyndns-pdns behind a reverse proxy. 22 | // Endpoint: /v1/host//sync?key= 23 | IngestModeRemoteAddress ingest.ModeType = "remoteAddress" 24 | ) 25 | 26 | // Config contains the primary configuration structure of the application. 27 | type Config struct { 28 | // Server contains the server configuration structure. 29 | Server Server `mapstructure:"server"` 30 | 31 | // Type sets the particular DNS provider type. 32 | // 33 | // Example: powerDNS 34 | DNSProviderType dnsprovider.Type `mapstructure:"dnsProviderType"` 35 | 36 | // PowerDNS sets the particular PowerDNS configuration, if the Type was set to "powerDNS". 37 | PowerDNS powerdns.PowerDNS `mapstructure:"powerDNS"` 38 | 39 | // KeyTable sets a list of host configurations. 40 | KeyTable []Key `mapstructure:"keyTable"` 41 | } 42 | 43 | // Server defines the structure of the server configuration. 44 | type Server struct { 45 | // ListenAddress defines the local listener of the dyndns-pdns server. 46 | // 47 | // Example: 127.0.0.1:8000 48 | ListenAddress string `mapstructure:"listenaddress"` 49 | 50 | // TLS defines the particular TLS configuration of the dyndns-pdns server. 51 | TLS TLS `mapstructure:"tls"` 52 | } 53 | 54 | // TLS defines the structure of the TLS configuration. 55 | type TLS struct { 56 | // Enable toggles the TLS mode off and on. 57 | Enable bool `mapstructure:"enable"` 58 | 59 | // CertFile is an absolute or relative path to the certificate file. 60 | CertFile string `mapstructure:"certFile"` 61 | 62 | // KeyFile is an absolute or relative path to the key file. 63 | KeyFile string `mapstructure:"keyFile"` 64 | } 65 | 66 | // Key defines the structure of a certain key item. 67 | type Key struct { 68 | // Name is a human-friendly name of the particular host. 69 | // 70 | // Example: homeRouter 71 | Name string `mapstructure:"name"` 72 | 73 | // Enable toggles updates for the host off and on. 74 | Enable bool `mapstructure:"enable"` 75 | 76 | // Key contains the password for the device, which is required in order to update IP addresses. 77 | Key string `mapstructure:"key"` 78 | 79 | // HostName contains the host part of the maintained resource record. 80 | // 81 | // The device will be reachable via .. 82 | // Example: home-router 83 | HostName string `mapstructure:"hostName"` 84 | 85 | // Mode specifies the ingest mode. 86 | // 87 | // Example: getParameter 88 | IngestMode ingest.ModeType `mapstructure:"ingestMode"` 89 | 90 | // CleanUpMode specifies the clean up mode. 91 | // 92 | // Example: any 93 | CleanUpMode CleanUpModeType `mapstructure:"cleanUpMode"` 94 | 95 | // TTL overrides the default TTL value in seconds. 96 | // 97 | // Example: 5 98 | TTL uint32 `mapstructure:"ttl"` 99 | } 100 | 101 | // CleanUpModeType defines the clean up mode type. 102 | type CleanUpModeType string 103 | 104 | const ( 105 | // CleanUpModeAny removes both A and AAAA resource records for the particular key item, even if only one IP address type was requested. 106 | // 107 | // Example: If an IPv4 address is given, this cleans all existing A (IPv4) and AAAA (IPv6) resource records. 108 | CleanUpModeAny CleanUpModeType = "any" 109 | 110 | // CleanUpModeRequestBased removes only the existing resource record which corresponds to the requested IP address type. 111 | // 112 | // Example: If an IPv4 address is given, this cleans only the existing A (IPv4) resource record while keeping the corresponding AAAA (IPv6) rersource record untouched. 113 | CleanUpModeRequestBased CleanUpModeType = "requested" 114 | ) 115 | 116 | // C initializes the primary configuration of the application. 117 | var C Config 118 | 119 | // ParseConfig loads a YAML configuration file 120 | func ParseConfig(config *Config, configFile *string) { 121 | viper.SetConfigFile(*configFile) 122 | if err := viper.ReadInConfig(); err != nil { 123 | panic(fmt.Errorf("%s", err)) 124 | } 125 | if err := viper.Unmarshal(&config); err != nil { 126 | panic(fmt.Errorf("%s", err)) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/asaskevich/govalidator v0.0.0-20180315120708-ccb8e960c48f h1:y2hSFdXeA1y5z5f0vfNO0Dg5qVY036qzlz3Pds0B92o= 2 | github.com/asaskevich/govalidator v0.0.0-20180315120708-ccb8e960c48f/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= 3 | github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= 4 | github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= 5 | github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= 6 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 7 | github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= 8 | github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 9 | github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= 10 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 14 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 16 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 17 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 18 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 19 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= 20 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 21 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 22 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 23 | github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= 24 | github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 25 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 26 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 27 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 28 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 29 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 30 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 31 | github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= 32 | github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 33 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 34 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 35 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 36 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 37 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 38 | github.com/google/jsonapi v1.0.0 h1:qIGgO5Smu3yJmSs+QlvhQnrscdZfFhiV6S8ryJAglqU= 39 | github.com/google/jsonapi v1.0.0/go.mod h1:YYHiRPJT8ARXGER8In9VuLv4qvLfDmA9ULQqptbLE4s= 40 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 41 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 42 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 43 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 44 | github.com/jarcoal/httpmock v1.0.4 h1:jp+dy/+nonJE4g4xbVtl9QdrUNbn6/3hDT5R4nDIZnA= 45 | github.com/jarcoal/httpmock v1.0.4/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= 46 | github.com/joeig/go-powerdns/v2 v2.5.0 h1:m/Ly+U8CegZjjibMMHr4AeVh/iay9lcoGGsKFrc9Wc4= 47 | github.com/joeig/go-powerdns/v2 v2.5.0/go.mod h1:VgLq0WK8knYT+c6RcD5dB/L3LUvUXHNnGZp/nmSwJBk= 48 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 49 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 50 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 51 | github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 52 | github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 53 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 54 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 55 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 56 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 57 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 58 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 59 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 60 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 61 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 62 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 63 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 64 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 65 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 66 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 67 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 68 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 69 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 70 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 71 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= 72 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 73 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 74 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 75 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 76 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 77 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 78 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= 79 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 80 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 81 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 82 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 83 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 84 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 85 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 86 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= 87 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 88 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 89 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 90 | github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= 91 | github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= 92 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 93 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 94 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 95 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 96 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 97 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 98 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 99 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 100 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 101 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 102 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 103 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 104 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 105 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 106 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 107 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 108 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 109 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 110 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 111 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 112 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 113 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 114 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 115 | golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= 116 | golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 117 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 118 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 119 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= 120 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= 121 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 122 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 123 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 124 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 125 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 126 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 127 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 128 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 129 | google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= 130 | google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 131 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 132 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 133 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 134 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 135 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 136 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 137 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 138 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 139 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 140 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 141 | --------------------------------------------------------------------------------