mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2024-11-24 02:37:05 +00:00
add DNSSEC
* sign zones * in a cron job, periodically re-sign zones because they expire (not tested)
This commit is contained in:
parent
aaa735dbfe
commit
88709506f8
@ -53,7 +53,18 @@ def mail_domains():
|
|||||||
@app.route('/dns/update', methods=['POST'])
|
@app.route('/dns/update', methods=['POST'])
|
||||||
def dns_update():
|
def dns_update():
|
||||||
from dns_update import do_dns_update
|
from dns_update import do_dns_update
|
||||||
return do_dns_update(env)
|
try:
|
||||||
|
return do_dns_update(env)
|
||||||
|
except Exception as e:
|
||||||
|
return (str(e), 500)
|
||||||
|
|
||||||
|
@app.route('/dns/ds')
|
||||||
|
def dns_get_ds_records():
|
||||||
|
from dns_update import get_ds_records
|
||||||
|
try:
|
||||||
|
return get_ds_records(env)
|
||||||
|
except Exception as e:
|
||||||
|
return (str(e), 500)
|
||||||
|
|
||||||
# System
|
# System
|
||||||
|
|
||||||
|
@ -2,12 +2,12 @@
|
|||||||
# and mail aliases and restarts nsd.
|
# and mail aliases and restarts nsd.
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
import os, os.path, urllib.parse, time, re
|
import os, os.path, urllib.parse, datetime, re
|
||||||
|
|
||||||
from mailconfig import get_mail_domains
|
from mailconfig import get_mail_domains
|
||||||
from utils import shell
|
from utils import shell, load_env_vars_from_file
|
||||||
|
|
||||||
def do_dns_update(env):
|
def get_dns_domains(env):
|
||||||
# What domains should we serve DNS for?
|
# What domains should we serve DNS for?
|
||||||
domains = set()
|
domains = set()
|
||||||
|
|
||||||
@ -16,20 +16,55 @@ def do_dns_update(env):
|
|||||||
|
|
||||||
# Add all domain names in use by email users and mail aliases.
|
# Add all domain names in use by email users and mail aliases.
|
||||||
domains |= get_mail_domains(env)
|
domains |= get_mail_domains(env)
|
||||||
|
|
||||||
# Make a nice and safe filename for each domain.
|
# Make a nice and safe filename for each domain.
|
||||||
zonefiles = []
|
zonefiles = []
|
||||||
for domain in domains:
|
for domain in domains:
|
||||||
zonefiles.append((domain, urllib.parse.quote(domain, safe='') + ".txt" ))
|
zonefiles.append([domain, urllib.parse.quote(domain, safe='') + ".txt"])
|
||||||
|
|
||||||
|
return zonefiles
|
||||||
|
|
||||||
|
|
||||||
|
def do_dns_update(env):
|
||||||
|
# What domains (and their zone filenames) should we build?
|
||||||
|
zonefiles = get_dns_domains(env)
|
||||||
|
|
||||||
# Write zone files.
|
# Write zone files.
|
||||||
os.makedirs('/etc/nsd/zones', exist_ok=True)
|
os.makedirs('/etc/nsd/zones', exist_ok=True)
|
||||||
updated_domains = []
|
updated_domains = []
|
||||||
for domain, zonefile in zonefiles:
|
for i, (domain, zonefile) in enumerate(zonefiles):
|
||||||
|
# Build the records to put in the zone.
|
||||||
records = build_zone(domain, env)
|
records = build_zone(domain, env)
|
||||||
if write_nsd_zone(domain, "/etc/nsd/zones/" + zonefile, records, env):
|
|
||||||
|
# See if the zone has changed, and if so update the serial number
|
||||||
|
# and write the zone file.
|
||||||
|
if not write_nsd_zone(domain, "/etc/nsd/zones/" + zonefile, records, env):
|
||||||
|
# Zone was not updated. There were no changes.
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If this is a .justtesting.email domain, then post the update.
|
||||||
|
try:
|
||||||
justtestingdotemail(domain, records)
|
justtestingdotemail(domain, records)
|
||||||
updated_domains.append(domain)
|
except:
|
||||||
|
# Hmm. Might be a network issue. If we stop now, will we end
|
||||||
|
# up in an inconsistent state? Let's just continue.
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Mark that we just updated this domain.
|
||||||
|
updated_domains.append(domain)
|
||||||
|
|
||||||
|
# Sign the zone.
|
||||||
|
#
|
||||||
|
# Every time we sign the zone we get a new result, which means
|
||||||
|
# we can't sign a zone without bumping the zone's serial number.
|
||||||
|
# Thus we only sign a zone if write_nsd_zone returned True
|
||||||
|
# indicating the zone changed, and thus it got a new serial number.
|
||||||
|
# write_nsd_zone is smart enough to check if a zone's signature
|
||||||
|
# is nearing experiation and if so it'll bump the serial number
|
||||||
|
# and return True so we get a chance to re-sign it.
|
||||||
|
#
|
||||||
|
# Also update the zone's filename so nsd.conf uses the signed file.
|
||||||
|
zonefiles[i][1] = sign_zone(domain, zonefile, env)
|
||||||
|
|
||||||
# Write the main nsd.conf file.
|
# Write the main nsd.conf file.
|
||||||
if write_nsd_conf(zonefiles):
|
if write_nsd_conf(zonefiles):
|
||||||
@ -94,8 +129,12 @@ def write_nsd_zone(domain, zonefile, records, env):
|
|||||||
# We set the administrative email address for every domain to domain_contact@[domain.com].
|
# We set the administrative email address for every domain to domain_contact@[domain.com].
|
||||||
# You should probably create an alias to your email address.
|
# You should probably create an alias to your email address.
|
||||||
|
|
||||||
|
# On the $ORIGIN line, there's typically a ';' comment at the end explaining
|
||||||
|
# what the $ORIGIN line does. Any further data after the domain confuses
|
||||||
|
# ldns-signzone, however. It used to say '; default zone domain'.
|
||||||
|
|
||||||
zone = """
|
zone = """
|
||||||
$ORIGIN {domain}. ; default zone domain
|
$ORIGIN {domain}.
|
||||||
$TTL 86400 ; default time to live
|
$TTL 86400 ; default time to live
|
||||||
|
|
||||||
@ IN SOA ns1.{primary_domain}. hostmaster.{primary_domain}. (
|
@ IN SOA ns1.{primary_domain}. hostmaster.{primary_domain}. (
|
||||||
@ -117,8 +156,36 @@ $TTL 86400 ; default time to live
|
|||||||
zone += "\tIN\t" + querytype + "\t"
|
zone += "\tIN\t" + querytype + "\t"
|
||||||
zone += value + "\n"
|
zone += value + "\n"
|
||||||
|
|
||||||
|
# DNSSEC requires re-signing a zone periodically. That requires
|
||||||
|
# bumping the serial number even if no other records have changed.
|
||||||
|
# We don't see the DNSSEC records yet, so we have to figure out
|
||||||
|
# if a re-signing is necessary so we can prematurely bump the
|
||||||
|
# serial number.
|
||||||
|
force_bump = False
|
||||||
|
if not os.path.exists(zonefile + ".signed"):
|
||||||
|
# No signed file yet. Shouldn't normally happen unless a box
|
||||||
|
# is going from not using DNSSEC to using DNSSEC.
|
||||||
|
force_bump = True
|
||||||
|
else:
|
||||||
|
# We've signed the domain. Check if we are close to the expiration
|
||||||
|
# time of the signature. If so, we'll force a bump of the serial
|
||||||
|
# number so we can re-sign it.
|
||||||
|
with open(zonefile + ".signed") as f:
|
||||||
|
signed_zone = f.read()
|
||||||
|
expiration_times = re.findall(r"\sRRSIG\s+SOA\s+\d+\s+\d+\s\d+\s+(\d{14})", signed_zone)
|
||||||
|
if len(expiration_times) == 0:
|
||||||
|
# weird
|
||||||
|
force_bump = True
|
||||||
|
else:
|
||||||
|
# All of the times should be the same, but if not choose the soonest.
|
||||||
|
expiration_time = min(expiration_times)
|
||||||
|
expiration_time = datetime.datetime.strptime(expiration_time, "%Y%m%d%H%M%S")
|
||||||
|
if expiration_time - datetime.datetime.now() < datetime.timedelta(days=3):
|
||||||
|
# We're within three days of the expiration, so bump serial & resign.
|
||||||
|
force_bump = True
|
||||||
|
|
||||||
# Set the serial number.
|
# Set the serial number.
|
||||||
serial = time.strftime("%Y%m%d00")
|
serial = datetime.datetime.now().strftime("%Y%m%d00")
|
||||||
if os.path.exists(zonefile):
|
if os.path.exists(zonefile):
|
||||||
# If the zone already exists, is different, and has a later serial number,
|
# If the zone already exists, is different, and has a later serial number,
|
||||||
# increment the number.
|
# increment the number.
|
||||||
@ -126,15 +193,20 @@ $TTL 86400 ; default time to live
|
|||||||
existing_zone = f.read()
|
existing_zone = f.read()
|
||||||
m = re.search(r"(\d+)\s*;\s*serial number", existing_zone)
|
m = re.search(r"(\d+)\s*;\s*serial number", existing_zone)
|
||||||
if m:
|
if m:
|
||||||
|
# Clear out the serial number in the existing zone file for the
|
||||||
|
# purposes of seeing if anything *else* in the zone has changed.
|
||||||
existing_serial = m.group(1)
|
existing_serial = m.group(1)
|
||||||
existing_zone = existing_zone.replace(m.group(0), "__SERIAL__ ; serial number")
|
existing_zone = existing_zone.replace(m.group(0), "__SERIAL__ ; serial number")
|
||||||
|
|
||||||
# If the existing zone is the same as the new zone (modulo the serial number),
|
# If the existing zone is the same as the new zone (modulo the serial number),
|
||||||
# there is no need to update the file.
|
# there is no need to update the file. Unless we're forcing a bump.
|
||||||
if zone == existing_zone:
|
if zone == existing_zone and not force_bump:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# If the existing serial is not less than the new one, increment it.
|
# If the existing serial is not less than a serial number
|
||||||
|
# based on the current date plus 00, increment it. Otherwise,
|
||||||
|
# the serial number is less than our desired new serial number
|
||||||
|
# so we'll use the desired new number.
|
||||||
if existing_serial >= serial:
|
if existing_serial >= serial:
|
||||||
serial = str(int(existing_serial) + 1)
|
serial = str(int(existing_serial) + 1)
|
||||||
|
|
||||||
@ -182,6 +254,89 @@ zone:
|
|||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
|
def sign_zone(domain, zonefile, env):
|
||||||
|
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/keys.conf'))
|
||||||
|
|
||||||
|
# In order to use the same keys for all domains, we have to generate
|
||||||
|
# a new .key file with a DNSSEC record for the specific domain. We
|
||||||
|
# can reuse the same key, but it won't validate without a DNSSEC
|
||||||
|
# record specifically for the domain.
|
||||||
|
#
|
||||||
|
# Copy the .key and .private files to /tmp to patch them up.
|
||||||
|
#
|
||||||
|
# Use os.umask and open().write() to securely create a copy that only
|
||||||
|
# we (root) can read.
|
||||||
|
files_to_kill = []
|
||||||
|
for key in ("KSK", "ZSK"):
|
||||||
|
if dnssec_keys.get(key, "").strip() == "": raise Exception("DNSSEC is not properly set up.")
|
||||||
|
oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/' + dnssec_keys[key])
|
||||||
|
newkeyfn = '/tmp/' + dnssec_keys[key].replace("_domain_", domain)
|
||||||
|
dnssec_keys[key] = newkeyfn
|
||||||
|
for ext in (".private", ".key"):
|
||||||
|
if not os.path.exists(oldkeyfn + ext): raise Exception("DNSSEC is not properly set up.")
|
||||||
|
with open(oldkeyfn + ext, "r") as fr:
|
||||||
|
keydata = fr.read()
|
||||||
|
keydata = keydata.replace("_domain_", domain) # trick ldns-signkey into letting our generic key be used by this zone
|
||||||
|
fn = newkeyfn + ext
|
||||||
|
prev_umask = os.umask(0o77) # ensure written file is not world-readable
|
||||||
|
try:
|
||||||
|
with open(fn, "w") as fw:
|
||||||
|
fw.write(keydata)
|
||||||
|
finally:
|
||||||
|
os.umask(prev_umask) # other files we write should be world-readable
|
||||||
|
files_to_kill.append(fn)
|
||||||
|
|
||||||
|
# Do the signing.
|
||||||
|
expiry_date = (datetime.datetime.now() + datetime.timedelta(days=30)).strftime("%Y%m%d")
|
||||||
|
shell('check_call', ["/usr/bin/ldns-signzone",
|
||||||
|
# expire the zone after 30 days
|
||||||
|
"-e", expiry_date,
|
||||||
|
|
||||||
|
# use NSEC3
|
||||||
|
"-n",
|
||||||
|
|
||||||
|
# zonefile to sign
|
||||||
|
"/etc/nsd/zones/" + zonefile,
|
||||||
|
|
||||||
|
# keys to sign with (order doesn't matter -- it'll figure it out)
|
||||||
|
dnssec_keys["KSK"],
|
||||||
|
dnssec_keys["ZSK"],
|
||||||
|
])
|
||||||
|
|
||||||
|
# Create a DS record based on the patched-up key files. The DS record is specific to the
|
||||||
|
# zone being signed, so we can't use the .ds files generated when we created the keys.
|
||||||
|
# The DS record points to the KSK only. Write this next to the zone file so we can
|
||||||
|
# get it later to give to the user with instructions on what to do with it.
|
||||||
|
rr_ds = shell('check_output', ["/usr/bin/ldns-key2ds",
|
||||||
|
"-n", # output to stdout
|
||||||
|
"-2", # SHA256
|
||||||
|
dnssec_keys["KSK"] + ".key"
|
||||||
|
])
|
||||||
|
with open("/etc/nsd/zones/" + zonefile + ".ds", "w") as f:
|
||||||
|
f.write(rr_ds)
|
||||||
|
|
||||||
|
# Remove our temporary file.
|
||||||
|
for fn in files_to_kill:
|
||||||
|
os.unlink(fn)
|
||||||
|
|
||||||
|
# Update the zone's filename so nsd.conf uses the signed file.
|
||||||
|
return zonefile + ".signed"
|
||||||
|
|
||||||
|
########################################################################
|
||||||
|
|
||||||
|
def get_ds_records(env):
|
||||||
|
zonefiles = get_dns_domains(env)
|
||||||
|
ret = ""
|
||||||
|
for domain, zonefile in zonefiles:
|
||||||
|
fn = "/etc/nsd/zones/" + zonefile + ".ds"
|
||||||
|
if os.path.exists(fn):
|
||||||
|
with open(fn, "r") as fr:
|
||||||
|
ret += fr.read().strip() + "\n"
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
########################################################################
|
||||||
|
|
||||||
def write_opendkim_tables(zonefiles, env):
|
def write_opendkim_tables(zonefiles, env):
|
||||||
# Append a record to OpenDKIM's KeyTable and SigningTable for each domain.
|
# Append a record to OpenDKIM's KeyTable and SigningTable for each domain.
|
||||||
#
|
#
|
||||||
|
@ -1,11 +1,16 @@
|
|||||||
def load_environment():
|
def load_environment():
|
||||||
# Load settings from /etc/mailinabox.conf.
|
# Load settings from /etc/mailinabox.conf.
|
||||||
import os.path
|
import os.path
|
||||||
env = { }
|
env = load_env_vars_from_file("/etc/mailinabox.conf")
|
||||||
for line in open("/etc/mailinabox.conf"): env.setdefault(*line.strip().split("=", 1))
|
|
||||||
env["CONF_DIR"] = os.path.join(os.path.dirname(__file__), "../conf")
|
env["CONF_DIR"] = os.path.join(os.path.dirname(__file__), "../conf")
|
||||||
return env
|
return env
|
||||||
|
|
||||||
|
def load_env_vars_from_file(fn):
|
||||||
|
# Load settings from a KEY=VALUE file.
|
||||||
|
env = { }
|
||||||
|
for line in open(fn): env.setdefault(*line.strip().split("=", 1))
|
||||||
|
return env
|
||||||
|
|
||||||
def exclusive_process(name):
|
def exclusive_process(name):
|
||||||
# Ensure that a process named `name` does not execute multiple
|
# Ensure that a process named `name` does not execute multiple
|
||||||
# times concurrently.
|
# times concurrently.
|
||||||
|
49
setup/dns.sh
49
setup/dns.sh
@ -8,7 +8,8 @@
|
|||||||
|
|
||||||
source setup/functions.sh # load our functions
|
source setup/functions.sh # load our functions
|
||||||
|
|
||||||
# Install nsd, our DNS server software.
|
# Install nsd, our DNS server software, and ldnsutils which helps
|
||||||
|
# us sign zones for DNSSEC.
|
||||||
|
|
||||||
# ...but first, we have to create the user because the
|
# ...but first, we have to create the user because the
|
||||||
# current Ubuntu forgets to do so in the .deb
|
# current Ubuntu forgets to do so in the .deb
|
||||||
@ -21,12 +22,56 @@ fi
|
|||||||
|
|
||||||
# Okay now install the packages.
|
# Okay now install the packages.
|
||||||
|
|
||||||
apt_install nsd
|
apt_install nsd ldnsutils
|
||||||
|
|
||||||
# Prepare nsd's configuration.
|
# Prepare nsd's configuration.
|
||||||
|
|
||||||
sudo mkdir -p /var/run/nsd
|
sudo mkdir -p /var/run/nsd
|
||||||
|
|
||||||
|
# Create DNSSEC signing keys.
|
||||||
|
|
||||||
|
mkdir -p "$STORAGE_ROOT/dns/dnssec";
|
||||||
|
if [ ! -f "$STORAGE_ROOT/dns/dnssec/keys.conf" ]; then
|
||||||
|
# These two steps take a while.
|
||||||
|
|
||||||
|
# Create the Key-Signing Key (KSK) (-k) which is the so-called
|
||||||
|
# Secure Entry Point. Use a NSEC3-compatible algorithm (best
|
||||||
|
# practice), and a nice and long keylength. Use /dev/urandom
|
||||||
|
# instead of /dev/random for noise or else we'll be waiting
|
||||||
|
# a very long time. The domain name we provide ("_domain_")
|
||||||
|
# doesn't matter -- we'll use the same keys for all our domains.
|
||||||
|
KSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a RSASHA1-NSEC3-SHA1 -b 2048 -k -r /dev/urandom _domain_);
|
||||||
|
|
||||||
|
# Now create a Zone-Signing Key (ZSK) which is expected to be
|
||||||
|
# rotated more often than a KSK, although we have no plans to
|
||||||
|
# rotate it (and doing so would be difficult to do without
|
||||||
|
# disturbing DNS availability.) Omit '-k' and use a shorter key.
|
||||||
|
ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a RSASHA1-NSEC3-SHA1 -b 1024 -r /dev/urandom _domain_);
|
||||||
|
|
||||||
|
# These generate two sets of files like:
|
||||||
|
# K_domain_.+007+08882.ds <- DS record for adding to NSD configuration files
|
||||||
|
# K_domain_.+007+08882.key <- public key (goes into DS record & upstream DNS provider like your registrar)
|
||||||
|
# K_domain_.+007+08882.private <- private key (secret!)
|
||||||
|
|
||||||
|
# The filenames are unpredictable and encode the key generation
|
||||||
|
# options. So we'll store the names of the files we just generated.
|
||||||
|
# We might have multiple keys down the road. This will identify
|
||||||
|
# what keys are the current keys.
|
||||||
|
cat > $STORAGE_ROOT/dns/dnssec/keys.conf << EOF;
|
||||||
|
KSK=$KSK
|
||||||
|
ZSK=$ZSK
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Force the dns_update script to be run every day to re-sign zones for DNSSEC.
|
||||||
|
cat > /etc/cron.daily/mailinabox-dnssec << EOF;
|
||||||
|
#!/bin/bash
|
||||||
|
# Mail-in-a-Box
|
||||||
|
# Re-sign any DNS zones with DNSSEC because the signatures expire periodically.
|
||||||
|
curl -d GO http://localhost:10222/dns/update
|
||||||
|
EOF
|
||||||
|
chmod +x /etc/cron.daily/mailinabox-dnssec
|
||||||
|
|
||||||
# Permit DNS queries on TCP/UDP in the firewall.
|
# Permit DNS queries on TCP/UDP in the firewall.
|
||||||
|
|
||||||
ufw_allow domain
|
ufw_allow domain
|
||||||
|
Loading…
Reference in New Issue
Block a user