Shovel in the DNS automation machinery
This commit is contained in:
parent
94fd9081ec
commit
0654ae2847
17 changed files with 420 additions and 0 deletions
10
projects/gandi/BUILD
Normal file
10
projects/gandi/BUILD
Normal file
|
@ -0,0 +1,10 @@
|
|||
py_library(
|
||||
name = "gandi",
|
||||
src = globs(["src/python/**/*.py"]),
|
||||
imports = [
|
||||
"src/python"
|
||||
],
|
||||
deps = [
|
||||
py_requirement("requests"),
|
||||
]
|
||||
)
|
7
projects/gandi/src/python/BUILD
Normal file
7
projects/gandi/src/python/BUILD
Normal file
|
@ -0,0 +1,7 @@
|
|||
python_library(
|
||||
name="gandi",
|
||||
sources=globs("*.py"),
|
||||
dependencies=[
|
||||
"//3rdparty/python:requests",
|
||||
]
|
||||
)
|
0
projects/gandi/src/python/__init__.py
Normal file
0
projects/gandi/src/python/__init__.py
Normal file
102
projects/gandi/src/python/client.py
Normal file
102
projects/gandi/src/python/client.py
Normal file
|
@ -0,0 +1,102 @@
|
|||
"""
|
||||
Quick and shitty Gandi REST API driver
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class GandiAPI(object):
|
||||
"""An extremely incomplete Gandi REST API driver class.
|
||||
|
||||
Exists to close over your API key, and make talking to the API slightly more idiomatic.
|
||||
|
||||
Note: In an effort to be nice, this API maintains a cache of the zones visible with your API
|
||||
key. The cache is maintained when using this driver, but concurrent modifications of your zone(s)
|
||||
via the web UI or other processes will obviously undermine it. This cache can be disabled by
|
||||
setting the `use_cache` kwarg to `False`.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, key=None, use_cache=True):
|
||||
self._base = "https://dns.api.gandi.net/api/v5"
|
||||
self._key = key
|
||||
self._use_cache = use_cache
|
||||
self._zones = None
|
||||
|
||||
# Helpers for making requests with the API key as required by the API.
|
||||
|
||||
def _do_request(self, method, path, headers=None, **kwargs):
|
||||
headers = headers or {}
|
||||
headers["X-Api-Key"] = self._key
|
||||
resp = method(self._base + path, headers=headers, **kwargs)
|
||||
if resp.status_code > 299:
|
||||
print(resp.text)
|
||||
resp.raise_for_status()
|
||||
|
||||
return resp
|
||||
|
||||
def _get(self, path, **kwargs):
|
||||
return self._do_request(requests.get, path, **kwargs)
|
||||
|
||||
def _post(self, path, **kwargs):
|
||||
return self._do_request(requests.post, path, **kwargs)
|
||||
|
||||
def _put(self, path, **kwargs):
|
||||
return self._do_request(requests.put, path, **kwargs)
|
||||
|
||||
# Intentional public API
|
||||
|
||||
def domain_records(self, domain):
|
||||
return self._get("/domains/{0}/records".format(domain)).json()
|
||||
|
||||
def get_zones(self):
|
||||
if self._use_cache:
|
||||
if not self._zones:
|
||||
self._zones = self._get("/zones").json()
|
||||
return self._zones
|
||||
else:
|
||||
return self._get("/zones").json()
|
||||
|
||||
def get_zone(self, zone_id):
|
||||
return self._get("/zones/{}".format(zone_id)).json()
|
||||
|
||||
def get_zone_by_name(self, zone_name):
|
||||
for zone in self.get_zones():
|
||||
if zone["name"] == zone_name:
|
||||
return zone
|
||||
|
||||
def create_zone(self, zone_name):
|
||||
new_zone_id = self._post("/zones",
|
||||
headers={"content-type": "application/json"},
|
||||
data=json.dumps({"name": zone_name}))\
|
||||
.headers["Location"]\
|
||||
.split("/")[-1]
|
||||
new_zone = self.get_zone(new_zone_id)
|
||||
|
||||
# Note: If the cache is active, update the cache.
|
||||
if self._use_cache and self._zones is not None:
|
||||
self._zones.append(new_zone)
|
||||
|
||||
return new_zone
|
||||
|
||||
def replace_domain(self, domain, records):
|
||||
date = datetime.now()
|
||||
date = "{:%A, %d. %B %Y %I:%M%p}".format(date)
|
||||
zone_name = f"updater generated domain - {domain} - {date}"
|
||||
|
||||
zone = self.get_zone_by_name(zone_name)
|
||||
if not zone:
|
||||
zone = self.create_zone(zone_name)
|
||||
|
||||
print("Using zone", zone["uuid"])
|
||||
|
||||
for r in records:
|
||||
self._post("/zones/{0}/records".format(zone["uuid"]),
|
||||
headers={"content-type": "application/json"},
|
||||
data=json.dumps(r))
|
||||
|
||||
return self._post("/zones/{0}/domains/{1}".format(zone["uuid"], domain),
|
||||
headers={"content-type": "application/json"})
|
8
projects/public-dns/.dockerignore
Normal file
8
projects/public-dns/.dockerignore
Normal file
|
@ -0,0 +1,8 @@
|
|||
.*
|
||||
3rdparty
|
||||
src
|
||||
test
|
||||
README.md
|
||||
pants
|
||||
pants.ini
|
||||
etc
|
9
projects/public-dns/BUILD
Normal file
9
projects/public-dns/BUILD
Normal 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"),
|
||||
]
|
||||
)
|
0
projects/public-dns/README.md
Normal file
0
projects/public-dns/README.md
Normal file
23
projects/public-dns/etc/build.sh
Normal file
23
projects/public-dns/etc/build.sh
Normal 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
|
4
projects/public-dns/src/docker/arrdem/updater/Dockerfile
Normal file
4
projects/public-dns/src/docker/arrdem/updater/Dockerfile
Normal 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"]
|
|
@ -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
|
12
projects/public-dns/src/python/arrdem/updater/BUILD
Normal file
12
projects/public-dns/src/python/arrdem/updater/BUILD
Normal 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",
|
||||
]
|
||||
)
|
176
projects/public-dns/src/python/arrdem/updater/__main__.py
Normal file
176
projects/public-dns/src/python/arrdem/updater/__main__.py
Normal 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()
|
4
projects/public-dns/src/resources/zonefiles/BUILD
Normal file
4
projects/public-dns/src/resources/zonefiles/BUILD
Normal file
|
@ -0,0 +1,4 @@
|
|||
python_library(
|
||||
name="zonefiles",
|
||||
sources=globs("*.j2"),
|
||||
)
|
20
projects/public-dns/src/resources/zonefiles/arrdem.com.j2
Normal file
20
projects/public-dns/src/resources/zonefiles/arrdem.com.j2
Normal 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"
|
|
@ -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.
|
9
projects/public-dns/src/resources/zonefiles/park.j2
Normal file
9
projects/public-dns/src/resources/zonefiles/park.j2
Normal 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.
|
|
@ -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 }}
|
Loading…
Reference in a new issue