Compare commits

...

33 Commits

Author SHA1 Message Date
Philippe PITTOLI 38da24fe66 Change the default serial. 2024-07-07 11:40:18 +02:00
Philippe PITTOLI b633dbe740 Powerdns-sync: wait for a few minutes before updating the zone. 2024-07-04 14:10:14 +02:00
Philippe PITTOLI 0eca58ffa8 PowerDNS: script to sync stuff. 2024-07-03 16:03:59 +02:00
Philippe PITTOLI 98b5ed2638 Removes the Bind9 zone file. 2024-07-02 14:36:49 +02:00
Philippe PITTOLI 2d64da170e Better typing, (minor) documentation improvements, remove orphans. 2024-07-01 23:48:26 +02:00
Philippe PITTOLI 1e66262884 Minor change in the code structure. 2024-07-01 11:56:50 +02:00
Philippe PITTOLI 519533c02d Typo. 2024-07-01 11:09:45 +02:00
Philippe PITTOLI a51fd8b31b Regenerate Bind9 zone files on any update. 2024-07-01 02:03:44 +02:00
Philippe PITTOLI 8d9f4051e6 PowerDNS instructions. 2024-06-30 17:37:05 +02:00
Philippe PITTOLI cc4df1775c Add the sql to final migration file format script. 2024-06-22 01:34:39 +02:00
Philippe PITTOLI 835415caac Makefile reworked. 2024-06-22 00:50:15 +02:00
Philippe PITTOLI 516ccf1e58 tmp 2024-06-14 16:16:04 +02:00
Philippe PITTOLI 749889aad8 Migration request. 2024-06-10 13:47:31 +02:00
Philippe PITTOLI 8119bf7f57 Migration: things are getting pretty serious. 2024-06-10 03:29:26 +02:00
Philippe PITTOLI 2509e8af15 Migration: can generate migration files. 2024-06-09 04:14:10 +02:00
Philippe PITTOLI cb8b0c93a1 Add a formatting script. 2024-06-08 23:23:05 +02:00
Philippe PITTOLI 925a456de7 Migration script: first draft. 2024-06-08 22:43:43 +02:00
Philippe PITTOLI b53b31b584 CAA entries. 2024-06-08 03:55:52 +02:00
Philippe PITTOLI 5cbcbdaa8f Removing a domain: domain may not have tokens. 2024-06-02 01:58:56 +02:00
Philippe PITTOLI de8beb0df8 New DODB API. 2024-06-01 03:35:53 +02:00
Philippe PITTOLI 7bee53d625 Adapt to the DODB cached mode. 2024-05-07 21:00:01 +02:00
Philippe PITTOLI 234259a9d0 Check if the user is admin with `is_admin?` function in `dnsmanagerd`. 2024-05-07 12:48:52 +02:00
Philippe PITTOLI 4923fb34f9 Use cached indexes. 2024-05-07 01:32:37 +02:00
Philippe PITTOLI 158d772727 User authentication: limit the number of domains sent in "Logged" to a hundred. 2024-04-28 17:16:06 +02:00
Philippe PITTOLI 723c1a83a0 Update expected messages. 2024-04-28 16:17:47 +02:00
Philippe PITTOLI ebd9bd75a5 Prevent a basic hack. 2024-04-28 16:17:28 +02:00
Philippe PITTOLI 4606f852cc Client: can be built again. Add a makefile rule to generate all zones. 2024-04-28 12:56:45 +02:00
Philippe PITTOLI 1f69839333 Only delete the domain when the user is the last owner. 2024-04-28 01:24:55 +02:00
Philippe PITTOLI a188d28f1d Ownership is now managed. 2024-04-27 23:10:01 +02:00
Philippe PITTOLI 8cafca13be WIP: ownership management. Some empty functions. 2024-04-27 20:08:34 +02:00
Karchnu a888551bbc Compatible with the webclient again. Full audit REQUIRED. 2024-04-27 18:30:49 +02:00
Karchnu 40fcc9c66e Complete overhaul of ownership management: add a domain db, remove user data db. 2024-04-27 17:32:22 +02:00
Karchnu 61bace491c Ownership: full overhaul in progress. 2024-04-27 05:48:28 +02:00
29 changed files with 1194 additions and 552 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ bin
drop
lib
templates/
docs/

123
Makefile
View File

@ -8,101 +8,108 @@ else
LOGIN_OPT = -l $(LOGIN)
endif
# No idea why, but I need that to run applications. Ignore that.
#LD_P ?= LD_PRELOAD=/usr/local/lib/libipc.so.0
OPTS ?= --progress
Q ?= @
SHOULD_UPDATE = ./bin/should-update
SOURCE_FILES = $(wildcard src/*.cr src/*/*.cr src/*/*/*.cr)
####################
### REQUEST EXAMPLES
####################
DOMAIN ?= example.com
build-write-zone-file: tools/write-zone-file.cr
$(Q)-([ ! -f bin/write-zone-file ] || [ tools/write-zone-file.cr -nt bin/write-zone-file ]) && shards build write-zone-file $(OPTS)
bin/write-zone-file: tools/write-zone-file.cr
$(Q)-shards build write-zone-file $(OPTS)
build-write-zone-file: bin/write-zone-file
zone-file: build-write-zone-file
$(Q)./bin/write-zone-file $(DOMAIN)
build-write-template-zone-file: tools/write-template-zone-file.cr
$(Q)-([ ! -f bin/write-template-zone-file ] || [ tools/write-template-zone-file.cr -nt bin/write-template-zone-file ]) && shards build write-template-zone-file $(OPTS)
bin/write-template-zone-file: tools/write-template-zone-file.cr
$(Q)-shards build write-template-zone-file $(OPTS)
build-write-template-zone-file: bin/write-template-zone-file
zone-basic-template-file: build-write-template-zone-file
$(Q)./bin/write-template-zone-file $(DOMAIN)
bin/powerdns-sync: tools/powerdns-sync.cr
$(Q)-shards build powerdns-sync $(OPTS)
build-powerdns-sync: bin/powerdns-sync
VERBOSITY ?= 4
run-client-verbosity:
$(Q)$(LD_P) ./bin/dnsmanager-client admin maintenance verbosity $(VERBOSITY) $(LOGIN_OPT)
run-client-domain-add:
$(Q)$(LD_P) ./bin/dnsmanager-client user domain add $(DOMAIN) $(LOGIN_OPT)
run-client-domain-del:
$(Q)$(LD_P) ./bin/dnsmanager-client user domain del $(DOMAIN) $(LOGIN_OPT)
run-client-domain-list:
$(Q)$(LD_P) ./bin/dnsmanager-client user domain list $(LOGIN_OPT)
run-client-zone-add:
$(Q)$(LD_P) ./bin/dnsmanager-client user zone add $(DOMAIN).json $(LOGIN_OPT)
run-client-zone-get:
$(Q)$(LD_P) ./bin/dnsmanager-client user zone get $(DOMAIN) $(LOGIN_OPT)
run-client-verbosity:; $(Q)./bin/dnsmanager-client admin maintenance verbosity $(VERBOSITY) $(LOGIN_OPT)
run-client-domain-add:; $(Q)./bin/dnsmanager-client user domain add $(DOMAIN) $(LOGIN_OPT)
run-client-domain-del:; $(Q)./bin/dnsmanager-client user domain del $(DOMAIN) $(LOGIN_OPT)
run-client-domain-list:; $(Q)./bin/dnsmanager-client user domain list $(LOGIN_OPT)
run-client-zone-add:; $(Q)./bin/dnsmanager-client user zone add $(DOMAIN).json $(LOGIN_OPT)
run-client-zone-get:; $(Q)./bin/dnsmanager-client user zone get $(DOMAIN) $(LOGIN_OPT)
RRID ?= 1
NAME ?=
TTL ?= 3600
TARGET ?=
run-client-rr-add-a:
$(Q)$(LD_P) ./bin/dnsmanager-client user rr add A $(DOMAIN) $(NAME) $(TTL) $(TARGET) $(LOGIN_OPT)
run-client-rr-update-a:
$(Q)$(LD_P) ./bin/dnsmanager-client user rr update A $(DOMAIN) $(RRID) $(NAME) $(TTL) $(TARGET) $(LOGIN_OPT)
run-client-rr-del:
$(Q)$(LD_P) ./bin/dnsmanager-client user rr del $(DOMAIN) $(RRID) $(LOGIN_OPT)
run-client-rr-add-a:; $(Q)./bin/dnsmanager-client user rr add A $(DOMAIN) $(NAME) $(TTL) $(TARGET) $(LOGIN_OPT)
run-client-rr-update-a:; $(Q)./bin/dnsmanager-client user rr update A $(DOMAIN) $(RRID) $(NAME) $(TTL) $(TARGET) $(LOGIN_OPT)
run-client-rr-del:; $(Q)./bin/dnsmanager-client user rr del $(DOMAIN) $(RRID) $(LOGIN_OPT)
run-client-genzones:; $(Q)./bin/dnsmanager-client admin genall $(LOGIN_OPT)
##################
### SETUP COMMANDS
##################
DBDIR=/tmp/DATA-dnsmanagerd
run-dnsmanagerd:
$(Q)$(LD_P) ./bin/dnsmanagerd -v $(VERBOSITY) -r $(DBDIR)
bin/dnsmanagerd: $(SOURCE_FILES); $(Q)shards build dnsmanagerd $(OPTS)
build-server: bin/dnsmanagerd
run-dnsmanagerd:; $(Q)./bin/dnsmanagerd -v $(VERBOSITY) -r $(DBDIR)
bin/dnsmanager-client: $(SOURCE_FILES); $(Q)shards build dnsmanager-client $(OPTS)
build-client: bin/dnsmanager-client
PORT ?= 8082
ADDR ?=
run-token-handler:
$(Q)$(LD_P) ./bin/token-handler $(PORT) $(ADDR)
bin/token-handler: tools/token-handler.cr; $(Q)shards build token-handler $(OPTS)
build-token-handler: bin/token-handler
run-token-handler: bin/token-handler; $(Q)./bin/token-handler $(PORT) $(ADDR)
build-server:
$(Q)-$(SHOULD_UPDATE) bin/dnsmanagerd && shards build dnsmanagerd $(OPTS)
build: build-server build-client build-token-handler build-powerdns-sync
build-client:
$(Q)-$(SHOULD_UPDATE) bin/dnsmanager-client && shards build dnsmanager-client $(OPTS)
print-messages:; cat src/requests/*.cr | ./bin/get-messages.awk
print-message-numbers:; make -s print-messages | grep -E "^[0-9]" | sort -n
print-messages-no-comments:; make -s print-messages | grep -vE '^[[:blank:]]+#'
print-response-messages:; cat src/responses/*.cr | ./bin/get-messages.awk
print-response-message-numbers:; make -s print-response-messages | grep -E "^[0-9]" | sort -n
print-response-messages-no-comments:; make -s print-response-messages | grep -vE '^[[:blank:]]+#'
build-token-handler:
$(Q)shards build token-handler $(OPTS)
# format: nb-domains <TAB> login <TAB> domain1 <TAB> domain2 <TAB> domain3
MIGRATION_FILE_TARGET = /tmp/dnsmanagerd-migration
SQLDB = /tmp/usrdb
POWERDNS_ZONEDIR = /var/powerdns
BINDDIR = /tmp/DATA-dnsmanagerd/bind9-zones
$(MIGRATION_FILE_TARGET):; ./bin/sql-to-migration-format.awk < $(SQLDB) > $(MIGRATION_FILE_TARGET)
run-migration-client:; ./bin/dnsmanager-client admin migration-script $(MIGRATION_FILE_TARGET) $(LOGIN)
migration-file: $(MIGRATION_FILE_TARGET)
copy-old-zones: ; cd $(BINDDIR) && for i in * ; do cp -v /tmp/rndczones/$$i . ; done
/tmp/rndczones:
@echo "you forgot to get a copy of old bind zones here: /tmp/rndczones"
exit 1
powerdns-create-zonedir:
-mkdir -p $(POWERDNS_ZONEDIR)
cp -v $(BINDDIR)/* $(POWERDNS_ZONEDIR)
powerdns-add-zones: powerdns-create-zonedir
cd $(POWERDNS_ZONEDIR) && for i in *; do pdns_control bind-add-zone $$i $(POWERDNS_ZONEDIR)/$$i; done
migration: build-client migration-file run-migration-client run-client-genzones copy-old-zones
@echo "next as root: make powerdns-add-zones"
build: build-server build-client build-token-handler
doc:
crystal docs src/main.cr src/client.cr lib/authd/src/client.cr
print-messages:
cat src/requests/*.cr | ./bin/get-messages.awk
print-message-numbers:
make -s print-messages | grep -E "^[0-9]" | sort -n
print-messages-without-comments:
make -s print-messages | grep -vE '^[[:blank:]]+#'
print-response-messages:
cat src/responses/*.cr | ./bin/get-messages.awk
print-response-message-numbers:
make -s print-response-messages | grep -E "^[0-9]" | sort -n
print-response-messages-without-comments:
make -s print-response-messages | grep -vE '^[[:blank:]]+#'
HTTPD_ACCESS_LOGS ?= /tmp/access-dnsmanager-docs.log
HTTPD_ADDR ?= 127.0.0.1
HTTPD_PORT ?= 9001
DIR ?= docs
serve-doc:
darkhttpd $(DIR) --addr $(HTTPD_ADDR) --port $(HTTPD_PORT) --log $(HTTPD_ACCESS_LOGS)
wipe-db:
rm -r $(DBDIR)
release:
make build-server OPTS="--release --progress"
make build-server OPTS="--release --progress --no-debug"

View File

@ -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:

22
bin/fix-last-element.awk Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/awk -f
BEGIN {
data = ""
}
/^\]/ {
gsub("},$","}",data)
print data
print
next
}
/},$/ {
print data
data = $0
next
}
{
data = $0
}

30
bin/format.awk Executable file
View File

@ -0,0 +1,30 @@
#!/usr/bin/awk -f
BEGIN {
data = ""
}
/^\[/ || /^\]/ {
print
next
}
/^ {/ && /},$/ {
print $0
next
}
/^ {/ {
data = $0
next
}
/},$/ {
data = sprintf("%s %s", data, $0)
print data
next
}
{
data = sprintf("%s %s", data, $0)
}

31
bin/migration-final.awk Executable file
View File

@ -0,0 +1,31 @@
#!/usr/bin/gawk -f
BEGIN {
OFS = "\t"
}
/^\[/ || /^\]/ {
next
}
{
domain = $0
login = $0
gsub("^.+domain\": \"", "", domain)
gsub("\",\"login\": .+", "", domain)
gsub(".+\",\"login\": \"", "", login)
gsub("\" }.?$", "", login)
data[login][domain] = 1
}
END {
for ( login in data ) {
domains = ""
nb = 0
for ( domain in data[login] ) {
nb += 1
domains = (domains "\t" domain)
}
print nb, login, domains
}
}

View File

@ -1,17 +0,0 @@
#!/bin/sh
# Should we run the build?
if [ $# -lt 1 ]; then
exec >& 2
echo "Usage: $0 <exe>"
exit 1
fi
exe=$1
# If the binary hasn't already be compiled.
[ -f "${exe}" ] || exit 0
v=`find src/ -type f -newer "${exe}" | wc -l`
test "${v}" != "0"

23
bin/sql-to-migration-format.awk Executable file
View File

@ -0,0 +1,23 @@
#!/usr/bin/awk -f
BEGIN {
OFS = "\t"
}
{
login = $1
domain = $3
data[login][domain] = 1
}
END {
for ( login in data ) {
domains = ""
nb = 0
for ( domain in data[login] ) {
nb += 1
domains = (domains "\t" domain)
}
print nb, login, domains
}
}

View File

@ -11,6 +11,7 @@ dependencies:
ipc:
git: https://git.baguette.netlib.re/Baguette/ipc.cr
dodb:
branch: master
git: https://git.baguette.netlib.re/Baguette/dodb.cr
authd:
git: https://git.baguette.netlib.re/Baguette/authd
@ -32,5 +33,7 @@ targets:
main: tools/write-template-zone-file.cr
token-handler:
main: tools/token-handler.cr
powerdns-sync:
main: tools/powerdns-sync.cr
license: ISC

View File

@ -51,7 +51,23 @@ class DNSManager::Client < IPC
def user_domain_add(domain : String)
request = Request::NewDomain.new domain
send_now request
parse_message [ Response::Success ], read
parse_message [ Response::DomainAdded,
Response::DomainAlreadyExists,
Response::InvalidDomainName,
Response::UnacceptableDomain
], read
end
# Migration: add a domain for a user.
def migration(login : String, domain : String)
request = Request::Migration.new login, domain
send_now request
parse_message [ Response::DomainAdded,
Response::DomainAlreadyExists,
Response::InvalidDomainName,
Response::UnacceptableDomain,
Response::UnknownUser
], read
end
# Remove a domain.

View File

@ -29,6 +29,7 @@ class Actions
# Maintenance.
@the_call["admin-maintenance"] = ->admin_maintenance
@the_call["admin-generate-zonefile"] = ->admin_generate_zonefile
@the_call["admin-migration-script"] = ->admin_migration_script
@the_call["admin-generate-all-zonefiles"] = ->admin_generate_all_zonefiles
# Domain operations.
@ -86,6 +87,60 @@ class Actions
end
end
# Migration script.
#
# Usage: dnsmanager-client admin migration-script user-db.txt
#
# user-db.txt should be formated as: login <TAB> domain1 <TAB> domain2 <TAB> domain3 (etc.)
def admin_migration_script
filename = Context.args.not_nil!.[0]
File.each_line(filename) do |line|
data = line.split "\t"
_ = data.shift
login = data.shift
data.select! { |x| x != "" }
nb_domains = data.size
begin
i = 0
data.each do |domain|
STDOUT.write "migrating '#{i}/#{nb_domains}' domains for '#{login}': #{ "%.100s" % domain }".to_slice
STDOUT.write ((" " * 80) + "\r").to_slice
i += 1
if (login == "Régis")
req = DNSManager::Request::Migration.new login, domain
puts ""
Baguette::Log.info "#{req.to_json.to_s}"
end
response = @dnsmanagerd.migration login, domain
case response
when DNSManager::Response::DomainAdded
# Do nothing.
else
case response
when DNSManager::Response::DomainAlreadyExists
#Baguette::Log.error "error: domain name '#{domain}' already exists"
when DNSManager::Response::InvalidDomainName
puts ""
Baguette::Log.error "error: invalid domain name '#{domain}'"
when DNSManager::Response::UnacceptableDomain
puts ""
Baguette::Log.error "error: unacceptable domain name '#{domain}' (login: '#{login}')"
when DNSManager::Response::UnknownUser
puts ""
Baguette::Log.error "error: unknown user #{login}"
break
else
puts ""
Baguette::Log.error "error: unknown error"
end
end
end
rescue e
puts "error for generate_zonefile: #{e.message}"
end
end
end
def admin_generate_all_zonefiles
pp! @dnsmanagerd.generate_all_zonefiles
rescue e

View File

@ -59,11 +59,13 @@ def parsing_cli(authd_config : Baguette::Configuration::Auth)
# either we test with the exact expected number of arguments or the least.
if exact = nexact
if args.size != exact
Baguette::Log.error "Wrong number of parameters: expected #{exact}, got #{args.size}"
Baguette::Log.error "#{parser}"
exit 1
end
elsif least = at_least
if args.size < least
Baguette::Log.error "Wrong number of parameters: expected at least #{least}, got #{args.size}"
Baguette::Log.error "#{parser}"
exit 1
end
@ -114,6 +116,13 @@ def parsing_cli(authd_config : Baguette::Configuration::Auth)
unrecognized_args_to_context_args.call parser, 0, nil
end
# Migration script.
parser.on("migration-script", "Migrate domains from dnsmanager v1.") do
Baguette::Log.info "migration script, provide domains to users."
Context.command = "admin-migration-script"
parser.banner = "COMMAND: admin migration-script user-db.txt"
unrecognized_args_to_context_args.call parser, 1, nil
end
end
# User section.

39
src/exceptions.cr Normal file
View File

@ -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

View File

@ -3,303 +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
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 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

View File

@ -7,7 +7,7 @@ class DNSManager::Request
def handle(dnsmanagerd : DNSManager::Service, event : IPC::Event) : IPC::JSON
user = dnsmanagerd.get_logged_user event
return Response::ErrorUserNotLogged.new unless user
dnsmanagerd.storage.get_orphan_domains dnsmanagerd, user.uid
dnsmanagerd.storage.get_orphan_domains user.uid
end
end
DNSManager.requests << GetOrphanDomains
@ -59,8 +59,7 @@ class DNSManager::Request
user = dnsmanagerd.get_logged_user event
return Response::ErrorUserNotLogged.new unless user
udata = dnsmanagerd.storage.get_user_data user
return Response::InsufficientRights.new unless udata.admin
dnsmanagerd.storage.user_must_be_admin! user.uid
dnsmanagerd.storage.generate_all_zonefiles
end
end
@ -77,8 +76,7 @@ class DNSManager::Request
user = dnsmanagerd.get_logged_user event
return Response::ErrorUserNotLogged.new unless user
udata = dnsmanagerd.storage.get_user_data user
return Response::InsufficientRights.new unless udata.admin
dnsmanagerd.storage.user_must_be_admin! user.uid
dnsmanagerd.storage.generate_zonefile @domain
end
end

39
src/requests/migration.cr Normal file
View File

@ -0,0 +1,39 @@
require "ipc/json"
require "grok"
class DNSManager::Request
IPC::JSON.message Migration, 24 do
property login : String
property domain : String
def initialize(@login, @domain)
end
def handle(dnsmanagerd : DNSManager::Service, event : IPC::Event) : IPC::JSON
user = dnsmanagerd.get_logged_user event
return Response::ErrorUserNotLogged.new unless user
dnsmanagerd.storage.user_must_be_admin! user.uid
response = dnsmanagerd.authd.get_user? @login
migrated_user = case response
when AuthD::Response::User
response.user
when AuthD::Response::ErrorMustBeAuthenticated
Baguette::Log.error "migration: not authenticated to authd"
return Response::UnknownUser.new
when AuthD::Response::ErrorUserNotFound
Baguette::Log.error "migration: user not found in authd"
return Response::UnknownUser.new
else
Baguette::Log.error "migration: unknown error while requesting authd for a user"
return Response::UnknownUser.new
end
return Response::UnknownUser.new unless migrated_user
dnsmanagerd.storage.new_domain migrated_user.uid, @domain
end
end
DNSManager.requests << Migration
end

61
src/requests/ownership.cr Normal file
View File

@ -0,0 +1,61 @@
class DNSManager::Request
IPC::JSON.message AskShareToken, 20 do
property domain : String
def initialize(@domain)
end
def handle(dnsmanagerd : DNSManager::Service, event : IPC::Event)
user = dnsmanagerd.get_logged_user event
return Response::ErrorUserNotLogged.new unless user
dnsmanagerd.storage.ask_share_token user.uid, @domain
end
end
DNSManager.requests << AskShareToken
IPC::JSON.message AskTransferToken, 21 do
property domain : String
def initialize(@domain)
end
def handle(dnsmanagerd : DNSManager::Service, event : IPC::Event)
user = dnsmanagerd.get_logged_user event
return Response::ErrorUserNotLogged.new unless user
dnsmanagerd.storage.ask_transfer_token user.uid, @domain
end
end
DNSManager.requests << AskTransferToken
IPC::JSON.message AskUnShareDomain, 22 do
property domain : String
def initialize(@domain)
end
def handle(dnsmanagerd : DNSManager::Service, event : IPC::Event)
user = dnsmanagerd.get_logged_user event
return Response::ErrorUserNotLogged.new unless user
dnsmanagerd.storage.ask_unshare_domain user.uid, @domain
end
end
DNSManager.requests << AskUnShareDomain
IPC::JSON.message GainOwnership, 23 do
property uuid : String
def initialize(@uuid)
end
def handle(dnsmanagerd : DNSManager::Service, event : IPC::Event)
user = dnsmanagerd.get_logged_user event
return Response::ErrorUserNotLogged.new unless user
dnsmanagerd.storage.gain_ownership user.uid, @uuid
end
end
DNSManager.requests << GainOwnership
end

View File

@ -14,13 +14,19 @@ class DNSManager::Request
#dnsmanagerd.auth.edit_profile_content user.uid, {
# "dnsmanager-last-connection" => JSON::Any.new Time.utc.to_s
#}
user_data = dnsmanagerd.storage.ensure_user_data response.user.uid
return Response::Error.new "invalid user" unless user_data
user_id = response.user.uid
accepted_domains = dnsmanagerd.configuration.accepted_domains.not_nil!
user_domains = user_data.domains
perms = dnsmanagerd.check_permissions response.user.uid, "*"
Response::Logged.new (perms == AuthD::User::PermissionLevel::Admin), accepted_domains, user_domains
# Limit the number of domains in this message.
# Pagination will be required beyond a hundred domains.
user_domains = dnsmanagerd.storage.user_domains(user_id).[0..100]
is_admin = dnsmanagerd.is_admin? user_id
Response::Logged.new is_admin, accepted_domains, user_domains
when AuthD::Response::ErrorUserNotFound
Baguette::Log.error "Trying to authenticate an unknown user."
Response::ErrorInvalidToken.new
else
Response::ErrorInvalidToken.new
end
@ -42,7 +48,7 @@ class DNSManager::Request
user = dnsmanagerd.get_logged_user event
return Response::ErrorUserNotLogged.new unless user
dnsmanagerd.storage.delete_user_data dnsmanagerd, user.uid, user_id
dnsmanagerd.storage.delete_user_data user.uid, user_id
end
end
DNSManager.requests << DeleteUser

View File

@ -13,9 +13,7 @@ class DNSManager::Request
user = dnsmanagerd.get_logged_user event
return Response::ErrorUserNotLogged.new unless user
accepted_domains = dnsmanagerd.configuration.accepted_domains.not_nil!
template_directory = dnsmanagerd.configuration.template_directory
dnsmanagerd.storage.new_domain accepted_domains, template_directory, user.uid, @domain
dnsmanagerd.storage.new_domain user.uid, @domain
end
end
DNSManager.requests << NewDomain
@ -69,7 +67,8 @@ class DNSManager::Request
def handle(dnsmanagerd : DNSManager::Service, event : IPC::Event) : IPC::JSON
user = dnsmanagerd.get_logged_user event
return Response::ErrorUserNotLogged.new unless user
dnsmanagerd.storage.user_domains user.uid
user_domain_names = dnsmanagerd.storage.user_domains(user.uid).map &.name
Response::DomainList.new user_domain_names
end
end
DNSManager.requests << UserDomains

View File

@ -1,4 +1,3 @@
class DNSManager::Response
IPC::JSON.message DomainDeleted, 9 do
property domain : String
@ -15,9 +14,9 @@ class DNSManager::Response
end
DNSManager.responses << InvalidZone
# Domain of a zone cannot change, for security reasons.
IPC::JSON.message DomainChanged, 11 do
def initialize
property domain : DNSManager::Storage::Domain
def initialize(@domain)
end
end
DNSManager.responses << DomainChanged
@ -52,7 +51,7 @@ class DNSManager::Response
IPC::JSON.message Logged, 16 do
property admin : Bool
property accepted_domains : Array(String)
property my_domains : Array(String)
property my_domains : Array(DNSManager::Storage::Domain)
def initialize(@admin, @accepted_domains, @my_domains)
end
end
@ -119,4 +118,3 @@ class DNSManager::Response
end
DNSManager.responses << OrphanDomainList
end

175
src/service.cr Normal file
View File

@ -0,0 +1,175 @@
require "ipc"
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

View File

@ -2,29 +2,56 @@ require "json"
require "uuid"
require "uuid/json"
require "baguette-crystal-base"
require "./service.cr"
require "dodb"
class DNSManager::Storage
getter user_data : DODB::CachedDataBase(UserData)
getter user_data_by_uid : DODB::Index(UserData)
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::CachedDataBase(Zone)
getter zones_by_domain : DODB::Index(Zone)
getter zones : DODB::Storage::Cached(Zone)
getter zones_by_domain : DODB::Trigger::IndexCached(Zone)
getter tokens : DODB::CachedDataBase(Token)
getter tokens_by_uuid : DODB::Index(Token)
getter tokens_by_domain : DODB::Partition(Token)
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)
@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"
@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::CachedDataBase(Token).new "#{@root}/tokens"
@tokens = DODB::Storage::Cached(Token).new "#{@root}/tokens"
@tokens_by_uuid = @tokens.new_index "uuid", &.uuid
@tokens_by_domain = @tokens.new_partition "domain", &.domain
@ -34,8 +61,8 @@ class DNSManager::Storage
Baguette::Log.info "storage initialized"
if reindex
Baguette::Log.debug "Reindexing user data..."
@user_data.reindex_everything!
Baguette::Log.debug "Reindexing domains..."
@domains.reindex_everything!
Baguette::Log.debug "Reindexing zones..."
@zones.reindex_everything!
Baguette::Log.debug "Reindexing tokens..."
@ -44,73 +71,55 @@ class DNSManager::Storage
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
# 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
def get_user_data(user : ::AuthD::User::Public)
get_user_data user.uid
end
# Safe write.
filename_final = "#{@zonefiledir}/#{zone.domain}"
filename_wip = "#{filename_final}.wip"
Baguette::Log.info "writing zone file #{filename_final}"
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
File.open(filename_wip, "w") do |file|
zone.to_bind9 file
end
user_data
# 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|
# 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
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
user_data = user_must_exist! user_id
zone = zone_must_exist! domain
user_should_own! user_data, zone.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
def new_domain(accepted_domains : Array(String),
template_directory : String,
user_id : UserDataID,
domain : String) : IPC::JSON
user_data = user_must_exist! user_id
# 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
@ -120,12 +129,14 @@ class DNSManager::Storage
# 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}"
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})"
# 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.
@ -138,23 +149,102 @@ class DNSManager::Storage
# 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 domain to the user's domain.
user_data.domains << domain
update_user_data user_data
# 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.
zones_by_domain.update_or_create domain, default_zone
update_zone 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
# 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 `shared_key`.
domain_cloned = domain.clone
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?
# 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 `shared_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}"
@ -163,25 +253,20 @@ class DNSManager::Storage
# Does the zone already exist?
if z = zones_by_domain.get? zone.domain
user_should_own! user_data, z.domain
user_should_own! user_id, 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
domains << Domain.new zone.domain
end
# Add -or replace- the zone.
zones_by_domain.update_or_create zone.domain, zone
update_zone 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
user_should_own! user_id, domain
# Test RR validity.
rr.get_errors.tap do |errors|
@ -200,18 +285,22 @@ class DNSManager::Storage
# 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)
# 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
user_data = user_must_exist! user_id
zone = zone_must_exist! domain
user_should_own! user_data, 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?
@ -220,17 +309,16 @@ class DNSManager::Storage
end
end
zone.update_rr rr
zone_clone.update_rr rr
update_zone zone
update_zone zone_clone
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
zone = zone_must_exist! domain
user_should_own! user_id, domain
rr = zone.rr_not_readonly! rrid
@ -239,46 +327,72 @@ class DNSManager::Storage
tokens_by_uuid.delete token_uuid
end
zone.delete_rr rrid
update_zone zone
zone_clone = zone.clone
zone_clone.delete_rr rrid
update_zone zone_clone
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
# Removes a Bind9 zonefile.
def remove_bind9_zonefile(domain : String) : Nil
Baguette::Log.info "Removing a Bind9 zone file."
File.delete "#{@zonefiledir}/#{domain}"
end
# Get all removed users from `authd`, list all their domains and remove their data from `dnsmanagerd`.
def get_orphan_domains(dnsmanagerd : DNSManager::Service, user_id : UserDataID) : IPC::JSON
user_must_be_admin! dnsmanagerd, user_id
# 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
user_data.each do |user|
begin
dnsmanagerd.authd.get_user? user.uid
rescue e
Baguette::Log.warning "no authd info on user #{user.uid}: #{e} (removing this user)"
Baguette::Log.debug "user #{user.uid} had #{user.domains.size} domains"
user.domains.each do |domain|
orphans << domain
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
wipe_user_data user
end
end
Baguette::Log.debug "total: #{orphans.size} orphans"
@ -287,75 +401,95 @@ class DNSManager::Storage
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
zone = zone_must_exist! domain
user_should_own! user_id, domain
Response::Zone.new zone
end
def wipe_user_data(user_data : UserData)
# Remove the user's domains.
user_data.domains.each do |domain|
tokens_by_domain.delete domain
zones_by_domain.delete domain
rescue e
Baguette::Log.error "while removing a domain: #{e}"
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.
user_data_by_uid.delete user_data.uid.to_s
# 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
def delete_user_data(dnsmanagerd : DNSManager::Service, 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! dnsmanagerd, user_id
# 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}."
user_must_exist! u
u
else
Baguette::Log.info "User #{user_data.uid} terminates his account."
user_data
Baguette::Log.info "User #{user_id} terminates his account."
user_id
end
wipe_user_data user_data_to_delete
wipe_user_data user_id_to_delete
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
# 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
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
# 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
def user_must_be_admin!(dnsmanagerd : DNSManager::Service, user_id : UserDataID) : UserData
user_data = user_must_exist! user_id
dnsmanagerd.assert_permissions! user_id, "*", AuthD::User::PermissionLevel::Admin
user_data
# 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
# 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
def user_should_own!(user_data : UserData, domain : String)
unless user_data.domains.includes?(domain) || user_data.admin
# Owning a domain means to be in the owners' list of the domain.
# TODO: accept admin users to override this test.
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 || user_must_be_admin! user_id
raise NoOwnershipException.new
end
end
# Asks a new token for a resource record.
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
user_should_own! user_id, domain
rr = zone.rr_must_exist! rrid
@ -366,10 +500,11 @@ class DNSManager::Storage
# 2. add token to the RR.
rr.token = token.uuid
zone.update_rr rr
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_or_create zone.domain, zone
zones_by_domain.update zone_clone
# 4. if there is an old token, remove it.
if old_token_uuid = old_token
@ -382,12 +517,14 @@ class DNSManager::Storage
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
@ -401,7 +538,7 @@ class DNSManager::Storage
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
update_zone zone
Response::Success.new
when Zone::AAAA
return Response::Error.new "invalid ipv6" unless Zone.is_ipv6_address_valid? address

19
src/storage/domain.cr Normal file
View File

@ -0,0 +1,19 @@
require "json"
require "uuid"
require "uuid/json"
class DNSManager::Storage::Domain
include JSON::Serializable
property name : String
property share_key : String? = nil
property transfer_key : String? = nil
# Users may have many domains, and a domain may have many owners.
property owners = [] of UserDataID
def initialize(@name, share_key = nil, transfer_key = nil, owners = [] of UserDataID)
end
def_clone
end

View File

@ -7,6 +7,8 @@ class DNSManager::Storage::Token
property domain : String
property rrid : UInt32
def_clone
def initialize(@domain, @rrid)
@uuid = UUID.random.to_s
end

View File

@ -1,19 +0,0 @@
require "json"
require "uuid"
require "uuid/json"
alias UserDataID = UInt32
class DNSManager::Storage::UserData
include JSON::Serializable
property uid : UserDataID
# Users may have many domains, and a domain can have many owners.
property domains = [] of String
property admin : Bool = false
def initialize(@uid)
end
end

1
src/storage/userid.cr Normal file
View File

@ -0,0 +1 @@
alias UserDataID = UInt32

View File

@ -35,6 +35,8 @@ class DNSManager::Storage::Zone
def initialize(@domain)
end
def_clone
alias Error = String
# Store a Resource Record: A, AAAA, TXT, PTR, CNAME…
@ -50,6 +52,7 @@ class DNSManager::Storage::Zone
NS: NS,
CNAME: CNAME,
MX: MX,
CAA: CAA,
SRV: SRV,
# Special resource records, which actually are TXT records.
@ -87,7 +90,7 @@ class DNSManager::Storage::Zone
def to_s(io : IO)
io << "(#{ "%4d" % @rrid }) "
io << "#{ "%30s" % @name} #{ "%6d" % @ttl} #{ "%10s" % @rrtype } #{ "%30s" % @target}\n"
io << "#{ "%.30s" % @name} #{ "%6d" % @ttl} #{ "%.10s" % @rrtype } #{ "%.30s" % @target}\n"
end
def to_bind9(io : IO)
@ -96,6 +99,8 @@ class DNSManager::Storage::Zone
end
class SOA < ResourceRecord
def_clone
# Start of Authority
property mname : String # Master Name Server for the zone.
property rname : String # admin email address john.doe@example.com => john\.doe.example.com
@ -115,6 +120,16 @@ class DNSManager::Storage::Zone
@rrtype = "SOA"
end
# Sets the serial number to the current date.
def reset_serial
t = Time.local
y = t.year
m = t.month
d = t.day
@serial = "#{y}#{ "%0.2d" % m}#{ "%0.2d" % d}00".to_u64
end
def to_s(io : IO)
io << "(#{ "%4d" % @rrid }) "
io << "#{name} #{ttl} SOA (#{mname} #{rname}\n"
@ -159,6 +174,8 @@ class DNSManager::Storage::Zone
end
class A < ResourceRecord
def_clone
def get_errors : Array(Error)
errors = [] of Error
@ -179,6 +196,8 @@ class DNSManager::Storage::Zone
end
class AAAA < ResourceRecord
def_clone
def get_errors : Array(Error)
errors = [] of Error
@ -199,6 +218,8 @@ class DNSManager::Storage::Zone
end
class TXT < ResourceRecord
def_clone
def get_errors : Array(Error)
errors = [] of Error
@ -215,7 +236,7 @@ class DNSManager::Storage::Zone
def to_s(io : IO)
io << "(#{ "%4d" % @rrid }) "
io << "#{ "%30s" % @name} #{ "%6d" % @ttl} #{ "%10s" % @rrtype } #{quoted_string @target}\n"
io << "#{ "%.30s" % @name} #{ "%6d" % @ttl} #{ "%.10s" % @rrtype } #{quoted_string @target}\n"
end
def to_bind9(io : IO)
@ -224,6 +245,8 @@ class DNSManager::Storage::Zone
end
class PTR < ResourceRecord
def_clone
def get_errors : Array(Error)
errors = [] of Error
@ -252,6 +275,8 @@ class DNSManager::Storage::Zone
end
class NS < ResourceRecord
def_clone
def get_errors : Array(Error)
errors = [] of Error
@ -268,6 +293,8 @@ class DNSManager::Storage::Zone
end
class CNAME < ResourceRecord
def_clone
def get_errors : Array(Error)
errors = [] of Error
@ -289,7 +316,7 @@ class DNSManager::Storage::Zone
# TODO: verifications + print.
# baguette.netlib.re. 3600 IN TXT "v=spf1 a mx ip4:<IP> a:mail.baguette.netlib.re ~all"
class SPF < ResourceRecord
def_clone
# SPF mechanisms are about policy, which comes with the form of a single character:
# - '?' means no policy (neutral),
@ -332,6 +359,8 @@ class DNSManager::Storage::Zone
property t : Type # type of modifier
property v : String # value
def_clone
def initialize(@t, @v)
end
@ -362,6 +391,8 @@ class DNSManager::Storage::Zone
INCLUDE # include foreign SPF policy
end
def_clone
property q : Qualifier = Qualifier::Pass
property t : Type # type of mechanism
property v : String # value
@ -444,7 +475,7 @@ class DNSManager::Storage::Zone
def to_s(io : IO)
io << "(#{ "%4d" % @rrid }) "
io << "#{ "%30s" % @name} #{ "%6d" % @ttl} SPF "
io << "#{ "%.30s" % @name} #{ "%6d" % @ttl} SPF "
io << '"'
@mechanisms.each do |m|
io << m
@ -484,6 +515,7 @@ class DNSManager::Storage::Zone
# TODO
class DKIM < ResourceRecord
def_clone
enum Version
DKIM1
end
@ -508,6 +540,8 @@ class DNSManager::Storage::Zone
io << "v=#{v};h=#{h.to_s.downcase};k=#{k.to_s.downcase};p=#{p}"
io << ";n=#{n}" unless n == ""
end
def_clone
end
property dkim : DKIMProperties
@ -532,7 +566,7 @@ class DNSManager::Storage::Zone
def to_s(io : IO)
io << "(#{ "%4d" % @rrid }) "
io << "#{ "%30s" % @name} #{ "%6d" % @ttl} DKIM #{split_line dkim.to_s}\n"
io << "#{ "%.30s" % @name} #{ "%6d" % @ttl} DKIM #{split_line dkim.to_s}\n"
end
def to_bind9(io : IO)
@ -542,6 +576,7 @@ class DNSManager::Storage::Zone
# TODO
class DMARC < ResourceRecord
def_clone
enum Version
DMARC1
end
@ -551,6 +586,8 @@ class DNSManager::Storage::Zone
property mail : String
property limit : UInt32? = nil
def_clone
def to_s(io : IO)
io << "#{mail}"
if l = @limit
@ -596,6 +633,8 @@ class DNSManager::Storage::Zone
property rf : Array(ReportFormat)? = nil
property ri : UInt32
def_clone
def initialize(@p, @sp, @v, @adkim, @aspf, @pct, @fo, @rua, @ruf, @rf, @ri)
end
@ -685,7 +724,7 @@ class DNSManager::Storage::Zone
def to_s(io : IO)
io << "(#{ "%4d" % @rrid }) "
io << "#{ "%30s" % @name} #{ "%6d" % @ttl} DMARC #{split_line dmarc.to_s}\n"
io << "#{ "%.30s" % @name} #{ "%6d" % @ttl} DMARC #{split_line dmarc.to_s}\n"
end
def to_bind9(io : IO)
@ -693,7 +732,66 @@ class DNSManager::Storage::Zone
end
end
class CAA < ResourceRecord
def_clone
enum Tag
ISSUE
ISSUEWILD
IODEF
CONTACTEMAIL
CONTACTPHONE
end
class CAAProperties
include JSON::Serializable
property flag : UInt8 = 0
property tag : Tag = Tag::ISSUE
property value : String = ""
def_clone
def initialize(@flag, @tag, @value)
end
end
property caa : CAAProperties
def initialize(@name, @ttl, @target, flag, tag, value)
@rrtype = "CAA"
@caa = CAAProperties.new flag, tag, value
end
def to_s(io : IO)
io << "(#{ "%4d" % @rrid }) "
io << "#{ "%.30s" % @name} #{ "%6d" % @ttl} CAA "
io << "#{ "%.3s" % @caa.flag} #{ "%.15s" % @caa.tag} #{@caa.value}\n"
end
def to_bind9(io : IO)
io << "#{@name} #{@ttl} IN CAA #{@caa.flag} #{@caa.tag.to_s.downcase} #{@caa.value}\n"
end
def get_errors : Array(Error)
errors = [] of Error
unless Zone.is_subdomain_valid? @name
errors << "CAA invalid subdomain: #{@name}"
end
if @ttl < Zone.ttl_limit_min
errors << "CAA invalid ttl: #{@ttl}, shouldn't be less than #{Zone.ttl_limit_min}"
end
# TODO: rest of the errors.
errors
end
end
class MX < ResourceRecord
def_clone
property priority : UInt32 = 10
def initialize(@name, @ttl, @target, @priority = 10)
@rrtype = "MX"
@ -701,7 +799,7 @@ class DNSManager::Storage::Zone
def to_s(io : IO)
io << "(#{ "%4d" % @rrid }) "
io << "#{ "%30s" % @name} #{ "%6d" % @ttl} MX #{ "%3d" % @priority} #{ "%30s" % @target}\n"
io << "#{ "%.30s" % @name} #{ "%6d" % @ttl} MX #{ "%3d" % @priority} #{ "%.30s" % @target}\n"
end
def to_bind9(io : IO)
@ -729,6 +827,7 @@ class DNSManager::Storage::Zone
end
class SRV < ResourceRecord
def_clone
property port : UInt16
property protocol : String = "tcp"
property priority : UInt32 = 10
@ -761,7 +860,7 @@ class DNSManager::Storage::Zone
io << "#{ "%3d" % @priority} "
io << "#{ "%3d" % @weight} "
io << "#{ "%5d" % @port} "
io << "#{ "%30s" % @target}\n"
io << "#{ "%.30s" % @target}\n"
end
def to_bind9(io : IO)
@ -920,6 +1019,16 @@ class DNSManager::Storage::Zone
false
end
# Selects the SOA entry then run the `reset_serial` procedure.
def reset_serial
@resources.each do |rr|
case rr
when SOA
rr.reset_serial
end
end
end
# When a new domain is recorded, we load a template which contains a placeholder domain.
# `replace_domain` replaces this domain name by the real one in the different (preloaded) RR.
# Do not forget the last dot ('.') to get a fully qualified domain name (FQDN).

97
tools/powerdns-sync.cr Normal file
View File

@ -0,0 +1,97 @@
if ARGV.size != 2
puts "usage: #{PROGRAM_NAME} dnsmanagerd-bind9-dir powerdns-bind9-dir"
exit 0
end
class Context
class_property dnsmanagerd_dir : String = ""
class_property powerdns_dir : String = ""
end
def copy_domain_files(domain : String) : Nil
src = "#{Context.dnsmanagerd_dir}/#{domain}"
dest = "#{Context.powerdns_dir}/#{domain}"
puts "copying #{src} -> #{dest}"
i = File.info src
File.copy src, dest
rescue e : File::AccessDeniedError
puts "You don't have enough rights: #{e}"
end
def run_process(cmd : String, params : Array(String), env : Hash(String, String)) : Nil
unless Process.run(cmd, params, env,
true # clear environment
# input: Process::Redirect::Inherit,
# output: Process::Redirect::Inherit,
# error: Process::Redirect::Inherit
).success?
puts "cannot run #{cmd} #{params.join(' ')}"
end
end
def pdns_reload(domain : String) : Nil
puts "reloading a domain: pdns_control bind-reload-now #{domain}"
run_process("pdns_control", [ "bind-reload-now", domain ], { "HOME" => "/" })
end
def update_domain(domain : String) : Nil
puts "domain to reload: #{domain}"
copy_domain_files domain
pdns_reload domain
end
def pdns_add(domain : String) : Nil
puts "adding a new domain: pdns_control bind-add-zone #{Context.powerdns_dir}/#{domain}"
run_process("pdns_control",
[ "bind-add-zone", domain, "#{Context.powerdns_dir}/#{domain}" ],
{ "HOME" => "/" })
end
def add_domain(domain : String) : Nil
puts "domain to add: #{domain}"
copy_domain_files domain
pdns_add domain
end
def delete_file(path : String)
File.delete path
rescue e : File::AccessDeniedError
puts "You don't have enough rights: #{e}"
end
def del_domain(domain : String) : Nil
puts "domain to delete: #{domain}"
delete_file "#{Context.powerdns_dir}/#{domain}"
# TODO: pdns_control ???
end
Context.dnsmanagerd_dir = ARGV[0]
Context.powerdns_dir = ARGV[1]
dnsmanagerd_dir_content = Dir.children(Context.dnsmanagerd_dir).select { |d| ! d.ends_with? ".wip" }
powerdns_dir_content = Dir.children(Context.powerdns_dir)
both = dnsmanagerd_dir_content & powerdns_dir_content
both.each do |d|
i1 = File.info "#{Context.dnsmanagerd_dir}/#{d}"
i2 = File.info "#{Context.powerdns_dir}/#{d}"
if i1.modification_time > i2.modification_time
puts "has been modified: #{d}"
# Wait for a few minutes before changing anything, to avoid useless reloads.
if Time.local > i1.modification_time.shift minutes: 5
puts "file was modified more than 5 minutes ago"
update_domain d
else
puts "file has been modified less than 5 minutes ago: do not update yet"
end
else
puts "hasn't been modified: #{d}"
end
end
to_add = dnsmanagerd_dir_content - powerdns_dir_content
to_add.each { |d| add_domain d }
to_delete = powerdns_dir_content - dnsmanagerd_dir_content
to_delete.each { |d| del_domain d }

View File

@ -1,5 +1,6 @@
require "authd"
require "ipc"
require "../src/client.cr"
require "http/server"