566 lines
17 KiB
Crystal
566 lines
17 KiB
Crystal
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/*"
|