diff --git a/.gitignore b/.gitignore index 621dce7..5ef7cc6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ *.swp -pdns.conf -pdns_backend.py -ddns +/docker/docker-compose.*.yml +/ddns dump.rdb diff --git a/LICENSE b/LICENSE index 8ad7ff4..38c0e80 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2014 Philipp Böhm +Copyright (c) 2018 Philipp Böhm Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index b24c227..6f6aced 100644 --- a/README.md +++ b/README.md @@ -1,109 +1,86 @@ -ddns -==== +# `ddns` - Dynamic DNS A self-hosted Dynamic DNS solution similar to DynDNS or NO-IP. -You can use a hosted version at [ddns.pboehm.org](http://ddns.pboehm.org/) where you can register a host under the `d.pboehm.de` domain (e.g `test.d.pboehm.de`). +You can use a hosted version at [ddns.pboehm.de](https://ddns.pboehm.de/) where you can register a +host under the `d.pboehm.de` domain (e.g `test.d.pboehm.de`). + +**Recent Changes** + +`ddns` has been massively restructured and refactored and now uses the PowerDNS +[Remote Backend](https://doc.powerdns.com/md/authoritative/backend-remote/) instead +of the [Pipe Backend](https://doc.powerdns.com/md/authoritative/backend-pipe/), which +is far easier to deploy. It now serves both the frontend and the backend other HTTP using different ports. + +The old `ddns` source code can be found at the [legacy](https://github.com/pboehm/ddns/tree/legacy) tag. ## How can I update my IP if it changes? -`ddns` is built around a small webservice, so that you can update your IP address, simply by calling an URL periodically through `curl` (using `cron`). Hosts that haven't been updated for 10 days will be automatically removed. This can be configured in your own instance. +`ddns` is built around a small webservice, so that you can update your IP address simply by calling +an URL periodically through `curl`. Hosts that haven't been updated for 10 days will +be automatically removed. This can be configured in your own instance. -An API similar to DynDNS/NO-IP hasn't been implemented yet. +An API similar to DynDNS/NO-IP has not been implemented yet. ## Self-Hosting ### Requirements -* A custom domain where the registrar allows NS-Records for subdomains -* A global accessible Server running an OS which is supported by the tools listed below -* A running [Redis](http://redis.io) instance for data storage -* An installation of [PowerDNS](https://www.powerdns.com/) with the Pipe-Backend included -* [Go](http://golang.org/) 1.3 +* A custom domain where the registrar allows setting `NS` records for subdomains. This is important because not all + DNS providers support this. +* A server with [docker](https://www.docker.com/) and [docker-compose](https://docs.docker.com/compose/) installed +* The following ports should be opened in the firewall: + * `53/udp` + * `80/tcp` + * `443/tcp` -### Installation +### DNS-Setup -The following instructions are valid for Ubuntu/Debian. Some files/packages -could have other names/locations, please search for it. +For the domain you want to use with `ddns` (`example.net` in the following sections, please adjust this to your domain) +you have to create the following two DNS records: -You should have a working Go environment (correct `$GOPATH`). +* `ddns.example.net` as a `CNAME` or `A`/`AAAA` record pointing to the server `ddns` will be running on. This record + will be used for accessing the `ddns` frontend in your browser or via `curl`. It is also the target for the + corresponding `NS` record. +* `d.example.net` as an `NS` record pointing to the previously created `ddns.example.net` record. This will delegate + all subdomains under `d.example.net` to the PowerDNS server running on `ddns.example.net`. - $ go version # check that you have go 1.3 installed - go version go1.3 linux/amd64 +### `ddns`-Setup -Then install `ddns` via: +Setting up `ddns` was kind of a hassle in the legacy version, because there are multiple components that have to +work together: - $ go get github.com/pboehm/ddns - $ ls $GOPATH/bin/ddns # the displayed path will be used later - /home/user/gocode/bin/ddns +* `ddns` that runs the frontend and provides and provides an API compatible with the + [Remote Backend](https://doc.powerdns.com/md/authoritative/backend-remote/) +* Redis as storage backend for `ddns` +* PowerDNS as DNS server, which uses the `ddns` backend API on Port `8053` +* A web server that makes the `ddns` frontend accessible to the Internet through HTTPS -#### Backend +The setup is now automated using [docker-compose](https://docs.docker.com/compose/) and only some customization has +to be made in a `docker-compose.override.yml` file. -Install `pdns` and `redis-server`: +#### Configuring the Setup - $ sudo apt-get install redis-server pdns-server pdns-backend-pipe +The setup included in this repository contains all the components described above and uses +[caddy](https://caddyserver.com/) as a web server, because it provides automatic HTTPS using Lets Encrypt. -Both services should start at boot automatically. You should open `udp/53` and -`tcp/53` on your Firewall so that `pdns` can be be used from outside of your -host. +``` +git clone git@github.com:pboehm/ddns.git +cd ddns/docker +cp docker-compose.override.yml.sample docker-compose.override.yml +``` - $ sudo vim /etc/powerdns/pdns.d/pipe.conf +Please adjust the settings in `docker-compose.override.yml` marked with the `#<<< ....` comments as follows: -`pipe.conf` should have the following content. Please adjust the path of `ddns` -and the values supplied to `--domain` and `--soa_fqdn`: +* adjust the domain part in lines marked with `# <<< ADJUST DOMAIN` according to your DNS-Setup +* insert your email address in lines marked with `# <<< INSERT EMAIL` which is required for getting certificates + from Lets Encrypt +* adjust the path component before the `:` in lines marked with `# <<< ADJUST LOCAL PATH` if the shown path + does not meet your requirements - launch=pipe - pipebackend-abi-version=1 - pipe-command=/home/user/gocode/bin/ddns --soa_fqdn=dns.example.com --domain=sub.example.com backend +Finally execute the following `docker-compose` command, which creates 4 containers in detached mode which are also +started automatically after reboot. -Then restart `pdns`: - - $ sudo service pdns restart - -#### Frontend - -`ddns` includes a webservice which is used for creating new hosts and updating -ip addresses. I prefer using `nginx` as a reverse proxy and not running `ddns` -on port 80. As a process manager, I prefer using `supervisord` so it is -described here. - - $ sudo apt-get install nginx supervisor - -Create a supervisor config file for ddns: - - $ sudo vim /etc/supervisor/conf.d/ddns.conf - $ cat /etc/supervisor/conf.d/ddns.conf - [program:ddns] - directory = /tmp/ - user = user - command = /home/user/gocode/bin/ddns --domain=sub.example.com web - autostart = True - autorestart = True - redirect_stderr = True - -Restart the `supervisor` daemon and `ddns` listens on Port 8080 (can be -changed by adding `--listen=:1234`). - - $ sudo service supervisor restart - -Now you have to add a nginx virtual host for `ddns`: - - $ sudo vim /etc/nginx/sites-enabled/default - $ cat /etc/nginx/sites-enabled/default - server { - listen 80; - server_name ddns.example.com; - - location / { - proxy_pass http://127.0.0.1:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-for $proxy_add_x_forwarded_for; - proxy_connect_timeout 300; - } - } - -Please adjust the `server_name` with a valid FQDN. Now we only have to restart -`nginx`: - - $ sudo service nginx restart +``` +docker-compose --project-name ddns up -d +``` diff --git a/backend.go b/backend.go deleted file mode 100644 index b5b0620..0000000 --- a/backend.go +++ /dev/null @@ -1,99 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "os" - "strings" - "time" -) - -type responder func() - -func respondWithFAIL() { - fmt.Printf("FAIL\n") -} - -func respondWithEND() { - fmt.Printf("END\n") -} - -// This function implements the PowerDNS-Pipe-Backend protocol and generates -// the response data it possible -func RunBackend(conn *RedisConnection) { - bio := bufio.NewReader(os.Stdin) - - // handshake with PowerDNS - _, _, _ = bio.ReadLine() - fmt.Printf("OK\tDDNS Go Backend\n") - - for { - line, _, err := bio.ReadLine() - if err != nil { - respondWithFAIL() - continue - } - - HandleRequest(string(line), conn)() - } -} - -func HandleRequest(line string, conn *RedisConnection) responder { - if Verbose { - fmt.Printf("LOG\t'%s'\n", line) - } - - parts := strings.Split(line, "\t") - if len(parts) != 6 { - return respondWithFAIL - } - - query_name := parts[1] - query_class := parts[2] - query_type := parts[3] - query_id := parts[4] - - var response, record string - record = query_type - - switch query_type { - case "SOA": - response = fmt.Sprintf("%s. hostmaster.example.com. %d 1800 3600 7200 5", - DdnsSoaFqdn, getSoaSerial()) - - case "NS": - response = fmt.Sprintf("%s.", DdnsSoaFqdn) - - case "A": - case "ANY": - // get the host part of the fqdn: pi.d.example.org -> pi - hostname := "" - if strings.HasSuffix(query_name, DdnsDomain) { - hostname = query_name[:len(query_name)-len(DdnsDomain)] - } - - if hostname == "" || !conn.HostExist(hostname) { - return respondWithFAIL - } - - host := conn.GetHost(hostname) - response = host.Ip - - record = "A" - if !host.IsIPv4() { - record = "AAAA" - } - - default: - return respondWithFAIL - } - - fmt.Printf("DATA\t%s\t%s\t%s\t10\t%s\t%s\n", - query_name, query_class, record, query_id, response) - return respondWithEND -} - -func getSoaSerial() int64 { - // return current time in milliseconds - return time.Now().UnixNano() -} diff --git a/backend/backend.go b/backend/backend.go new file mode 100644 index 0000000..15457ab --- /dev/null +++ b/backend/backend.go @@ -0,0 +1,59 @@ +package backend + +import ( + "github.com/pboehm/ddns/shared" + "gopkg.in/gin-gonic/gin.v1" + "log" + "strings" +) + +type Backend struct { + config *shared.Config + lookup *HostLookup +} + +func NewBackend(config *shared.Config, lookup *HostLookup) *Backend { + return &Backend{ + config: config, + lookup: lookup, + } +} + +func (b *Backend) Run() error { + r := gin.New() + r.Use(gin.Recovery()) + + if b.config.Verbose { + r.Use(gin.Logger()) + } + + r.GET("/dnsapi/lookup/:qname/:qtype", func(c *gin.Context) { + request := &Request{ + QName: strings.TrimRight(c.Param("qname"), "."), + QType: c.Param("qtype"), + } + + response, err := b.lookup.Lookup(request) + if err == nil { + c.JSON(200, gin.H{ + "result": []*Response{response}, + }) + } else { + if b.config.Verbose { + log.Printf("Error during lookup: %v", err) + } + + c.JSON(200, gin.H{ + "result": false, + }) + } + }) + + r.GET("/dnsapi/getDomainMetadata/:name/:kind", func(c *gin.Context) { + c.JSON(200, gin.H{ + "result": false, + }) + }) + + return r.Run(b.config.ListenBackend) +} diff --git a/backend/lookup.go b/backend/lookup.go new file mode 100644 index 0000000..8c3dfe8 --- /dev/null +++ b/backend/lookup.go @@ -0,0 +1,96 @@ +package backend + +import ( + "errors" + "fmt" + "github.com/pboehm/ddns/shared" + "strings" + "time" +) + +type Request struct { + QType string + QName string + Remote string + Local string + RealRemote string + ZoneId string +} + +type Response struct { + QType string `json:"qtype"` + QName string `json:"qname"` + Content string `json:"content"` + TTL int `json:"ttl"` +} + +type HostLookup struct { + config *shared.Config + hosts shared.HostBackend +} + +func NewHostLookup(config *shared.Config, hostsBackend shared.HostBackend) *HostLookup { + return &HostLookup{config, hostsBackend} +} + +func (l *HostLookup) Lookup(request *Request) (*Response, error) { + responseRecord := request.QType + responseContent := "" + + switch request.QType { + case "SOA": + responseContent = fmt.Sprintf("%s. hostmaster%s. %d 1800 3600 7200 5", + l.config.SOAFqdn, l.config.Domain, l.currentSOASerial()) + + case "NS": + responseContent = l.config.SOAFqdn + + case "A", "AAAA", "ANY": + hostname, err := l.extractHostname(request.QName) + if err != nil { + return nil, err + } + + var host *shared.Host + if host, err = l.hosts.GetHost(hostname); err != nil { + return nil, err + } + + responseContent = host.Ip + + responseRecord = "A" + if !host.IsIPv4() { + responseRecord = "AAAA" + } + + if (request.QType == "A" || request.QType == "AAAA") && request.QType != responseRecord { + return nil, errors.New("IP address is not valid for requested record") + } + + default: + return nil, errors.New("Invalid request") + } + + return &Response{QType: responseRecord, QName: request.QName, Content: responseContent, TTL: 5}, nil +} + +// extractHostname extract the host part of the fqdn: pi.d.example.org -> pi +func (l *HostLookup) extractHostname(rawQueryName string) (string, error) { + queryName := strings.ToLower(rawQueryName) + + hostname := "" + if strings.HasSuffix(queryName, l.config.Domain) { + hostname = queryName[:len(queryName)-len(l.config.Domain)] + } + + if hostname == "" { + return "", errors.New("Query name does not correspond to our domain") + } + + return hostname, nil +} + +// currentSOASerial get the current SOA serial by returning the current time in seconds +func (l *HostLookup) currentSOASerial() int64 { + return time.Now().Unix() +} diff --git a/backend/lookup_test.go b/backend/lookup_test.go new file mode 100644 index 0000000..7fb52ce --- /dev/null +++ b/backend/lookup_test.go @@ -0,0 +1,153 @@ +package backend + +import ( + "errors" + "github.com/pboehm/ddns/shared" + "github.com/stretchr/testify/assert" + "testing" +) + +type testHostBackend struct { + hosts map[string]*shared.Host +} + +func (b *testHostBackend) GetHost(hostname string) (*shared.Host, error) { + host, ok := b.hosts[hostname] + if ok { + return host, nil + } else { + return nil, errors.New("Host not found") + } +} + +func (b *testHostBackend) SetHost(host *shared.Host) error { + b.hosts[host.Hostname] = host + return nil +} + +func buildLookup(domain string) (*shared.Config, *testHostBackend, *HostLookup) { + config := &shared.Config{ + Verbose: false, + Domain: domain, + SOAFqdn: "dns" + domain, + } + + hosts := &testHostBackend{ + hosts: map[string]*shared.Host{ + "www": { + Hostname: "www", + Ip: "10.11.12.13", + Token: "abcdef", + }, + "v4": { + Hostname: "v4", + Ip: "10.10.10.10", + Token: "ghijkl", + }, + "v6": { + Hostname: "v6", + Ip: "2001:db8:85a3::8a2e:370:7334", + Token: "ghijkl", + }, + }, + } + + return config, hosts, &HostLookup{config, hosts} +} + +func buildRequest(queryName, queryType string) *Request { + return &Request{ + QType: queryType, + QName: queryName, + } +} + +func TestRequestHandling(t *testing.T) { + _, _, lookup := buildLookup(".example.org") + + response, err := lookup.Lookup(buildRequest("example.org", "SOA")) + assert.Nil(t, err) + assert.NotNil(t, response) + assert.Equal(t, "SOA", response.QType) + assert.Equal(t, "example.org", response.QName) + assert.Regexp(t, "dns\\.example\\.org\\. hostmaster\\.example.org\\. \\d+ 1800 3600 7200 5", response.Content) + assert.Equal(t, 5, response.TTL) + + response, err = lookup.Lookup(buildRequest("example.org", "NS")) + assert.Nil(t, err) + assert.NotNil(t, response) + assert.Equal(t, "NS", response.QType) + assert.Equal(t, "example.org", response.QName) + assert.Equal(t, "dns.example.org", response.Content) + assert.Equal(t, 5, response.TTL) + + response, err = lookup.Lookup(buildRequest("www.example.org", "ANY")) + assert.Nil(t, err) + assert.NotNil(t, response) + assert.Equal(t, "A", response.QType) + assert.Equal(t, "www.example.org", response.QName) + assert.Equal(t, "10.11.12.13", response.Content) + assert.Equal(t, 5, response.TTL) + + response, err = lookup.Lookup(buildRequest("www.example.org", "A")) + assert.Nil(t, err) + assert.NotNil(t, response) + assert.Equal(t, "A", response.QType) + assert.Equal(t, "www.example.org", response.QName) + assert.Equal(t, "10.11.12.13", response.Content) + assert.Equal(t, 5, response.TTL) + + // Allow hostname to be mixed case which is used by Let's Encrypt for a little bit more security + response, err = lookup.Lookup(buildRequest("wWW.eXaMPlE.oRg", "A")) + assert.Nil(t, err) + assert.NotNil(t, response) + assert.Equal(t, "A", response.QType) + assert.Equal(t, "wWW.eXaMPlE.oRg", response.QName) + assert.Equal(t, "10.11.12.13", response.Content) + assert.Equal(t, 5, response.TTL) + + response, err = lookup.Lookup(buildRequest("notexisting.example.org", "A")) + assert.NotNil(t, err) + assert.Nil(t, response) + + // Correct Handling of IPv4/IPv6 and ANY/A/AAAA + response, err = lookup.Lookup(buildRequest("v4.example.org", "ANY")) + assert.Nil(t, err) + assert.NotNil(t, response) + assert.Equal(t, "A", response.QType) + assert.Equal(t, "v4.example.org", response.QName) + assert.Equal(t, "10.10.10.10", response.Content) + assert.Equal(t, 5, response.TTL) + + response, err = lookup.Lookup(buildRequest("v4.example.org", "A")) + assert.Nil(t, err) + assert.NotNil(t, response) + assert.Equal(t, "A", response.QType) + assert.Equal(t, "v4.example.org", response.QName) + assert.Equal(t, "10.10.10.10", response.Content) + assert.Equal(t, 5, response.TTL) + + response, err = lookup.Lookup(buildRequest("v4.example.org", "AAAA")) + assert.NotNil(t, err) + assert.Nil(t, response) + + response, err = lookup.Lookup(buildRequest("v6.example.org", "ANY")) + assert.Nil(t, err) + assert.NotNil(t, response) + assert.Equal(t, "AAAA", response.QType) + assert.Equal(t, "v6.example.org", response.QName) + assert.Equal(t, "2001:db8:85a3::8a2e:370:7334", response.Content) + assert.Equal(t, 5, response.TTL) + + response, err = lookup.Lookup(buildRequest("v6.example.org", "AAAA")) + assert.Nil(t, err) + assert.NotNil(t, response) + assert.Equal(t, "AAAA", response.QType) + assert.Equal(t, "v6.example.org", response.QName) + assert.Equal(t, "2001:db8:85a3::8a2e:370:7334", response.Content) + assert.Equal(t, 5, response.TTL) + + response, err = lookup.Lookup(buildRequest("v6.example.org", "A")) + assert.NotNil(t, err) + assert.Nil(t, response) +} diff --git a/ddns.go b/ddns.go index 515d64b..ea125a8 100644 --- a/ddns.go +++ b/ddns.go @@ -1,92 +1,37 @@ package main import ( - "flag" + "github.com/pboehm/ddns/backend" + "github.com/pboehm/ddns/frontend" + "github.com/pboehm/ddns/shared" + "golang.org/x/sync/errgroup" "log" - "strings" ) -func HandleErr(err error) { - if err != nil { - log.Fatal(err) - } -} - -const ( - CmdBackend string = "backend" - CmdWeb string = "web" -) - -var ( - DdnsDomain string - DdnsWebListenSocket string - DdnsRedisHost string - DdnsSoaFqdn string - Verbose bool -) +var serviceConfig *shared.Config = &shared.Config{} func init() { - flag.StringVar(&DdnsDomain, "domain", "", - "The subdomain which should be handled by DDNS") - - flag.StringVar(&DdnsWebListenSocket, "listen", ":8080", - "Which socket should the web service use to bind itself") - - flag.StringVar(&DdnsRedisHost, "redis", ":6379", - "The Redis socket that should be used") - - flag.StringVar(&DdnsSoaFqdn, "soa_fqdn", "", - "The FQDN of the DNS server which is returned as a SOA record") - - flag.BoolVar(&Verbose, "verbose", false, - "Be more verbose") -} - -func ValidateCommandArgs(cmd string) { - if DdnsDomain == "" { - log.Fatal("You have to supply the domain via --domain=DOMAIN") - } else if !strings.HasPrefix(DdnsDomain, ".") { - // get the domain in the right format - DdnsDomain = "." + DdnsDomain - } - - if cmd == CmdBackend { - if DdnsSoaFqdn == "" { - log.Fatal("You have to supply the server FQDN via --soa_fqdn=FQDN") - } - } -} - -func PrepareForExecution() string { - flag.Parse() - - if len(flag.Args()) != 1 { - usage() - } - cmd := flag.Args()[0] - - ValidateCommandArgs(cmd) - return cmd + serviceConfig.Initialize() } func main() { - cmd := PrepareForExecution() + serviceConfig.Validate() - conn := OpenConnection(DdnsRedisHost) - defer conn.Close() + redis := shared.NewRedisBackend(serviceConfig) + defer redis.Close() - switch cmd { - case CmdBackend: - log.Printf("Starting PDNS Backend\n") - RunBackend(conn) - case CmdWeb: - log.Printf("Starting Web Service\n") - RunWebService(conn) - default: - usage() + var group errgroup.Group + + group.Go(func() error { + lookup := backend.NewHostLookup(serviceConfig, redis) + return backend.NewBackend(serviceConfig, lookup).Run() + }) + + group.Go(func() error { + return frontend.NewFrontend(serviceConfig, redis).Run() + }) + + if err := group.Wait(); err != nil { + log.Fatal(err) } } - -func usage() { - log.Fatal("Usage: ./ddns [backend|web]") -} diff --git a/docker/caddy/Caddyfile b/docker/caddy/Caddyfile new file mode 100644 index 0000000..d25a6c0 --- /dev/null +++ b/docker/caddy/Caddyfile @@ -0,0 +1,6 @@ +{$DDNS_CADDY_DOMAIN} { + tls {$DDNS_CADDY_TLS_EMAIL} + proxy / {$DDNS_FRONTEND_HOST} { + transparent + } +} \ No newline at end of file diff --git a/docker/ddns/Dockerfile b/docker/ddns/Dockerfile new file mode 100644 index 0000000..8105f09 --- /dev/null +++ b/docker/ddns/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.9-alpine3.7 + +RUN apk add --no-cache git + +WORKDIR /go/src/github.com/pboehm/ddns +COPY . . + +RUN go-wrapper download # "go get -d -v ./..." +RUN go-wrapper install # "go install -v ." + +ENV GIN_MODE release + +CMD /go/bin/ddns --domain=${DDNS_DOMAIN} --soa_fqdn=${DDNS_SOA_DOMAIN} --redis=${DDNS_REDIS_HOST} diff --git a/docker/docker-compose.override.yml.sample b/docker/docker-compose.override.yml.sample new file mode 100644 index 0000000..6124c58 --- /dev/null +++ b/docker/docker-compose.override.yml.sample @@ -0,0 +1,31 @@ +version: '2' + +services: + ddns: + environment: + DDNS_DOMAIN: d.example.net # <<< ADJUST DOMAIN + DDNS_SOA_DOMAIN: ddns.example.net # <<< ADJUST DOMAIN + + powerdns: + ports: + - "53/udp:53/udp" + + redis: + volumes: + - "/root/ddns-redis:/data" # <<< ADJUST LOCAL PATH + + caddy: + restart: unless-stopped + image: abiosoft/caddy:latest + depends_on: + - ddns + environment: + DDNS_FRONTEND_HOST: ddns:8080 + DDNS_CADDY_DOMAIN: ddns.example.net # <<< ADJUST DOMAIN + DDNS_CADDY_TLS_EMAIL: changeme@example.net # <<< INSERT EMAIL + volumes: + - "${PWD}/caddy/Caddyfile:/etc/Caddyfile" + - "/root/ddns-caddy:/root/.caddy" # <<< ADJUST LOCAL PATH + ports: + - "80:80" + - "443:443" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..0969eb1 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,28 @@ +version: '2' + +services: + ddns: + restart: unless-stopped + build: + context: .. + dockerfile: docker/ddns/Dockerfile + depends_on: + - redis + environment: + DDNS_DOMAIN: d.example.net + DDNS_SOA_DOMAIN: ns.example.net + DDNS_REDIS_HOST: redis:6379 + + powerdns: + restart: unless-stopped + build: + context: powerdns/ + dockerfile: Dockerfile + depends_on: + - ddns + environment: + PDNS_REMOTE_HTTP_HOST: "ddns:8053" + + redis: + restart: unless-stopped + image: redis:4-alpine \ No newline at end of file diff --git a/docker/powerdns/Dockerfile b/docker/powerdns/Dockerfile new file mode 100644 index 0000000..48777e1 --- /dev/null +++ b/docker/powerdns/Dockerfile @@ -0,0 +1,19 @@ +FROM buildpack-deps:jessie-scm + +# the setup procedure according to https://repo.powerdns.com/ (Debian 8 Jessie) +RUN echo "deb http://repo.powerdns.com/debian jessie-auth-41 main" > /etc/apt/sources.list.d/pdns.list \ + && echo "Package: pdns-*\nPin: origin repo.powerdns.com\nPin-Priority: 600\n" >> /etc/apt/preferences.d/pdns \ + && curl https://repo.powerdns.com/FD380FBB-pub.asc | apt-key add - \ + && apt-get -y update \ + && apt-get install -y pdns-server pdns-backend-remote \ + && rm -rf /var/lib/apt/lists/* + +COPY pdns.conf /etc/powerdns/pdns.conf + +COPY entrypoint.sh / +RUN chmod +x /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] + +EXPOSE 53 + +CMD ["pdns_server", "--daemon=no"] \ No newline at end of file diff --git a/docker/powerdns/entrypoint.sh b/docker/powerdns/entrypoint.sh new file mode 100644 index 0000000..da767ff --- /dev/null +++ b/docker/powerdns/entrypoint.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +CONFIG_FILE=/etc/powerdns/pdns.conf + +sed -i 's/{{PDNS_REMOTE_HTTP_HOST}}/'"${PDNS_REMOTE_HTTP_HOST}"'/g' ${CONFIG_FILE} + +exec "$@" \ No newline at end of file diff --git a/docker/powerdns/pdns.conf b/docker/powerdns/pdns.conf new file mode 100644 index 0000000..9d519cc --- /dev/null +++ b/docker/powerdns/pdns.conf @@ -0,0 +1,9 @@ +disable-tcp=yes +cache-ttl=0 +loglevel=7 +log-dns-details=yes +disable-axfr=yes + +launch=remote +remote-dnssec=no +remote-connection-string=http:url=http://{{PDNS_REMOTE_HTTP_HOST}}/dnsapi \ No newline at end of file diff --git a/web.go b/frontend/frontend.go similarity index 52% rename from web.go rename to frontend/frontend.go index fe9c18a..c00af83 100644 --- a/web.go +++ b/frontend/frontend.go @@ -1,49 +1,79 @@ -package main +package frontend import ( "fmt" - "github.com/gin-gonic/gin" + "github.com/pboehm/ddns/shared" + "gopkg.in/gin-gonic/gin.v1" "html/template" + "log" "net" "net/http" "regexp" ) -func RunWebService(conn *RedisConnection) { - r := gin.Default() - r.SetHTMLTemplate(BuildTemplate()) +type Frontend struct { + config *shared.Config + hosts shared.HostBackend +} + +func NewFrontend(config *shared.Config, hosts shared.HostBackend) *Frontend { + return &Frontend{ + config: config, + hosts: hosts, + } +} + +func (f *Frontend) Run() error { + r := gin.New() + r.Use(gin.Recovery()) + + if f.config.Verbose { + r.Use(gin.Logger()) + } + + r.SetHTMLTemplate(buildTemplate()) r.GET("/", func(g *gin.Context) { - g.HTML(200, "index.html", gin.H{"domain": DdnsDomain}) + g.HTML(200, "index.html", gin.H{"domain": f.config.Domain}) }) r.GET("/available/:hostname", func(c *gin.Context) { - hostname, valid := ValidHostname(c.Params.ByName("hostname")) + hostname, valid := isValidHostname(c.Params.ByName("hostname")) + + if valid { + _, err := f.hosts.GetHost(hostname) + valid = err != nil + } c.JSON(200, gin.H{ - "available": valid && !conn.HostExist(hostname), + "available": valid, }) }) r.GET("/new/:hostname", func(c *gin.Context) { - hostname, valid := ValidHostname(c.Params.ByName("hostname")) + hostname, valid := isValidHostname(c.Params.ByName("hostname")) if !valid { c.JSON(404, gin.H{"error": "This hostname is not valid"}) return } - if conn.HostExist(hostname) { + var err error + + if h, err := f.hosts.GetHost(hostname); err == nil { c.JSON(403, gin.H{ - "error": "This hostname has already been registered.", + "error": fmt.Sprintf("This hostname has already been registered. %v", h), }) return } - host := &Host{Hostname: hostname, Ip: "127.0.0.1"} + host := &shared.Host{Hostname: hostname, Ip: "127.0.0.1"} host.GenerateAndSetToken() - conn.SaveHost(host) + if err = f.hosts.SetHost(host); err != nil { + c.JSON(400, gin.H{"error": "Could not register host."}) + return + } c.JSON(200, gin.H{ "hostname": host.Hostname, @@ -53,7 +83,7 @@ func RunWebService(conn *RedisConnection) { }) r.GET("/update/:hostname/:token", func(c *gin.Context) { - hostname, valid := ValidHostname(c.Params.ByName("hostname")) + hostname, valid := isValidHostname(c.Params.ByName("hostname")) token := c.Params.ByName("token") if !valid { @@ -61,15 +91,14 @@ func RunWebService(conn *RedisConnection) { return } - if !conn.HostExist(hostname) { + host, err := f.hosts.GetHost(hostname) + if err != nil { c.JSON(404, gin.H{ "error": "This hostname has not been registered or is expired.", }) return } - host := conn.GetHost(hostname) - if host.Token != token { c.JSON(403, gin.H{ "error": "You have supplied the wrong token to manipulate this host", @@ -77,7 +106,7 @@ func RunWebService(conn *RedisConnection) { return } - ip, err := GetRemoteAddr(c.Request) + ip, err := extractRemoteAddr(c.Request) if err != nil { c.JSON(400, gin.H{ "error": "Your sender IP address is not in the right format", @@ -86,7 +115,11 @@ func RunWebService(conn *RedisConnection) { } host.Ip = ip - conn.SaveHost(host) + if err = f.hosts.SetHost(host); err != nil { + c.JSON(400, gin.H{ + "error": "Could not update registered IP address", + }) + } c.JSON(200, gin.H{ "current_ip": ip, @@ -94,13 +127,13 @@ func RunWebService(conn *RedisConnection) { }) }) - r.Run(DdnsWebListenSocket) + return r.Run(f.config.ListenFrontend) } // Get the Remote Address of the client. At First we try to get the // X-Forwarded-For Header which holds the IP if we are behind a proxy, // otherwise the RemoteAddr is used -func GetRemoteAddr(req *http.Request) (string, error) { +func extractRemoteAddr(req *http.Request) (string, error) { header_data, ok := req.Header["X-Forwarded-For"] if ok { @@ -112,14 +145,16 @@ func GetRemoteAddr(req *http.Request) (string, error) { } // Get index template from bindata -func BuildTemplate() *template.Template { +func buildTemplate() *template.Template { html, err := template.New("index.html").Parse(indexTemplate) - HandleErr(err) + if err != nil { + log.Fatal(err) + } return html } -func ValidHostname(host string) (string, bool) { +func isValidHostname(host string) (string, bool) { valid, _ := regexp.Match("^[a-z0-9]{1,32}$", []byte(host)) return host, valid diff --git a/template.go b/frontend/template.go similarity index 97% rename from template.go rename to frontend/template.go index 117402f..8c38d57 100644 --- a/template.go +++ b/frontend/template.go @@ -1,4 +1,4 @@ -package main +package frontend const indexTemplate string = ` @@ -168,7 +168,7 @@ const indexTemplate string = ` function validate() { var hostname = $('#hostname').val(); - $.getJSON("/available/" + hostname + "/", function( data ) { + $.getJSON("/available/" + hostname, function( data ) { if (data.available) { isValid(); } else { @@ -188,7 +188,7 @@ const indexTemplate string = ` $('#register').click(function() { var hostname = $("#hostname").val(); - $.getJSON("/new/" + hostname + "/", function( data ) { + $.getJSON("/new/" + hostname, function( data ) { console.log(data); var host = location.protocol + '//' + location.host; diff --git a/redis.go b/redis.go deleted file mode 100644 index 5f2c89f..0000000 --- a/redis.go +++ /dev/null @@ -1,100 +0,0 @@ -package main - -import ( - "crypto/sha1" - "fmt" - "github.com/garyburd/redigo/redis" - "strings" - "time" -) - -const HostExpirationSeconds int = 10 * 24 * 60 * 60 // 10 Days - -type RedisConnection struct { - *redis.Pool -} - -func OpenConnection(server string) *RedisConnection { - return &RedisConnection{newPool(server)} -} - -func newPool(server string) *redis.Pool { - return &redis.Pool{ - MaxIdle: 3, - - IdleTimeout: 240 * time.Second, - - Dial: func() (redis.Conn, error) { - c, err := redis.Dial("tcp", server) - if err != nil { - return nil, err - } - return c, err - }, - - TestOnBorrow: func(c redis.Conn, t time.Time) error { - _, err := c.Do("PING") - return err - }, - } -} - -func (self *RedisConnection) GetHost(name string) *Host { - conn := self.Get() - defer conn.Close() - - host := Host{Hostname: name} - - if self.HostExist(name) { - data, err := redis.Values(conn.Do("HGETALL", host.Hostname)) - HandleErr(err) - - HandleErr(redis.ScanStruct(data, &host)) - } - - return &host -} - -func (self *RedisConnection) SaveHost(host *Host) { - conn := self.Get() - defer conn.Close() - - _, err := conn.Do("HMSET", redis.Args{}.Add(host.Hostname).AddFlat(host)...) - HandleErr(err) - - _, err = conn.Do("EXPIRE", host.Hostname, HostExpirationSeconds) - HandleErr(err) -} - -func (self *RedisConnection) HostExist(name string) bool { - conn := self.Get() - defer conn.Close() - - exists, err := redis.Bool(conn.Do("EXISTS", name)) - HandleErr(err) - - return exists -} - -type Host struct { - Hostname string `redis:"-"` - Ip string `redis:"ip"` - Token string `redis:"token"` -} - -func (self *Host) GenerateAndSetToken() { - hash := sha1.New() - hash.Write([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))) - hash.Write([]byte(self.Hostname)) - - self.Token = fmt.Sprintf("%x", hash.Sum(nil)) -} - -// Returns true when this host has a IPv4 Address and false if IPv6 -func (self *Host) IsIPv4() bool { - if strings.Contains(self.Ip, ".") { - return true - } - - return false -} diff --git a/shared/config.go b/shared/config.go new file mode 100644 index 0000000..807e7f7 --- /dev/null +++ b/shared/config.go @@ -0,0 +1,55 @@ +package shared + +import ( + "flag" + "log" + "strings" +) + +type Config struct { + Verbose bool + Domain string + SOAFqdn string + HostExpirationDays int + ListenFrontend string + ListenBackend string + RedisHost string +} + +func (c *Config) Initialize() { + flag.StringVar(&c.Domain, "domain", "", + "The subdomain which should be handled by DDNS") + + flag.StringVar(&c.SOAFqdn, "soa_fqdn", "", + "The FQDN of the DNS server which is returned as a SOA record") + + flag.StringVar(&c.ListenBackend, "listen-backend", ":8053", + "Which socket should the backend web service use to bind itself") + + flag.StringVar(&c.ListenFrontend, "listen-frontend", ":8080", + "Which socket should the frontend web service use to bind itself") + + flag.StringVar(&c.RedisHost, "redis", ":6379", + "The Redis socket that should be used") + + flag.IntVar(&c.HostExpirationDays, "expiration-days", 10, + "The number of days after a host is released when it is not updated") + + flag.BoolVar(&c.Verbose, "verbose", false, + "Be more verbose") +} + +func (c *Config) Validate() { + flag.Parse() + + if c.Domain == "" { + log.Fatal("You have to supply the domain via --domain=DOMAIN") + } else if !strings.HasPrefix(c.Domain, ".") { + // get the domain in the right format + c.Domain = "." + c.Domain + } + + if c.SOAFqdn == "" { + log.Fatal("You have to supply the server FQDN via --soa_fqdn=FQDN") + } +} diff --git a/shared/hosts.go b/shared/hosts.go new file mode 100644 index 0000000..bfc3bf3 --- /dev/null +++ b/shared/hosts.go @@ -0,0 +1,36 @@ +package shared + +import ( + "crypto/sha1" + "fmt" + "strings" + "time" +) + +type Host struct { + Hostname string `redis:"-"` + Ip string `redis:"ip"` + Token string `redis:"token"` +} + +func (h *Host) GenerateAndSetToken() { + hash := sha1.New() + hash.Write([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))) + hash.Write([]byte(h.Hostname)) + + h.Token = fmt.Sprintf("%x", hash.Sum(nil)) +} + +func (h *Host) IsIPv4() bool { + if strings.Contains(h.Ip, ".") { + return true + } + + return false +} + +type HostBackend interface { + GetHost(string) (*Host, error) + + SetHost(*Host) error +} diff --git a/shared/redis.go b/shared/redis.go new file mode 100644 index 0000000..22badd6 --- /dev/null +++ b/shared/redis.go @@ -0,0 +1,80 @@ +package shared + +import ( + "errors" + "github.com/garyburd/redigo/redis" + "time" +) + +type RedisBackend struct { + expirationSeconds int + pool *redis.Pool +} + +func NewRedisBackend(config *Config) *RedisBackend { + return &RedisBackend{ + expirationSeconds: config.HostExpirationDays * 24 * 60 * 60, + pool: &redis.Pool{ + MaxIdle: 3, + IdleTimeout: 240 * time.Second, + + Dial: func() (redis.Conn, error) { + c, err := redis.Dial("tcp", config.RedisHost) + if err != nil { + return nil, err + } + return c, err + }, + + TestOnBorrow: func(c redis.Conn, t time.Time) error { + _, err := c.Do("PING") + return err + }, + }, + } +} + +func (r *RedisBackend) Close() { + r.pool.Close() +} + +func (r *RedisBackend) GetHost(name string) (*Host, error) { + conn := r.pool.Get() + defer conn.Close() + + host := Host{Hostname: name} + + var err error + var data []interface{} + + if data, err = redis.Values(conn.Do("HGETALL", host.Hostname)); err != nil { + return nil, err + } + + if len(data) == 0 { + return nil, errors.New("Host does not exist") + } + + if err = redis.ScanStruct(data, &host); err != nil { + return nil, err + } + + return &host, nil +} + +func (r *RedisBackend) SetHost(host *Host) error { + conn := r.pool.Get() + defer conn.Close() + + var err error + + if _, err = conn.Do("HMSET", redis.Args{}.Add(host.Hostname).AddFlat(host)...); err != nil { + return err + } + + if _, err = conn.Do("EXPIRE", host.Hostname, r.expirationSeconds); err != nil { + return err + } + + return nil +}