Shovel in the DNS automation machinery

This commit is contained in:
Reid 'arrdem' McKenzie 2021-05-08 12:36:29 -06:00
commit 0654ae2847
17 changed files with 420 additions and 0 deletions

View file

@ -0,0 +1,8 @@
.*
3rdparty
src
test
README.md
pants
pants.ini
etc

View file

@ -0,0 +1,9 @@
py_binary(
name = "updater",
entry_point = "src/python/arrdem/updater/__main__.py",
deps = [
"//projects/gandi",
py_requirement("jinja2"),
py_requirement("pyyaml"),
]
)

View file

View file

@ -0,0 +1,23 @@
#!/bin/bash
function pants2docker() {
# $1 MUST BE a pants target including : final component
./pants -q binary "$1"
f=$(mktemp)
target=$(echo "$1" | awk -F: '{print $2; exit}')
pex="${target}.pex"
sed "s/\$pex/$pex/g" "${DOCKERFILE:-src/docker/pex.docker}" > "${f}"
# Build with tags
t="arrdem.${target}:latest"
rt="registry.apartment.arrdem.com:5000/${t}"
# FIXME (arrdem 2019-09-17):
# Create a tag for the SHA and short SHA of the current ref if clean
docker build -f $f -t "${t}" -t "${rt}" $gt .
docker push "${rt}"
}
DOCKERFILE=src/docker/arrdem/updater/Dockerfile pants2docker src/python/arrdem/updater:updater
sudo docker stack deploy --compose-file ./src/docker/arrdem/updater/docker-compose.yml arrdem_updater

View file

@ -0,0 +1,4 @@
FROM python:3.7
COPY "./dist/$pex" "/app/$pex"
WORKDIR /app
CMD ["/app/$pex", "--config", "/run/secrets/public-dns.yml"]

View file

@ -0,0 +1,20 @@
version: '3.7'
services:
cron:
image: registry.apartment.arrdem.com:5000/arrdem.updater:latest
deploy:
replicas: 0
restart_policy:
condition: none
labels:
- swarm.cronjob.enable=true
- "swarm.cronjob.schedule=0 */10 * * * *"
- swarm.cronjob.skip-running=false
- node.platform.arch == x86_64
secrets:
- public-dns.yml
secrets:
public-dns.yml:
file: ../../../../config.yml

View file

@ -0,0 +1,12 @@
python_binary(
name='updater',
source='__main__.py',
dependencies=[
"//src/resources/zonefiles",
"//src/python/gandi",
"//3rdparty/python:requests",
"//3rdparty/python:jinja2",
"//3rdparty/python:PyYAML",
"//3rdparty/python:meraki",
]
)

View file

@ -0,0 +1,176 @@
"""
A quick and dirty public DNS script, super tightly coupled to my infrastructure.
"""
import argparse
import re
from pprint import pprint
import jinja2
import pkg_resources
import yaml
from gandi.client import GandiAPI
from meraki import meraki
RECORD_LINE_PATTERN = re.compile(
"^(?P<rrset_name>\S+)\s+"
"(?P<rrset_ttl>\S+)\s+"
"IN\s+"
"(?P<rrset_type>\S+)\s+"
"(?P<rrset_values>.+)$")
def update(m, k, f, *args, **kwargs):
"""clojure.core/update for Python's stateful maps."""
if k in m:
m[k] = f(m[k], *args, **kwargs)
return m
def parse_zone_record(line):
match = RECORD_LINE_PATTERN.search(line)
if match:
dat = match.groupdict()
dat = update(dat, "rrset_ttl", int)
dat = update(dat, "rrset_values", lambda x: [x])
return dat
def same_record(lr, rr):
"""
A test to see if two records name the same zone entry.
"""
return lr["rrset_name"] == rr["rrset_name"] and \
lr["rrset_type"] == rr["rrset_type"]
def records_equate(lr, rr):
"""
Equality, ignoring rrset_href which is generated by the API.
"""
if not same_record(lr, rr):
return False
elif lr["rrset_ttl"] != rr["rrset_ttl"]:
return False
elif set(lr["rrset_values"]) != set(rr["rrset_values"]):
return False
else:
return True
def template_and_parse_zone(template_name, template_bindings):
assert template_name is not None
assert template_bindings is not None
dat = pkg_resources.resource_string("zonefiles", template_name).decode("utf-8")
dat = jinja2.Template(dat).render(**template_bindings)
uncommitted_records = []
for line in dat.splitlines():
if line and not line[0] == "#":
record = parse_zone_record(line)
if record:
uncommitted_records.append(record)
records = []
for uncommitted_r in uncommitted_records:
flag = False
for committed_r in records:
if same_record(uncommitted_r, committed_r):
# Join the two records
committed_r["rrset_values"].extend(uncommitted_r["rrset_values"])
flag = True
if not flag:
records.append(uncommitted_r)
sorted(records, key=lambda x: (x["rrset_type"], x["rrset_name"]))
return records
def diff_zones(left_zone, right_zone):
"""
Equality between unordered lists of records constituting a zone.
"""
in_left_not_right = []
for lr in left_zone:
flag = False
for rr in right_zone:
if same_record(lr, rr) and records_equate(lr, rr):
flag = True
break
if not flag:
in_left_not_right.append(lr)
in_right_not_left = []
for rr in right_zone:
flag = False
for lr in left_zone:
if same_record(lr, rr) and records_equate(lr, rr):
flag = True
break
if not flag:
in_right_not_left.append(lr)
return in_left_not_right or in_right_not_left
parser = argparse.ArgumentParser(description="\"Dynamic\" DNS updating for self-hosted services")
parser.add_argument("--config", dest="config_file")
parser.add_argument("--dry-run", dest="dry", action="store_true", default=False)
def main():
args = parser.parse_args()
config = yaml.load(open(args.config_file, "r"))
uplinks = meraki.getdeviceuplink(config["meraki"]["key"],
config["meraki"]["network"],
config["meraki"]["router_serial"],
True)
template_bindings = {
"local": {
# One of the two
"public_v4s": [link.get("publicIp") for link in uplinks if link.get("publicIp")],
},
# Why isn't there a merge method
**config["bindings"]
}
api = GandiAPI(config["gandi"]["key"])
for task in config["tasks"]:
if isinstance(task, str):
task = {"template": task + ".j2",
"zones": [task]}
computed_zone = template_and_parse_zone(task["template"], template_bindings)
for zone_name in task["zones"]:
try:
live_zone = api.domain_records(zone_name)
if diff_zones(computed_zone, live_zone):
print("Zone {} differs, computed zone:".format(zone_name))
pprint(computed_zone)
if not args.dry:
print(api.replace_domain(zone_name, computed_zone))
else:
print("Zone {} up to date".format(zone_name))
except Exception as e:
print("While processing zone {}".format(zone_name))
raise e
if __name__ == "__main__" or 1:
main()

View file

@ -0,0 +1,4 @@
python_library(
name="zonefiles",
sources=globs("*.j2"),
)

View file

@ -0,0 +1,20 @@
# -*- mode: conf -*-
# Core records
{% for addr in local.public_v4s %}
@ {{ ttl }} IN A {{ addr }}
www {{ ttl }} IN A {{ addr }}
git {{ ttl }} IN A {{ addr }}
paperbrainz {{ ttl }} IN A {{ addr }}
{% endfor %}
# Service setup
@ 10800 IN MX 10 in1-smtp.messagingengine.com.
@ 10800 IN MX 20 in2-smtp.messagingengine.com.
mesmtp._domainkey 10800 IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC/arz66E8pNtYK+jWh57KkT+/bIp2iO9JEVb+9xqb9Z/R/l6zvFsUm5voKdhEJKsYkb35xpWJ699HbXWTENzWCLOvFxpPx8+MOh5i5gQDEg7P8vHfs5dDwj+dH5wQHcYTYPOIm5PFdkKemGwzHayFFOGhr5Xgf4PuiohtWZZSxzwIDAQAB"
email.lists 10800 IN CNAME mailgun.org.
lists 10800 IN MX 10 mxa.mailgun.org.
lists 10800 IN MX 10 mxb.mailgun.org.
lists 10800 IN TXT "v=spf1 include:mailgun.org ~all"
mx._domainkey.lists 10800 IN TXT "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCcbCDs5bW+LWM9cWLbQTPTCNoiSe7giSFISx9LIU9LBSeE8483wQhx6FMe7jOR3+4njEpIyFtyOrz3ol2JdH+R7d/ZoeUeQekU7/paF90ZS5hvE2wyKCtVFCmBevxI73pcH0JUXZe9/VCTRGtt5Ksny4cBrhMwWQGk5UFyUfBGOQIDAQAB"

View file

@ -0,0 +1,3 @@
@ {{ ttl }} IN A 217.70.184.38
www {{ ttl }} IN CNAME paren.party.s3-website-us-west-2.amazonaws.com.
egalitarian {{ ttl }} IN CNAME paren.party.s3-website-us-west-2.amazonaws.com.

View file

@ -0,0 +1,9 @@
# -*- mode: conf -*-
@ 10800 IN A 217.70.184.38
@ 10800 IN MX 10 spool.mail.gandi.net.
@ 10800 IN MX 50 fb.mail.gandi.net.
@ 10800 IN TXT "v=spf1 include:_mailcust.gandi.net ?all"
blog 10800 IN CNAME blogs.vip.gandi.net.
webmail 10800 IN CNAME webmail.gandi.net.
www 10800 IN CNAME webredir.vip.gandi.net.

View file

@ -0,0 +1,13 @@
# -*- mode: conf -*-
# Core records
{% for link in local.public_v4s %}
@ {{ ttl }} IN A {{ link }}
www {{ ttl }} IN A {{ link }}
registry {{ ttl }} IN A {{ link }}
mirror {{ ttl }} IN A {{ link }}
buildcache {{ ttl }} IN A {{ link }}
{% endfor %}
# Gitlab
gitlab {{ ttl }} IN A {{ sroo.public_v4 }}