require "json" require "uuid" require "uuid/json" require "baguette-crystal-base" require "dodb" class DNSManager::Storage getter user_data : DODB::CachedDataBase(UserData) getter user_data_by_uid : DODB::Index(UserData) getter zones : DODB::CachedDataBase(Zone) getter zones_by_domain : DODB::Index(Zone) getter tokens : DODB::CachedDataBase(Token) getter tokens_by_uuid : DODB::Index(Token) getter tokens_by_domain : DODB::Partition(Token) getter root : String getter zonefiledir : String def initialize(@root : String, reindex : Bool = false) @user_data = DODB::CachedDataBase(UserData).new "#{@root}/user-data" @user_data_by_uid = @user_data.new_index "uid", &.uid.to_s @zones = DODB::CachedDataBase(Zone).new "#{@root}/zones" @zones_by_domain = @zones.new_index "domain", &.domain @tokens = DODB::CachedDataBase(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 user data..." @user_data.reindex_everything! Baguette::Log.debug "Reindexing zones..." @zones.reindex_everything! Baguette::Log.debug "Reindexing tokens..." @tokens.reindex_everything! Baguette::Log.debug "Reindexed!" end end def get_user_data(uid : UserDataID) user_data_by_uid.get uid.to_s rescue e : DODB::MissingEntry entry = UserData.new uid entry end def get_user_data(user : ::AuthD::User::Public) get_user_data user.uid end def update_user_data(user_data : UserData) user_data_by_uid.update_or_create user_data.uid.to_s, user_data end def ensure_user_data(user_id : UserDataID) user_data = user_data_by_uid.get? user_id.to_s unless user_data Baguette::Log.info "New user #{user_id}" user_data = UserData.new user_id @user_data << user_data end user_data 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| # TODO: safe write. File.open("#{@zonefiledir}/#{zone.domain}", "w") do |file| zone.to_bind9 file end end Response::Success.new end # Only an admin can access this function. def generate_zonefile(domain : String) : IPC::JSON zone = zone_must_exist! domain Baguette::Log.info "writing zone file #{@zonefiledir}/#{zone.domain}" # TODO: safe write. File.open("#{@zonefiledir}/#{zone.domain}", "w") do |file| zone.to_bind9 file end Response::Success.new end def get_generated_zonefile(user_id : UserDataID, domain : String) : IPC::JSON user_data = user_must_exist! user_id zone = zone_must_exist! domain user_should_own! user_data, zone.domain io = IO::Memory.new zone.to_bind9 io Response::GeneratedZone.new domain, (String.new io.buffer, io.pos) end def new_domain(accepted_domains : Array(String), template_directory : String, user_id : UserDataID, domain : String) : IPC::JSON user_data = user_must_exist! user_id 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| 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 # # Actually write data on-disk. # # Add the domain to the user's domain. user_data.domains << domain update_user_data user_data # Add the new zone in the database. zones_by_domain.update_or_create domain, default_zone Response::DomainAdded.new domain end def add_or_update_zone(user_id : UserDataID, zone : Zone) : IPC::JSON user_data = user_must_exist! user_id # 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_data, z.domain else # Add the domain to the user's domain. user_data.domains << zone.domain # Actually write data on-disk. update_user_data user_data end # Add -or replace- the zone. zones_by_domain.update_or_create zone.domain, zone Response::Success.new end def add_rr(user_id : UserDataID, domain : String, rr : Zone::ResourceRecord) : IPC::JSON user_data = user_must_exist! user_id zone = zone_must_exist! domain user_should_own! user_data, 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. def update_zone(zone : Zone) zone.update_serial zones_by_domain.update_or_create zone.domain, zone end def update_rr(user_id : UserDataID, domain : String, rr : Zone::ResourceRecord) : IPC::JSON user_data = user_must_exist! user_id zone = zone_must_exist! domain user_should_own! user_data, domain zone.rr_not_readonly! rr.rrid # 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.update_rr rr update_zone zone Response::RRUpdated.new domain, rr end def delete_rr(user_id : UserDataID, domain : String, rrid : UInt32) : IPC::JSON user_data = user_must_exist! user_id zone = zone_must_exist! domain user_should_own! user_data, 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.delete_rr rrid update_zone zone Response::RRDeleted.new rrid end def delete_domain(user_id : UserDataID, domain : String) : IPC::JSON user_data = user_must_exist! user_id zone_must_exist! domain user_should_own! user_data, domain # Remove this domain from the list of user's domains. user_data.domains.delete domain # Update on-disk user data. update_user_data user_data # Remove the related zone and their registered tokens. zones_by_domain.delete domain tokens_by_domain.delete domain Response::DomainDeleted.new domain end def get_orphan_domains(authd : AuthD::Client, user_id : UserDataID) : IPC::JSON user_must_be_admin! user_id Baguette::Log.warning "list all orphan domains (long computation)" # Get all removed users from `authd`. orphans = [] of String user_data.each do |user| begin authd.get_user? user.uid rescue e Baguette::Log.warning "cannot get authd info on user #{user.uid}: #{e}" Baguette::Log.warning "-> get his domains!" # Verify the user still exists in `authd`. user.domains.each do |domain| orphans << domain end end end Baguette::Log.warning "total: #{orphans.size} orphans" Response::OrphanDomainList.new orphans end def get_zone(user_id : UserDataID, domain : String) : IPC::JSON user_data = user_must_exist! user_id zone = zone_must_exist! domain user_should_own! user_data, domain Response::Zone.new zone end def delete_user_data(user_id : UserDataID, user_to_delete : UserDataID?) : IPC::JSON user_data = user_must_exist! user_id user_data_to_delete = if u = user_to_delete user_must_be_admin! user_id Baguette::Log.info "Admin #{user_id} removes data of user #{u}." user_must_exist! u else Baguette::Log.info "User #{user_data.uid} terminates his account." user_data end # Remove the user's domains. user_data_to_delete.domains.each do |domain| zones_by_domain.delete domain rescue e Baguette::Log.error "while removing a user: #{e}" end # Remove the user. user_data_by_uid.delete user_data_to_delete.uid.to_s Response::Success.new end def user_domains(user_id : UserDataID) : IPC::JSON user_data = user_must_exist! user_id Response::DomainList.new user_data.domains end def user_must_exist!(user_id : UserDataID) : UserData user_data = user_data_by_uid.get? user_id.to_s raise UnknownUserException.new unless user_data user_data end def user_must_be_admin!(user_id : UserDataID) : UserData user_data = user_must_exist! user_id raise AdminAuthorizationException.new unless user_data.admin user_data end def zone_must_exist!(domain : String) : Zone zone = zones_by_domain.get? domain raise DomainNotFoundException.new unless zone zone end def user_should_own!(user_data : UserData, domain : String) unless user_data.domains.includes?(domain) || user_data.admin raise NoOwnershipException.new end end def new_token(user_id : UserDataID, domain : String, rrid : UInt32) : IPC::JSON user_data = user_must_exist! user_id zone = zone_must_exist! domain user_should_own! user_data, 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.update_rr rr # 3. update the zone (no need to call `update_zone` to change the zone serial). zones_by_domain.update_or_create zone.domain, zone # 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 def token_must_exist!(token_uuid : String) : Token token = tokens_by_uuid.get? token_uuid raise TokenNotFoundException.new unless token token end 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 zones_by_domain.update_or_create zone.domain, 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/*"