dnsmanager/src/storage/zone.cr

725 lines
17 KiB
Crystal

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:<IP> 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)
to_bind9 io
end
def to_bind9(io : IO)
io << "#{t.to_s.downcase}:#{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