From 1e66262884f867020abee4bd48b7f765bdc69289 Mon Sep 17 00:00:00 2001 From: Philippe PITTOLI Date: Mon, 1 Jul 2024 11:56:50 +0200 Subject: [PATCH] Minor change in the code structure. --- TODO.md | 4 + src/exceptions.cr | 39 +++++ src/main.cr | 356 +++++++++-------------------------------- src/service.cr | 172 +++++++++++++++++++- src/storage.cr | 9 +- tools/token-handler.cr | 1 + 6 files changed, 295 insertions(+), 286 deletions(-) create mode 100644 src/exceptions.cr diff --git a/TODO.md b/TODO.md index 66b1916..227eb97 100644 --- a/TODO.md +++ b/TODO.md @@ -9,6 +9,10 @@ TODO: * (server) Resource Records to add, del and modify * (server) Zone validity on modification * (client) check for errors in the list of possible returned messages +- optimization: RAMOnly-DB for connected users + 1. dnsmanagerd should be able to (un)subscribe for a user data + 2. avoid requests to AuthD (don't ask for user data twice) + 3. AuthD should send updates on a user in real-time DONE: diff --git a/src/exceptions.cr b/src/exceptions.cr new file mode 100644 index 0000000..22b6482 --- /dev/null +++ b/src/exceptions.cr @@ -0,0 +1,39 @@ +module DNSManager + class Exception < ::Exception + end + + class NotLoggedException < ::Exception + end + + class NoOwnershipException < ::Exception + end + + class AuthorizationException < ::Exception + end + + class UnknownUserException < ::Exception + end + + class RRReadOnlyException < ::Exception + property domain : String + property rr : DNSManager::Storage::Zone::ResourceRecord + def initialize(@domain, @rr) + end + end + + class CannotCheckPermissionsException < ::Exception + property uid : UserDataID + property resource : String + def initialize(@uid, @resource) + end + end + + class DomainNotFoundException < ::Exception + end + + class RRNotFoundException < ::Exception + end + + class TokenNotFoundException < ::Exception + end +end diff --git a/src/main.cr b/src/main.cr index f8d4669..d251523 100644 --- a/src/main.cr +++ b/src/main.cr @@ -3,309 +3,99 @@ require "option_parser" require "ipc" require "ipc/json" + require "authd" -require "baguette-crystal-base" - -require "./config" - -module DNSManager - class Exception < ::Exception - end - class DomainNotFoundException < ::Exception - end - class UnknownUserException < ::Exception - end - class RRReadOnlyException < ::Exception - property domain : String - property rr : DNSManager::Storage::Zone::ResourceRecord - def initialize(@domain, @rr) - end - end - class CannotCheckPermissionsException < ::Exception - property uid : UserDataID - property resource : String - def initialize(@uid, @resource) - end - end - class AuthorizationException < ::Exception - end - class NoOwnershipException < ::Exception - end - class NotLoggedException < ::Exception - end - class RRNotFoundException < ::Exception - end - class TokenNotFoundException < ::Exception - end -end - +require "./service" require "./storage.cr" require "./network.cr" +# First option parsing, same with all Baguette (service) applications. +simulation, no_configuration, configuration_file = Baguette::Configuration.option_parser -class DNSManager::Service < IPC - property configuration : Baguette::Configuration::DNSManager - getter storage : DNSManager::Storage - getter logged_users : Hash(Int32, AuthD::User::Public) - - property authd : AuthD::Client - - def initialize(@configuration) - super() - @storage = DNSManager::Storage.new @configuration.storage_directory, @configuration.recreate_indexes - - @logged_users = Hash(Int32, AuthD::User::Public).new - - # TODO: auth service isn't in the FDs pool. - # If the service crashes, dnsmanagerd won't know it. - @authd = AuthD::Client.new - response = authd.login? @configuration.login, @configuration.pass.not_nil! - case response - when AuthD::Response::Login - uid = response.uid - token = response.token - Baguette::Log.info "Authenticated as #{@configuration.login} #{uid}, token: #{token}" - else - @authd.close - raise "Cannot authenticate to authd with login #{@configuration.login}: #{response}." - end - - @storage.dnsmanagerd = self - self.timer @configuration.ipc_timer - self.service_init @configuration.service_name - end - - def get_logged_user(event : IPC::Event) - @logged_users[event.fd]? - end - - def decode_token(token : String) - @authd.decode_token token - end - - def is_admin?(uid : UInt32) : Bool - perms = check_permissions uid, "*" - (perms == AuthD::User::PermissionLevel::Admin) - end - - def check_permissions(uid : UInt32, resource : String) : AuthD::User::PermissionLevel - response = @authd.check_permission uid, "dnsmanager", resource - case response - when AuthD::Response::PermissionCheck - return response.permission - end - raise CannotCheckPermissionsException.new uid, resource - rescue e - Baguette::Log.error "error while checking permissions: #{e}" - raise CannotCheckPermissionsException.new uid, resource - end - - def assert_permissions!(uid : UInt32, resource : String, perms : AuthD::User::PermissionLevel) - if check_permissions(uid, resource) < perms - raise AuthorizationException.new - end - end - - def handle_request(event : IPC::Event) - request_start = Time.utc - - array = event.message.not_nil! - slice = Slice.new array.to_unsafe, array.size - message = IPCMessage::TypedMessage.deserialize slice - request = DNSManager.requests.parse_ipc_json message.not_nil! - - if request.nil? - raise "unknown request type" - end - - reqname = request.class.name.sub /^DNSManager::Request::/, "" - - response = begin - request.handle self, event - rescue e : AuthorizationException - Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} authorization error" - Response::Error.new "authorization error" - rescue e : DomainNotFoundException - Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} domain not found" - Response::DomainNotFound.new - rescue e : CannotCheckPermissionsException - Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} cannot check permissions of user '#{e.uid}' on resource '#{e.resource}'" - Response::InsufficientRights.new - rescue e : UnknownUserException - Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} unknown user" - Response::UnknownUser.new - rescue e : NoOwnershipException - Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} no ownership error" - Response::NoOwnership.new - rescue e : NotLoggedException - Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} user not logged" - Response::Error.new "user not logged" - rescue e : RRNotFoundException - Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} RR not found" - Response::RRNotFound.new - rescue e : TokenNotFoundException - Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} Token not found" - Response::Error.new "token not found" - rescue e : RRReadOnlyException - Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} RR is read only" - Response::RRReadOnly.new e.domain, e.rr - rescue e # Generic case - Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} generic error #{e}" - Response::Error.new "generic error" - end - - # If clients sent requests with an “id” field, it is copied - # in the responses. Allows identifying responses easily. - response.id = request.id - - schedule event.fd, response - - duration = Time.utc - request_start - - response_name = response.class.name.sub /^DNSManager::Response::/, "" - - if response.is_a? DNSManager::Response::Error - Baguette::Log.warning "fd #{"%4d" % event.fd} (#{duration}) #{reqname} >> #{response_name} (#{response.reason})" - else - if reqname != "KeepAlive" || @configuration.print_keepalive - Baguette::Log.debug "fd #{"%4d" % event.fd} (#{duration}) #{reqname} >> #{response_name}" - end - end - end - - def run - Baguette::Log.title "Starting #{@configuration.service_name}" - - self.loop do |event| - begin - case event.type - when LibIPC::EventType::Timer - Baguette::Log.debug "Timer." if @configuration.print_ipc_timer - - when LibIPC::EventType::Connection - Baguette::Log.debug "New connection!" if @configuration.print_ipc_connection - - when LibIPC::EventType::Disconnection - Baguette::Log.debug "Disconnection from #{event.fd}." if @configuration.print_ipc_disconnection - @logged_users.delete event.fd - - when LibIPC::EventType::MessageTx - Baguette::Log.debug "Message sent to #{event.fd}." if @configuration.print_ipc_message_sent - - when LibIPC::EventType::MessageRx - Baguette::Log.debug "Message received from #{event.fd}." if @configuration.print_ipc_message_received - handle_request event - - else - Baguette::Log.warning "Unhandled IPC event: #{event.class}." - if event.responds_to?(:fd) - fd = event.fd - Baguette::Log.warning "closing #{fd}" - close fd - @logged_users.delete fd - end - end - - rescue exception - Baguette::Log.error "exception: #{typeof(exception)} - #{exception.message}" - end - end - end +# DNSManagerd configuration. +configuration = if no_configuration + Baguette::Log.info "do not load a configuration file." + Baguette::Configuration::DNSManager.new +else + # In case there is a configuration file helping with the parameters. + Baguette::Configuration::DNSManager.get(configuration_file) || + Baguette::Configuration::DNSManager.new end -def main - - # First option parsing, same with all Baguette (service) applications. - simulation, no_configuration, configuration_file = Baguette::Configuration.option_parser - - # DNSManagerd configuration. - configuration = if no_configuration - Baguette::Log.info "do not load a configuration file." - Baguette::Configuration::DNSManager.new - else - # In case there is a configuration file helping with the parameters. - Baguette::Configuration::DNSManager.get(configuration_file) || - Baguette::Configuration::DNSManager.new +OptionParser.parse do |parser| + parser.on "-v verbosity-level", "--verbosity level", "Verbosity." do |opt| + Baguette::Log.info "Verbosity level: #{opt}" + configuration.verbosity = opt.to_i end - - OptionParser.parse do |parser| - parser.on "-v verbosity-level", "--verbosity level", "Verbosity." do |opt| - Baguette::Log.info "Verbosity level: #{opt}" - configuration.verbosity = opt.to_i - end - - # IPC Service options - parser.on "-s service_name", "--service_name service_name", "Service name (IPC)." do |service_name| - Baguette::Log.info "Service name: #{service_name}" - configuration.service_name = service_name - end - - parser.on "-r storage_directory", "--root storage_directory", "Storage directory." do |storage_directory| - Baguette::Log.info "Storage directory: #{storage_directory}" - configuration.storage_directory = storage_directory - end - - parser.on "-l login", "--login login", "DNS manager authd login." do |login| - Baguette::Log.info "Authd login for dnsmanager: #{login}" - configuration.login = login - end - - parser.on "-p pass", "--pass pass", "DNS manager authd pass." do |pass| - Baguette::Log.info "Authd pass (not echoed)" - configuration.pass = pass - end - - parser.on "-h", "--help", "Show this help" do - puts parser - exit 0 - end + # IPC Service options + parser.on "-s service_name", "--service_name service_name", "Service name (IPC)." do |service_name| + Baguette::Log.info "Service name: #{service_name}" + configuration.service_name = service_name end - unless File.directory? configuration.template_directory - Baguette::Log.warning "template directory '#{configuration.template_directory}' doesn't exist" - if File.directory? "./templates" - Baguette::Log.info "using template directory './templates'" - configuration.template_directory = "./templates" - else - Baguette::Log.error "tried template directory './templates', but doesn't exist either" - Baguette::Log.error "no template directory detected, quitting" - exit 1 - end + parser.on "-r storage_directory", "--root storage_directory", "Storage directory." do |storage_directory| + Baguette::Log.info "Storage directory: #{storage_directory}" + configuration.storage_directory = storage_directory end - dir = configuration.template_directory - accepted_domains = configuration.accepted_domains - - unless accepted_domains - Baguette::Log.error "Not even a single accepted domain configured. Probably an error." - exit 1 + parser.on "-l login", "--login login", "DNS manager authd login." do |login| + Baguette::Log.info "Authd login for dnsmanager: #{login}" + configuration.login = login end - accepted_domains.each do |domain| - template_file = "#{dir}/#{domain}.json" - zone = DNSManager::Storage::Zone.from_json File.read "#{template_file}" - puts "default zone for #{domain}: #{zone}" - rescue e - Baguette::Log.error "error reading template #{template_file}: #{e}" - exit 1 + parser.on "-p pass", "--pass pass", "DNS manager authd pass." do |pass| + Baguette::Log.info "Authd pass (not echoed)" + configuration.pass = pass end - if simulation - pp! configuration + parser.on "-h", "--help", "Show this help" do + puts parser exit 0 end - - unless configuration.pass - Baguette::Log.error "no pass found" - Baguette::Log.error "Should be present in dnsmanager.yml or via command line arguments (-p)" - exit 1 - end - - service = DNSManager::Service.new configuration - service.run end -main +unless File.directory? configuration.template_directory + Baguette::Log.warning "template directory '#{configuration.template_directory}' doesn't exist" + if File.directory? "./templates" + Baguette::Log.info "using template directory './templates'" + configuration.template_directory = "./templates" + else + Baguette::Log.error "tried template directory './templates', but doesn't exist either" + Baguette::Log.error "no template directory detected, quitting" + exit 1 + end +end + +dir = configuration.template_directory +accepted_domains = configuration.accepted_domains + +unless accepted_domains + Baguette::Log.error "Not even a single accepted domain configured. Probably an error." + exit 1 +end + +accepted_domains.each do |domain| + template_file = "#{dir}/#{domain}.json" + zone = DNSManager::Storage::Zone.from_json File.read "#{template_file}" + puts "default zone for #{domain}: #{zone}" +rescue e + Baguette::Log.error "error reading template #{template_file}: #{e}" + exit 1 +end + +if simulation + pp! configuration + exit 0 +end + +unless configuration.pass + Baguette::Log.error "no pass found" + Baguette::Log.error "Should be present in dnsmanager.yml or via command line arguments (-p)" + exit 1 +end + +service = DNSManager::Service.new configuration +service.run diff --git a/src/service.cr b/src/service.cr index 018071c..be68b23 100644 --- a/src/service.cr +++ b/src/service.cr @@ -1,5 +1,175 @@ require "ipc" -# Useful to enable the client to be built. +require "baguette-crystal-base" +require "./config" +require "./exceptions" + class DNSManager::Service < IPC + property configuration : Baguette::Configuration::DNSManager + getter storage : DNSManager::Storage + getter logged_users : Hash(Int32, AuthD::User::Public) + + property authd : AuthD::Client + + def initialize(@configuration) + super() + @storage = DNSManager::Storage.new @configuration.storage_directory, @configuration.recreate_indexes + + @logged_users = Hash(Int32, AuthD::User::Public).new + + # TODO: auth service isn't in the FDs pool. + # If the service crashes, dnsmanagerd won't know it. + @authd = AuthD::Client.new + response = authd.login? @configuration.login, @configuration.pass.not_nil! + case response + when AuthD::Response::Login + uid = response.uid + token = response.token + Baguette::Log.info "Authenticated as #{@configuration.login} #{uid}, token: #{token}" + else + @authd.close + raise "Cannot authenticate to authd with login #{@configuration.login}: #{response}." + end + + @storage.dnsmanagerd = self + self.timer @configuration.ipc_timer + self.service_init @configuration.service_name + end + + def get_logged_user(event : IPC::Event) + @logged_users[event.fd]? + end + + def decode_token(token : String) + @authd.decode_token token + end + + def is_admin?(uid : UInt32) : Bool + perms = check_permissions uid, "*" + (perms == AuthD::User::PermissionLevel::Admin) + end + + def check_permissions(uid : UInt32, resource : String) : AuthD::User::PermissionLevel + response = @authd.check_permission uid, "dnsmanager", resource + case response + when AuthD::Response::PermissionCheck + return response.permission + end + raise CannotCheckPermissionsException.new uid, resource + rescue e + Baguette::Log.error "error while checking permissions: #{e}" + raise CannotCheckPermissionsException.new uid, resource + end + + def assert_permissions!(uid : UInt32, resource : String, perms : AuthD::User::PermissionLevel) + if check_permissions(uid, resource) < perms + raise AuthorizationException.new + end + end + + def handle_request(event : IPC::Event) + request_start = Time.utc + + array = event.message.not_nil! + slice = Slice.new array.to_unsafe, array.size + message = IPCMessage::TypedMessage.deserialize slice + request = DNSManager.requests.parse_ipc_json message.not_nil! + + if request.nil? + raise "unknown request type" + end + + reqname = request.class.name.sub /^DNSManager::Request::/, "" + + response = begin + request.handle self, event + rescue e : AuthorizationException + Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} authorization error" + Response::Error.new "authorization error" + rescue e : DomainNotFoundException + Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} domain not found" + Response::DomainNotFound.new + rescue e : CannotCheckPermissionsException + Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} cannot check permissions of user '#{e.uid}' on resource '#{e.resource}'" + Response::InsufficientRights.new + rescue e : UnknownUserException + Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} unknown user" + Response::UnknownUser.new + rescue e : NoOwnershipException + Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} no ownership error" + Response::NoOwnership.new + rescue e : NotLoggedException + Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} user not logged" + Response::Error.new "user not logged" + rescue e : RRNotFoundException + Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} RR not found" + Response::RRNotFound.new + rescue e : TokenNotFoundException + Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} Token not found" + Response::Error.new "token not found" + rescue e : RRReadOnlyException + Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} RR is read only" + Response::RRReadOnly.new e.domain, e.rr + rescue e # Generic case + Baguette::Log.error "(fd #{"%4d" % event.fd}) #{reqname} generic error #{e}" + Response::Error.new "generic error" + end + + # If clients sent requests with an “id” field, it is copied + # in the responses. Allows identifying responses easily. + response.id = request.id + + schedule event.fd, response + + duration = Time.utc - request_start + + response_name = response.class.name.sub /^DNSManager::Response::/, "" + + if response.is_a? DNSManager::Response::Error + Baguette::Log.warning "fd #{"%4d" % event.fd} (#{duration}) #{reqname} >> #{response_name} (#{response.reason})" + else + if reqname != "KeepAlive" || @configuration.print_keepalive + Baguette::Log.debug "fd #{"%4d" % event.fd} (#{duration}) #{reqname} >> #{response_name}" + end + end + end + + def run + Baguette::Log.title "Starting #{@configuration.service_name}" + + self.loop do |event| + begin + case event.type + when LibIPC::EventType::Timer + Baguette::Log.debug "Timer." if @configuration.print_ipc_timer + + when LibIPC::EventType::Connection + Baguette::Log.debug "New connection!" if @configuration.print_ipc_connection + + when LibIPC::EventType::Disconnection + Baguette::Log.debug "Disconnection from #{event.fd}." if @configuration.print_ipc_disconnection + @logged_users.delete event.fd + + when LibIPC::EventType::MessageTx + Baguette::Log.debug "Message sent to #{event.fd}." if @configuration.print_ipc_message_sent + + when LibIPC::EventType::MessageRx + Baguette::Log.debug "Message received from #{event.fd}." if @configuration.print_ipc_message_received + handle_request event + + else + Baguette::Log.warning "Unhandled IPC event: #{event.class}." + if event.responds_to?(:fd) + fd = event.fd + Baguette::Log.warning "closing #{fd}" + close fd + @logged_users.delete fd + end + end + + rescue exception + Baguette::Log.error "exception: #{typeof(exception)} - #{exception.message}" + end + end + end end diff --git a/src/storage.cr b/src/storage.cr index ed3d914..bd7ee34 100644 --- a/src/storage.cr +++ b/src/storage.cr @@ -71,6 +71,9 @@ class DNSManager::Storage 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 @@ -87,7 +90,8 @@ class DNSManager::Storage File.rename filename_wip, filename_final end - # Only an admin can access this function. + # 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 @@ -102,6 +106,7 @@ class DNSManager::Storage 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 @@ -111,8 +116,8 @@ class DNSManager::Storage 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 diff --git a/tools/token-handler.cr b/tools/token-handler.cr index 883f8f0..fbae2a3 100644 --- a/tools/token-handler.cr +++ b/tools/token-handler.cr @@ -1,5 +1,6 @@ require "authd" require "ipc" + require "../src/client.cr" require "http/server"