Compare commits

...

15 commits

Author SHA1 Message Date
mwiegand
dbf6298cba wip 2021-11-07 09:48:02 +01:00
mwiegand
be7580aca5 case 2021-11-06 16:26:21 +01:00
mwiegand
684c2b46e1 aliases 2021-11-06 16:11:48 +01:00
mwiegand
4f633eab8e wip 2021-11-06 15:16:46 +01:00
mwiegand
fd15227637 acme_node 2021-11-06 13:30:26 +01:00
mwiegand
e13f493c22 wip 2021-11-06 13:12:10 +01:00
mwiegand
64a7f3d03c wip 2021-11-06 12:46:21 +01:00
mwiegand
3859db1146 wip 2021-11-06 12:44:32 +01:00
mwiegand
c252ae4734 wip 2021-11-06 12:09:57 +01:00
mwiegand
7d68291762 wip 2021-11-06 12:02:52 +01:00
mwiegand
d2ea2ccd9c wip 2021-11-06 11:44:46 +01:00
mwiegand
28a2b53a9d wip 2021-11-06 09:23:25 +01:00
mwiegand
7cbc8e1029 wip 2021-11-06 08:03:48 +01:00
mwiegand
9035f8c445 wip 2021-11-06 07:36:05 +01:00
mwiegand
f87d58bb41 wip 2021-11-06 06:40:18 +01:00
19 changed files with 240 additions and 85 deletions

View file

@ -0,0 +1,36 @@
@metadata_reactor.provides(
'dns',
)
def acme_records(metadata):
domains = set()
for other_node in repo.nodes:
for domain, conf in other_node.metadata.get('letsencrypt/domains', {}).items():
domains.add(domain)
domains.update(conf.get('aliases', []))
return {
'dns': {
f'_acme-challenge.{domain}': {
'CNAME': {f"{domain}.{metadata.get('bind/acme_zone')}."},
}
for domain in domains
}
}
@metadata_reactor.provides(
'bind/zones',
)
def acme_zone(metadata):
return {
'bind': {
'zones': {
metadata.get('bind/acme_zone'): {
'dynamic': True,
'views': ['external'],
'records': set(),
},
},
},
}

View file

@ -11,8 +11,8 @@ $TTL 600
900 ;Negative response caching TTL
)
% for record in sorted(records, key=lambda r: (r['name'], r['type'], r['value'])):
${(record['name'] or '@').ljust(column_width('name', records))} \
% for record in sorted(records, key=lambda r: (tuple(reversed(r['name'].split('.'))), r['type'], r['value'])):
(${(record['name'] or '@').rjust(column_width('name', records))}) \
IN \
${record['type'].ljust(column_width('type', records))} \
% if record['type'] == 'TXT':

View file

@ -4,6 +4,13 @@ acl "${view['name']}" {
};
% endfor
% for name, token in keys.items():
key "${name}" {
algorithm hmac-sha512;
secret "${token}";
};
% endfor
% for view in views:
view "${view['name']}" {
match-clients { ${view['name']}; };
@ -25,12 +32,16 @@ view "${view['name']}" {
8.8.8.8;
};
% for zone in zones:
% for zone, conf in sorted(zones.items()):
<% if view['name'] not in conf.get('views', ['internal', 'external']): continue %>
zone "${zone}" {
type ${type};
% if type == 'slave':
% if type == 'slave':
masters { ${master_ip}; };
% endif
% endif
% if type == 'master' and zone in keys:
allow-update { key "${zone}"; };
% endif
file "/var/lib/bind/${view['name']}/db.${zone}";
};
% endfor

View file

@ -3,7 +3,7 @@ options {
dnssec-validation auto;
listen-on-v6 { any; };
allow-query { any; };
allow-query { any; };
max-cache-size 30%;
querylog yes;

View file

@ -14,6 +14,8 @@ else:
slave_ips = []
directories[f'/var/lib/bind'] = {
'owner': 'bind',
'group': 'bind',
'purge': True,
'needed_by': [
'svc_systemd:bind9',
@ -51,6 +53,7 @@ files['/etc/bind/named.conf.options'] = {
'context': {
'type': node.metadata.get('bind/type'),
'slave_ips': sorted(slave_ips),
'master_ip': master_ip,
},
'owner': 'root',
'group': 'bind',
@ -92,7 +95,9 @@ files['/etc/bind/named.conf.local'] = {
'type': node.metadata.get('bind/type'),
'master_ip': master_ip,
'views': views,
'zones': sorted(zones),
'zones': zones,
'hostname': node.metadata.get('bind/hostname'),
'keys': node.metadata.get('bind/keys'),
},
'owner': 'root',
'group': 'bind',
@ -127,6 +132,8 @@ def record_matches_view(record, records, view):
for view in views:
directories[f"/var/lib/bind/{view['name']}"] = {
'owner': 'bind',
'group': 'bind',
'purge': True,
'needed_by': [
'svc_systemd:bind9',
@ -136,7 +143,11 @@ for view in views:
],
}
for zone, records in zones.items():
for zone, conf in zones.items():
if view['name'] not in conf.get('views', ['internal', 'external']):
continue
records = conf['records']
unique_records = [
dict(record_tuple)
for record_tuple in set(
@ -145,18 +156,8 @@ for view in views:
]
files[f"/var/lib/bind/{view['name']}/db.{zone}"] = {
'owner': 'bind',
'group': 'bind',
'source': 'db',
'content_type': 'mako',
'context': {
'view': view['name'],
'serial': datetime.now().strftime('%Y%m%d%H'),
'records': list(filter(
lambda record: record_matches_view(record, records, view['name']),
unique_records
)),
'hostname': node.metadata.get('bind/hostname'),
},
'needs': [
f"directory:/var/lib/bind/{view['name']}",
],
@ -167,6 +168,23 @@ for view in views:
'svc_systemd:bind9:restart',
],
}
if True or node.metadata.get('bind/type') == 'master': #FIXME: slave doesnt get updated if db doesnt get rewritten on each apply
files[f"/var/lib/bind/{view['name']}/db.{zone}"].update({
'source': 'db',
'content_type': 'mako',
'unless': f"test -f /var/lib/bind/{view['name']}/db.{zone}" if conf.get('dynamic', False) else 'false',
'context': {
'view': view['name'],
'serial': datetime.now().strftime('%Y%m%d%H'),
'records': list(filter(
lambda record: record_matches_view(record, records, view['name']),
unique_records
)),
'hostname': node.metadata.get('bind/hostname'),
'type': node.metadata.get('bind/type'),
'keys': node.metadata.get('bind/keys'),
},
})
svc_systemd['bind9'] = {}
@ -175,5 +193,6 @@ actions['named-checkconf'] = {
'unless': 'named-checkconf -z',
'needs': [
'svc_systemd:bind9',
'svc_systemd:bind9:restart',
]
}

View file

@ -11,6 +11,7 @@ defaults = {
'bind': {
'zones': {},
'slaves': {},
'keys': {},
},
'telegraf': {
'config': {
@ -76,7 +77,8 @@ def collect_records(metadata):
for type, values in records.items():
for value in values:
zones\
.setdefault(zone, set())\
.setdefault(zone, {})\
.setdefault('records', set())\
.add(
h({'name': name, 'type': type, 'value': value})
)
@ -106,10 +108,13 @@ def ns_records(metadata):
'bind': {
'zones': {
zone: {
# FIXME: bw currently cant handle lists of dicts :(
h({'name': '@', 'type': 'NS', 'value': f"{nameserver}."})
for nameserver in nameservers
} for zone in metadata.get('bind/zones').keys()
'records': {
# FIXME: bw currently cant handle lists of dicts :(
h({'name': '@', 'type': 'NS', 'value': f"{nameserver}."})
for nameserver in nameservers
}
}
for zone in metadata.get('bind/zones').keys()
},
},
}
@ -131,3 +136,24 @@ def slaves(metadata):
],
},
}
@metadata_reactor.provides(
'bind/keys',
)
def generate_keys(metadata):
return {
'bind': {
'keys': {
zone: repo.libs.hmac.hmac_sha512(
zone,
str(repo.vault.random_bytes_as_base64_for(
f"{metadata.get('id')} bind key {zone}",
length=32,
)),
)
for zone, conf in metadata.get('bind/zones').items()
if conf.get('dynamic', False)
},
},
}

View file

@ -0,0 +1,9 @@
https://github.com/dehydrated-io/dehydrated/wiki/example-dns-01-nsupdate-script
```
printf "server 127.0.0.1
zone acme.resolver.name.
update add _acme-challenge.ckn.li.acme.resolver.name. 600 IN TXT "hello"
send
" | nsupdate -y hmac-sha512:acme:Y9BHl85l352BGZDXa/vg90hh2+5PYe4oJxpkq/oQvIODDkW8bAyQSFr0gKQQxjyIOyYlTjf0MGcdWFv46G/3Rg==
```

View file

@ -3,3 +3,4 @@ BASEDIR=/var/lib/dehydrated
WELLKNOWN="${BASEDIR}/acme-challenges"
DOMAINS_TXT="/etc/dehydrated/domains.txt"
HOOK="/etc/dehydrated/hook.sh"
CA="https://acme-staging-v02.api.letsencrypt.org/directory"

View file

@ -1,3 +1,3 @@
% for domain, aliases in sorted(node.metadata.get('letsencrypt/domains', {}).items()):
${domain} ${' '.join(sorted(aliases))}
% for domain, conf in sorted(domains.items()):
${domain} ${' '.join(sorted(conf.get('aliases', [])))}
% endfor

View file

@ -1,37 +1,57 @@
deploy_cert() {<%text>
local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}"</%text>
% for service, config in node.metadata.get('letsencrypt/concat_and_deploy', {}).items():
set -e
set -u
set -o pipefail
# concat_and_deploy ${service}
if [ "$DOMAIN" = "${config['match_domain']}" ]; then
cat $KEYFILE > ${config['target']}
cat $FULLCHAINFILE >> ${config['target']}
% if 'chown' in config:
chown ${config['chown']} ${config['target']}
% endif
% if 'chmod' in config:
chmod ${config['chmod']} ${config['target']}
% endif
% if 'commands' in config:
% for command in config['commands']:
${command}
% endfor
% endif
fi
% endfor
deploy_challenge() {
echo "
server ${server}
zone ${zone}.
update add $1.${zone}. 60 IN TXT \"$3\"
send
" | tee | nsupdate -y hmac-sha512:${zone}:${acme_key}
sleep 10
}
exit_hook() {<%text>
local ERROR="${1:-}"</%text>
% for service in sorted(node.metadata.get('letsencrypt/reload_after', set())):
systemctl reload-or-restart ${service}
% endfor
clean_challenge() {
echo "
server ${server}
zone ${zone}.
update delete $1.${zone}. TXT
send
" | tee | nsupdate -y hmac-sha512:${zone}:${acme_key}
}
deploy_cert() {
DOMAIN="$1"
KEYFILE="$2"
CERTFILE="$3"
FULLCHAINFILE="$4"
CHAINFILE="$5"
case $DOMAIN in
% for domain, conf in sorted(domains.items()):
<% if not conf: continue %>\
${domain})
% if conf.get('location', None):
cat "$KEYFILE" > "${conf['location']}/privkey.pem"
cat "$CERTFILE" > "${conf['location']}/cert.pem"
cat "$FULLCHAINFILE" > "${conf['location']}/fullchain.pem"
cat "$CHAINFILE" > "${conf['location']}/chain.pem"
% endif
% if conf.get('owner', None):
chown ${conf['owner']} "${conf['location']}/privkey.pem" "${conf['location']}/cert.pem" "${conf['location']}/fullchain.pem" "${conf['location']}/chain.pem"
% endif
% for service in sorted(conf.get('reload', [])):
systemctl reload-or-restart ${service}
% endfor
;;
% endfor
esac
}
<%text>
HANDLER="$1"; shift
if [[ "${HANDLER}" =~ ^(deploy_cert|exit_hook)$ ]]; then
if [[ $HANDLER =~ ^(deploy_cert|deploy_challenge|clean_challenge)$ ]]
then
"$HANDLER" "$@"
fi</%text>
fi

View file

@ -1,6 +1,9 @@
assert node.has_bundle('nginx')
from ipaddress import ip_interface
delegated = 'delegate_to_node' in node.metadata.get('letsencrypt')
acme_node = repo.get_node(node.metadata.get('letsencrypt/acme_node'))
directories = {
'/etc/dehydrated/conf.d': {},
@ -10,6 +13,9 @@ directories = {
files = {
'/etc/dehydrated/domains.txt': {
'content_type': 'mako',
'context': {
'domains': node.metadata.get('letsencrypt/domains'),
},
'triggers': {
'action:letsencrypt_update_certificates',
},
@ -21,6 +27,15 @@ files = {
},
'/etc/dehydrated/hook.sh': {
'content_type': 'mako',
'context': {
'server': ip_interface(acme_node.metadata.get('network/external/ipv4')).ip,
'zone': acme_node.metadata.get('bind/acme_zone'),
'acme_key': acme_node.metadata.get('bind/keys/' + acme_node.metadata.get('bind/acme_zone')),
'domains': node.metadata.get('letsencrypt/domains'),
},
'mode': '0755',
},
'/etc/dehydrated/letsencrypt-ensure-some-certificate': {
'mode': '0755',
},
'/etc/dehydrated/letsencrypt-ensure-some-certificate': {
@ -29,7 +44,7 @@ files = {
}
actions['letsencrypt_update_certificates'] = {
'command': 'dehydrated --cron --accept-terms --challenge http-01',
'command': 'dehydrated --cron --accept-terms --challenge dns-01',
'triggered': True,
'skip': delegated,
'needs': {
@ -48,6 +63,6 @@ for domain in node.metadata.get('letsencrypt/domains').keys():
'svc_systemd:nginx',
},
'triggers': {
'action:letsencrypt_update_certificates',
'action:letsencrypt_update_certificates',
},
}

View file

@ -4,16 +4,17 @@ defaults = {
'apt': {
'packages': {
'dehydrated': {},
'dnsutils': {},
},
},
'letsencrypt': {
'domains': {
# 'example.com': {'alias1.example.com', 'alias2.example.com'},
},
},
'pacman': {
'packages': {
'dehydrated': {},
# 'example.com': {
# 'aliases': {'www.example.com'},
# 'reload': {'nginx'},
# 'owner': 'www-data',
# 'location': '/opt/app/certs',
# },
},
},
}
@ -40,7 +41,7 @@ def renew(metadata):
return {
'systemd-timers': {
'letsencrypt': {
'command': '/bin/bash -c "/usr/bin/dehydrated --cron --accept-terms --challenge http-01 && /usr/bin/dehydrated --cleanup"',
'command': '/bin/bash -c "/usr/bin/dehydrated --cron --accept-terms --challenge dns-01 && /usr/bin/dehydrated --cleanup"',
'when': 'daily',
},
},
@ -53,22 +54,19 @@ def renew(metadata):
)
def delegated_domains(metadata):
delegated_domains = {
domain
domain: conf
for other_node in repo.nodes
if other_node.has_bundle('letsencrypt')
and other_node.metadata.get('letsencrypt/delegate_to_node', None) == node.name
for domain in other_node.metadata.get('letsencrypt/domains').keys()
and other_node.metadata.get('letsencrypt/delegate_to_node', None) == node.name
for domain, conf in other_node.metadata.get('letsencrypt/domains').items()
}
return {
'letsencrypt': {
'domains': {
domain: set()
for domain in delegated_domains
},
'domains': delegated_domains,
},
'dns': {
domain: repo.libs.dns.get_a_records(metadata, internal=False)
for domain in delegated_domains
for domain in delegated_domains.keys()
},
}

View file

@ -60,7 +60,9 @@ def letsencrypt(metadata):
return {
'letsencrypt': {
'domains': {
metadata.get('mailserver/hostname'): set(),
metadata.get('mailserver/hostname'): {
'reload': {'dovecot', 'postfix'},
},
},
},
}

View file

@ -84,7 +84,7 @@ def letsencrypt(metadata):
return {
'letsencrypt': {
'domains': {
metadata.get('mosquitto/hostname'): set(),
metadata.get('mosquitto/hostname'): {},
},
},
}

View file

@ -104,10 +104,10 @@ def letsencrypt(metadata):
return {
'letsencrypt': {
'domains': {
domain: set() for domain in metadata.get('nginx/vhosts').keys()
},
'reload_after': {
'nginx',
domain: {
'reload': {'nginx'},
}
for domain in metadata.get('nginx/vhosts').keys()
},
},
}

View file

@ -17,5 +17,8 @@
},
},
},
'letsencrypt': {
'acme_node': 'htz.mails',
},
}
}

10
libs/hmac.py Normal file
View file

@ -0,0 +1,10 @@
import hmac, hashlib, base64
def hmac_sha512(secret, iv):
return base64.b64encode(
hmac.new(
bytes(iv , 'latin-1'),
msg=bytes(secret , 'latin-1'),
digestmod=hashlib.sha512
).digest()
).decode()

View file

@ -30,9 +30,6 @@
'gateway4': '10.0.0.1',
},
},
'letsencrypt': {
'delegate_to_node': 'htz.mails',
},
'nginx': {
'vhosts': {
'openhab.ckn.li': {

View file

@ -10,6 +10,7 @@
'dnsserver',
],
'bundles': [
'bind-acme',
'islamicstate.eu',
'wireguard',
'zfs',
@ -31,6 +32,7 @@
},
'bind': {
'hostname': 'resolver.name',
'acme_zone': 'acme.sublimity.de',
'zones': {
'sublimity.de': {},
'freibrief.net': {},
@ -61,9 +63,15 @@
},
'letsencrypt': {
'domains': {
'ckn.li': set(),
'sublimity.de': set(),
'freibrief.net': set(),
'ckn.li': {},
'test6.ckn.li': {
'aliases': {'www.test6.ckn.li'},
'location': '/root/temp',
'owner': 'telegraf',
'reload': {'telegraf'},
},
'sublimity.de': {},
'freibrief.net': {},
},
},
'mailserver': {