require "ipaddress" # Store a DNS zone. # # Several common resource record types are accepted: SOA PTR A AAAA TXT CNAME NS MX SRV. # Some special entries are accepted too, which makes it simpler to deal with configuration errors. # Thus, we accept new resource records named SPF, DKIM and DMARC to provide insightful errors to novice users. class DNSManager::Storage::Zone include JSON::Serializable property domain : String property resources = [] of DNSManager::Storage::Zone::ResourceRecord property current_rrid : UInt32 = 0 # We don't want to accept less than 30 seconds TTL. class_property ttl_limit_min = 30 def initialize(@domain) end alias Error = String # Store a Resource Record: A, AAAA, TXT, PTR, CNAME… abstract class ResourceRecord include JSON::Serializable use_json_discriminator "rrtype", { A: A, AAAA: AAAA, SOA: SOA, TXT: TXT, PTR: PTR, NS: NS, CNAME: CNAME, MX: MX, SRV: SRV, # Special resource records, which actually are TXT records. SPF: SPF, DKIM: DKIM, DMARC: DMARC } # Used to discriminate between classes. property rrtype : String = "" property rrid : UInt32 = 0 property name : String property ttl : UInt32 property target : String # RR entries can be writable or read only. # For example, default SOA and NS entries shouldn't be writable. # No need to allow for (mostly unskilled) users to mess up some ABSOLUTELY NECESSARY entries. # Yes. It already happened. Many, MANY times. I WANT MY FUCKING TIME BACK. property readonly : Bool = false # zone class is omited, it always will be IN in our case. def initialize(@name, @ttl, @target) @rrtype = self.class.name.upcase.gsub /DNSMANAGER::STORAGE::ZONE::/, "" end def get_errors : Array(Error) [] of Error end def to_s(io : IO) io << "(#{ "%4d" % @rrid }) " io << "#{ "%30s" % @name} #{ "%6d" % @ttl} IN #{ "%10s" % @rrtype } #{ "%30s" % @target}\n" end def to_bind9(io : IO) io << "#{@name} #{@ttl} IN #{@rrtype} #{@target}\n" end end class SOA < ResourceRecord # Start of Authority property mname : String # Master Name Server for the zone. property rname : String # admin email address john.doe@example.com => john\.doe.example.com property serial : UInt64 = 0 # Number for tracking new versions of the zone (master-slaves). property refresh : UInt64 = 86400 # #seconds before requesting new zone version (master-slave). property retry : UInt64 = 7200 # #seconds before retry accessing new data from the master. property expire : UInt64 = 3600000 # #seconds slaves should consider master dead. property minttl : UInt64 = 600 # #seconds cache lifetime for answers about inexistent records. def initialize(@name, @ttl, @mname, @rname, @serial = 0.to_u64, @refresh = 86400.to_u64, @retry = 7200.to_u64, @expire = 3600000.to_u64, @minttl = 600.to_u64) @target = "" # no use @rrtype = "SOA" end def to_s(io : IO) io << "(#{ "%4d" % @rrid }) " io << "#{name} #{ttl} IN SOA (#{mname} #{rname}\n" io << "\t\t#{ "%10d" % serial } ; serial\n" io << "\t\t#{ "%10d" % refresh } ; refresh\n" io << "\t\t#{ "%10d" % retry } ; retry\n" io << "\t\t#{ "%10d" % expire } ; expire\n" io << "\t\t#{ "%10d" % minttl } ; minttl\n" io << "\t)\n" end def to_bind9(io : IO) # No "name" because the name of the SOA RR is the origin (FQDN). io << "@ #{ttl} IN SOA (#{mname} #{rname}\n" io << "\t\t#{ "%10d" % serial } ; serial\n" io << "\t\t#{ "%10d" % refresh} ; refresh\n" io << "\t\t#{ "%10d" % retry } ; retry\n" io << "\t\t#{ "%10d" % expire } ; expire\n" io << "\t\t#{ "%10d" % minttl } ; minttl\n" io << "\t)\n" end def get_errors : Array(Error) errors = [] of Error # TODO: name if @ttl < Zone.ttl_limit_min errors << "SOA invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}" end # TODO: target # TODO: mname # TODO: rname # TODO: serial # TODO: refresh # TODO: retry # TODO: expire errors end end class A < ResourceRecord def get_errors : Array(Error) errors = [] of Error unless Zone.is_subdomain_valid? @name errors << "A invalid subdomain: #{@name}" end if @ttl < Zone.ttl_limit_min errors << "A invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}" end unless Zone.is_ipv4_address_valid? @target errors << "A target not valid ipv4: #{@target}" end errors end end class AAAA < ResourceRecord def get_errors : Array(Error) errors = [] of Error unless Zone.is_subdomain_valid? @name errors << "AAAA invalid subdomain: #{@name}" end if @ttl < Zone.ttl_limit_min errors << "AAAA invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}" end unless Zone.is_ipv6_address_valid? @target errors << "AAAA target not valid ipv6: #{@target}" end errors end end class TXT < ResourceRecord def get_errors : Array(Error) errors = [] of Error unless Zone.is_subdomain_valid? @name errors << "TXT invalid subdomain: #{@name}" end if @ttl < Zone.ttl_limit_min errors << "TXT invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}" end errors end end class PTR < ResourceRecord def get_errors : Array(Error) errors = [] of Error # TODO: PTR name verification. # PTR name is different from others. # Its name contains numerical-only subdomains. unless Zone.is_ptr_name_valid? @target errors << "PTR invalid subdomain: #{@target}" end # The PTR name has to end with in-addr.arpa or ip6.arpa. unless @name.ends_with?(/in-addr.arpa\.?/) || @name.ends_with?(/ip6.arpa\.?/) errors << "PTR invalid name: doesn't end with 'in-addr.arpa' or 'ip6.arpa'" end if @ttl < Zone.ttl_limit_min errors << "PTR invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}" end unless Zone.is_domain_valid? @target errors << "PTR invalid subdomain: #{@target}" end errors end end class NS < ResourceRecord def get_errors : Array(Error) errors = [] of Error unless Zone.is_subdomain_valid? @name errors << "NS invalid subdomain: #{@name}" end if @ttl < Zone.ttl_limit_min errors << "NS invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}" end errors end end class CNAME < ResourceRecord def get_errors : Array(Error) errors = [] of Error unless Zone.is_subdomain_valid? @name errors << "CNAME invalid subdomain: #{@name}" end if @ttl < Zone.ttl_limit_min errors << "CNAME invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}" end unless Zone.is_subdomain_valid? @target errors << "CNAME invalid target: #{@target}" end errors end end # TODO: verifications + print. # baguette.netlib.re. 3600 IN TXT "v=spf1 a mx ip4: a:mail.baguette.netlib.re ~all" class SPF < ResourceRecord # SPF mechanisms are about policy, which comes with the form of a single character: # - '?' means no policy (neutral), # - '+' means PASS result. Default behavior for any mechanism except `all`. # - '~' (tilde) means SOFTFAIL, a debugging aid between NEUTRAL and FAIL. # SOFTFAIL messages are accepted but tagged for later analysis. # - '-' (minus) means FAIL, the mail should be rejected. # # The following `q` character is the behavior for the `all` mechanism. # The `all` mechanism won't be included in the final domain zone if the # `REDIRECT` modifier provided. enum Qualifier Pass # + None # ? SoftFail # ~ HardFail # - end # Modifiers allow future extensions of the framework. # See RFC 4408. class Modifier include JSON::Serializable enum Type # "explanation", used for debug purposes, not accepted for now. EXP # REDIRECT: replaces the ALL mechanism (new default behavior). # From RFC 4408: # ``` # This facility is intended for use by organizations that wish to apply # the same record to multiple domains. For example: # # la.example.com. TXT "v=spf1 redirect=_spf.example.com" # ny.example.com. TXT "v=spf1 redirect=_spf.example.com" # sf.example.com. TXT "v=spf1 redirect=_spf.example.com" # _spf.example.com. TXT "v=spf1 mx:example.com -all" # ``` REDIRECT end property t : Type # type of modifier property v : String # value def initialize(@t, @v) end # TODO def get_errors : Array(Error) errors = [] of Error errors end def to_s(io : IO) io << "#{@t}=#{v}" end end class Mechanism include JSON::Serializable enum Type A # address (domain) IP4 # ipv4 or range IP6 # ipv6 or range MX # use the MX record of the domain PTR # "pretty complicated for no reason" type, will be discarded for now EXISTS # TODO INCLUDE # include foreign SPF policy end property q : Qualifier = Qualifier::Pass property t : Type # type of mechanism property v : String # value def initialize(@t, @v, @q) end # TODO def get_errors : Array(Error) errors = [] of Error case t when .a? when .ip4? when .ip6? when .mx? when .ptr? when .exists? when .include? end errors end def to_s(io : IO) to_bind9(io) end def to_bind9(io : IO) case q when .pass? "#{t}=#{v}" else io << "#{qualifier_to_char q}" end io << t.to_s.downcase io << "=#{v}" if v != "" end end property v : String = "spf1" property mechanisms : Array(Mechanism) property modifiers : Array(Modifier)? = nil # The `all` mechanism is the default behavior for email coming from non conforming # IP addresses, meaning senders not respecting the SPF record. property q : Qualifier = Qualifier::None def initialize(@name, @ttl, @target, @v, @mechanisms, @q) @rrtype = "SPF" end # TODO def get_errors : Array(Error) errors = [] of Error unless Zone.is_subdomain_valid? @name errors << "SPF invalid subdomain: #{@name}" end if @ttl < Zone.ttl_limit_min errors << "SPF invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}" end @mechanisms.each do |m| m.get_errors.each do |e| errors << e end end if mods = @modifiers mods.each do |m| m.get_errors.each do |e| errors << e end end end errors end def to_s(io : IO) io << "(#{ "%4d" % @rrid }) " io << "#{ "%30s" % @name} #{ "%6d" % @ttl} IN SPF " io << '"' @mechanisms.each do |m| io << m io << ' ' end if mod = @modifiers mod.each do |m| io << m io << ' ' end end io << qualifier_to_string @q io << '"' io << "\n" end def to_bind9(io : IO) io << "#{@name} #{@ttl} IN TXT " io << '"' @mechanisms.each do |m| io << m io << ' ' end if mod = @modifiers mod.each do |m| io << m io << ' ' end end io << qualifier_to_string @q io << '"' io << "\n" end end # TODO class DKIM < ResourceRecord property v : String = "DKIM1" # DKIM version property h : String = "sha256" # encrypting cryptographic algorithm property k : String = "rsa" # signing cryptographic algorithm property p : String # public key def initialize(@name, @ttl, @target, @v, @h, @k, @p) @rrtype = "DKIM" end def get_errors : Array(Error) errors = [] of Error unless Zone.is_subdomain_valid? @name errors << "CNAME invalid subdomain: #{@name}" end if @ttl < Zone.ttl_limit_min errors << "CNAME invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}" end errors end end # TODO class DMARC < ResourceRecord def initialize(@name, @ttl, @target) @rrtype = "DMARC" end def get_errors : Array(Error) errors = [] of Error unless Zone.is_subdomain_valid? @name errors << "CNAME invalid subdomain: #{@name}" end if @ttl < Zone.ttl_limit_min errors << "CNAME invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}" end errors end end class MX < ResourceRecord property priority : UInt32 = 10 def initialize(@name, @ttl, @target, @priority = 10) @rrtype = "MX" end def to_s(io : IO) io << "(#{ "%4d" % @rrid }) " io << "#{ "%30s" % @name} #{ "%6d" % @ttl} IN MX #{ "%3d" % @priority} #{ "%30s" % @target}\n" end def to_bind9(io : IO) io << "#{@name} #{@ttl} IN MX #{@priority} #{@target}\n" end def get_errors : Array(Error) errors = [] of Error unless Zone.is_subdomain_valid? @name errors << "MX invalid subdomain: #{@name}" end if @ttl < Zone.ttl_limit_min errors << "MX invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}" end # MX target can either be a subdomain or a FQDN. unless Zone.is_subdomain_valid? @target unless Zone.is_domain_valid? @target errors << "MX invalid target (domain): #{@target}" end end errors end end class SRV < ResourceRecord property port : UInt16 property protocol : String = "tcp" property priority : UInt32 = 10 property weight : UInt32 = 10 def initialize(@name, @ttl, @target, @port, @protocol = "tcp", @priority = 10, @weight = 10) @rrtype = "SRV" end def get_errors : Array(Error) errors = [] of Error # SRV name should be created from scratch, client should send an empty name. # WON'T FIX: name verification. if @ttl < Zone.ttl_limit_min errors << "SRV invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}" end unless Zone.is_domain_valid? @target errors << "SRV invalid target (domain): #{@target}" end errors end def to_s(io : IO) io << "(#{ "%4d" % @rrid }) " io << "#{ "%30s" % @name} " io << "#{ "%6d" % @ttl} IN SRV " io << "#{ "%3d" % @priority} " io << "#{ "%3d" % @weight} " io << "#{ "%5d" % @port} " io << "#{ "%30s" % @target}\n" end def to_bind9(io : IO) io << "#{@name} #{@ttl} IN SRV #{@priority} #{@weight} #{@port} #{@target}\n" end end def <<(rr : ResourceRecord) rr.rrid = current_rrid @resources << rr @current_rrid += 1 end def to_s(io : IO) io << "DOMAIN #{@domain}.\n" @resources.each do |rr| io << rr end end def to_bind9(io : IO) io << "$ORIGIN #{@domain}.\n" @resources.each do |rr| rr.to_bind9 io end end def get_errors? : Array(Error)? errors = [] of Error unless Zone.is_domain_valid? @domain errors << "invalid domain #{@domain}" end # Checking each resource. @resources.each do |r| r.get_errors().each do |error| errors << error end end # Minimal resources: SOA and NS. unless @resources.any? &.is_a?(Zone::SOA) errors << "invalid zone: no SOA record" end unless @resources.any? &.is_a?(Zone::NS) errors << "invalid zone: no NS record" end if errors.empty? nil else errors end end # This regex only is "good enough for now". def self.is_domain_valid?(domain) : Bool if domain =~ /^(((?!-))(xn--|_{1,1})?[a-z0-9-]{0,61}[a-z0-9]{1,1}\.)*((xn--)?[a-z0-9][a-z0-9\-]{0,60}[a-z0-9]|(xn--)?[a-z0-9]{1,60})\.[a-z]{2,}\.?$/ true else false end rescue e Baguette::Log.error "invalid zone domain #{domain}: #{e}" false end # This regex only is "good enough for now". def self.is_subdomain_valid?(subdomain) : Bool if subdomain =~ /^(((?!-))(xn--|_{1,1})?[a-z0-9-]{0,61}[a-z0-9]{1,1}\.)*(xn--)?[a-z0-9][a-z0-9\-]{0,60}[a-z0-9]*[a-z]+|[a-z]+|[a-z][a-z0-9]+[a-z]+$/ true else false end rescue e Baguette::Log.error "invalid zone subdomain #{subdomain}: #{e}" false end # TODO: PTR name verification. def self.is_ptr_name_valid?(subdomain) : Bool if subdomain =~ /^(((?!-))(xn--|_{1,1})?[a-z0-9-]{0,61}[a-z0-9]{1,1}\.)*(xn--)?[a-z0-9][a-z0-9\-]{0,60}[a-z0-9]*[a-z]+|[a-z]+|[a-z][a-z0-9]+[a-z]+$/ true else false end rescue e Baguette::Log.error "invalid zone subdomain #{subdomain}: #{e}" false end # This only is "good enough for now". # Regex only matches for invalid characters. def self.is_ipv4_address_valid?(address) : Bool if ! address =~ /^[0-9\.]+$/ false elsif ip = IPAddress::IPv4.new address true else false end rescue e Baguette::Log.warning "wrong IPv4 address: #{address}" false end # This only is "good enough for now". # Regex only matches for invalid characters. def self.is_ipv6_address_valid?(address) : Bool if ! address =~ /^[0-9a-f:]+$/ false elsif ip = IPAddress::IPv6.new address true else false end rescue e Baguette::Log.warning "wrong IPv6 address: #{address}" false end # When a new domain is recorded, we load a template which contains a placeholder domain. # `replace_domain` replaces this domain name by the real one in the different (preloaded) RR. # Do not forget the last dot ('.') to get a fully qualified domain name (FQDN). def replace_domain(new_domain : String) @domain = new_domain fqdn = "#{new_domain}." @resources.each do |rr| case rr when SOA rr.name = fqdn when NS rr.name = fqdn else Baguette::Log.debug "new domain, rr type #{rr.class} (nothing to do)" end end end end def qualifier_to_char(qualifier : DNSManager::Storage::Zone::SPF::Qualifier) : Char case qualifier when .pass? '+' when .none? '?' when .soft_fail? '~' # when .hard_fail? else '-' end end def qualifier_to_string(qualifier : DNSManager::Storage::Zone::SPF::Qualifier) : String "#{qualifier_to_char qualifier}all" end