2018-01-15 17:43:32 +00:00
#!/usr/local/lib/mailinabox/env/bin/python
2014-07-17 13:02:39 +00:00
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.
########################################################################
2022-02-04 23:26:24 +00:00
import sys
import os
import os . path
import urllib . parse
import datetime
import re
import hashlib
import base64
2014-08-23 23:03:45 +00:00
import ipaddress
2014-06-17 21:39:26 +00:00
import rtyaml
2021-08-23 01:06:10 +00:00
import idna
2014-10-05 14:53:42 +00:00
import dns . resolver
2014-06-03 13:24:48 +00:00
2021-08-23 01:06:10 +00:00
from utils import shell , load_env_vars_from_file , safe_domain_name , sort_domains , load_settings
2020-05-29 19:30:07 +00:00
from ssl_certificates import get_ssl_certificates , check_certificate
2014-06-03 13:24:48 +00:00
2017-06-11 11:56:30 +00:00
# From https://stackoverflow.com/questions/3026957/how-to-validate-a-domain-name-using-regex-php/16491074#16491074
2017-09-22 16:31:49 +00:00
# This regular expression matches domain names according to RFCs, it also accepts fqdn with an leading dot,
2017-11-05 17:22:59 +00:00
# underscores, as well as asteriks which are allowed in domain names but not hostnames (i.e. allowed in
2017-09-22 16:31:49 +00:00
# DNS but not in URLs), which are common in certain record types like for DKIM.
2017-11-05 17:22:59 +00:00
DOMAIN_RE = " ^(?! \ -)(?:[*][.])?(?:[a-zA-Z \ d \ -_] { 0,62}[a-zA-Z \ d_] \ .) { 1,126}(?! \ d+)[a-zA-Z \ d_] { 1,63}( \ .?)$ "
2017-06-11 11:56:30 +00:00
2022-02-04 23:26:24 +00:00
# 24 hours; MIAB-generated records and all custom records without a specified TTL will use this one.
DEFAULT_TTL = 86400
# 30 seconds; Most resolvers will not honor TTL values below this one. Some have an higher min TTL.
TTL_MIN = 30
TTL_MAX = 2592000 # 30 days; some DNS services have lower caps (7 days)
2021-09-16 14:35:04 +00:00
2014-06-22 16:24:15 +00:00
def get_dns_domains ( env ) :
2021-06-27 21:24:26 +00:00
# Add all domain names in use by email users and mail aliases, any
# domains we serve web for (except www redirects because that would
# lead to infinite recursion here) and ensure PRIMARY_HOSTNAME is in the list.
from mailconfig import get_mail_domains
from web_update import get_web_domains
2014-06-03 13:24:48 +00:00
domains = set ( )
2021-06-27 21:24:26 +00:00
domains | = set ( get_mail_domains ( env ) )
domains | = set ( get_web_domains ( env , include_www_redirects = False ) )
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
2022-02-04 23:26:24 +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-09-27 20:04:15 +00:00
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 ( )
2022-02-04 23:26:24 +00:00
for domain in sorted ( domains , key = lambda d : len ( d ) ) :
2014-06-22 16:24:15 +00:00
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.
2022-02-04 23:26:24 +00:00
zone_order = sort_domains ( [ zone [ 0 ] for zone in zonefiles ] , env )
zonefiles . sort ( key = lambda zone : zone_order . index ( zone [ 0 ] ) )
2014-06-22 15:34:36 +00:00
2014-06-17 22:21:12 +00:00
return zonefiles
2014-09-27 20:04:15 +00:00
2022-02-04 23:26:24 +00:00
2014-08-01 12:05:34 +00:00
def do_dns_update ( env , force = False ) :
2014-06-03 13:24:48 +00:00
# Write zone files.
os . makedirs ( ' /etc/nsd/zones ' , exist_ok = True )
2015-11-29 14:59:35 +00:00
zonefiles = [ ]
2014-06-03 13:24:48 +00:00
updated_domains = [ ]
2015-11-29 14:59:35 +00:00
for ( domain , zonefile , records ) in build_zones ( env ) :
# The final set of files will be signed.
zonefiles . append ( ( domain , zonefile + " .signed " ) )
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.
2022-02-04 23:26:24 +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
# 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 )
2014-06-03 13:24:48 +00:00
# Write the main nsd.conf file.
2015-11-29 14:59:35 +00:00
if write_nsd_conf ( zonefiles , list ( get_custom_dns_config ( env ) ) , 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
2021-06-27 21:24:26 +00:00
# Write the OpenDKIM configuration tables for all of the mail domains.
from mailconfig import get_mail_domains
2016-02-23 14:52:10 +00:00
if write_opendkim_tables ( get_mail_domains ( env ) , env ) :
2014-08-17 20:42:17 +00:00
# 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
2015-09-18 13:00:53 +00:00
# Clear bind9's DNS cache so our own DNS resolver is up to date.
# (ignore errors with trap=True)
shell ( ' check_call ' , [ " /usr/sbin/rndc " , " flush " ] , trap = True )
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
2022-02-04 23:26:24 +00:00
2014-06-03 13:24:48 +00:00
########################################################################
2022-02-04 23:26:24 +00:00
2015-11-29 14:59:35 +00:00
def build_zones ( env ) :
# What domains (and their zone filenames) should we build?
domains = get_dns_domains ( env )
zonefiles = get_dns_zones ( env )
2021-06-27 21:24:26 +00:00
# Create a dictionary of domains to a set of attributes for each
# domain, such as whether there are mail users at the domain.
from mailconfig import get_mail_domains
2015-11-29 14:59:35 +00:00
from web_update import get_web_domains
2021-06-27 21:24:26 +00:00
mail_domains = set ( get_mail_domains ( env ) )
2022-02-04 23:26:24 +00:00
# i.e. will log in for mail, Nextcloud
mail_user_domains = set ( get_mail_domains ( env , users_only = True ) )
2021-06-27 21:24:26 +00:00
web_domains = set ( get_web_domains ( env ) )
auto_domains = web_domains - set ( get_web_domains ( env , include_auto = False ) )
2022-02-04 23:26:24 +00:00
domains | = auto_domains # www redirects not included in the initial list, see above
2021-06-27 21:24:26 +00:00
# Add ns1/ns2+PRIMARY_HOSTNAME which must also have A/AAAA records
# when the box is acting as authoritative DNS server for its domains.
for ns in ( " ns1 " , " ns2 " ) :
d = ns + " . " + env [ " PRIMARY_HOSTNAME " ]
domains . add ( d )
auto_domains . add ( d )
domains = {
domain : {
" user " : domain in mail_user_domains ,
" mail " : domain in mail_domains ,
" web " : domain in web_domains ,
" auto " : domain in auto_domains ,
}
for domain in domains
}
# For MTA-STS, we'll need to check if the PRIMARY_HOSTNAME certificate is
# singned and valid. Check that now rather than repeatedly for each domain.
2022-02-04 23:26:24 +00:00
domains [ env [ " PRIMARY_HOSTNAME " ] ] [
" certificate-is-valid " ] = is_domain_cert_signed_and_valid (
env [ " PRIMARY_HOSTNAME " ] , env )
2021-06-27 21:24:26 +00:00
# Load custom records to add to zones.
additional_records = list ( get_custom_dns_config ( env ) )
2015-11-29 14:59:35 +00:00
# Build DNS records for each zone.
for domain , zonefile in zonefiles :
# Build the records to put in the zone.
2021-06-27 21:24:26 +00:00
records = build_zone ( domain , domains , additional_records , env )
2015-11-29 14:59:35 +00:00
yield ( domain , zonefile , records )
2022-02-04 23:26:24 +00:00
def build_zone ( domain ,
domain_properties ,
additional_records ,
env ,
is_zone = True ) :
2014-06-04 23:00:31 +00:00
records = [ ]
2022-02-04 23:26:24 +00:00
2021-08-23 01:06:10 +00:00
# Are there any other authorized servers for this domain?
settings = load_settings ( env )
spf_extra = None
2022-02-18 00:25:03 +00:00
relay_on = settings . get ( " SMTP_RELAY_ENABLED " , False )
if relay_on :
2021-08-23 01:06:10 +00:00
spf_extra = " "
# Convert settings to spf elements
for r in settings . get ( " SMTP_RELAY_AUTHORIZED_SERVERS " , [ ] ) :
sr = " "
if r [ 0 : 4 ] == " spf: " :
sr = f " include: { r [ 4 : ] } "
elif " / " in r :
net = ipaddress . ip_network ( r )
if isinstance ( net , ipaddress . IPv4Network ) :
sr = " ip4: " + net . compressed
elif isinstance ( net , ipaddress . IPv6Network ) :
sr = " ip6: " + net . compressed
elif not ( re . fullmatch ( r " [0-9 \ . \ :]+ " , r ) is None ) :
addr = ipaddress . ip_address ( r )
if isinstance ( addr , ipaddress . IPv4Address ) :
sr = " ip4: " + addr . compressed
elif isinstance ( addr , ipaddress . IPv6Address ) :
sr = " ip6: " + addr . compressed
elif idna . encode ( r ) :
sr = " a: " + idna . encode ( r ) . decode ( )
else :
2022-02-04 23:26:24 +00:00
raise ValueError (
f " Unexpected entry on authorized servers: { r } " )
2021-08-23 01:06:10 +00:00
spf_extra + = f " { sr } "
if spf_extra . strip ( ) == " " :
spf_extra = None
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 :
2021-06-27 21:24:26 +00:00
# Obligatory NS record to ns1.PRIMARY_HOSTNAME.
2022-02-04 23:26:24 +00:00
records . append (
( None , " NS " , " ns1. %s . " % env [ " PRIMARY_HOSTNAME " ] , False , None ) )
2014-10-05 14:53:42 +00:00
2021-06-27 21:24:26 +00:00
# NS record to ns2.PRIMARY_HOSTNAME or whatever the user overrides.
2015-07-01 19:02:40 +00:00
# User may provide one or more additional nameservers
2015-07-10 15:42:33 +00:00
secondary_ns_list = get_secondary_dns ( additional_records , mode = " NS " ) \
2022-02-04 23:26:24 +00:00
or [ " ns2. " + env [ " PRIMARY_HOSTNAME " ] ]
2015-07-10 15:42:33 +00:00
for secondary_ns in secondary_ns_list :
2022-02-04 23:26:24 +00:00
records . append ( ( None , " NS " , secondary_ns + ' . ' , False , None ) )
2014-06-17 23:30:00 +00:00
2014-07-17 13:02:39 +00:00
# In PRIMARY_HOSTNAME...
if domain == env [ " PRIMARY_HOSTNAME " ] :
# 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.
2022-02-04 23:26:24 +00:00
records . append ( ( None , " A " , env [ " PUBLIC_IP " ] ,
" Required. Sets the IP address of the box. " , None ) )
if env . get ( " PUBLIC_IPV6 " ) :
records . append (
( None , " AAAA " , env [ " PUBLIC_IPV6 " ] ,
" Required. Sets the IPv6 address of the box. " , None ) )
2014-07-17 13:02:39 +00:00
# Add a DANE TLSA record for SMTP.
2022-02-04 23:26:24 +00:00
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. " ,
None ) )
2014-07-17 13:02:39 +00:00
2015-07-12 15:12:34 +00:00
# Add a DANE TLSA record for HTTPS, which some browser extensions might make use of.
2022-02-04 23:26:24 +00:00
records . append ( ( " _443._tcp " , " TLSA " , build_tlsa_record (
env
) , " Optional. When DNSSEC is enabled, provides out-of-band HTTPS certificate validation for a few web clients that support it. " ,
None ) )
2015-07-12 15:12:34 +00:00
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 ( ) :
2022-02-04 23:26:24 +00:00
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. " ,
None ) )
2014-08-27 12:56:17 +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.
2022-02-04 23:26:24 +00:00
if is_zone : # don't recurse when we're just loading data for a subdomain
2021-06-27 21:24:26 +00:00
subdomains = [ d for d in domain_properties if d . endswith ( " . " + domain ) ]
for subdomain in subdomains :
subdomain_qname = subdomain [ 0 : - len ( " . " + domain ) ]
2022-02-04 23:26:24 +00:00
subzone = build_zone ( subdomain ,
domain_properties ,
additional_records ,
env ,
is_zone = False )
2021-09-16 14:35:04 +00:00
for child_qname , child_rtype , child_value , child_explanation , child_ttl in subzone :
2021-06-27 21:24:26 +00:00
if child_qname == None :
child_qname = subdomain_qname
else :
child_qname + = " . " + subdomain_qname
2022-02-04 23:26:24 +00:00
records . append ( ( child_qname , child_rtype , child_value ,
child_explanation , child_ttl ) )
has_rec_base = list ( records ) # clone current state
2014-06-17 23:30:00 +00:00
2014-09-26 14:00:05 +00:00
def has_rec ( qname , rtype , prefix = None ) :
2015-05-03 01:10:28 +00:00
for rec in has_rec_base :
2022-02-04 23:26:24 +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.
2015-03-30 00:51:19 +00:00
# Don't put any TXT records above this line, or it'll prevent any custom TXT records.
2022-02-04 23:26:24 +00:00
for qname , rtype , value , ttl in filter_custom_records (
domain , additional_records ) :
2015-05-03 01:10:28 +00:00
# Don't allow custom records for record types that override anything above.
# But allow multiple custom records for the same rtype --- see how has_rec_base is used.
2022-02-04 23:26:24 +00:00
if has_rec ( qname , rtype ) :
continue
2015-05-03 01:10:28 +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 value == " local " :
value = env [ " PUBLIC_IP " ]
if rtype == " AAAA " and value == " local " :
if " PUBLIC_IPV6 " in env :
value = env [ " PUBLIC_IPV6 " ]
else :
continue
2021-09-16 14:35:04 +00:00
records . append ( ( qname , rtype , value , " (Set by user.) " , ttl ) )
2014-06-17 21:39:26 +00:00
2021-06-27 21:24:26 +00:00
# Add A/AAAA defaults if not overridden by the user's custom settings (and not otherwise configured).
2015-06-17 10:18:32 +00:00
# Any CNAME or A record on the qname overrides A and AAAA. But when we set the default A record,
# we should not cause the default AAAA record to be skipped because it thinks a custom A record
# was set. So set has_rec_base to a clone of the current set of DNS settings, and don't update
# during this process.
has_rec_base = list ( records )
2021-06-27 21:24:26 +00:00
a_expl = " 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
if domain_properties [ domain ] [ " auto " ] :
2022-02-04 23:26:24 +00:00
if domain . startswith ( " ns1. " ) or domain . startswith ( " ns2. " ) :
a_expl = False # omit from 'External DNS' page since this only applies if box is its own DNS server
if domain . startswith ( " www. " ) :
a_expl = " Optional. Sets the IP address that %s resolves to so that the box can provide a redirect to the parent domain. " % domain
if domain . startswith ( " mta-sts. " ) :
a_expl = " Optional. MTA-STS Policy Host serving /.well-known/mta-sts.txt. "
if domain . startswith ( " autoconfig. " ) :
a_expl = " Provides email configuration autodiscovery support for Thunderbird Autoconfig. "
if domain . startswith ( " autodiscover. " ) :
a_expl = " Provides email configuration autodiscovery support for Z-Push ActiveSync Autodiscover. "
2014-07-17 12:36:45 +00:00
defaults = [
2022-02-04 23:26:24 +00:00
( None , " A " , env [ " PUBLIC_IP " ] , a_expl ) ,
( 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
]
for qname , rtype , value , explanation in defaults :
2022-02-04 23:26:24 +00:00
if value is None or value . strip ( ) == " " :
continue # skip IPV6 if not set
if not is_zone and qname == " www " :
continue # don't create any default 'www' subdomains on what are themselves subdomains
2015-02-04 02:51:19 +00:00
# Set the default record, but not if:
# (1) there is not a user-set record of the same type already
# (2) there is not a CNAME record already, since you can't set both and who knows what takes precedence
# (2) there is not an A record already (if this is an A record this is a dup of (1), and if this is an AAAA record then don't set a default AAAA record if the user sets a custom A record, since the default wouldn't make sense and it should not resolve if the user doesn't provide a new AAAA record)
2022-02-04 23:26:24 +00:00
if not has_rec ( qname , rtype ) and not has_rec (
qname , " CNAME " ) and not has_rec ( qname , " A " ) :
2021-09-16 14:35:04 +00:00
records . append ( ( qname , rtype , value , explanation , None ) )
2014-06-17 21:39:26 +00:00
2015-06-17 10:18:32 +00:00
# Don't pin the list of records that has_rec checks against anymore.
has_rec_base = records
2021-06-27 21:24:26 +00:00
if domain_properties [ domain ] [ " mail " ] :
# The MX record says where email for the domain should be delivered: Here!
if not has_rec ( None , " MX " , prefix = " 10 " ) :
2022-02-04 23:26:24 +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 , None ) )
2021-06-27 21:24:26 +00:00
# Append a WKD record.
# Skip if the user has set a WKD record already.
2022-02-04 23:26:24 +00:00
if not has_rec ( " openpgpkey " , " CNAME " ) and not has_rec (
" openpgpkey " , " A " ) and not has_rec ( " openpgpkey " , " AAAA " ) :
2021-06-27 21:24:26 +00:00
wkd_records = [
2022-02-04 23:26:24 +00:00
( " openpgpkey " , " A " , env [ " PUBLIC_IP " ] ,
" Optional. Specifies that this machine is an authoritative public key source for @ %s user-id ' s. "
% domain ) ,
( " openpgpkey " , " AAAA " , env [ " PUBLIC_IPV6 " ] ,
" Optional. Specifies that this machine is an authoritative public key source for @ %s user-id ' s. "
% domain )
2021-06-27 21:24:26 +00:00
]
for qname , rtype , value , explanation in wkd_records :
2022-02-04 23:26:24 +00:00
if value is None or value . strip ( ) == " " :
continue # skip IPV6 if not set
2021-06-27 21:24:26 +00:00
if not has_rec ( qname , rtype ) :
2021-09-16 14:35:04 +00:00
records . append ( ( qname , rtype , value , explanation , None ) )
2021-06-27 21:24:26 +00:00
# SPF record: Permit the box ('mx', see above) to send mail on behalf of
2021-08-23 01:06:10 +00:00
# the domain, and no one else (unless the user is using an SMTP relay and authorized other servers).
2021-06-27 21:24:26 +00:00
# Skip if the user has set a custom SPF record.
if not has_rec ( None , " TXT " , prefix = " v=spf1 " ) :
2022-02-18 00:25:03 +00:00
if settings . get ( " SMTP_RELAY_SPF_RECORD " , " " ) . strip ( ) != " " and relay_on :
2022-02-18 00:03:41 +00:00
records . append ( ( None , " TXT " , settings . get ( " SMTP_RELAY_SPF_RECORD " ) , " Added by your SMTP Relay provider so that they can send @ %s mail on your behalf. " % domain , None ) )
elif spf_extra is None :
records . append ( ( None , " TXT " , " v=spf1 mx -all " , " Recommended. Specifies that only the box is permitted to send @ %s mail. " % domain , None ) )
2021-08-23 01:06:10 +00:00
else :
2022-02-18 00:03:41 +00:00
records . append ( ( None , " TXT " , f " v=spf1 mx { spf_extra } -all " , " Recommended. Specifies that only the box and the server(s) you authorized are permitted to send @ %s mail. " % domain , None ) )
2021-06-27 21:24:26 +00:00
# Append the DKIM TXT record to the zone as generated by OpenDKIM.
# Skip if the user has set a DKIM record already.
2022-02-04 23:26:24 +00:00
opendkim_record_file = os . path . join ( env [ ' STORAGE_ROOT ' ] ,
' mail/dkim/mail.txt ' )
2021-06-27 21:24:26 +00:00
with open ( opendkim_record_file ) as orf :
2022-02-04 23:26:24 +00:00
m = re . match ( r ' ( \ S+) \ s+IN \ s+TXT \ s+ \ ( ((?: " [^ " ]+ " \ s+)+) \ ) ' ,
orf . read ( ) , re . S )
2021-06-27 21:24:26 +00:00
val = " " . join ( re . findall ( r ' " ([^ " ]+) " ' , m . group ( 2 ) ) )
2021-08-23 01:06:10 +00:00
rname = f " { settings . get ( ' local_dkim_selector ' , ' mail ' ) } ._domainkey "
if not has_rec ( rname , " TXT " , prefix = " v=DKIM1; " ) :
2022-02-04 23:26:24 +00:00
records . append ( (
rname , " TXT " , val ,
" Recommended. Provides a way for recipients to verify that this machine sent @ %s mail. "
% domain , None ) )
2021-08-23 01:06:10 +00:00
# Append the DKIM TXT record relative to the SMTP relay, if applicable.
# Skip if manually set by the user.
relay_ds = settings . get ( " SMTP_RELAY_DKIM_SELECTOR " )
rr = settings . get ( " SMTP_RELAY_DKIM_RR " , { } )
2022-02-20 20:45:14 +00:00
if relay_on and relay_ds is not None and not has_rec (
2022-02-04 23:26:24 +00:00
f " { relay_ds } ._domainkey " , " TXT " ,
prefix = " v=DKIM1; " ) and rr . get ( " p " ) is not None :
2021-08-23 01:06:10 +00:00
dkim_rrtxt = " "
2022-02-04 23:26:24 +00:00
for c , d in ( ( " v " , " DKIM1 " ) , ( " h " , None ) , ( " k " , " rsa " ) ,
( " n " , None ) , ( " s " , None ) , ( " t " , None ) ) :
2021-08-23 01:06:10 +00:00
txt = rr . get ( c , d )
if txt is None :
continue
else :
dkim_rrtxt + = f " { c } = { txt } ; "
dkim_rrtxt + = f " p= { rr . get ( ' p ' ) } "
2022-02-04 23:26:24 +00:00
records . append ( (
f " { relay_ds } ._domainkey " , " TXT " , dkim_rrtxt ,
" Recommended. Provides a way for recipients to verify that the SMTP relay you set up sent @ %s mail. "
% domain , None ) )
2021-06-27 21:24:26 +00:00
# Append a DMARC record.
# Skip if the user has set a DMARC record already.
if not has_rec ( " _dmarc " , " TXT " , prefix = " v=DMARC1; " ) :
2022-02-04 23:26:24 +00:00
records . append ( (
" _dmarc " , " TXT " , ' v=DMARC1; p=quarantine ' ,
" Recommended. Specifies that mail that does not originate from the box but claims to be from @ %s or which does not have a valid DKIM signature is suspect and should be quarantined by the recipient ' s mail system. "
% domain , None ) )
2021-06-27 21:24:26 +00:00
if domain_properties [ domain ] [ " user " ] :
# Add CardDAV/CalDAV SRV records on the non-primary hostname that points to the primary hostname
# for autoconfiguration of mail clients (so only domains hosting user accounts need it).
# The SRV record format is priority (0, whatever), weight (0, whatever), port, service provider hostname (w/ trailing dot).
if domain != env [ " PRIMARY_HOSTNAME " ] :
for dav in ( " card " , " cal " ) :
qname = " _ " + dav + " davs._tcp "
if not has_rec ( qname , " SRV " ) :
2022-02-04 23:26:24 +00:00
records . append ( (
qname , " SRV " ,
" 0 0 443 " + env [ " PRIMARY_HOSTNAME " ] + " . " ,
" Recommended. Specifies the hostname of the server that handles CardDAV/CalDAV services for email addresses on this domain. " ,
None ) )
2019-05-09 17:13:24 +00:00
2020-05-17 16:10:38 +00:00
# If this is a domain name that there are email addresses configured for, i.e. "something@"
# this domain name, then the domain name is a MTA-STS (https://tools.ietf.org/html/rfc8461)
# Policy Domain.
2020-05-29 19:30:07 +00:00
#
2020-05-30 12:04:07 +00:00
# A "_mta-sts" TXT record signals the presence of a MTA-STS policy. The id field helps clients
# cache the policy. It should be stable so we don't update DNS unnecessarily but change when
# the policy changes. It must be at most 32 letters and numbers, so we compute a hash of the
# policy file.
2020-05-17 16:10:38 +00:00
#
# The policy itself is served at the "mta-sts" (no underscore) subdomain over HTTPS. Therefore
# the TLS certificate used by Postfix for STARTTLS must be a valid certificate for the MX
# domain name (PRIMARY_HOSTNAME) *and* the TLS certificate used by nginx for HTTPS on the mta-sts
# subdomain must be valid certificate for that domain. Do not set an MTA-STS policy if either
# certificate in use is not valid (e.g. because it is self-signed and a valid certificate has not
2020-05-30 12:04:07 +00:00
# yet been provisioned). Since we cannot provision a certificate without A/AAAA records, we
2021-06-27 21:24:26 +00:00
# always set them (by including them in the www domains) --- only the TXT records depend on there
# being valid certificates.
2022-02-04 23:26:24 +00:00
mta_sts_records = [ ]
2021-06-27 21:24:26 +00:00
if domain_properties [ domain ] [ " mail " ] \
2022-02-04 23:26:24 +00:00
and domain_properties [ env [ " PRIMARY_HOSTNAME " ] ] [ " certificate-is-valid " ] \
and is_domain_cert_signed_and_valid ( " mta-sts. " + domain , env ) :
2020-06-12 17:09:11 +00:00
# Compute an up-to-32-character hash of the policy file. We'll take a SHA-1 hash of the policy
# file (20 bytes) and encode it as base-64 (28 bytes, using alphanumeric alternate characters
# instead of '+' and '/' which are not allowed in an MTA-STS policy id) but then just take its
# first 20 characters, which is more than sufficient to change whenever the policy file changes
# (and ensures any '=' padding at the end of the base64 encoding is dropped).
2020-05-30 12:04:07 +00:00
with open ( " /var/lib/mailinabox/mta-sts.txt " , " rb " ) as f :
2022-02-04 23:26:24 +00:00
mta_sts_policy_id = base64 . b64encode (
hashlib . sha1 ( f . read ( ) ) . digest ( ) ,
altchars = b " AA " ) . decode ( " ascii " ) [ 0 : 20 ]
mta_sts_records . extend ( [ (
" _mta-sts " , " TXT " , " v=STSv1; id= " + mta_sts_policy_id ,
" Optional. Part of the MTA-STS policy for incoming mail. If set, a MTA-STS policy must also be published. "
) ] )
2020-05-30 12:04:07 +00:00
2020-09-21 19:56:27 +00:00
# Enable SMTP TLS reporting (https://tools.ietf.org/html/rfc8460) if the user has set a config option.
2020-05-29 19:30:07 +00:00
# Skip if the rules below if the user has set a custom _smtp._tls record.
2022-02-04 23:26:24 +00:00
if env . get ( " MTA_STS_TLSRPT_RUA " ) and not has_rec (
" _smtp._tls " , " TXT " , prefix = " v=TLSRPTv1; " ) :
mta_sts_records . append (
( " _smtp._tls " , " TXT " ,
" v=TLSRPTv1; rua= " + env [ " MTA_STS_TLSRPT_RUA " ] ,
" Optional. Enables MTA-STS reporting. " , None ) )
2020-05-30 12:04:07 +00:00
for qname , rtype , value , explanation in mta_sts_records :
2019-07-10 10:28:37 +00:00
if not has_rec ( qname , rtype ) :
2021-09-16 14:35:04 +00:00
records . append ( ( qname , rtype , value , explanation , None ) )
2019-05-09 17:13:24 +00:00
2021-06-27 21:24:26 +00:00
# Add no-mail-here records for any qname that has an A or AAAA record
# but no MX record. This would include domain itself if domain is a
# non-mail domain and also may include qnames from custom DNS records.
# Do this once at the end of generating a zone.
if is_zone :
2022-02-04 23:26:24 +00:00
qnames_with_a = set ( qname for ( qname , rtype , value , explanation ,
ttl ) in records
if rtype in ( " A " , " AAAA " ) )
qnames_with_mx = set ( qname for ( qname , rtype , value , explanation ,
ttl ) in records if rtype == " MX " )
2021-06-27 21:24:26 +00:00
for qname in qnames_with_a - qnames_with_mx :
# Mark this domain as not sending mail with hard-fail SPF and DMARC records.
2022-02-04 23:26:24 +00:00
d = ( qname + " . " if qname else " " ) + domain
2021-06-27 21:24:26 +00:00
if not has_rec ( qname , " TXT " , prefix = " v=spf1 " ) :
2022-02-04 23:26:24 +00:00
records . append ( (
qname , " TXT " , ' v=spf1 -all ' ,
" Recommended. Prevents use of this domain name for outbound mail by specifying that no servers are valid sources for mail from @ %s . If you do send email from this domain name you should either override this record such that the SPF rule does allow the originating server, or, take the recommended approach and have the box handle mail for this domain (simply add any receiving alias at this domain name to make this machine treat the domain name as one of its mail domains). "
% d , None ) )
if not has_rec ( " _dmarc " + ( " . " + qname if qname else " " ) ,
" TXT " ,
prefix = " v=DMARC1; " ) :
records . append ( (
" _dmarc " + ( " . " + qname if qname else " " ) , " TXT " ,
' v=DMARC1; p=reject ' ,
" Recommended. Prevents use of this domain name for outbound mail by specifying that the SPF rule should be honoured for mail from @ %s . "
% d , None ) )
2021-06-27 21:24:26 +00:00
# And with a null MX record (https://explained-from-first-principles.com/email/#null-mx-record)
if not has_rec ( qname , " MX " ) :
2022-02-04 23:26:24 +00:00
records . append ( (
qname , " MX " , ' 0 . ' ,
" Recommended. Prevents use of this domain name for incoming mail. " ,
None ) )
2021-06-27 21:24:26 +00:00
2021-10-19 13:42:07 +00:00
# Add no-mail-here records for any qname that has an A or AAAA record
# but no MX record. This would include domain itself if domain is a
# non-mail domain and also may include qnames from custom DNS records.
# Do this once at the end of generating a zone.
if is_zone :
2022-02-04 23:26:24 +00:00
qnames_with_a = set ( qname for ( qname , rtype , value , explanation ,
ttl ) in records
if rtype in ( " A " , " AAAA " ) )
qnames_with_mx = set ( qname for ( qname , rtype , value , explanation ,
ttl ) in records if rtype == " MX " )
2021-10-19 13:42:07 +00:00
for qname in qnames_with_a - qnames_with_mx :
# Mark this domain as not sending mail with hard-fail SPF and DMARC records.
2022-02-04 23:26:24 +00:00
d = ( qname + " . " if qname else " " ) + domain
2021-10-19 13:42:07 +00:00
if not has_rec ( qname , " TXT " , prefix = " v=spf1 " ) :
2022-02-04 23:26:24 +00:00
records . append ( (
qname , " TXT " , ' v=spf1 -all ' ,
" Recommended. Prevents use of this domain name for outbound mail by specifying that no servers are valid sources for mail from @ %s . If you do send email from this domain name you should either override this record such that the SPF rule does allow the originating server, or, take the recommended approach and have the box handle mail for this domain (simply add any receiving alias at this domain name to make this machine treat the domain name as one of its mail domains). "
% d , None ) )
if not has_rec ( " _dmarc " + ( " . " + qname if qname else " " ) ,
" TXT " ,
prefix = " v=DMARC1; " ) :
records . append ( (
" _dmarc " + ( " . " + qname if qname else " " ) , " TXT " ,
' v=DMARC1; p=reject ' ,
" Recommended. Prevents use of this domain name for outbound mail by specifying that the SPF rule should be honoured for mail from @ %s . "
% d , None ) )
2021-10-19 13:42:07 +00:00
# And with a null MX record (https://explained-from-first-principles.com/email/#null-mx-record)
if not has_rec ( qname , " MX " ) :
2022-02-04 23:26:24 +00:00
records . append ( (
qname , " MX " , ' 0 . ' ,
" Recommended. Prevents use of this domain name for incoming mail. " ,
None ) )
2021-05-09 12:59:44 +00:00
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.
2022-02-04 23:26:24 +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
2022-02-04 23:26:24 +00:00
2021-06-27 21:24:26 +00:00
def is_domain_cert_signed_and_valid ( domain , env ) :
cert = get_ssl_certificates ( env ) . get ( domain )
2022-02-04 23:26:24 +00:00
if not cert :
return False # no certificate provisioned
cert_status = check_certificate ( domain , cert [ ' certificate ' ] ,
cert [ ' private-key ' ] )
2021-06-27 21:24:26 +00:00
return cert_status [ 0 ] == ' OK '
2022-02-04 23:26:24 +00:00
2014-06-04 23:00:31 +00:00
########################################################################
2022-02-04 23:26:24 +00:00
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
2015-12-26 15:38:49 +00:00
# must use TLS and the certificate must match a particular criteria.
2014-06-19 01:39:27 +00:00
#
# Thanks to http://blog.huque.com/2012/10/dnssec-and-certificates.html
2015-12-26 15:38:49 +00:00
# and https://community.letsencrypt.org/t/please-avoid-3-0-1-and-3-0-2-dane-tlsa-records-with-le-certificates/7022
# for explaining all of this! Also see https://tools.ietf.org/html/rfc6698#section-2.1
# and https://github.com/mail-in-a-box/mailinabox/issues/268#issuecomment-167160243.
#
# There are several criteria. We used to use "3 0 1" criteria, which
# meant to pin a leaf (3) certificate (0) with SHA256 hash (1). But
# certificates change, and especially as we move to short-lived certs
# they change often. The TLSA record handily supports the criteria of
# a leaf certificate (3)'s subject public key (1) with SHA256 hash (1).
# The subject public key is the public key portion of the private key
# that generated the CSR that generated the certificate. Since we
# generate a private key once the first time Mail-in-a-Box is set up
# and reuse it for all subsequent certificates, the TLSA record will
# remain valid indefinitely.
from ssl_certificates import load_cert_chain , load_pem
from cryptography . hazmat . primitives . serialization import Encoding , PublicFormat
fn = os . path . join ( env [ " STORAGE_ROOT " ] , " ssl " , " ssl_certificate.pem " )
cert = load_pem ( load_cert_chain ( fn ) [ 0 ] )
2022-02-04 23:26:24 +00:00
subject_public_key = cert . public_key ( ) . public_bytes (
Encoding . DER , PublicFormat . SubjectPublicKeyInfo )
2015-12-26 15:38:49 +00:00
# We could have also loaded ssl_private_key.pem and called priv_key.public_key().public_bytes(...)
pk_hash = hashlib . sha256 ( subject_public_key ) . hexdigest ( )
2014-06-19 01:39:27 +00:00
# Specify the TLSA parameters:
2015-12-26 15:38:49 +00:00
# 3: Match the (leaf) certificate. (No CA, no trust path needed.)
# 1: Match its subject public key.
# 1: Use SHA256.
return " 3 1 1 " + pk_hash
2014-06-19 01:39:27 +00:00
2022-02-04 23:26:24 +00:00
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 ,
2017-03-01 13:02:41 +00:00
" ssh-ed25519 " : 4 ,
2014-08-27 12:56:17 +00:00
}
# Get our local fingerprints by running ssh-keyscan. The output looks
2014-10-07 15:15:22 +00:00
# like the known_hosts file: hostname, keytype, fingerprint. The order
# of the output is arbitrary, so sort it to prevent spurrious updates
2018-07-07 18:41:41 +00:00
# to the zone file (that trigger bumping the serial number). However,
# if SSH has been configured to listen on a nonstandard port, we must
# specify that port to sshkeyscan.
2021-06-27 21:24:26 +00:00
2018-07-07 18:41:41 +00:00
port = 22
2016-10-15 19:36:13 +00:00
with open ( ' /etc/ssh/sshd_config ' , ' r ' ) as f :
2018-07-07 18:41:41 +00:00
for line in f :
s = line . rstrip ( ) . split ( )
2016-10-15 19:36:13 +00:00
if len ( s ) == 2 and s [ 0 ] == ' Port ' :
2018-07-07 18:41:41 +00:00
try :
port = int ( s [ 1 ] )
except ValueError :
pass
break
2021-06-27 21:24:26 +00:00
2022-02-04 23:26:24 +00:00
keys = shell ( " check_output " , [
" ssh-keyscan " , " -t " , " rsa,dsa,ecdsa,ed25519 " , " -p " ,
str ( port ) , " localhost "
] )
2021-06-27 21:24:26 +00:00
keys = sorted ( keys . split ( " \n " ) )
for key in keys :
2022-02-04 23:26:24 +00:00
if key . strip ( ) == " " or key [ 0 ] == " # " :
continue
2014-08-27 12:56:17 +00:00
try :
host , keytype , pubkey = key . split ( " " )
yield " %d %d ( %s ) " % (
algorithm_number [ keytype ] ,
2022-02-04 23:26:24 +00:00
2 , # specifies we are using SHA-256 on next line
2014-08-27 12:56:17 +00:00
hashlib . sha256 ( base64 . b64decode ( pubkey ) ) . hexdigest ( ) . upper ( ) ,
2022-02-04 23:26:24 +00:00
)
2014-08-27 12:56:17 +00:00
except :
# Lots of things can go wrong. Don't let it disturb the DNS
# zone.
pass
2014-09-27 20:04:15 +00:00
2022-02-04 23:26:24 +00:00
2014-06-19 01:39:27 +00:00
########################################################################
2022-02-04 23:26:24 +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'.
2021-06-27 21:24:26 +00:00
#
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.
2021-06-27 21:24:26 +00:00
#
2014-09-01 23:05:42 +00:00
# For the refresh through TTL fields, a good reference is:
2022-01-08 14:38:41 +00:00
# https://www.ripe.net/publications/docs/ripe-203
2021-06-27 21:24:26 +00:00
#
# A hash of the available DNSSEC keys are added in a comment so that when
# the keys change we force a re-generation of the zone which triggers
# re-signing it.
2014-09-01 23:05:42 +00:00
2014-06-03 13:24:48 +00:00
zone = """
2014-06-17 22:21:12 +00:00
$ ORIGIN { domain } .
2021-09-16 14:35:04 +00:00
$ TTL { ttl } ; default time to live
2014-06-03 13:24:48 +00:00
@ IN SOA ns1 . { primary_domain } . hostmaster . { primary_domain } . (
2022-02-04 23:26:24 +00:00
__SERIAL__ ; serial number
7200 ; Refresh ( secondary nameserver update interval )
3600 ; Retry ( when refresh fails , how often to try again , should be lower than the refresh )
1209600 ; Expire ( when refresh fails , how long secondary nameserver will keep records around anyway )
{ ttl } ; Negative TTL ( how long negative responses are cached )
)
2014-06-03 13:24:48 +00:00
"""
# Replace replacement strings.
2022-02-04 23:26:24 +00:00
zone = zone . format ( domain = domain ,
primary_domain = env [ " PRIMARY_HOSTNAME " ] ,
ttl = DEFAULT_TTL )
2014-06-03 13:24:48 +00:00
2014-06-04 23:00:31 +00:00
# Add records.
2021-09-16 14:35:04 +00:00
for subdomain , querytype , value , explanation , ttl in records :
2014-06-04 23:00:31 +00:00
if subdomain :
store IDNs (internationalized domain names) in IDNA (ASCII) in our database, not in Unicode
I changed my mind. In 1bf8f1991f6f08e0fb1e3d2572d280d894a5e431 I allowed Unicode domain names to go into the database. I thought that was nice because it's what the user *means*. But it's not how the web works. Web and DNS were working, but mail wasn't. Postfix (as shipped with Ubuntu 14.04 without support for SMTPUTF8) exists in an ASCII-only world. When it goes to the users/aliases table, it queries in ASCII (IDNA) only and had no hope of delivering mail if the domain was in full Unicode in the database. I was thinking ahead to SMTPUTF8, where we *could* put Unicode in the database (though that would prevent IDNA-encoded addressing from being deliverable) not realizing it isn't well supported yet anyway.
It's IDNA that goes on the wire in most places anyway (SMTP without SMTPUTF8 (and therefore how Postfix queries our users/aliases tables), DNS zone files, nginx config, CSR 'CN' field, X509 Common Name and Subject Alternative Names fields), so we should really be talking in terms of IDNA (i.e. ASCII).
This partially reverts commit 1bf8f1991f6f08e0fb1e3d2572d280d894a5e431, where I added a lot of Unicode=>IDNA conversions when writing configuration files. Instead I'm doing Unicode=>IDNA before email addresses get into the users/aliases table. Now we assume the database uses IDNA-encoded ASCII domain names. When adding/removing aliases, addresses are converted to ASCII (w/ IDNA). User accounts must be ASCII-only anyway because of Dovecot's auth limitations, so we don't do any IDNA conversion (don't want to change the user's login info behind their back!). The aliases control panel page converts domains back to Unicode for display to be nice. The status checks converts the domains to Unicode just for the output headings.
A migration is added to convert existing aliases with Unicode domains into IDNA. Any custom DNS or web settings with Unicode may need to be changed.
Future support for SMTPUTF8 will probably need to add columns in the users/aliases table so that it lists both IDNA and Unicode forms.
2015-03-29 13:33:31 +00:00
zone + = subdomain
2021-09-16 14:35:04 +00:00
if ttl is not None :
zone + = " \t " + str ( ttl )
2014-06-04 23:00:31 +00:00
zone + = " \t IN \t " + querytype + " \t "
2014-09-07 11:42:20 +00:00
if querytype == " TXT " :
2015-06-25 13:02:40 +00:00
# Divide into 255-byte max substrings.
v2 = " "
while len ( value ) > 0 :
s = value [ 0 : 255 ]
value = value [ 255 : ]
2022-02-04 23:26:24 +00:00
s = s . replace ( ' \\ ' , ' \\ \\ ' ) # escape backslashes
s = s . replace ( ' " ' , ' \\ " ' ) # escape quotes
s = ' " ' + s + ' " ' # wrap in quotes
2015-06-25 13:02:40 +00:00
v2 + = s + " "
value = v2
2014-06-04 23:00:31 +00:00
zone + = value + " \n "
2014-06-03 13:24:48 +00:00
2021-06-27 21:24:26 +00:00
# Append a stable hash of DNSSEC signing keys in a comment.
2022-02-04 23:26:24 +00:00
zone + = " \n ; DNSSEC signing keys hash: {} \n " . format (
hash_dnssec_keys ( domain , env ) )
2021-06-27 21:24:26 +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 ( )
2022-02-04 23:26:24 +00:00
expiration_times = re . findall (
r " \ sRRSIG \ s+SOA \ s+ \ d+ \ s+ \ d+ \ s \ d+ \ s+( \ d {14} ) " , signed_zone )
2014-06-17 22:21:12 +00:00
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 )
2022-02-04 23:26:24 +00:00
expiration_time = datetime . datetime . strptime (
expiration_time , " % Y % m %d % H % M % S " )
if expiration_time - datetime . datetime . now ( ) < datetime . timedelta (
days = 3 ) :
2014-06-17 22:21:12 +00:00
# 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 )
2022-02-04 23:26:24 +00:00
existing_zone = existing_zone . replace (
m . group ( 0 ) , " __SERIAL__ ; serial number " )
2014-06-03 13:24:48 +00:00
# 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 )
2022-02-04 23:26:24 +00:00
return True # file is updated
2014-06-03 13:24:48 +00:00
2020-11-16 11:03:41 +00:00
def get_dns_zonefile ( zone , env ) :
for domain , fn in get_dns_zones ( env ) :
if zone == domain :
break
else :
2022-02-04 23:26:24 +00:00
raise ValueError (
" %s is not a domain name that corresponds to a zone. " % zone )
2020-11-16 11:03:41 +00:00
nsd_zonefile = " /etc/nsd/zones/ " + fn
with open ( nsd_zonefile , " r " ) as f :
return f . read ( )
2022-02-04 23:26:24 +00:00
2014-06-03 13:24:48 +00:00
########################################################################
2022-02-04 23:26:24 +00:00
2014-10-05 14:53:42 +00:00
def write_nsd_conf ( zonefiles , additional_records , env ) :
2015-05-04 11:19:48 +00:00
# Write the list of zones to a configuration file.
2021-09-24 12:07:40 +00:00
nsd_conf_file = " /etc/nsd/nsd.conf.d/zones.conf "
2015-05-04 11:19:48 +00:00
nsdconf = " "
2014-09-27 20:04:15 +00:00
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
store IDNs (internationalized domain names) in IDNA (ASCII) in our database, not in Unicode
I changed my mind. In 1bf8f1991f6f08e0fb1e3d2572d280d894a5e431 I allowed Unicode domain names to go into the database. I thought that was nice because it's what the user *means*. But it's not how the web works. Web and DNS were working, but mail wasn't. Postfix (as shipped with Ubuntu 14.04 without support for SMTPUTF8) exists in an ASCII-only world. When it goes to the users/aliases table, it queries in ASCII (IDNA) only and had no hope of delivering mail if the domain was in full Unicode in the database. I was thinking ahead to SMTPUTF8, where we *could* put Unicode in the database (though that would prevent IDNA-encoded addressing from being deliverable) not realizing it isn't well supported yet anyway.
It's IDNA that goes on the wire in most places anyway (SMTP without SMTPUTF8 (and therefore how Postfix queries our users/aliases tables), DNS zone files, nginx config, CSR 'CN' field, X509 Common Name and Subject Alternative Names fields), so we should really be talking in terms of IDNA (i.e. ASCII).
This partially reverts commit 1bf8f1991f6f08e0fb1e3d2572d280d894a5e431, where I added a lot of Unicode=>IDNA conversions when writing configuration files. Instead I'm doing Unicode=>IDNA before email addresses get into the users/aliases table. Now we assume the database uses IDNA-encoded ASCII domain names. When adding/removing aliases, addresses are converted to ASCII (w/ IDNA). User accounts must be ASCII-only anyway because of Dovecot's auth limitations, so we don't do any IDNA conversion (don't want to change the user's login info behind their back!). The aliases control panel page converts domains back to Unicode for display to be nice. The status checks converts the domains to Unicode just for the output headings.
A migration is added to convert existing aliases with Unicode domains into IDNA. Any custom DNS or web settings with Unicode may need to be changed.
Future support for SMTPUTF8 will probably need to add columns in the users/aliases table so that it lists both IDNA and Unicode forms.
2015-03-29 13:33:31 +00:00
""" % (domain, zonefile)
2014-10-11 15:52:53 +00:00
2015-07-01 19:02:40 +00:00
# If custom secondary nameservers have been set, allow zone transfers
2019-11-10 16:58:22 +00:00
# and, if not a subnet, notifies to them.
2015-07-10 15:42:33 +00:00
for ipaddr in get_secondary_dns ( additional_records , mode = " xfr " ) :
2019-11-10 16:58:22 +00:00
if " / " not in ipaddr :
nsdconf + = " \n \t notify: %s NOKEY " % ( ipaddr )
nsdconf + = " \n \t provide-xfr: %s NOKEY \n " % ( ipaddr )
2014-10-05 14:53:42 +00:00
2015-05-04 11:19:48 +00:00
# Check if the file is changing. If it isn't changing,
2014-06-06 12:41:57 +00:00
# return False to flag that no change was made.
2015-05-04 11:19:48 +00:00
if os . path . exists ( nsd_conf_file ) :
with open ( nsd_conf_file ) as f :
if f . read ( ) == nsdconf :
return False
# Write out new contents and return True to signal that
# configuration changed.
with open ( nsd_conf_file , " w " ) as f :
2014-06-06 12:41:57 +00:00
f . write ( nsdconf )
return True
2014-06-03 13:24:48 +00:00
2022-02-04 23:26:24 +00:00
2014-06-03 13:24:48 +00:00
########################################################################
2022-02-04 23:26:24 +00:00
2021-06-27 21:24:26 +00:00
def find_dnssec_signing_keys ( domain , env ) :
# For key that we generated (one per algorithm)...
d = os . path . join ( env [ ' STORAGE_ROOT ' ] , ' dns/dnssec ' )
keyconfs = [ f for f in os . listdir ( d ) if f . endswith ( " .conf " ) ]
for keyconf in keyconfs :
# Load the file holding the KSK and ZSK key filenames.
keyconf_fn = os . path . join ( d , keyconf )
keyinfo = load_env_vars_from_file ( keyconf_fn )
# Skip this key if the conf file has a setting named DOMAINS,
# holding a comma-separated list of domain names, and if this
# domain is not in the list. This allows easily disabling a
# key by setting "DOMAINS=" or "DOMAINS=none", other than
# deleting the key's .conf file, which might result in the key
# being regenerated next upgrade. Keys should be disabled if
# they are not needed to reduce the DNSSEC query response size.
2022-02-04 23:26:24 +00:00
if " DOMAINS " in keyinfo and domain not in [
dd . strip ( ) for dd in keyinfo [ " DOMAINS " ] . split ( " , " )
] :
2021-06-27 21:24:26 +00:00
continue
for keytype in ( " KSK " , " ZSK " ) :
yield keytype , keyinfo [ keytype ]
2022-02-04 23:26:24 +00:00
2021-06-27 21:24:26 +00:00
def hash_dnssec_keys ( domain , env ) :
# Create a stable (by sorting the items) hash of all of the private keys
# that will be used to sign this domain.
keydata = [ ]
for keytype , keyfn in sorted ( find_dnssec_signing_keys ( domain , env ) ) :
2022-02-04 23:26:24 +00:00
oldkeyfn = os . path . join ( env [ ' STORAGE_ROOT ' ] , ' dns/dnssec ' ,
keyfn + " .private " )
2021-06-27 21:24:26 +00:00
keydata . append ( keytype )
keydata . append ( keyfn )
with open ( oldkeyfn , " r " ) as fr :
2022-02-04 23:26:24 +00:00
keydata . append ( fr . read ( ) )
2021-06-27 21:24:26 +00:00
keydata = " " . join ( keydata ) . encode ( " utf8 " )
return hashlib . sha1 ( keydata ) . hexdigest ( )
2014-10-04 17:29:42 +00:00
2022-02-04 23:26:24 +00:00
2014-06-17 22:21:12 +00:00
def sign_zone ( domain , zonefile , env ) :
2021-06-27 21:24:26 +00:00
# Sign the zone with all of the keys that were generated during
# setup so that the user can choose which to use in their DS record at
# their registrar, and also to support migration to newer algorithms.
2014-06-17 22:21:12 +00:00
2021-06-27 21:24:26 +00:00
# In order to use the key files generated at setup which are for
# the domain _domain_, we have to re-write the files and place
# the actual domain name in it, so that ldns-signzone works.
2014-09-27 20:04:15 +00:00
#
2021-06-27 21:24:26 +00:00
# Patch each key, storing the patched version in /tmp for now.
# Each key has a .key and .private file. Collect a list of filenames
# for all of the keys (and separately just the key-signing keys).
all_keys = [ ]
ksk_keys = [ ]
for keytype , keyfn in find_dnssec_signing_keys ( domain , env ) :
newkeyfn = ' /tmp/ ' + keyfn . replace ( " _domain_ " , domain )
2014-06-17 22:21:12 +00:00
for ext in ( " .private " , " .key " ) :
2021-06-27 21:24:26 +00:00
# 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.
2022-02-04 23:26:24 +00:00
oldkeyfn = os . path . join ( env [ ' STORAGE_ROOT ' ] , ' dns/dnssec ' ,
keyfn + ext )
2021-06-27 21:24:26 +00:00
with open ( oldkeyfn , " r " ) as fr :
2014-06-17 22:21:12 +00:00
keydata = fr . read ( )
2021-06-27 21:24:26 +00:00
keydata = keydata . replace ( " _domain_ " , domain )
2022-02-04 23:26:24 +00:00
# ensure written file is not world-readable
prev_umask = os . umask ( 0o77 )
2014-06-17 22:21:12 +00:00
try :
2021-06-27 21:24:26 +00:00
with open ( newkeyfn + ext , " w " ) as fw :
2014-06-17 22:21:12 +00:00
fw . write ( keydata )
finally :
2022-02-04 23:26:24 +00:00
# other files we write should be world-readable
os . umask ( prev_umask )
2021-06-27 21:24:26 +00:00
# Put the patched key filename base (without extension) into the list of keys we'll sign with.
all_keys . append ( newkeyfn )
2022-02-04 23:26:24 +00:00
if keytype == " KSK " :
ksk_keys . append ( newkeyfn )
2014-06-17 22:21:12 +00:00
# Do the signing.
2022-02-04 23:26:24 +00:00
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 ,
]
2014-06-17 22:21:12 +00:00
# keys to sign with (order doesn't matter -- it'll figure it out)
2022-02-04 23:26:24 +00:00
+ all_keys )
2014-06-17 22:21:12 +00:00
# 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
#
2021-06-27 21:24:26 +00:00
# Generate a DS record for each key. There are also several possible hash algorithms that may
# be used, so we'll pre-generate all for each key. One DS record per line. Only one
# needs to actually be deployed at the registrar. We'll select the preferred one
# in the status checks.
2014-06-17 22:21:12 +00:00
with open ( " /etc/nsd/zones/ " + zonefile + " .ds " , " w " ) as f :
2021-06-27 21:24:26 +00:00
for key in ksk_keys :
for digest_type in ( ' 1 ' , ' 2 ' , ' 4 ' ) :
2022-02-04 23:26:24 +00:00
rr_ds = shell (
' check_output ' ,
[
" /usr/bin/ldns-key2ds " ,
" -n " , # output to stdout
" - " + digest_type , # 1=SHA1, 2=SHA256, 4=SHA384
key + " .key "
] )
2021-06-27 21:24:26 +00:00
f . write ( rr_ds )
# Remove the temporary patched key files.
for fn in all_keys :
os . unlink ( fn + " .private " )
os . unlink ( fn + " .key " )
2014-08-27 12:56:17 +00:00
2022-02-04 23:26:24 +00:00
2014-06-17 22:21:12 +00:00
########################################################################
2022-02-04 23:26:24 +00:00
2015-02-16 23:13:49 +00:00
def write_opendkim_tables ( domains , env ) :
# Append a record to OpenDKIM's KeyTable and SigningTable for each domain
# that we send mail from (zones and all subdomains).
2014-06-03 13:24:48 +00:00
2022-02-04 23:26:24 +00:00
opendkim_key_file = os . path . join ( env [ ' STORAGE_ROOT ' ] ,
' mail/dkim/mail.private ' )
2021-08-23 01:06:10 +00:00
config = load_settings ( env )
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 " :
2022-02-04 23:26:24 +00:00
" " . join ( " *@ {domain} {domain} \n " . format ( domain = domain )
for domain in domains ) ,
2014-08-17 20:42:17 +00:00
# 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 " :
2022-02-04 23:26:24 +00:00
" " . join ( " {domain} {domain} : {selector} : {key_file} \n " . format (
domain = domain ,
key_file = opendkim_key_file ,
selector = config . get ( " local_dkim_selector " , " mail " ) )
for domain in domains ) ,
2014-08-17 20:42:17 +00:00
}
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
2022-02-04 23:26:24 +00:00
2014-06-04 23:39:58 +00:00
########################################################################
2022-02-04 23:26:24 +00:00
2021-02-28 14:35:40 +00:00
def get_custom_dns_config ( env , only_real_records = False ) :
2015-05-03 01:10:28 +00:00
try :
2022-02-04 23:26:24 +00:00
custom_dns = rtyaml . load (
open ( os . path . join ( env [ ' STORAGE_ROOT ' ] , ' dns/custom.yaml ' ) ) )
if not isinstance ( custom_dns , dict ) :
raise ValueError ( ) # caught below
2015-05-03 01:10:28 +00:00
except :
2022-02-04 23:26:24 +00:00
return [ ]
2015-05-03 01:10:28 +00:00
2021-09-16 14:35:04 +00:00
for qname , entry in custom_dns . items ( ) :
2022-02-04 23:26:24 +00:00
if qname == " _secondary_nameserver " and only_real_records :
continue # skip fake record
2021-02-28 14:35:40 +00:00
2015-05-03 01:10:28 +00:00
# Short form. Mapping a domain name to a string is short-hand
# for creating A records.
2021-09-16 14:35:04 +00:00
if isinstance ( entry , str ) :
values = [ ( " A " , entry ) ]
2015-05-03 01:10:28 +00:00
# A mapping creates multiple records.
2021-09-16 14:35:04 +00:00
elif isinstance ( entry , dict ) :
values = entry . items ( )
2015-05-03 01:10:28 +00:00
# No other type of data is allowed.
else :
raise ValueError ( )
2021-09-16 14:35:04 +00:00
for rtype , value in values :
if isinstance ( value , str ) :
yield ( qname , rtype , value , None )
elif isinstance ( value , dict ) :
yield ( qname , rtype , value . get ( " value " ) , value . get ( " ttl " ) )
elif isinstance ( value , list ) :
for val in value :
if isinstance ( val , str ) :
yield ( qname , rtype , val , None )
elif isinstance ( val , dict ) :
yield ( qname , rtype , val . get ( " value " ) , val . get ( " ttl " ) )
else :
# No other type of data is allowed.
raise ValueError ( )
2015-05-03 01:10:28 +00:00
else :
2021-09-16 14:35:04 +00:00
# No other type of data is allowed.
2015-05-03 01:10:28 +00:00
raise ValueError ( )
2022-02-04 23:26:24 +00:00
2015-05-03 01:10:28 +00:00
def filter_custom_records ( domain , custom_dns_iter ) :
2021-09-16 14:35:04 +00:00
for qname , rtype , value , ttl in custom_dns_iter :
2015-05-03 01:10:28 +00:00
# We don't count the secondary nameserver config (if present) as a record - that would just be
# confusing to users. Instead it is accessed/manipulated directly via (get/set)_custom_dns_config.
2022-02-04 23:26:24 +00:00
if qname == " _secondary_nameserver " :
continue
2015-05-03 01:10:28 +00:00
# Is this record for the domain or one of its subdomains?
# If `domain` is None, return records for all domains.
2022-02-04 23:26:24 +00:00
if domain is not None and qname != domain and not qname . endswith (
" . " + domain ) :
continue
2015-05-03 01:10:28 +00:00
# Turn the fully qualified domain name in the YAML file into
# our short form (None => domain, or a relative QNAME) if
# domain is not None.
if domain is not None :
if qname == domain :
qname = None
else :
2022-02-04 23:26:24 +00:00
qname = qname [ 0 : len ( qname ) - len ( " . " + domain ) ]
2015-05-03 01:10:28 +00:00
2021-09-16 14:35:04 +00:00
yield ( qname , rtype , value , ttl )
2015-05-03 01:10:28 +00:00
2022-02-04 23:26:24 +00:00
2015-05-03 01:10:28 +00:00
def write_custom_dns_config ( config , env ) :
2021-09-16 14:35:04 +00:00
# We get a list of (qname, rtype, value, ttl) triples. Convert this into a
2015-05-03 01:10:28 +00:00
# nice dictionary format for storage on disk.
from collections import OrderedDict
config = list ( config )
dns = OrderedDict ( )
seen_qnames = set ( )
# Process the qnames in the order we see them.
for qname in [ rec [ 0 ] for rec in config ] :
2022-02-04 23:26:24 +00:00
if qname in seen_qnames :
continue
2015-05-03 01:10:28 +00:00
seen_qnames . add ( qname )
2022-02-04 23:26:24 +00:00
records = [ ( rec [ 1 ] , rec [ 2 ] , rec [ 3 ] ) for rec in config
if rec [ 0 ] == qname ]
if len (
records ) == 1 and records [ 0 ] [ 0 ] == " A " and records [ 0 ] [ 2 ] is None :
2015-05-03 01:10:28 +00:00
dns [ qname ] = records [ 0 ] [ 1 ]
else :
dns [ qname ] = OrderedDict ( )
seen_rtypes = set ( )
# Process the rtypes in the order we see them.
for rtype in [ rec [ 0 ] for rec in records ] :
2022-02-04 23:26:24 +00:00
if rtype in seen_rtypes :
continue
2015-05-03 01:10:28 +00:00
seen_rtypes . add ( rtype )
2022-02-04 23:26:24 +00:00
values = [ ( rec [ 1 ] if rec [ 2 ] is None else {
" value " : rec [ 1 ] ,
" ttl " : min ( max ( TTL_MIN , rec [ 2 ] ) , TTL_MAX )
} ) for rec in records if rec [ 0 ] == rtype ]
2015-05-03 01:10:28 +00:00
if len ( values ) == 1 :
values = values [ 0 ]
dns [ qname ] [ rtype ] = values
2014-09-27 20:04:15 +00:00
# Write.
2015-05-03 01:10:28 +00:00
config_yaml = rtyaml . dump ( dns )
with open ( os . path . join ( env [ ' STORAGE_ROOT ' ] , ' dns/custom.yaml ' ) , " w " ) as f :
f . write ( config_yaml )
2022-02-04 23:26:24 +00:00
2021-09-16 14:35:04 +00:00
def set_custom_dns_record ( qname , rtype , value , action , env , ttl = None ) :
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.
2022-02-04 23:26:24 +00:00
if qname == zone or qname . endswith ( " . " + zone ) :
2014-09-21 13:20:37 +00:00
break
else :
# No match.
2015-05-03 01:10:28 +00:00
if qname != " _secondary_nameserver " :
2022-02-04 23:26:24 +00:00
raise ValueError (
" %s is not a domain name or a subdomain of a domain name managed by this box. "
% qname )
2014-09-21 13:20:37 +00:00
# validate rtype
2014-08-23 23:03:45 +00:00
rtype = rtype . upper ( )
2015-05-03 01:10:28 +00:00
if value is not None and qname != " _secondary_nameserver " :
2017-07-21 12:20:37 +00:00
if not re . search ( DOMAIN_RE , qname ) :
raise ValueError ( " Invalid name. " )
2014-08-23 23:03:45 +00:00
if rtype in ( " A " , " AAAA " ) :
2022-02-04 23:26:24 +00:00
if value != " local " : # "local" is a special flag for us
# raises a ValueError if there's a problem
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. " )
2017-06-11 11:56:30 +00:00
elif rtype in ( " CNAME " , " NS " ) :
if rtype == " NS " and qname == zone :
raise ValueError ( " NS records can only be set for subdomains. " )
# ensure value has a trailing dot
if not value . endswith ( " . " ) :
value = value + " . "
if not re . search ( DOMAIN_RE , value ) :
raise ValueError ( " Invalid value. " )
2017-04-30 12:58:00 +00:00
elif rtype in ( " CNAME " , " TXT " , " SRV " , " MX " , " SSHFP " , " CAA " ) :
2014-08-23 23:03:45 +00:00
# anything goes
pass
else :
raise ValueError ( " Unknown record type ' %s ' . " % rtype )
# load existing config
2015-05-03 01:10:28 +00:00
config = list ( get_custom_dns_config ( env ) )
2014-08-23 23:03:45 +00:00
# update
2015-05-03 01:10:28 +00:00
newconfig = [ ]
made_change = False
needs_add = True
2021-09-16 14:35:04 +00:00
for _qname , _rtype , _value , _ttl in config :
2015-05-03 01:10:28 +00:00
if action == " add " :
if ( _qname , _rtype , _value ) == ( qname , rtype , value ) :
# Record already exists. Bail.
2014-08-23 23:03:45 +00:00
return False
2015-05-03 01:10:28 +00:00
elif action == " set " :
if ( _qname , _rtype ) == ( qname , rtype ) :
2021-09-16 14:35:04 +00:00
if _value == value and _ttl == ttl :
2015-05-03 01:10:28 +00:00
# Flag that the record already exists, don't
# need to add it.
needs_add = False
2014-08-23 23:03:45 +00:00
else :
2015-05-03 01:10:28 +00:00
# Drop any other values for this (qname, rtype).
made_change = True
continue
elif action == " remove " :
if ( _qname , _rtype , _value ) == ( qname , rtype , value ) :
# Drop this record.
made_change = True
continue
if value == None and ( _qname , _rtype ) == ( qname , rtype ) :
# Drop all qname-rtype records.
made_change = True
continue
else :
raise ValueError ( " Invalid action: " + action )
2014-08-23 23:03:45 +00:00
2015-05-03 01:10:28 +00:00
# Preserve this record.
2021-09-16 14:35:04 +00:00
newconfig . append ( ( _qname , _rtype , _value , _ttl ) )
2014-08-23 23:03:45 +00:00
2015-05-03 01:10:28 +00:00
if action in ( " add " , " set " ) and needs_add and value is not None :
2021-09-16 14:35:04 +00:00
newconfig . append ( ( qname , rtype , value , ttl ) )
2015-05-03 01:10:28 +00:00
made_change = True
if made_change :
# serialize & save
write_custom_dns_config ( newconfig , env )
return made_change
2014-08-23 23:03:45 +00:00
2022-02-04 23:26:24 +00:00
2014-08-23 23:03:45 +00:00
########################################################################
2022-02-04 23:26:24 +00:00
2015-07-10 15:42:33 +00:00
def get_secondary_dns ( custom_dns , mode = None ) :
resolver = dns . resolver . get_default_resolver ( )
2015-11-11 12:24:01 +00:00
resolver . timeout = 10
2015-07-01 19:02:40 +00:00
values = [ ]
2021-09-16 14:35:04 +00:00
for qname , rtype , value , ttl in custom_dns :
2022-02-04 23:26:24 +00:00
if qname != ' _secondary_nameserver ' :
continue
2015-07-10 15:42:33 +00:00
for hostname in value . split ( " " ) :
hostname = hostname . strip ( )
if mode == None :
# Just return the setting.
values . append ( hostname )
2015-07-20 11:25:16 +00:00
continue
2015-07-10 15:42:33 +00:00
# This is a hostname. Before including in zone xfr lines,
# resolve to an IP address. Otherwise just return the hostname.
2019-11-19 14:57:27 +00:00
# It may not resolve to IPv6, so don't throw an exception if it
# doesn't.
2015-07-10 15:42:33 +00:00
if not hostname . startswith ( " xfr: " ) :
if mode == " xfr " :
2022-02-04 23:26:24 +00:00
response = dns . resolver . resolve ( hostname + ' . ' ,
" A " ,
raise_on_no_answer = False )
2019-10-28 10:31:50 +00:00
values . extend ( map ( str , response ) )
2022-02-04 23:26:24 +00:00
response = dns . resolver . resolve ( hostname + ' . ' ,
" AAAA " ,
raise_on_no_answer = False )
2019-10-28 10:31:50 +00:00
values . extend ( map ( str , response ) )
continue
2015-07-10 15:42:33 +00:00
values . append ( hostname )
# This is a zone-xfer-only IP address. Do not return if
# we're querying for NS record hostnames. Only return if
# we're querying for zone xfer IP addresses - return the
# IP address.
elif mode == " xfr " :
values . append ( hostname [ 4 : ] )
2015-07-01 19:02:40 +00:00
return values
2015-05-03 01:10:28 +00:00
2022-02-04 23:26:24 +00:00
2015-07-10 15:42:33 +00:00
def set_secondary_dns ( hostnames , env ) :
2015-07-01 19:02:40 +00:00
if len ( hostnames ) > 0 :
2015-07-10 15:42:33 +00:00
# Validate that all hostnames are valid and that all zone-xfer IP addresses are valid.
2014-10-05 14:53:42 +00:00
resolver = dns . resolver . get_default_resolver ( )
2015-11-11 12:24:01 +00:00
resolver . timeout = 5
2015-07-01 19:02:40 +00:00
for item in hostnames :
2015-07-10 15:42:33 +00:00
if not item . startswith ( " xfr: " ) :
# Resolve hostname.
try :
2020-07-26 00:00:17 +00:00
response = resolver . resolve ( item , " A " )
2022-02-04 23:26:24 +00:00
except ( dns . resolver . NoNameservers , dns . resolver . NXDOMAIN ,
dns . resolver . NoAnswer ) :
2020-07-08 22:26:47 +00:00
try :
response = resolver . query ( item , " AAAA " )
2022-02-04 23:26:24 +00:00
except ( dns . resolver . NoNameservers , dns . resolver . NXDOMAIN ,
dns . resolver . NoAnswer ) :
raise ValueError (
" Could not resolve the IP address of %s . " % item )
2015-07-10 15:42:33 +00:00
else :
# Validate IP address.
try :
2019-08-31 12:15:38 +00:00
if " / " in item [ 4 : ] :
2022-02-04 23:26:24 +00:00
# raises a ValueError if there's a problem
v = ipaddress . ip_network ( item [ 4 : ] )
2019-08-31 12:15:38 +00:00
else :
2022-02-04 23:26:24 +00:00
# raises a ValueError if there's a problem
v = ipaddress . ip_address ( item [ 4 : ] )
2015-07-10 15:42:33 +00:00
except ValueError :
2022-02-04 23:26:24 +00:00
raise ValueError (
" ' %s ' is not an IPv4 or IPv6 address or subnet. " %
item [ 4 : ] )
2015-07-10 15:42:33 +00:00
# Set.
2022-02-04 23:26:24 +00:00
set_custom_dns_record ( " _secondary_nameserver " , " A " ,
" " . join ( hostnames ) , " set " , env )
2015-07-01 19:02:40 +00:00
else :
# Clear.
2015-07-10 15:42:33 +00:00
set_custom_dns_record ( " _secondary_nameserver " , " A " , None , " set " , env )
2014-10-05 14:53:42 +00:00
2015-05-03 01:10:28 +00:00
# Apply.
2014-10-05 14:53:42 +00:00
return do_dns_update ( env )
2016-12-07 11:58:51 +00:00
def get_custom_dns_records ( custom_dns , qname , rtype ) :
2021-09-16 14:35:04 +00:00
for qname1 , rtype1 , value , ttl in custom_dns :
2015-11-03 12:06:03 +00:00
if qname1 == qname and rtype1 == rtype :
2016-12-07 11:58:51 +00:00
yield value
2015-11-03 12:06:03 +00:00
return None
2022-02-04 23:26:24 +00:00
2014-10-05 14:53:42 +00:00
########################################################################
2022-02-04 23:26:24 +00:00
2014-08-17 22:43:57 +00:00
def build_recommended_dns ( env ) :
ret = [ ]
2015-11-29 14:59:35 +00:00
for ( domain , zonefile , records ) in build_zones ( 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
2022-02-04 23:26:24 +00:00
records . sort ( key = lambda r : 0 if r [ 3 ] . startswith ( " Required. " ) else
( 1 if r [ 3 ] . startswith ( " Recommended. " ) else 2 ) )
2014-07-17 13:02:39 +00:00
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
2022-02-04 23:26:24 +00:00
2014-08-17 22:43:57 +00:00
if __name__ == " __main__ " :
from utils import load_environment
env = load_environment ( )
2015-05-03 01:10:28 +00:00
if sys . argv [ - 1 ] == " --lint " :
write_custom_dns_config ( get_custom_dns_config ( env ) , env )
else :
for zone , records in build_recommended_dns ( env ) :
for record in records :
print ( " ; " + record [ ' explanation ' ] )
2022-02-04 23:26:24 +00:00
print ( record [ ' qname ' ] ,
record [ ' rtype ' ] ,
record [ ' value ' ] ,
sep = " \t " )
2015-05-03 01:10:28 +00:00
print ( )