2020-12-13 03:56:32 +01:00
|
|
|
require "ipaddress"
|
2020-12-09 19:01:33 +01:00
|
|
|
|
|
|
|
# Store a DNS zone.
|
2024-02-27 04:51:18 +01:00
|
|
|
#
|
|
|
|
# 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.
|
2020-12-09 19:01:33 +01:00
|
|
|
class DNSManager::Storage::Zone
|
|
|
|
include JSON::Serializable
|
|
|
|
|
|
|
|
property domain : String
|
|
|
|
property resources = [] of DNSManager::Storage::Zone::ResourceRecord
|
2023-05-08 16:36:21 +02:00
|
|
|
property current_rrid : UInt32 = 0
|
2020-12-09 19:01:33 +01:00
|
|
|
|
2020-12-13 05:56:29 +01:00
|
|
|
# We don't want to accept less than 30 seconds TTL.
|
|
|
|
class_property ttl_limit_min = 30
|
|
|
|
|
2020-12-09 19:01:33 +01:00
|
|
|
def initialize(@domain)
|
|
|
|
end
|
|
|
|
|
2020-12-12 05:38:16 +01:00
|
|
|
alias Error = String
|
2020-12-09 19:01:33 +01:00
|
|
|
|
|
|
|
# Store a Resource Record: A, AAAA, TXT, PTR, CNAME…
|
|
|
|
abstract class ResourceRecord
|
|
|
|
include JSON::Serializable
|
|
|
|
|
2020-12-09 23:38:28 +01:00
|
|
|
use_json_discriminator "rrtype", {
|
2023-06-28 17:59:13 +02:00
|
|
|
A: A,
|
|
|
|
AAAA: AAAA,
|
|
|
|
SOA: SOA,
|
|
|
|
TXT: TXT,
|
|
|
|
PTR: PTR,
|
|
|
|
NS: NS,
|
|
|
|
CNAME: CNAME,
|
|
|
|
MX: MX,
|
2024-02-27 04:51:18 +01:00
|
|
|
SRV: SRV,
|
|
|
|
|
|
|
|
# Special resource records, which actually are TXT records.
|
|
|
|
SPF: SPF,
|
|
|
|
DKIM: DKIM,
|
|
|
|
DMARC: DMARC
|
2020-12-09 19:01:33 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
# Used to discriminate between classes.
|
2020-12-12 05:38:16 +01:00
|
|
|
property rrtype : String = ""
|
2020-12-09 19:01:33 +01:00
|
|
|
|
2023-05-08 16:36:21 +02:00
|
|
|
property rrid : UInt32 = 0
|
2020-12-09 19:01:33 +01:00
|
|
|
property name : String
|
|
|
|
property ttl : UInt32
|
|
|
|
property target : String
|
|
|
|
|
2023-06-28 02:25:43 +02:00
|
|
|
# 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
|
|
|
|
|
2020-12-09 19:01:33 +01:00
|
|
|
# zone class is omited, it always will be IN in our case.
|
|
|
|
def initialize(@name, @ttl, @target)
|
2023-06-28 00:55:39 +02:00
|
|
|
@rrtype = self.class.name.upcase.gsub /DNSMANAGER::STORAGE::ZONE::/, ""
|
2020-12-09 19:01:33 +01:00
|
|
|
end
|
2020-12-12 05:38:16 +01:00
|
|
|
|
2020-12-13 03:56:32 +01:00
|
|
|
def get_errors : Array(Error)
|
|
|
|
[] of Error
|
2020-12-12 05:38:16 +01:00
|
|
|
end
|
2023-05-08 04:01:33 +02:00
|
|
|
|
|
|
|
def to_s(io : IO)
|
2024-02-24 07:30:16 +01:00
|
|
|
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"
|
2023-05-08 04:01:33 +02:00
|
|
|
end
|
2020-12-09 19:01:33 +01:00
|
|
|
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
|
2023-06-28 00:55:39 +02:00
|
|
|
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.
|
2023-06-29 08:41:35 +02:00
|
|
|
property minttl : UInt64 = 600 # #seconds cache lifetime for answers about inexistent records.
|
2023-06-28 00:55:39 +02:00
|
|
|
|
|
|
|
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"
|
2020-12-09 19:01:33 +01:00
|
|
|
end
|
2020-12-14 16:21:28 +01:00
|
|
|
|
2023-05-08 04:01:33 +02:00
|
|
|
def to_s(io : IO)
|
2024-02-24 07:30:16 +01:00
|
|
|
io << "(#{ "%4d" % @rrid }) "
|
|
|
|
io << "#{name} #{ttl} IN SOA (#{mname} #{rname}\n"
|
2023-06-28 00:55:39 +02:00
|
|
|
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"
|
2023-05-08 04:01:33 +02:00
|
|
|
io << "\t)\n"
|
|
|
|
end
|
|
|
|
|
2024-02-24 07:30:16 +01:00
|
|
|
def to_bind9(io : IO)
|
2024-02-25 04:13:18 +01:00
|
|
|
# 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"
|
2024-02-24 07:30:16 +01:00
|
|
|
io << "\t)\n"
|
|
|
|
end
|
|
|
|
|
2020-12-14 16:21:28 +01:00
|
|
|
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
|
2020-12-09 19:01:33 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
class A < ResourceRecord
|
2020-12-13 03:56:32 +01:00
|
|
|
def get_errors : Array(Error)
|
|
|
|
errors = [] of Error
|
|
|
|
|
|
|
|
unless Zone.is_subdomain_valid? @name
|
2020-12-14 16:21:28 +01:00
|
|
|
errors << "A invalid subdomain: #{@name}"
|
2020-12-13 03:56:32 +01:00
|
|
|
end
|
|
|
|
|
2020-12-13 05:56:29 +01:00
|
|
|
if @ttl < Zone.ttl_limit_min
|
2020-12-14 16:21:28 +01:00
|
|
|
errors << "A invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}"
|
2020-12-13 05:56:29 +01:00
|
|
|
end
|
2020-12-13 03:56:32 +01:00
|
|
|
|
|
|
|
unless Zone.is_ipv4_address_valid? @target
|
2020-12-14 16:21:28 +01:00
|
|
|
errors << "A target not valid ipv4: #{@target}"
|
2020-12-13 03:56:32 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
errors
|
|
|
|
end
|
2020-12-09 19:01:33 +01:00
|
|
|
end
|
2020-12-13 05:56:29 +01:00
|
|
|
|
2020-12-09 19:01:33 +01:00
|
|
|
class AAAA < ResourceRecord
|
2020-12-13 03:56:32 +01:00
|
|
|
def get_errors : Array(Error)
|
|
|
|
errors = [] of Error
|
|
|
|
|
|
|
|
unless Zone.is_subdomain_valid? @name
|
2020-12-14 16:21:28 +01:00
|
|
|
errors << "AAAA invalid subdomain: #{@name}"
|
2020-12-13 03:56:32 +01:00
|
|
|
end
|
|
|
|
|
2020-12-13 05:56:29 +01:00
|
|
|
if @ttl < Zone.ttl_limit_min
|
2020-12-14 16:21:28 +01:00
|
|
|
errors << "AAAA invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}"
|
2020-12-13 05:56:29 +01:00
|
|
|
end
|
2020-12-13 03:56:32 +01:00
|
|
|
|
|
|
|
unless Zone.is_ipv6_address_valid? @target
|
2020-12-14 16:21:28 +01:00
|
|
|
errors << "AAAA target not valid ipv6: #{@target}"
|
2020-12-13 03:56:32 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
errors
|
|
|
|
end
|
2020-12-09 19:01:33 +01:00
|
|
|
end
|
2020-12-13 05:56:29 +01:00
|
|
|
|
2020-12-09 19:01:33 +01:00
|
|
|
class TXT < ResourceRecord
|
2020-12-13 05:56:29 +01:00
|
|
|
def get_errors : Array(Error)
|
|
|
|
errors = [] of Error
|
|
|
|
|
|
|
|
unless Zone.is_subdomain_valid? @name
|
2020-12-14 16:21:28 +01:00
|
|
|
errors << "TXT invalid subdomain: #{@name}"
|
2020-12-13 05:56:29 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
if @ttl < Zone.ttl_limit_min
|
2020-12-14 16:21:28 +01:00
|
|
|
errors << "TXT invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}"
|
2020-12-13 05:56:29 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
errors
|
|
|
|
end
|
2020-12-09 19:01:33 +01:00
|
|
|
end
|
2020-12-13 05:56:29 +01:00
|
|
|
|
2020-12-09 19:01:33 +01:00
|
|
|
class PTR < ResourceRecord
|
2020-12-13 05:56:29 +01:00
|
|
|
def get_errors : Array(Error)
|
|
|
|
errors = [] of Error
|
|
|
|
|
2020-12-14 16:21:28 +01:00
|
|
|
# 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'"
|
2020-12-13 05:56:29 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
if @ttl < Zone.ttl_limit_min
|
2020-12-14 16:21:28 +01:00
|
|
|
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}"
|
2020-12-13 05:56:29 +01:00
|
|
|
end
|
2020-12-14 16:21:28 +01:00
|
|
|
|
2020-12-13 05:56:29 +01:00
|
|
|
errors
|
|
|
|
end
|
2020-12-09 19:01:33 +01:00
|
|
|
end
|
2020-12-13 05:56:29 +01:00
|
|
|
|
2020-12-09 19:01:33 +01:00
|
|
|
class NS < ResourceRecord
|
2020-12-13 05:56:29 +01:00
|
|
|
def get_errors : Array(Error)
|
|
|
|
errors = [] of Error
|
|
|
|
|
|
|
|
unless Zone.is_subdomain_valid? @name
|
2020-12-14 16:21:28 +01:00
|
|
|
errors << "NS invalid subdomain: #{@name}"
|
2020-12-13 05:56:29 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
if @ttl < Zone.ttl_limit_min
|
2020-12-14 16:21:28 +01:00
|
|
|
errors << "NS invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}"
|
2020-12-13 05:56:29 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
errors
|
|
|
|
end
|
2020-12-09 19:01:33 +01:00
|
|
|
end
|
2020-12-13 05:56:29 +01:00
|
|
|
|
2020-12-09 19:01:33 +01:00
|
|
|
class CNAME < ResourceRecord
|
2020-12-13 05:56:29 +01:00
|
|
|
def get_errors : Array(Error)
|
|
|
|
errors = [] of Error
|
|
|
|
|
|
|
|
unless Zone.is_subdomain_valid? @name
|
2020-12-14 16:21:28 +01:00
|
|
|
errors << "CNAME invalid subdomain: #{@name}"
|
2020-12-13 05:56:29 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
if @ttl < Zone.ttl_limit_min
|
2020-12-14 16:21:28 +01:00
|
|
|
errors << "CNAME invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}"
|
2020-12-13 05:56:29 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
unless Zone.is_subdomain_valid? @target
|
2020-12-14 16:21:28 +01:00
|
|
|
errors << "CNAME invalid target: #{@target}"
|
2020-12-13 05:56:29 +01:00
|
|
|
end
|
|
|
|
errors
|
2024-02-27 04:51:18 +01:00
|
|
|
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
|
|
|
|
|
2024-02-29 04:51:40 +01:00
|
|
|
|
2024-02-27 04:51:18 +01:00
|
|
|
# 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
|
2024-02-29 04:51:40 +01:00
|
|
|
|
2024-03-01 02:27:28 +01:00
|
|
|
def to_s(io : IO)
|
2024-03-01 18:34:48 +01:00
|
|
|
io << "#{t.to_s.downcase}=#{v}"
|
2024-02-29 04:51:40 +01:00
|
|
|
end
|
2024-02-27 04:51:18 +01:00
|
|
|
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
|
2024-03-01 02:27:28 +01:00
|
|
|
case t
|
|
|
|
when .a?
|
|
|
|
when .ip4?
|
|
|
|
when .ip6?
|
|
|
|
when .mx?
|
|
|
|
when .ptr?
|
|
|
|
when .exists?
|
|
|
|
when .include?
|
2024-02-27 04:51:18 +01:00
|
|
|
end
|
|
|
|
errors
|
|
|
|
end
|
2024-02-29 04:51:40 +01:00
|
|
|
|
2024-03-01 02:27:28 +01:00
|
|
|
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 != ""
|
2024-02-29 04:51:40 +01:00
|
|
|
end
|
2024-02-27 04:51:18 +01:00
|
|
|
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
|
2024-02-29 04:51:40 +01:00
|
|
|
errors << "SPF invalid subdomain: #{@name}"
|
2024-02-27 04:51:18 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
if @ttl < Zone.ttl_limit_min
|
2024-02-29 04:51:40 +01:00
|
|
|
errors << "SPF invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}"
|
2024-02-27 04:51:18 +01:00
|
|
|
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)
|
2024-02-29 04:51:40 +01:00
|
|
|
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"
|
2024-02-27 04:51:18 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
def to_bind9(io : IO)
|
2024-02-29 04:51:40 +01:00
|
|
|
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"
|
2024-02-27 04:51:18 +01:00
|
|
|
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
|
2020-12-13 05:56:29 +01:00
|
|
|
end
|
2020-12-09 19:01:33 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
class MX < ResourceRecord
|
|
|
|
property priority : UInt32 = 10
|
|
|
|
def initialize(@name, @ttl, @target, @priority = 10)
|
2023-06-28 00:55:39 +02:00
|
|
|
@rrtype = "MX"
|
2020-12-09 19:01:33 +01:00
|
|
|
end
|
2020-12-13 05:56:29 +01:00
|
|
|
|
2023-05-08 04:01:33 +02:00
|
|
|
def to_s(io : IO)
|
2024-02-24 07:30:16 +01:00
|
|
|
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"
|
2023-05-08 04:01:33 +02:00
|
|
|
end
|
|
|
|
|
2020-12-13 05:56:29 +01:00
|
|
|
def get_errors : Array(Error)
|
|
|
|
errors = [] of Error
|
|
|
|
|
|
|
|
unless Zone.is_subdomain_valid? @name
|
2020-12-14 16:21:28 +01:00
|
|
|
errors << "MX invalid subdomain: #{@name}"
|
2020-12-13 05:56:29 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
if @ttl < Zone.ttl_limit_min
|
2020-12-14 16:21:28 +01:00
|
|
|
errors << "MX invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}"
|
2020-12-13 05:56:29 +01:00
|
|
|
end
|
|
|
|
|
2023-05-08 04:01:33 +02:00
|
|
|
# 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
|
2020-12-13 05:56:29 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
errors
|
|
|
|
end
|
2020-12-09 19:01:33 +01:00
|
|
|
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)
|
2023-06-28 00:55:39 +02:00
|
|
|
@rrtype = "SRV"
|
2020-12-09 19:01:33 +01:00
|
|
|
end
|
2020-12-14 16:21:28 +01:00
|
|
|
|
|
|
|
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
|
2024-02-24 07:30:16 +01:00
|
|
|
|
|
|
|
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
|
2020-12-09 19:01:33 +01:00
|
|
|
end
|
|
|
|
|
2023-05-08 16:36:21 +02:00
|
|
|
def <<(rr : ResourceRecord)
|
|
|
|
rr.rrid = current_rrid
|
|
|
|
@resources << rr
|
|
|
|
@current_rrid += 1
|
|
|
|
end
|
|
|
|
|
2020-12-09 19:01:33 +01:00
|
|
|
def to_s(io : IO)
|
2024-02-25 04:13:18 +01:00
|
|
|
io << "DOMAIN #{@domain}.\n"
|
2023-05-08 04:01:33 +02:00
|
|
|
@resources.each do |rr|
|
|
|
|
io << rr
|
|
|
|
end
|
2020-12-09 19:01:33 +01:00
|
|
|
end
|
2020-12-12 05:38:16 +01:00
|
|
|
|
2024-02-24 07:30:16 +01:00
|
|
|
def to_bind9(io : IO)
|
2024-02-25 04:13:18 +01:00
|
|
|
io << "$ORIGIN #{@domain}.\n"
|
2024-02-24 07:30:16 +01:00
|
|
|
@resources.each do |rr|
|
|
|
|
rr.to_bind9 io
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2023-05-07 05:06:15 +02:00
|
|
|
def get_errors? : Array(Error)?
|
2020-12-12 05:38:16 +01:00
|
|
|
errors = [] of Error
|
|
|
|
unless Zone.is_domain_valid? @domain
|
2020-12-13 05:56:29 +01:00
|
|
|
errors << "invalid domain #{@domain}"
|
2020-12-12 05:38:16 +01:00
|
|
|
end
|
|
|
|
|
2020-12-14 16:21:28 +01:00
|
|
|
# Checking each resource.
|
2020-12-12 05:38:16 +01:00
|
|
|
@resources.each do |r|
|
2020-12-13 03:56:32 +01:00
|
|
|
r.get_errors().each do |error|
|
2020-12-12 05:38:16 +01:00
|
|
|
errors << error
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2020-12-14 16:21:28 +01:00
|
|
|
# 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
|
|
|
|
|
2023-05-07 05:06:15 +02:00
|
|
|
if errors.empty?
|
|
|
|
nil
|
|
|
|
else
|
|
|
|
errors
|
|
|
|
end
|
2020-12-12 05:38:16 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
# This regex only is "good enough for now".
|
|
|
|
def self.is_domain_valid?(domain) : Bool
|
2023-07-11 04:09:46 +02:00
|
|
|
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,}\.?$/
|
2020-12-12 05:38:16 +01:00
|
|
|
true
|
|
|
|
else
|
|
|
|
false
|
|
|
|
end
|
|
|
|
rescue e
|
|
|
|
Baguette::Log.error "invalid zone domain #{domain}: #{e}"
|
|
|
|
false
|
|
|
|
end
|
|
|
|
|
2020-12-13 03:56:32 +01:00
|
|
|
# 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
|
|
|
|
|
2020-12-14 16:21:28 +01:00
|
|
|
# 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
|
|
|
|
|
|
|
|
|
2020-12-13 03:56:32 +01:00
|
|
|
# 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
|
2023-05-08 04:01:33 +02:00
|
|
|
Baguette::Log.warning "wrong IPv6 address: #{address}"
|
2020-12-13 03:56:32 +01:00
|
|
|
false
|
|
|
|
end
|
2020-12-12 05:38:16 +01:00
|
|
|
|
2023-06-29 18:29:25 +02:00
|
|
|
# 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.
|
2023-06-30 00:12:40 +02:00
|
|
|
# Do not forget the last dot ('.') to get a fully qualified domain name (FQDN).
|
2023-06-29 18:29:25 +02:00
|
|
|
def replace_domain(new_domain : String)
|
|
|
|
@domain = new_domain
|
2023-06-30 00:12:40 +02:00
|
|
|
fqdn = "#{new_domain}."
|
2023-06-29 18:29:25 +02:00
|
|
|
@resources.each do |rr|
|
|
|
|
case rr
|
|
|
|
when SOA
|
2023-06-30 00:12:40 +02:00
|
|
|
rr.name = fqdn
|
2023-06-29 18:29:25 +02:00
|
|
|
when NS
|
2023-06-30 00:12:40 +02:00
|
|
|
rr.name = fqdn
|
2023-06-29 18:29:25 +02:00
|
|
|
else
|
|
|
|
Baguette::Log.debug "new domain, rr type #{rr.class} (nothing to do)"
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
2020-12-09 19:01:33 +01:00
|
|
|
end
|
2024-03-01 02:27:28 +01:00
|
|
|
|
|
|
|
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
|