require "json" require "uuid" require "uuid/json" require "baguette-crystal-base" require "./service.cr" require "dodb" class DNSManager::Storage getter domains : DODB::Storage::Cached(Domain) getter domains_by_name : DODB::Trigger::IndexCached(Domain) getter domains_by_share_key : DODB::Trigger::IndexCached(Domain) getter domains_by_transfer_key : DODB::Trigger::IndexCached(Domain) getter domains_by_owners : DODB::Trigger::Tags(Domain) getter zones : DODB::Storage::Cached(Zone) getter zones_by_domain : DODB::Trigger::IndexCached(Zone) getter tokens : DODB::Storage::Cached(Token) getter tokens_by_uuid : DODB::Trigger::IndexCached(Token) getter tokens_by_domain : DODB::Trigger::Partition(Token) getter root : String getter zonefiledir : String property dnsmanagerd : DNSManager::Service? = nil def dnsmanagerd() : DNSManager::Service @dnsmanagerd.not_nil! rescue raise Exception.new "dnsmanagerd not defined" end def initialize(@root : String, reindex : Bool = false) @domains = DODB::Storage::Cached(Domain).new "#{@root}/domains" @domains_by_name = @domains.new_index "name", &.name @domains_by_share_key = @domains.new_index "share-key", do |d| if k = d.share_key k else DODB.no_index end end @domains_by_transfer_key = @domains.new_index "transfer-key", do |d| if k = d.transfer_key k else DODB.no_index end end @domains_by_owners = @domains.new_tags "owners", &.owners.map &.to_s @zones = DODB::Storage::Cached(Zone).new "#{@root}/zones" @zones_by_domain = @zones.new_index "domain", &.domain @tokens = DODB::Storage::Cached(Token).new "#{@root}/tokens" @tokens_by_uuid = @tokens.new_index "uuid", &.uuid @tokens_by_domain = @tokens.new_partition "domain", &.domain @zonefiledir = "#{@root}/bind9-zones" Dir.mkdir_p @zonefiledir Baguette::Log.info "storage initialized" if reindex Baguette::Log.debug "Reindexing domains..." @domains.reindex_everything! Baguette::Log.debug "Reindexing zones..." @zones.reindex_everything! Baguette::Log.debug "Reindexing tokens..." @tokens.reindex_everything! Baguette::Log.debug "Reindexed!" end end # Generate Bind9 zone files. # The file is written in a temporary file then moved, enabling safe manipulation of the file # since its content always will be consistent even if not up-to-date. def generate_bind9_zonefile(domain : String) : Nil zone = zone_must_exist! domain # Safe write. filename_final = "#{@zonefiledir}/#{zone.domain}" filename_wip = "#{filename_final}.wip" Baguette::Log.info "writing zone file #{filename_final}" File.open(filename_wip, "w") do |file| zone.to_bind9 file end # Rename WIP filename to final file name. File.rename filename_wip, filename_final end # Request to generate a zone file. # Only an admin can access this function, so there is no need to verify user's authorizations a second time. def generate_zonefile(domain : String) : IPC::JSON generate_bind9_zonefile domain Response::Success.new end # Only an admin can access this function. def generate_all_zonefiles() : IPC::JSON Baguette::Log.info "writing all zone files in #{@zonefiledir}/" zones.each do |zone| generate_bind9_zonefile zone.domain end Response::Success.new end # Provides the generated zone file to a user. def get_generated_zonefile(user_id : UserDataID, domain : String) : IPC::JSON zone = zone_must_exist! domain user_should_own! user_id, zone.domain io = IO::Memory.new zone.to_bind9 io Response::GeneratedZone.new domain, (String.new io.buffer, io.pos) end # Adds a new domain. def new_domain(user_id : UserDataID, domain : String) : IPC::JSON accepted_domains = dnsmanagerd.configuration.accepted_domains.not_nil! template_directory = dnsmanagerd.configuration.template_directory # Prevent future very confusing errors. domain = domain.downcase return Response::DomainAlreadyExists.new if zones_by_domain.get? domain # Verify if the domain is acceptable. matching_domains = accepted_domains.select { |adomain| domain.ends_with? adomain } unless matching_domains.size > 0 Baguette::Log.warning "trying to add an unacceptable domain: '#{domain}'" return Response::UnacceptableDomain.new end matching_domains.each do |md| # Prevent empty domains (from crafted requests) to be accepted. return Response::InvalidDomainName.new unless (domain.chomp md).size > 1 #Baguette::Log.info "Add new domain #{domain} (matching domain #{md})" end # Verify the domain name validity. return Response::InvalidDomainName.new unless Zone.is_domain_valid? domain # Fill a template zone. tld = matching_domains.pop default_zone = Zone.from_json File.read "#{template_directory}/#{tld}.json" # Replace domain by the real domain. default_zone.replace_domain domain # Reset the serial number so it represents the current date. # This is particularly useful when a zone is deleted then re-used again right after, in this case # the serial would be a problem since the new zone may not be usable for some time, depending on the # configuration. default_zone.reset_serial # # Actually write data on-disk. # # Add the new domain. the_new_domain = Domain.new domain the_new_domain.owners = [user_id] @domains << the_new_domain # Add the new zone in the database. update_zone default_zone Response::DomainAdded.new domain end # Asks for a "share token". def ask_share_token(user_id : UserDataID, domain_name : String) : IPC::JSON user_should_own! user_id, domain_name domain = @domains_by_name.get domain_name if domain.share_key.nil? # Clone the domain so the database doesn't try to remove the new `share_key`. domain_cloned = domain.clone # Sharing the domain cancels domain transfer. domain_cloned.transfer_key = nil domain_cloned.share_key = UUID.random.to_s @domains_by_name.update domain_name, domain_cloned Response::DomainChanged.new domain_cloned else Response::Error.new "The domain already have a share key." end end # Asks for a "transfer token". def ask_transfer_token(user_id : UserDataID, domain_name : String) : IPC::JSON user_should_own! user_id, domain_name domain = @domains_by_name.get domain_name if domain.transfer_key.nil? # You cannot transfer the domain in case it has a share key. return Response::Error.new "The domain has a share key." unless domain.share_key.nil? # Clone the domain so the database doesn't try to remove the new `transfer_key`. domain_cloned = domain.clone domain_cloned.transfer_key = UUID.random.to_s @domains_by_name.update domain_name, domain_cloned Response::DomainChanged.new domain_cloned else Response::Error.new "The domain already have a transfer key." end end # Check the domain owners. # In case there's only the requesting user, allow him to gain full control. def ask_unshare_domain(user_id : UserDataID, domain_name : String) : IPC::JSON user_should_own! user_id, domain_name domain = @domains_by_name.get domain_name if domain.owners.size == 1 && domain.owners[0] == user_id # Clone the domain so the old `share_key` still exists in the old version. domain_cloned = domain.clone domain_cloned.share_key = nil @domains_by_name.update domain_cloned Response::DomainChanged.new domain_cloned else Response::Error.new "You are not the only owner." end end # Uses either a "share" or a "transfer" token. def gain_ownership(user_id : UserDataID, uuid : String) : IPC::JSON if domain = @domains_by_share_key.get? uuid if domain.owners.includes? user_id return Response::Error.new "You already own this domain." end domain_cloned = domain.clone domain_cloned.owners << user_id @domains_by_name.update domain_cloned Response::DomainChanged.new domain_cloned elsif domain = @domains_by_transfer_key.get? uuid if domain.owners.includes? user_id return Response::Error.new "You already own this domain." end # Clone the domain so the old `transfer_key` still exists in the old version. domain_cloned = domain.clone domain_cloned.transfer_key = nil domain_cloned.owners = [user_id] @domains_by_name.update domain_cloned Response::DomainChanged.new domain_cloned else Response::Error.new "There is no key with this UUID." end end def add_or_update_zone(user_id : UserDataID, zone : Zone) : IPC::JSON # Test zone validity. if errors = zone.get_errors? Baguette::Log.warning "zone #{zone.domain} update with errors: #{errors}" return Response::InvalidZone.new errors end # Does the zone already exist? if z = zones_by_domain.get? zone.domain user_should_own! user_id, z.domain else # Add the domain to the user's domain. domains << Domain.new zone.domain end update_zone zone Response::Success.new end def add_rr(user_id : UserDataID, domain : String, rr : Zone::ResourceRecord) : IPC::JSON zone = zone_must_exist! domain user_should_own! user_id, domain # Test RR validity. rr.get_errors.tap do |errors| unless errors.empty? Baguette::Log.warning "add RR with errors: #{errors}" return Response::InvalidRR.new errors end end zone << rr update_zone zone Response::RRAdded.new zone.domain, rr end # Any modification of the zone must be performed here. # This function updates the SOA serial before storing the modified zone. # Also, this function generate the Bind9 file. def update_zone(zone : Zone) : Nil zone.update_serial zones_by_domain.update_or_create zone.domain, zone generate_bind9_zonefile zone.domain end def update_rr(user_id : UserDataID, domain : String, rr : Zone::ResourceRecord) : IPC::JSON zone = zone_must_exist! domain user_should_own! user_id, domain zone.rr_not_readonly! rr.rrid zone_clone = zone.clone # Test RR validity. rr.get_errors.tap do |errors| unless errors.empty? Baguette::Log.warning "update RR with errors: #{errors}" return Response::InvalidRR.new errors end end zone_clone.update_rr rr update_zone zone_clone Response::RRUpdated.new domain, rr end def delete_rr(user_id : UserDataID, domain : String, rrid : UInt32) : IPC::JSON zone = zone_must_exist! domain user_should_own! user_id, domain rr = zone.rr_not_readonly! rrid # Remove token from the db. if token_uuid = rr.token tokens_by_uuid.delete token_uuid end zone_clone = zone.clone zone_clone.delete_rr rrid update_zone zone_clone Response::RRDeleted.new rrid end # Removes a Bind9 zonefile. def remove_bind9_zonefile(domain : String) : Nil Baguette::Log.info "Removing a Bind9 zone file." File.delete "#{@zonefiledir}/#{domain}" end # Deletes a domain. def delete_domain(user_id : UserDataID, domain_name : String) : IPC::JSON zone_must_exist! domain_name user_should_own! user_id, domain_name domain = @domains_by_name.get domain_name # The user isn't the only owner, just remove him from the list of owners. if domain.owners.size > 1 domain_cloned = domain.clone domain_cloned.owners.select! { |o| o != user_id } @domains_by_name.update domain_cloned # Not really a domain deletion, but that'll do the trick. return Response::DomainDeleted.new domain_name end # Remove the related zone and their registered tokens. zones_by_domain.delete domain_name # The domain may not have tokens. tokens_by_domain.delete? domain_name # Remove this domain_name from the list of user's domains. domains_by_name.delete domain_name remove_bind9_zonefile domain_name Response::DomainDeleted.new domain_name end # Gets all removed users from `authd`, list all their domains and remove their data from `dnsmanagerd`. def get_orphan_domains(user_id : UserDataID) : IPC::JSON user_must_be_admin! user_id Baguette::Log.debug "list all orphan domains (long computation)" orphans = [] of String domains.each do |domain| domain.owners.each do |owner| begin user_must_exist! owner rescue e Baguette::Log.warning "no authd info on user #{owner}: #{e} (removing this user)" if owned_domains = domains_by_owners.get? owner.to_s Baguette::Log.debug "user #{owner} had #{owned_domains.size} domains" owned_domains.each do |d| orphans << d.name end else Baguette::Log.warning "no domain indexed for user #{owner}, but owns domain #{domain.name}" end wipe_user_data owner end end end Baguette::Log.debug "total: #{orphans.size} orphans" Response::OrphanDomainList.new orphans end def get_zone(user_id : UserDataID, domain : String) : IPC::JSON zone = zone_must_exist! domain user_should_own! user_id, domain Response::Zone.new zone end # Removes user data. def wipe_user_data(user_id : UserDataID) : Nil domains_by_owners.get(user_id.to_s).each do |domain| domain_cloned = domain.clone domain_cloned.owners.delete user_id # Remove the user's domain when he is the only owner. if domain_cloned.owners.empty? # The domain may not have tokens. @tokens_by_domain.delete? domain_cloned.name @zones_by_domain.delete domain_cloned.name @domains_by_name.delete domain_cloned.name else @domains_by_name.update domain_cloned end end rescue e Baguette::Log.error "while removing a domain: #{e}" end # Removes user data. def delete_user_data(user_id : UserDataID, user_to_delete : UserDataID?) : IPC::JSON user_id_to_delete = if u = user_to_delete user_must_be_admin! user_id Baguette::Log.info "Admin #{user_id} removes data of user #{u}." u else Baguette::Log.info "User #{user_id} terminates his account." user_id end wipe_user_data user_id_to_delete Response::Success.new end # Gets a list of user domains. def user_domains(user_id : UserDataID) : Array(Domain) if doms = domains_by_owners.get? user_id.to_s doms else Array(Domain).new end end # TODO: is the user known from authd? def user_must_exist!(user_id : UserDataID) response = dnsmanagerd().authd.get_user? user_id case response when AuthD::Response::User response.user else raise UnknownUserException.new "user #{user_id} doesn't exist" end end # Asks `authd` for the user's permissions and verifies the `dnsmanager` permissions are "Admin"-level. def user_must_be_admin!(user_id : UserDataID) : Nil dnsmanagerd().assert_permissions! user_id, "*", AuthD::User::PermissionLevel::Admin end # Asks `authd` for the user's permissions and verifies the `dnsmanager` permissions are "Admin"-level. def is_admin?(user_id : UserDataID) : Bool dnsmanagerd().is_admin? user_id end # Verifies the existence of a zone. def zone_must_exist!(domain : String) : Zone zone = zones_by_domain.get? domain raise DomainNotFoundException.new unless zone zone end # Owning a domain means to be in the owners' list of the domain. def user_should_own!(user_id : UserDataID, domain : String) : Nil d = domains_by_name.get? domain raise DomainNotFoundException.new if d.nil? unless d.owners.includes? user_id raise NoOwnershipException.new unless is_admin? user_id end end # Asks a new token for a resource record. def new_token(user_id : UserDataID, domain : String, rrid : UInt32) : IPC::JSON zone = zone_must_exist! domain user_should_own! user_id, domain rr = zone.rr_must_exist! rrid old_token = rr.token # 1. create token token = Token.new domain, rrid # 2. add token to the RR. rr.token = token.uuid zone_clone = zone.clone zone_clone.update_rr rr # 3. update the zone (no need to call `update_zone` to change the zone serial). zones_by_domain.update zone_clone # 4. if there is an old token, remove it. if old_token_uuid = old_token tokens_by_uuid.delete old_token_uuid end # 5. add token to the db. @tokens << token Response::RRUpdated.new domain, rr end # Verifies the existence and retrieves a token for automatic updates. def token_must_exist!(token_uuid : String) : Token token = tokens_by_uuid.get? token_uuid raise TokenNotFoundException.new unless token token end # Uses a token to automatically update a resource record. def use_token(token_uuid : String, address : String) : IPC::JSON token = token_must_exist! token_uuid zone = zone_must_exist! token.domain rr = zone.rr_must_exist! token.rrid # Same address, no need to change anything. return Response::Success.new if rr.target == address case rr when Zone::A return Response::Error.new "invalid ipv4" unless Zone.is_ipv4_address_valid? address rr.target = address zone.update_rr rr update_zone zone Response::Success.new when Zone::AAAA return Response::Error.new "invalid ipv6" unless Zone.is_ipv6_address_valid? address rr.target = address zone.update_rr rr update_zone zone Response::Success.new else Response::Error.new "use token on invalid entry (not A or AAAA)" end end end require "./storage/*"