diff --git a/Dockerfile b/Dockerfile index 79c4016..8bd9859 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,9 @@ FROM golang:alpine +ARG COREDNS_VERSION=v1.0.5 + ADD ./build.sh /usr/local/bin/build.sh +ADD ./cron_generate.go /src/cron_generate.go RUN set -ex \ && apk --no-cache add git bash \ && bash /usr/local/bin/build.sh @@ -11,6 +14,11 @@ LABEL maintainer Knut Ahlers COPY --from=0 /go/bin/coredns /usr/local/bin/ +ADD ./requirements.txt /src/requirements.txt +RUN set -ex \ + && apk --no-cache add python3 \ + && pip3 install -r /src/requirements.txt + ADD . /src WORKDIR /src diff --git a/build.sh b/build.sh index 8e8c123..d3e2c54 100644 --- a/build.sh +++ b/build.sh @@ -1,25 +1,17 @@ #!/bin/bash set -euxo pipefail -IFS=$'\n' - -GOPKGS=( - 'github.com/coredns/coredns' - 'github.com/Luzifer/alias' -) - -for pkg in ${GOPKGS[@]}; do - go get -d -v "${pkg}" -done - -PLUGINS=( - '/^file:file/ i alias:github.com/Luzifer/alias' -) +# Download sourcecode +mkdir -p /go/src/github.com/coredns +git clone https://github.com/coredns/coredns.git /go/src/github.com/coredns/coredns +# Ensure version pinning cd /go/src/github.com/coredns/coredns -for insert in ${PLUGINS[@]}; do - sed -i "${insert}" plugin.cfg -done +git reset --hard ${COREDNS_VERSION} -go generate +# Copy cron drop-in +cp /src/cron_generate.go . + +# Get dependencies and build +go get -d -v go install diff --git a/cron_generate.go b/cron_generate.go new file mode 100644 index 0000000..6d69e42 --- /dev/null +++ b/cron_generate.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "os/exec" + "time" + + "github.com/Sirupsen/logrus" + "github.com/robfig/cron" +) + +func init() { + c := cron.New() + c.AddFunc("0 * * * * *", generateZonefiles) + c.Start() +} + +func generateZonefiles() { + logger := logrus.WithFields(logrus.Fields{ + "fkt": "generateZonefiles", + }) + + var ( + iw = logger.WriterLevel(logrus.InfoLevel) + ew = logger.WriterLevel(logrus.ErrorLevel) + ) + + defer iw.Close() + defer ew.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 59*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "/usr/bin/python", "generateZonefiles.py") + cmd.Stdout = iw + cmd.Stderr = ew + cmd.Dir = "/src" + + if err := cmd.Run(); err != nil { + logger.WithError(err).Error("Command execution failed") + } +} diff --git a/generateZonefiles.py b/generateZonefiles.py new file mode 100644 index 0000000..3093a82 --- /dev/null +++ b/generateZonefiles.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os + +# Third-party imports +import dns.resolver +import dns.rdatatype +import jinja2 +import yaml + +DEFAULT_TTL = 3600 + + +def default(d, key, default=None): + if key in d: + return d[key] + return default + + +def resolve_alias(entry): + result = [] + + answers = [] + answers.extend(dns.resolver.query( + entry['alias'], 'A', raise_on_no_answer=False)) + answers.extend(dns.resolver.query( + entry['alias'], 'AAAA', raise_on_no_answer=False)) + + if len(answers) == 0: + raise Exception( + "Alias {} was not resolvable: No answers!".format(entry['alias'])) + + for rdata in answers: + new_entry = entry.copy() + del new_entry['alias'] + new_entry['type'] = dns.rdatatype.to_text(rdata.rdtype) + new_entry['data'] = rdata.address + result.append(new_entry) + + return result + + +def sanitize(entry): + result = [] + + if entry['name'] == '': + entry['name'] = '@' + + if 'alias' in entry: + return resolve_alias(entry) + + for rr in entry['records']: + new_entry = entry.copy() + del new_entry['records'] + new_entry['data'] = rr + + if new_entry['type'] == 'TXT' and new_entry['data'][0] != '"': + new_entry['data'] = '"{}"'.format(new_entry['data']) + + result.append(new_entry) + + return result + + +def write_zone(zone, ttl, soa, nameserver, mailserver, entries): + tpl = jinja2.Template(open("zone_template.j2").read()) + zone_content = tpl.render({ + "zone": zone, + "ttl": ttl, + "soa": soa, + "nameserver": nameserver, + "mailserver": mailserver, + "entries": entries, + }) + + with open("zones/tmp.{}".format(zone), 'w') as zf: + zf.write(zone_content) + + # FIXME (kahlers): Check if contents changed + os.rename("zones/tmp.{}".format(zone), "zones/db.{}".format(zone)) + + +def main(): + zone_data = yaml.load(open("zones.yml")) + + for zone, config in zone_data['zones'].items(): + ttl = default(config, "default_ttl", DEFAULT_TTL) + + entries = [] + for entry in config['entries']: + entries.extend(sanitize(entry)) + + write_zone(zone, ttl, zone_data['soa'], + zone_data['nameserver'], default(config, 'mailserver', {}), entries) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bed4974 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +jinja2 +PyYAML +dnspython diff --git a/zone_template.j2 b/zone_template.j2 new file mode 100644 index 0000000..87ac3a9 --- /dev/null +++ b/zone_template.j2 @@ -0,0 +1,18 @@ +; Auto-generated using generateZonefiles.py + +$ORIGIN {{ zone }}. +$TTL {{ ttl }} + +{{ zone }}. {{ ttl }} IN SOA {{ soa.auth_ns }} {{ soa.contact }} {{ soa.serial }} {{ soa.refresh }} {{ soa.retry }} {{ soa.expire }} {{ soa.ttl }} + +{% for ns in nameserver -%} +{{ zone }}. {{ ttl }} IN NS {{ ns }} +{% endfor %} +{%- if mailserver | length > 0 %} +{% for mailserver, weight in mailserver.items() -%} +{{ zone }}. {{ ttl }} IN MX {{ weight }} {{ mailserver }} +{% endfor -%} +{%- endif %} +{% for entry in entries -%} +{{ entry.name }} {{ entry.ttl | default(ttl) }} {{ entry.class | default('IN') }} {{ entry.type }} {% if entry.weight %}{{ entry.weight }} {% endif %}{{ entry.data }} +{% endfor -%}