2014-07-17 13:02:39 +00:00
#!/usr/bin/python3
2014-06-03 13:24:48 +00:00
# Creates DNS zone files for all of the domains of all of the mail users
# and mail aliases and restarts nsd.
########################################################################
2014-08-27 12:56:17 +00:00
import os , os . path , urllib . parse , datetime , re , hashlib , base64
2014-08-23 23:03:45 +00:00
import ipaddress
2014-06-17 21:39:26 +00:00
import rtyaml
2014-10-05 14:53:42 +00:00
import dns . resolver
2014-06-03 13:24:48 +00:00
from mailconfig import get_mail_domains
2014-06-22 15:34:36 +00:00
from utils import shell , load_env_vars_from_file , safe_domain_name , sort_domains
2014-06-03 13:24:48 +00:00
2014-06-22 16:24:15 +00:00
def get_dns_domains ( env ) :
# Add all domain names in use by email users and mail aliases and ensure
2014-06-30 13:15:36 +00:00
# PRIMARY_HOSTNAME is in the list.
2014-06-03 13:24:48 +00:00
domains = set ( )
domains | = get_mail_domains ( env )
2014-06-30 13:15:36 +00:00
domains . add ( env [ ' PRIMARY_HOSTNAME ' ] )
2014-06-22 16:24:15 +00:00
return domains
2014-06-17 22:21:12 +00:00
2014-06-22 16:28:55 +00:00
def get_dns_zones ( env ) :
2014-06-22 16:24:15 +00:00
# What domains should we create DNS zones for? Never create a zone for
# a domain & a subdomain of that domain.
2014-06-22 16:28:55 +00:00
domains = get_dns_domains ( env )
2014-06-22 16:24:15 +00:00
# Exclude domains that are subdomains of other domains we know. Proceed
# by looking at shorter domains first.
zone_domains = set ( )
for domain in sorted ( domains , key = lambda d : len ( d ) ) :
for d in zone_domains :
if domain . endswith ( " . " + d ) :
# We found a parent domain already in the list.
break
else :
# 'break' did not occur: there is no parent domain.
zone_domains . add ( domain )
2014-06-17 23:30:00 +00:00
2014-06-03 13:24:48 +00:00
# Make a nice and safe filename for each domain.
zonefiles = [ ]
2014-06-22 16:24:15 +00:00
for domain in zone_domains :
2014-06-20 01:16:38 +00:00
zonefiles . append ( [ domain , safe_domain_name ( domain ) + " .txt " ] )
2014-06-17 22:21:12 +00:00
2014-06-22 15:34:36 +00:00
# Sort the list so that the order is nice and so that nsd.conf has a
# stable order so we don't rewrite the file & restart the service
# meaninglessly.
zone_order = sort_domains ( [ zone [ 0 ] for zone in zonefiles ] , env )
zonefiles . sort ( key = lambda zone : zone_order . index ( zone [ 0 ] ) )
2014-06-17 22:21:12 +00:00
return zonefiles
2014-07-20 15:15:33 +00:00
def get_custom_dns_config ( env ) :
try :
return rtyaml . load ( open ( os . path . join ( env [ ' STORAGE_ROOT ' ] , ' dns/custom.yaml ' ) ) )
except :
return { }
2014-06-17 22:21:12 +00:00
2014-10-05 14:53:42 +00:00
def write_custom_dns_config ( config , env ) :
config_yaml = rtyaml . dump ( config )
with open ( os . path . join ( env [ ' STORAGE_ROOT ' ] , ' dns/custom.yaml ' ) , " w " ) as f :
f . write ( config_yaml )
2014-08-01 12:05:34 +00:00
def do_dns_update ( env , force = False ) :
2014-06-17 22:21:12 +00:00
# What domains (and their zone filenames) should we build?
2014-06-22 16:24:15 +00:00
domains = get_dns_domains ( env )
2014-06-22 16:28:55 +00:00
zonefiles = get_dns_zones ( env )
2014-06-03 13:24:48 +00:00
2014-06-22 19:33:30 +00:00
# Custom records to add to zones.
2014-07-20 15:15:33 +00:00
additional_records = get_custom_dns_config ( env )
2014-06-22 19:33:30 +00:00
2014-06-03 13:24:48 +00:00
# Write zone files.
os . makedirs ( ' /etc/nsd/zones ' , exist_ok = True )
updated_domains = [ ]
2014-06-17 22:21:12 +00:00
for i , ( domain , zonefile ) in enumerate ( zonefiles ) :
# Build the records to put in the zone.
2014-07-17 13:02:39 +00:00
records = build_zone ( domain , domains , additional_records , env )
2014-06-17 22:21:12 +00:00
# See if the zone has changed, and if so update the serial number
# and write the zone file.
2014-08-01 12:05:34 +00:00
if not write_nsd_zone ( domain , " /etc/nsd/zones/ " + zonefile , records , env , force ) :
2014-06-17 22:21:12 +00:00
# Zone was not updated. There were no changes.
continue
# If this is a .justtesting.email domain, then post the update.
try :
2014-06-04 23:39:58 +00:00
justtestingdotemail ( domain , records )
2014-06-17 22:21:12 +00:00
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
2014-08-01 12:15:02 +00:00
# is nearing expiration and if so it'll bump the serial number
2014-06-17 22:21:12 +00:00
# and return True so we get a chance to re-sign it.
2014-06-18 23:30:35 +00:00
sign_zone ( domain , zonefile , env )
# Now that all zones are signed (some might not have changed and so didn't
# just get signed now, but were before) update the zone filename so nsd.conf
# uses the signed file.
for i in range ( len ( zonefiles ) ) :
zonefiles [ i ] [ 1 ] + = " .signed "
2014-06-03 13:24:48 +00:00
# Write the main nsd.conf file.
2014-10-05 14:53:42 +00:00
if write_nsd_conf ( zonefiles , additional_records , env ) :
2014-06-06 12:41:57 +00:00
# Make sure updated_domains contains *something* if we wrote an updated
# nsd.conf so that we know to restart nsd.
if len ( updated_domains ) == 0 :
updated_domains . append ( " DNS configuration " )
# Kick nsd if anything changed.
if len ( updated_domains ) > 0 :
2014-06-13 01:06:04 +00:00
shell ( ' check_call ' , [ " /usr/sbin/service " , " nsd " , " restart " ] )
2014-06-03 13:24:48 +00:00
2014-06-04 22:44:13 +00:00
# Write the OpenDKIM configuration tables.
2014-08-17 20:42:17 +00:00
if write_opendkim_tables ( zonefiles , env ) :
# Settings changed. Kick opendkim.
shell ( ' check_call ' , [ " /usr/sbin/service " , " opendkim " , " restart " ] )
if len ( updated_domains ) == 0 :
# If this is the only thing that changed?
updated_domains . append ( " OpenDKIM configuration " )
2014-06-03 13:24:48 +00:00
2014-06-06 12:41:57 +00:00
if len ( updated_domains ) == 0 :
2014-07-06 12:16:50 +00:00
# if nothing was updated (except maybe OpenDKIM's files), don't show any output
2014-06-06 12:41:57 +00:00
return " "
else :
2014-07-06 12:16:50 +00:00
return " updated DNS: " + " , " . join ( updated_domains ) + " \n "
2014-06-03 13:24:48 +00:00
########################################################################
2014-07-17 13:07:53 +00:00
def build_zone ( domain , all_domains , additional_records , env , is_zone = True ) :
2014-06-04 23:00:31 +00:00
records = [ ]
2014-06-17 23:30:00 +00:00
2014-10-05 14:53:42 +00:00
# For top-level zones, define the authoritative name servers.
#
# Normally we are our own nameservers. Some TLDs require two distinct IP addresses,
# so we allow the user to override the second nameserver definition so that
# secondary DNS can be set up elsewhere.
#
2014-07-17 12:36:45 +00:00
# 'False' in the tuple indicates these records would not be used if the zone
# is managed outside of the box.
2014-07-17 13:07:53 +00:00
if is_zone :
2014-10-05 14:53:42 +00:00
# Obligatory definition of ns1.PRIMARY_HOSTNAME.
2014-07-17 12:36:45 +00:00
records . append ( ( None , " NS " , " ns1. %s . " % env [ " PRIMARY_HOSTNAME " ] , False ) )
2014-10-05 14:53:42 +00:00
# Define ns2.PRIMARY_HOSTNAME or whatever the user overrides.
secondary_ns = additional_records . get ( " _secondary_nameserver " , " ns2. " + env [ " PRIMARY_HOSTNAME " ] )
records . append ( ( None , " NS " , secondary_ns + ' . ' , False ) )
2014-06-17 23:30:00 +00:00
2014-07-17 13:02:39 +00:00
# In PRIMARY_HOSTNAME...
if domain == env [ " PRIMARY_HOSTNAME " ] :
# Define ns1 and ns2.
# 'False' in the tuple indicates these records would not be used if the zone
# is managed outside of the box.
records . append ( ( " ns1 " , " A " , env [ " PUBLIC_IP " ] , False ) )
records . append ( ( " ns2 " , " A " , env [ " PUBLIC_IP " ] , False ) )
2014-07-18 11:03:09 +00:00
if env . get ( ' PUBLIC_IPV6 ' ) :
2014-07-18 11:05:32 +00:00
records . append ( ( " ns1 " , " AAAA " , env [ " PUBLIC_IPV6 " ] , False ) )
records . append ( ( " ns2 " , " AAAA " , env [ " PUBLIC_IPV6 " ] , False ) )
2014-07-17 13:02:39 +00:00
# Set the A/AAAA records. Do this early for the PRIMARY_HOSTNAME so that the user cannot override them
# and we can provide different explanatory text.
records . append ( ( None , " A " , env [ " PUBLIC_IP " ] , " Required. Sets the IP address of the box. " ) )
if env . get ( " PUBLIC_IPV6 " ) : records . append ( ( None , " AAAA " , env [ " PUBLIC_IPV6 " ] , " Required. Sets the IPv6 address of the box. " ) )
# Add a DANE TLSA record for SMTP.
records . append ( ( " _25._tcp " , " TLSA " , build_tlsa_record ( env ) , " Recommended when DNSSEC is enabled. Advertises to mail servers connecting to the box that mandatory encryption should be used. " ) )
2014-08-27 12:56:17 +00:00
# Add a SSHFP records to help SSH key validation. One per available SSH key on this system.
for value in build_sshfp_records ( ) :
records . append ( ( None , " SSHFP " , value , " Optional. Provides an out-of-band method for verifying an SSH key before connecting. Use ' VerifyHostKeyDNS yes ' (or ' VerifyHostKeyDNS ask ' ) when connecting with ssh. " ) )
2014-06-24 03:24:41 +00:00
# The MX record says where email for the domain should be delivered: Here!
2014-08-17 22:43:57 +00:00
records . append ( ( None , " MX " , " 10 %s . " % env [ " PRIMARY_HOSTNAME " ] , " Required. Specifies the hostname (and priority) of the machine that handles @ %s mail. " % domain ) )
2014-06-24 03:24:41 +00:00
# SPF record: Permit the box ('mx', see above) to send mail on behalf of
# the domain, and no one else.
2014-09-07 11:42:20 +00:00
records . append ( ( None , " TXT " , ' v=spf1 mx -all ' , " Recommended. Specifies that only the box is permitted to send @ %s mail. " % domain ) )
2014-06-04 23:00:31 +00:00
2014-07-17 13:02:39 +00:00
# Add DNS records for any subdomains of this domain. We should not have a zone for
# both a domain and one of its subdomains.
subdomains = [ d for d in all_domains if d . endswith ( " . " + domain ) ]
2014-06-22 16:24:15 +00:00
for subdomain in subdomains :
subdomain_qname = subdomain [ 0 : - len ( " . " + domain ) ]
2014-07-17 13:07:53 +00:00
subzone = build_zone ( subdomain , [ ] , { } , env , is_zone = False )
2014-07-17 12:36:45 +00:00
for child_qname , child_rtype , child_value , child_explanation in subzone :
2014-06-17 23:30:00 +00:00
if child_qname == None :
2014-06-22 16:24:15 +00:00
child_qname = subdomain_qname
2014-06-17 23:30:00 +00:00
else :
2014-06-22 16:24:15 +00:00
child_qname + = " . " + subdomain_qname
2014-07-17 12:36:45 +00:00
records . append ( ( child_qname , child_rtype , child_value , child_explanation ) )
2014-06-17 23:30:00 +00:00
2014-09-26 14:00:05 +00:00
def has_rec ( qname , rtype , prefix = None ) :
2014-06-17 21:39:26 +00:00
for rec in records :
2014-09-26 14:00:05 +00:00
if rec [ 0 ] == qname and rec [ 1 ] == rtype and ( prefix is None or rec [ 2 ] . startswith ( prefix ) ) :
2014-06-17 21:39:26 +00:00
return True
return False
# The user may set other records that don't conflict with our settings.
2014-07-20 14:53:55 +00:00
for qname , rtype , value in get_custom_records ( domain , additional_records , env ) :
2014-07-20 14:48:20 +00:00
if has_rec ( qname , rtype ) : continue
records . append ( ( qname , rtype , value , " (Set by user.) " ) )
2014-06-17 21:39:26 +00:00
2014-09-26 13:37:09 +00:00
# Add defaults if not overridden by the user's custom settings (and not otherwise configured).
2014-07-17 12:36:45 +00:00
defaults = [
2014-09-15 10:00:50 +00:00
( None , " A " , env [ " PUBLIC_IP " ] , " Required. May have a different value. Sets the IP address that %s resolves to for web hosting and other services besides mail. The A record must be present but its value does not affect mail delivery. " % domain ) ,
2014-07-17 12:36:45 +00:00
( " www " , " A " , env [ " PUBLIC_IP " ] , " Optional. Sets the IP address that www. %s resolves to, e.g. for web hosting. " % domain ) ,
2014-08-17 22:43:57 +00:00
( None , " AAAA " , env . get ( ' PUBLIC_IPV6 ' ) , " Optional. Sets the IPv6 address that %s resolves to, e.g. for web hosting. (It is not necessary for receiving mail on this domain.) " % domain ) ,
2014-07-17 12:36:45 +00:00
( " www " , " AAAA " , env . get ( ' PUBLIC_IPV6 ' ) , " Optional. Sets the IPv6 address that www. %s resolves to, e.g. for web hosting. " % domain ) ,
]
for qname , rtype , value , explanation in defaults :
if value is None or value . strip ( ) == " " : continue # skip IPV6 if not set
2014-07-17 13:07:53 +00:00
if not is_zone and qname == " www " : continue # don't create any default 'www' subdomains on what are themselves subdomains
2014-07-17 12:36:45 +00:00
if not has_rec ( qname , rtype ) :
records . append ( ( qname , rtype , value , explanation ) )
2014-06-17 21:39:26 +00:00
2014-09-26 13:37:09 +00:00
# Append the DKIM TXT record to the zone as generated by OpenDKIM.
2014-06-04 23:00:31 +00:00
opendkim_record_file = os . path . join ( env [ ' STORAGE_ROOT ' ] , ' mail/dkim/mail.txt ' )
2014-09-26 13:37:09 +00:00
with open ( opendkim_record_file ) as orf :
m = re . match ( r ' ( \ S+) \ s+IN \ s+TXT \ s+ \ ( " ([^ " ]+) " \ s+ " ([^ " ]+) " \ s* \ ) ' , orf . read ( ) , re . S )
val = m . group ( 2 ) + m . group ( 3 )
records . append ( ( m . group ( 1 ) , " TXT " , val , " Recommended. Provides a way for recipients to verify that this machine sent @ %s mail. " % domain ) )
# Append a DMARC record.
records . append ( ( " _dmarc " , " TXT " , ' v=DMARC1; p=quarantine ' , " Optional. Specifies that mail that does not originate from the box but claims to be from @ %s is suspect and should be quarantined by the recipient ' s mail system. " % domain ) )
2014-06-04 23:00:31 +00:00
2014-09-26 14:00:05 +00:00
# For any subdomain with an A record but no SPF or DMARC record, add strict policy records.
all_resolvable_qnames = set ( r [ 0 ] for r in records if r [ 1 ] in ( " A " , " AAAA " ) )
for qname in all_resolvable_qnames :
if not has_rec ( qname , " TXT " , prefix = " v=spf1 " ) :
records . append ( ( qname , " TXT " , ' v=spf1 a mx -all ' , " Prevents unauthorized use of this domain name for outbound mail by requiring outbound mail to originate from the indicated host(s). " ) )
dmarc_qname = " _dmarc " + ( " " if qname is None else " . " + qname )
if not has_rec ( dmarc_qname , " TXT " , prefix = " v=DMARC1; " ) :
records . append ( ( dmarc_qname , " TXT " , ' v=DMARC1; p=reject ' , " Prevents unauthorized use of this domain name for outbound mail by requiring a valid DKIM signature. " ) )
2014-07-17 12:36:45 +00:00
# Sort the records. The None records *must* go first in the nsd zone file. Otherwise it doesn't matter.
2014-06-17 23:34:06 +00:00
records . sort ( key = lambda rec : list ( reversed ( rec [ 0 ] . split ( " . " ) ) if rec [ 0 ] is not None else " " ) )
2014-06-17 21:39:26 +00:00
2014-06-04 23:00:31 +00:00
return records
########################################################################
2014-07-20 14:53:55 +00:00
def get_custom_records ( domain , additional_records , env ) :
2014-07-20 14:48:20 +00:00
for qname , value in additional_records . items ( ) :
# Is this record for the domain or one of its subdomains?
if qname != domain and not qname . endswith ( " . " + domain ) : continue
# Turn the fully qualified domain name in the YAML file into
# our short form (None => domain, or a relative QNAME).
if qname == domain :
qname = None
else :
qname = qname [ 0 : len ( qname ) - len ( " . " + domain ) ]
# Short form. Mapping a domain name to a string is short-hand
# for creating A records.
if isinstance ( value , str ) :
values = [ ( " A " , value ) ]
2014-07-20 14:53:55 +00:00
if value == " local " and env . get ( " PUBLIC_IPV6 " ) :
2014-08-26 19:58:34 +00:00
values . append ( ( " AAAA " , value ) )
2014-07-20 14:48:20 +00:00
# A mapping creates multiple records.
elif isinstance ( value , dict ) :
values = value . items ( )
# No other type of data is allowed.
else :
raise ValueError ( )
for rtype , value2 in values :
2014-07-20 14:53:55 +00:00
# The "local" keyword on A/AAAA records are short-hand for our own IP.
# This also flags for web configuration that the user wants a website here.
if rtype == " A " and value2 == " local " :
value2 = env [ " PUBLIC_IP " ]
if rtype == " AAAA " and value2 == " local " :
if " PUBLIC_IPV6 " not in env : continue # no IPv6 address is available so don't set anything
value2 = env [ " PUBLIC_IPV6 " ]
2014-07-20 14:48:20 +00:00
yield ( qname , rtype , value2 )
########################################################################
2014-06-19 01:39:27 +00:00
def build_tlsa_record ( env ) :
2014-06-21 22:15:53 +00:00
# A DANE TLSA record in DNS specifies that connections on a port
# must use TLS and the certificate must match a particular certificate.
2014-06-19 01:39:27 +00:00
#
# Thanks to http://blog.huque.com/2012/10/dnssec-and-certificates.html
# for explaining all of this!
# Get the hex SHA256 of the DER-encoded server certificate:
certder = shell ( " check_output " , [
" /usr/bin/openssl " ,
" x509 " ,
" -in " , os . path . join ( env [ " STORAGE_ROOT " ] , " ssl " , " ssl_certificate.pem " ) ,
" -outform " , " DER "
] ,
return_bytes = True )
certhash = hashlib . sha256 ( certder ) . hexdigest ( )
# Specify the TLSA parameters:
# 3: This is the certificate that the client should trust. No CA is needed.
# 0: The whole certificate is matched.
# 1: The certificate is SHA256'd here.
return " 3 0 1 " + certhash
2014-08-27 12:56:17 +00:00
def build_sshfp_records ( ) :
# The SSHFP record is a way for us to embed this server's SSH public
# key fingerprint into the DNS so that remote hosts have an out-of-band
# method to confirm the fingerprint. See RFC 4255 and RFC 6594. This
# depends on DNSSEC.
#
# On the client side, set SSH's VerifyHostKeyDNS option to 'ask' to
# include this info in the key verification prompt or 'yes' to trust
# the SSHFP record.
#
# See https://github.com/xelerance/sshfp for inspiriation.
algorithm_number = {
" ssh-rsa " : 1 ,
" ssh-dss " : 2 ,
" ecdsa-sha2-nistp256 " : 3 ,
}
# Get our local fingerprints by running ssh-keyscan. The output looks
# like the known_hosts file: hostname, keytype, fingerprint.
keys = shell ( " check_output " , [ " ssh-keyscan " , " localhost " ] )
for key in keys . split ( " \n " ) :
if key . strip ( ) == " " or key [ 0 ] == " # " : continue
try :
host , keytype , pubkey = key . split ( " " )
yield " %d %d ( %s ) " % (
algorithm_number [ keytype ] ,
2 , # specifies we are using SHA-256 on next line
hashlib . sha256 ( base64 . b64decode ( pubkey ) ) . hexdigest ( ) . upper ( ) ,
)
except :
# Lots of things can go wrong. Don't let it disturb the DNS
# zone.
pass
2014-06-19 01:39:27 +00:00
########################################################################
2014-08-01 12:05:34 +00:00
def write_nsd_zone ( domain , zonefile , records , env , force ) :
2014-06-17 22:21:12 +00:00
# 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'.
2014-09-01 23:05:42 +00:00
# The SOA contact address for all of the domains on this system is hostmaster
# @ the PRIMARY_HOSTNAME. Hopefully that's legit.
# For the refresh through TTL fields, a good reference is:
# http://www.peerwisdom.org/2013/05/15/dns-understanding-the-soa-record/
2014-06-03 13:24:48 +00:00
zone = """
2014-06-17 22:21:12 +00:00
$ ORIGIN { domain } .
2014-09-01 23:05:42 +00:00
$ TTL 1800 ; default time to live
2014-06-03 13:24:48 +00:00
@ IN SOA ns1 . { primary_domain } . hostmaster . { primary_domain } . (
__SERIAL__ ; serial number
2014-09-01 23:05:42 +00:00
7200 ; Refresh ( secondary nameserver update interval )
1800 ; Retry ( when refresh fails , how often to try again )
1209600 ; Expire ( when refresh fails , how long secondary nameserver will keep records around anyway )
1800 ; Negative TTL ( how long negative responses are cached )
2014-06-03 13:24:48 +00:00
)
"""
# Replace replacement strings.
2014-06-30 13:15:36 +00:00
zone = zone . format ( domain = domain , primary_domain = env [ " PRIMARY_HOSTNAME " ] )
2014-06-03 13:24:48 +00:00
2014-06-04 23:00:31 +00:00
# Add records.
2014-07-17 12:36:45 +00:00
for subdomain , querytype , value , explanation in records :
2014-06-04 23:00:31 +00:00
if subdomain :
zone + = subdomain
zone + = " \t IN \t " + querytype + " \t "
2014-09-07 11:42:20 +00:00
if querytype == " TXT " :
value = value . replace ( ' \\ ' , ' \\ \\ ' ) # escape backslashes
value = value . replace ( ' " ' , ' \\ " ' ) # escape quotes
value = ' " ' + value + ' " ' # wrap in quotes
2014-06-04 23:00:31 +00:00
zone + = value + " \n "
2014-06-03 13:24:48 +00:00
2014-06-17 22:21:12 +00:00
# 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
2014-06-03 13:24:48 +00:00
# Set the serial number.
2014-06-17 22:21:12 +00:00
serial = datetime . datetime . now ( ) . strftime ( " % Y % m %d 00 " )
2014-06-03 13:24:48 +00:00
if os . path . exists ( zonefile ) :
# If the zone already exists, is different, and has a later serial number,
# increment the number.
with open ( zonefile ) as f :
existing_zone = f . read ( )
m = re . search ( r " ( \ d+) \ s*; \ s*serial number " , existing_zone )
if m :
2014-06-17 22:21:12 +00:00
# Clear out the serial number in the existing zone file for the
# purposes of seeing if anything *else* in the zone has changed.
2014-06-03 13:24:48 +00:00
existing_serial = m . group ( 1 )
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),
2014-06-17 22:21:12 +00:00
# there is no need to update the file. Unless we're forcing a bump.
2014-08-01 12:05:34 +00:00
if zone == existing_zone and not force_bump and not force :
2014-06-03 13:24:48 +00:00
return False
2014-06-17 22:21:12 +00:00
# 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.
2014-06-03 13:24:48 +00:00
if existing_serial > = serial :
serial = str ( int ( existing_serial ) + 1 )
zone = zone . replace ( " __SERIAL__ " , serial )
# Write the zone file.
with open ( zonefile , " w " ) as f :
f . write ( zone )
return True # file is updated
########################################################################
2014-10-05 14:53:42 +00:00
def write_nsd_conf ( zonefiles , additional_records , env ) :
2014-06-18 23:41:35 +00:00
# Basic header.
2014-06-06 12:41:57 +00:00
nsdconf = """
2014-06-03 13:24:48 +00:00
server :
hide - version : yes
# identify the server (CH TXT ID.SERVER entry).
identity : " "
# The directory for zonefile: files.
zonesdir : " /etc/nsd/zones "
2014-06-06 12:41:57 +00:00
"""
2014-06-18 23:41:35 +00:00
# Since we have bind9 listening on localhost for locally-generated
2014-07-29 23:24:10 +00:00
# DNS queries that require a recursive nameserver, and the system
# might have other network interfaces for e.g. tunnelling, we have
# to be specific about the network interfaces that nsd binds to.
for ipaddr in ( env . get ( " PRIVATE_IP " , " " ) + " " + env . get ( " PRIVATE_IPV6 " , " " ) ) . split ( " " ) :
if ipaddr == " " : continue
2014-06-18 23:53:52 +00:00
nsdconf + = " ip-address: %s \n " % ipaddr
2014-06-18 23:41:35 +00:00
# Append the zones.
2014-06-22 15:34:36 +00:00
for domain , zonefile in zonefiles :
2014-06-06 12:41:57 +00:00
nsdconf + = """
2014-06-03 13:24:48 +00:00
zone :
name : % s
zonefile : % s
2014-06-06 12:41:57 +00:00
""" % (domain, zonefile)
2014-10-05 14:53:42 +00:00
# If a custom secondary nameserver has been set, allow zone transfers
# and notifies to that nameserver.
if additional_records . get ( " _secondary_nameserver " ) :
# Get the IP address of the nameserver by resolving it.
hostname = additional_records . get ( " _secondary_nameserver " )
resolver = dns . resolver . get_default_resolver ( )
response = dns . resolver . query ( hostname , " A " )
ipaddr = str ( response [ 0 ] )
nsdconf + = """ \t notify: %s NOKEY
provide - xfr : % s NOKEY
""" % (ipaddr, ipaddr)
2014-06-06 12:41:57 +00:00
# Check if the nsd.conf is changing. If it isn't changing,
# return False to flag that no change was made.
with open ( " /etc/nsd/nsd.conf " ) as f :
if f . read ( ) == nsdconf :
return False
with open ( " /etc/nsd/nsd.conf " , " w " ) as f :
f . write ( nsdconf )
return True
2014-06-03 13:24:48 +00:00
########################################################################
2014-10-04 17:29:42 +00:00
def dnssec_choose_algo ( domain , env ) :
if domain . endswith ( " .email " ) :
# At least at GoDaddy, this is the only algorithm supported.
return " RSASHA256 "
# For any domain we were able to sign before, don't change the algorithm
# on existing users. We'll probably want to migrate to SHA256 later.
return " RSASHA1-NSEC3-SHA1 "
2014-06-17 22:21:12 +00:00
def sign_zone ( domain , zonefile , env ) :
2014-10-04 17:29:42 +00:00
algo = dnssec_choose_algo ( domain , env )
dnssec_keys = load_env_vars_from_file ( os . path . join ( env [ ' STORAGE_ROOT ' ] , ' dns/dnssec/ %s .conf ' % algo ) )
2014-06-17 22:21:12 +00:00
# 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.
2014-08-01 12:15:02 +00:00
#
# We want to be able to validate DS records too, but multiple forms may be valid depending
# on the digest type. So we'll write all (both) valid records. Only one DS record should
# actually be deployed. Preferebly the first.
2014-06-17 22:21:12 +00:00
with open ( " /etc/nsd/zones/ " + zonefile + " .ds " , " w " ) as f :
2014-08-01 12:15:02 +00:00
for digest_type in ( ' 2 ' , ' 1 ' ) :
rr_ds = shell ( ' check_output ' , [ " /usr/bin/ldns-key2ds " ,
" -n " , # output to stdout
" - " + digest_type , # 1=SHA1, 2=SHA256
dnssec_keys [ " KSK " ] + " .key "
] )
f . write ( rr_ds )
2014-06-17 22:21:12 +00:00
# Remove our temporary file.
for fn in files_to_kill :
os . unlink ( fn )
2014-08-27 12:56:17 +00:00
2014-06-17 22:21:12 +00:00
########################################################################
2014-06-03 13:24:48 +00:00
def write_opendkim_tables ( zonefiles , env ) :
# Append a record to OpenDKIM's KeyTable and SigningTable for each domain.
opendkim_key_file = os . path . join ( env [ ' STORAGE_ROOT ' ] , ' mail/dkim/mail.private ' )
2014-08-17 20:42:17 +00:00
if not os . path . exists ( opendkim_key_file ) :
# Looks like OpenDKIM is not installed.
return False
config = {
# The SigningTable maps email addresses to a key in the KeyTable that
# specifies signing information for matching email addresses. Here we
# map each domain to a same-named key.
#
# Elsewhere we set the DMARC policy for each domain such that mail claiming
# to be From: the domain must be signed with a DKIM key on the same domain.
# So we must have a separate KeyTable entry for each domain.
" SigningTable " :
" " . join (
" *@ {domain} {domain} \n " . format ( domain = domain )
for domain , zonefile in zonefiles
) ,
# The KeyTable specifies the signing domain, the DKIM selector, and the
# path to the private key to use for signing some mail. Per DMARC, the
# signing domain must match the sender's From: domain.
" KeyTable " :
" " . join (
" {domain} {domain} :mail: {key_file} \n " . format ( domain = domain , key_file = opendkim_key_file )
for domain , zonefile in zonefiles
) ,
}
did_update = False
for filename , content in config . items ( ) :
# Don't write the file if it doesn't need an update.
if os . path . exists ( " /etc/opendkim/ " + filename ) :
with open ( " /etc/opendkim/ " + filename ) as f :
if f . read ( ) == content :
continue
# The contents needs to change.
with open ( " /etc/opendkim/ " + filename , " w " ) as f :
f . write ( content )
did_update = True
# Return whether the files changed. If they didn't change, there's
# no need to kick the opendkim process.
return did_update
2014-06-03 13:24:48 +00:00
2014-06-04 23:39:58 +00:00
########################################################################
2014-08-23 23:03:45 +00:00
def set_custom_dns_record ( qname , rtype , value , env ) :
2014-09-21 13:20:37 +00:00
# validate qname
for zone , fn in get_dns_zones ( env ) :
# It must match a zone apex or be a subdomain of a zone
# that we are otherwise hosting.
if qname == zone or qname . endswith ( " . " + zone ) :
break
else :
# No match.
raise ValueError ( " %s is not a domain name or a subdomain of a domain name managed by this box. " % qname )
# validate rtype
2014-08-23 23:03:45 +00:00
rtype = rtype . upper ( )
if value is not None :
if rtype in ( " A " , " AAAA " ) :
v = ipaddress . ip_address ( value )
if rtype == " A " and not isinstance ( v , ipaddress . IPv4Address ) : raise ValueError ( " That ' s an IPv6 address. " )
if rtype == " AAAA " and not isinstance ( v , ipaddress . IPv6Address ) : raise ValueError ( " That ' s an IPv4 address. " )
elif rtype in ( " CNAME " , " TXT " ) :
# anything goes
pass
else :
raise ValueError ( " Unknown record type ' %s ' . " % rtype )
# load existing config
config = get_custom_dns_config ( env )
# update
if qname not in config :
if value is None :
# Is asking to delete a record that does not exist.
return False
elif rtype == " A " :
# Add this record using the short form 'qname: value'.
config [ qname ] = value
else :
# Add this record. This is the qname's first record.
config [ qname ] = { rtype : value }
else :
if isinstance ( config [ qname ] , str ) :
# This is a short-form 'qname: value' implicit-A record.
if value is None and rtype != " A " :
# Is asking to delete a record that doesn't exist.
return False
elif value is None and rtype == " A " :
# Delete record.
del config [ qname ]
elif rtype == " A " :
# Update, keeping short form.
if config [ qname ] == " value " :
# No change.
return False
config [ qname ] = value
else :
# Expand short form so we can add a new record type.
config [ qname ] = { " A " : config [ qname ] , rtype : value }
else :
# This is the qname: { ... } (dict) format.
if value is None :
if rtype not in config [ qname ] :
# Is asking to delete a record that doesn't exist.
return False
else :
# Delete the record. If it's the last record, delete the domain.
del config [ qname ] [ rtype ]
if len ( config [ qname ] ) == 0 :
del config [ qname ]
else :
# Update the record.
if config [ qname ] . get ( rtype ) == " value " :
# No change.
return False
config [ qname ] [ rtype ] = value
# serialize & save
2014-10-05 14:53:42 +00:00
write_custom_dns_config ( config , env )
2014-08-23 23:03:45 +00:00
return True
########################################################################
2014-10-05 14:53:42 +00:00
def set_secondary_dns ( hostname , env ) :
config = get_custom_dns_config ( env )
if hostname in ( None , " " ) :
# Clear.
if " _secondary_nameserver " in config :
del config [ " _secondary_nameserver " ]
else :
# Validate.
hostname = hostname . strip ( ) . lower ( )
resolver = dns . resolver . get_default_resolver ( )
try :
response = dns . resolver . query ( hostname , " A " )
except ( dns . resolver . NoNameservers , dns . resolver . NXDOMAIN , dns . resolver . NoAnswer ) :
raise ValueError ( " Could not resolve the IP address of %s . " % hostname )
# Set.
config [ " _secondary_nameserver " ] = hostname
# Save and apply.
write_custom_dns_config ( config , env )
return do_dns_update ( env )
########################################################################
2014-06-04 23:39:58 +00:00
def justtestingdotemail ( domain , records ) :
# If the domain is a subdomain of justtesting.email, which we own,
# automatically populate the zone where it is set up on dns4e.com.
# Ideally if dns4e.com supported NS records we would just have it
# delegate DNS to us, but instead we will populate the whole zone.
import subprocess , json , urllib . parse
if not domain . endswith ( " .justtesting.email " ) :
return
2014-07-17 12:36:45 +00:00
for subdomain , querytype , value , explanation in records :
2014-06-04 23:39:58 +00:00
if querytype in ( " NS " , ) : continue
if subdomain in ( " www " , " ns1 " , " ns2 " ) : continue # don't do unnecessary things
if subdomain == None :
subdomain = domain
else :
subdomain = subdomain + " . " + domain
if querytype == " TXT " :
# nsd requires parentheses around txt records with multiple parts,
# but DNS4E requires there be no parentheses; also it goes into
# nsd with a newline and a tab, which we replace with a space here
value = re . sub ( " ^ \ s* \ ( \ s*([ \ w \ W]*) \ ) " , r " \ 1 " , value )
value = re . sub ( " \ s+ " , " " , value )
else :
continue
print ( " Updating DNS for %s / %s ... " % ( subdomain , querytype ) )
resp = json . loads ( subprocess . check_output ( [
" curl " ,
" -s " ,
" https://api.dns4e.com/v7/ %s / %s " % ( urllib . parse . quote ( subdomain ) , querytype . lower ( ) ) ,
" --user " , " 2ddbd8e88ed1495fa0ec:A97TDJV26CVUJS6hqAs0CKnhj4HvjTM7MwAAg8xb " ,
" --data " , " record= %s " % urllib . parse . quote ( value ) ,
] ) . decode ( " utf8 " ) )
print ( " \t ... " , resp . get ( " message " , " ? " ) )
2014-07-17 13:02:39 +00:00
########################################################################
2014-08-17 22:43:57 +00:00
def build_recommended_dns ( env ) :
ret = [ ]
2014-07-17 13:02:39 +00:00
domains = get_dns_domains ( env )
zonefiles = get_dns_zones ( env )
2014-08-25 23:35:44 +00:00
additional_records = get_custom_dns_config ( env )
2014-07-17 13:02:39 +00:00
for domain , zonefile in zonefiles :
2014-08-25 23:35:44 +00:00
records = build_zone ( domain , domains , additional_records , env )
2014-07-17 13:02:39 +00:00
# remove records that we don't dislay
records = [ r for r in records if r [ 3 ] is not False ]
2014-08-17 22:43:57 +00:00
# put Required at the top, then Recommended, then everythiing else
2014-07-17 13:02:39 +00:00
records . sort ( key = lambda r : 0 if r [ 3 ] . startswith ( " Required. " ) else ( 1 if r [ 3 ] . startswith ( " Recommended. " ) else 2 ) )
2014-08-17 22:43:57 +00:00
# expand qnames
for i in range ( len ( records ) ) :
if records [ i ] [ 0 ] == None :
2014-07-17 13:02:39 +00:00
qname = domain
else :
2014-08-17 22:43:57 +00:00
qname = records [ i ] [ 0 ] + " . " + domain
records [ i ] = {
" qname " : qname ,
" rtype " : records [ i ] [ 1 ] ,
" value " : records [ i ] [ 2 ] ,
" explanation " : records [ i ] [ 3 ] ,
}
# return
ret . append ( ( domain , records ) )
return ret
if __name__ == " __main__ " :
from utils import load_environment
env = load_environment ( )
for zone , records in build_recommended_dns ( env ) :
for record in records :
print ( " ; " + record [ ' explanation ' ] )
print ( record [ ' qname ' ] , record [ ' rtype ' ] , record [ ' value ' ] , sep = " \t " )
2014-07-17 13:02:39 +00:00
print ( )