internetee-registry/app/services/dns_validator.rb
2025-08-07 15:07:28 +03:00

661 lines
No EOL
20 KiB
Ruby

require 'resolv'
require 'dnsruby'
class DNSValidator
include Dnsruby
attr_reader :domain, :results, :record_type
def initialize(domain:, name:, record_type:)
@domain = domain.present? ? domain : Domain.find_by_name(name)
raise "Domain not found" if @domain.blank?
@record_type = record_type
@results = {
nameservers: {},
dns_records: {},
dnssec: {},
csync: {},
errors: [],
warnings: []
}
end
def self.validate(domain:, name:, record_type:)
new(domain: domain, name: name, record_type: record_type).validate
end
def validate
Rails.logger.info "Starting DNS validation for domain: #{domain.name}"
case record_type
when 'NS'
validate_nameservers
when 'A', 'AAAA'
validate_dns_records
when 'DNSKEY'
check_dnssec_sync_records
when 'CSYNC'
check_csync_records
when 'all'
validate_nameservers
validate_dns_records
check_dnssec_sync_records
check_csync_records
else
raise "Invalid record type: #{record_type}"
end
apply_enforcement_actions
Rails.logger.info "DNS validation completed for domain: #{domain.name}"
@results
rescue StandardError => e
Rails.logger.error "DNS validation failed for #{domain.name}: #{e.message}"
@results[:errors] << "Validation failed: #{e.message}"
@results
end
private
def validate_nameservers
Rails.logger.info "Validating nameservers for domain: #{domain.name}"
domain.nameservers.each do |nameserver|
result = validate_single_nameserver(nameserver)
@results[:nameservers][nameserver.hostname] = result
if result[:valid]
# Update nameserver validation status
nameserver.update_columns(
validation_datetime: Time.current,
validation_counter: 0,
failed_validation_reason: nil
)
else
@results[:errors] << "Nameserver #{nameserver.hostname} failed validation: #{result[:reason]}"
# Update failure counter
counter = (nameserver.validation_counter || 0) + 1
nameserver.update_columns(
validation_datetime: Time.current,
validation_counter: counter,
failed_validation_reason: result[:reason]
)
end
end
end
def validate_single_nameserver(nameserver)
result = {
hostname: nameserver.hostname,
valid: false,
authoritative: false,
ns_records: [],
reason: nil
}
begin
resolver = create_resolver(nameserver.hostname)
# Query SOA to check if nameserver is authoritative for this domain
soa_response = resolver.query(domain.name, 'SOA')
if soa_response.answer.empty?
result[:reason] = 'No SOA record found'
return result
end
# Check for CNAME at domain apex (invalid)
if soa_response.answer.any? { |a| a.type == 'CNAME' }
result[:reason] = 'Domain has CNAME record at apex (invalid)'
return result
end
# Check SOA record
soa_record = soa_response.answer.find { |a| a.type == 'SOA' }
if soa_record
result[:authoritative] = true
end
# Query NS records to verify this nameserver is listed
ns_response = resolver.query(domain.name, 'NS')
result[:ns_records] = ns_response.answer.map { |a| a.nsdname.to_s if a.type == 'NS' }.compact
# Check if this nameserver is in the NS records
if result[:ns_records].any? { |ns| ns.downcase == nameserver.hostname.downcase }
result[:valid] = true
else
result[:reason] = 'Nameserver not listed in zone NS records'
end
rescue Dnsruby::NXDomain
result[:reason] = 'Domain not found'
rescue Dnsruby::Refused
result[:reason] = 'Query refused'
rescue StandardError => e
result[:reason] = "Query failed: #{e.message}"
end
result
end
# Story 2: Resolve and Validate DNS Records
def validate_dns_records
Rails.logger.info "Validating DNS records for domain: #{domain.name}"
@results[:dns_records] = {
a_records: [],
aaaa_records: [],
cname_records: []
}
# Only check records from valid nameservers
valid_nameservers = domain.nameservers.select do |ns|
@results[:nameservers][ns.hostname]&.dig(:valid)
end
if valid_nameservers.empty?
@results[:warnings] << "No valid nameservers found for DNS record validation"
return
end
valid_nameservers.each do |nameserver|
validate_records_from_nameserver(nameserver)
end
# Check for glue records if needed
validate_glue_records
end
def validate_records_from_nameserver(nameserver)
resolver = create_resolver(nameserver.hostname)
# Validate A records
begin
a_response = resolver.query(domain.name, 'A')
a_response.answer.each do |record|
next unless record.type == 'A'
@results[:dns_records][:a_records] << {
address: record.address.to_s,
ttl: record.ttl,
nameserver: nameserver.hostname
}
end
rescue Dnsruby::NXDomain
# No A records
rescue StandardError => e
@results[:warnings] << "Failed to query A records from #{nameserver.hostname}: #{e.message}"
end
# Validate AAAA records
begin
aaaa_response = resolver.query(domain.name, 'AAAA')
aaaa_response.answer.each do |record|
next unless record.type == 'AAAA'
@results[:dns_records][:aaaa_records] << {
address: record.address.to_s,
ttl: record.ttl,
nameserver: nameserver.hostname
}
end
rescue Dnsruby::NXDomain
# No AAAA records
rescue StandardError => e
@results[:warnings] << "Failed to query AAAA records from #{nameserver.hostname}: #{e.message}"
end
# Check for CNAME records (should not exist at apex)
begin
cname_response = resolver.query(domain.name, 'CNAME')
cname_response.answer.each do |record|
next unless record.type == 'CNAME'
@results[:dns_records][:cname_records] << {
target: record.cname.to_s,
ttl: record.ttl,
nameserver: nameserver.hostname
}
@results[:errors] << "CNAME record found at domain apex (invalid DNS configuration)"
end
rescue Dnsruby::NXDomain
# No CNAME records (good)
rescue StandardError => e
@results[:warnings] << "Failed to query CNAME records from #{nameserver.hostname}: #{e.message}"
end
end
def validate_glue_records
domain.nameservers.each do |nameserver|
# Check if nameserver is in-bailiwick (subdomain of the domain)
next unless nameserver.hostname.end_with?(".#{domain.name}")
# Validate IPv4 glue records
nameserver.ipv4.each do |ip|
if valid_ipv4?(ip)
@results[:dns_records][:a_records] << {
address: ip,
ttl: 0,
nameserver: 'glue',
type: 'glue_record'
}
else
@results[:errors] << "Invalid IPv4 glue record for #{nameserver.hostname}: #{ip}"
end
end
# Validate IPv6 glue records
nameserver.ipv6.each do |ip|
if valid_ipv6?(ip)
@results[:dns_records][:aaaa_records] << {
address: ip,
ttl: 0,
nameserver: 'glue',
type: 'glue_record'
}
else
@results[:errors] << "Invalid IPv6 glue record for #{nameserver.hostname}: #{ip}"
end
end
end
end
# Story 4: Check CDS/CDNSKEY records for DNSSEC synchronization
def check_dnssec_sync_records
Rails.logger.info "Checking DNSSEC synchronization records for domain: #{domain.name}"
@results[:dnssec] = {
cds_records: [],
cdnskey_records: [],
ds_updates_needed: []
}
return unless domain.dnskeys.any? # Only check if DNSSEC is enabled
domain.nameservers.each do |nameserver|
check_cds_records(nameserver)
check_cdnskey_records(nameserver)
end
end
def check_cds_records(nameserver)
begin
resolver = create_resolver(nameserver.hostname)
response = resolver.query(domain.name, 'CDS')
response.answer.each do |record|
next unless record.type == 'CDS'
cds_data = {
key_tag: record.key_tag,
algorithm: record.algorithm,
digest_type: record.digest_type,
digest: record.digest,
nameserver: nameserver.hostname
}
@results[:dnssec][:cds_records] << cds_data
# Check if DS record update is needed
if record.algorithm == 0
@results[:dnssec][:ds_updates_needed] << {
action: 'remove_ds',
reason: 'CDS record with algorithm 0 indicates DS removal'
}
else
# Check if we need to update DS records
existing_ds = domain.dnskeys.find_by(ds_key_tag: record.key_tag)
if !existing_ds || existing_ds.ds_digest != record.digest
@results[:dnssec][:ds_updates_needed] << {
action: 'update_ds',
cds_data: cds_data,
reason: 'CDS record indicates DS record update needed'
}
end
end
end
rescue Dnsruby::NXDomain
# No CDS records
rescue StandardError => e
@results[:warnings] << "Failed to query CDS records from #{nameserver.hostname}: #{e.message}"
end
end
def check_cdnskey_records(nameserver)
begin
resolver = create_resolver(nameserver.hostname)
response = resolver.query(domain.name, 'CDNSKEY')
response.answer.each do |record|
next unless record.type == 'CDNSKEY'
cdnskey_data = {
flags: record.flags,
protocol: record.protocol,
algorithm: record.algorithm,
public_key: Base64.strict_encode64(record.key),
nameserver: nameserver.hostname
}
@results[:dnssec][:cdnskey_records] << cdnskey_data
# Check if this DNSKEY exists in our database
existing_key = domain.dnskeys.find_by(
flags: record.flags,
protocol: record.protocol,
alg: record.algorithm,
public_key: cdnskey_data[:public_key]
)
unless existing_key
@results[:dnssec][:ds_updates_needed] << {
action: 'add_dnskey',
cdnskey_data: cdnskey_data,
reason: 'CDNSKEY record indicates new DNSKEY should be added'
}
end
end
rescue Dnsruby::NXDomain
# No CDNSKEY records
rescue StandardError => e
@results[:warnings] << "Failed to query CDNSKEY records from #{nameserver.hostname}: #{e.message}"
end
end
# Story 5: Check CSYNC records for delegation synchronization
def check_csync_records
Rails.logger.info "Checking CSYNC records for domain: #{domain.name}"
@results[:csync] = {
csync_records: [],
delegation_updates_needed: []
}
domain.nameservers.each do |nameserver|
check_single_csync_record(nameserver)
end
end
def check_single_csync_record(nameserver)
begin
resolver = create_resolver(nameserver.hostname)
response = resolver.query(domain.name, 'CSYNC')
response.answer.each do |record|
next unless record.type == 'CSYNC'
csync_data = {
serial: record.serial,
flags: record.flags,
type_bitmap: parse_type_bitmap(record.types),
nameserver: nameserver.hostname
}
@results[:csync][:csync_records] << csync_data
# Check what needs to be synchronized
if csync_data[:type_bitmap].include?('NS')
check_ns_sync_needed(nameserver)
end
if csync_data[:type_bitmap].include?('A')
check_a_sync_needed(nameserver)
end
if csync_data[:type_bitmap].include?('AAAA')
check_aaaa_sync_needed(nameserver)
end
end
rescue Dnsruby::NXDomain
# No CSYNC records
rescue StandardError => e
@results[:warnings] << "Failed to query CSYNC records from #{nameserver.hostname}: #{e.message}"
end
end
def check_ns_sync_needed(nameserver)
# Get NS records from child zone
child_ns_records = @results[:nameservers].values
.select { |ns| ns[:valid] }
.flat_map { |ns| ns[:ns_records] }
.uniq
# Compare with current nameservers
current_ns = domain.nameservers.map(&:hostname)
to_add = child_ns_records - current_ns
to_remove = current_ns - child_ns_records
if to_add.any? || to_remove.any?
@results[:csync][:delegation_updates_needed] << {
type: 'ns_records',
add: to_add,
remove: to_remove,
reason: 'CSYNC indicates NS record synchronization needed'
}
end
end
def check_a_sync_needed(nameserver)
# Check A record synchronization for in-bailiwick nameservers
domain.nameservers.each do |ns|
next unless ns.hostname.end_with?(".#{domain.name}")
# Query A records from child zone
child_a_records = query_a_records_for_host(ns.hostname)
if child_a_records != ns.ipv4
@results[:csync][:delegation_updates_needed] << {
type: 'a_records',
hostname: ns.hostname,
current_ips: ns.ipv4,
child_ips: child_a_records,
reason: 'CSYNC indicates A record glue synchronization needed'
}
end
end
end
def check_aaaa_sync_needed(nameserver)
# Check AAAA record synchronization for in-bailiwick nameservers
domain.nameservers.each do |ns|
next unless ns.hostname.end_with?(".#{domain.name}")
# Query AAAA records from child zone
child_aaaa_records = query_aaaa_records_for_host(ns.hostname)
if child_aaaa_records != ns.ipv6
@results[:csync][:delegation_updates_needed] << {
type: 'aaaa_records',
hostname: ns.hostname,
current_ips: ns.ipv6,
child_ips: child_aaaa_records,
reason: 'CSYNC indicates AAAA record glue synchronization needed'
}
end
end
end
# Story 6: Apply enforcement actions based on validation results
def apply_enforcement_actions
Rails.logger.info "Applying enforcement actions for domain: #{domain.name}"
# Handle failed nameservers
domain.nameservers.each do |nameserver|
if nameserver.validation_counter && nameserver.validation_counter >= 3
@results[:warnings] << "Nameserver #{nameserver.hostname} has failed validation #{nameserver.validation_counter} times"
# Auto-remove if configured and we have enough nameservers
if should_auto_remove_nameserver? && domain.nameservers.count > 2
Rails.logger.info "Auto-removing failed nameserver: #{nameserver.hostname}"
# Notify registrar
create_notification(
"Nameserver #{nameserver.hostname} was automatically removed from domain #{domain.name} due to repeated validation failures"
)
nameserver.destroy
@results[:warnings] << "Automatically removed nameserver #{nameserver.hostname}"
end
end
end
# Apply DNSSEC updates if needed
@results[:dnssec][:ds_updates_needed].each do |update|
case update[:action]
when 'update_ds'
update_ds_record(update[:cds_data])
when 'remove_ds'
remove_ds_records
when 'add_dnskey'
add_dnskey(update[:cdnskey_data])
end
end
# Apply delegation updates if needed
@results[:csync][:delegation_updates_needed].each do |update|
case update[:type]
when 'ns_records'
update_ns_records(update)
when 'a_records', 'aaaa_records'
update_glue_records(update)
end
end
# Send notifications if there are errors
if @results[:errors].any?
create_notification(
"DNS validation errors found for domain #{domain.name}: #{@results[:errors].join(', ')}"
)
end
end
# Helper methods
def create_resolver(nameserver_ip)
resolver = Dnsruby::Resolver.new
resolver.nameserver = nameserver_ip
resolver.query_timeout = 5
resolver.retry_times = 2
resolver.recurse = 0 # Non-recursive queries
resolver.do_caching = false
resolver
end
def valid_ipv4?(ip)
ip.match?(Nameserver::IPV4_REGEXP)
end
def valid_ipv6?(ip)
ip.match?(Nameserver::IPV6_REGEXP)
end
def parse_type_bitmap(types)
types.is_a?(Array) ? types : [types].compact
end
def query_a_records_for_host(hostname)
resolver = Dnsruby::Resolver.new
response = resolver.query(hostname, 'A')
response.answer.select { |r| r.type == 'A' }.map { |r| r.address.to_s }
rescue StandardError
[]
end
def query_aaaa_records_for_host(hostname)
resolver = Dnsruby::Resolver.new
response = resolver.query(hostname, 'AAAA')
response.answer.select { |r| r.type == 'AAAA' }.map { |r| r.address.to_s }
rescue StandardError
[]
end
def should_auto_remove_nameserver?
# Check if auto-removal is enabled (you can add this setting)
false # Disabled by default for safety
end
def create_notification(text)
begin
domain.registrar.notifications.create!(
text: text,
attached_obj_type: domain.class.to_s,
attached_obj_id: domain.id
)
rescue StandardError => e
Rails.logger.warn "Failed to create notification: #{e.message}"
end
end
def update_ds_record(cds_data)
dnskey = domain.dnskeys.find_or_initialize_by(ds_key_tag: cds_data[:key_tag])
dnskey.ds_alg = cds_data[:algorithm]
dnskey.ds_digest_type = cds_data[:digest_type]
dnskey.ds_digest = cds_data[:digest]
if dnskey.save
Rails.logger.info "Updated DS record for #{domain.name}"
else
Rails.logger.error "Failed to update DS record: #{dnskey.errors.full_messages.join(', ')}"
end
end
def remove_ds_records
domain.dnskeys.update_all(
ds_digest: nil,
ds_alg: nil,
ds_digest_type: nil,
ds_key_tag: nil
)
Rails.logger.info "Removed DS records for #{domain.name}"
end
def add_dnskey(cdnskey_data)
dnskey = domain.dnskeys.build(
flags: cdnskey_data[:flags],
protocol: cdnskey_data[:protocol],
alg: cdnskey_data[:algorithm],
public_key: cdnskey_data[:public_key]
)
if dnskey.save
Rails.logger.info "Added DNSKEY for #{domain.name}"
else
Rails.logger.error "Failed to add DNSKEY: #{dnskey.errors.full_messages.join(', ')}"
end
end
def update_ns_records(update)
# Add new nameservers
update[:add].each do |hostname|
domain.nameservers.find_or_create_by(hostname: hostname)
end
# Remove old nameservers
update[:remove].each do |hostname|
domain.nameservers.where(hostname: hostname).destroy_all
end
Rails.logger.info "Updated nameservers for #{domain.name}"
end
def update_glue_records(update)
nameserver = domain.nameservers.find_by(hostname: update[:hostname])
return unless nameserver
if update[:type] == 'a_records'
nameserver.ipv4 = update[:child_ips]
else
nameserver.ipv6 = update[:child_ips]
end
nameserver.save
Rails.logger.info "Updated glue records for #{nameserver.hostname}"
end
# Class methods for easy usage
class << self
def validate_domain(domain, record_type: 'all')
validator = new(domain: domain, name: domain.name, record_type: record_type)
validator.validate
end
end
end