commit 58584991136901c86f81a5f5a64dcdc37e6b66d1 Author: Ben Charlton Date: Sun Apr 14 10:29:57 2019 +0100 initial prototype diff --git a/dns.d/mythicdns.template.erb b/dns.d/mythicdns.template.erb new file mode 100644 index 0000000..e803435 --- /dev/null +++ b/dns.d/mythicdns.template.erb @@ -0,0 +1,71 @@ +# +# Nameserver records. +# +@ <%= ttl %> NS ns1.mythic-beasts.com. +@ <%= ttl %> NS ns2.mythic-beasts.com. + +% if ipv4? +# +# The domain name itself +# +@ <%= ttl %> A <%= ip %> +ftp <%= ttl %> A <%= ip %> +www <%= ttl %> A <%= ip %> +mail <%= ttl %> A <%= ip %> +mx <%= ttl %> A <%= ip %> + + +% end +% if ipv6? + +@ <%= ttl %> AAAA <%= ipv6 %> +ftp <%= ttl %> AAAA <%= ipv6 %> +www <%= ttl %> AAAA <%= ipv6 %> +mail <%= ttl %> AAAA <%= ipv6 %> +mx <%= ttl %> AAAA <%= ipv6 %> +% end +# +# MX record -- no IP defined, as this is done separately above. +# +@ <%= ttl %> MX 15 mx + +% if domain.respond_to?(:has_spf?) and domain.has_spf? +# +# SPF records +# +@ <%= ttl %> TXT <%= domain.spf_record %> + +% end +% if domain.respond_to?(:has_dkim?) and domain.has_dkim? +# +# DKIM records +# +<%= domain.dkim_selector %>._domainkey <%= ttl %> TXT v=DKIM1; k=rsa; p=<%= domain.dkim_public_key_b64 %> + +% end +% if domain.respond_to?(:has_dmarc?) and domain.has_dmarc? +# +# DMARC records +# +_dmarc <%= ttl %> TXT <%= domain.dmarc_record %> + +% end +% if domain.respond_to?(:has_xmpp?) and domain.has_xmpp? +# +# SRV records for XMPP. +# +_xmpp-client._tcp <%= ttl %> SRV <%= domain.srv_record_for(0,5,5222, domain) %> +_xmpp-server._tcp <%= ttl %> SRV<%= domain.srv_record_for(0,5,5269, domain) %> + +% end +% if domain.respond_to?(:mailboxes) and domain.mailboxes.length > 0 +# +# SRV records for various mail services +# +_submission._tcp <%= ttl %> SRV <%= domain.srv_record_for(0,5,587, "mail."+domain) %> +_imap._tcp <%= ttl %> SRV <%= domain.srv_record_for(0,5,143, "mail."+domain) %> +_imaps._tcp <%= ttl %> SRV <%= domain.srv_record_for(0,5,993, "mail."+domain) %> +_pop3._tcp <%= ttl %> SRV <%= domain.srv_record_for(10,5,110, "mail."+domain) %> +_pop3s._tcp <%= ttl %> SRV <%= domain.srv_record_for(10,5,995, "mail."+domain) %> + +% end diff --git a/lib/symbiosis/config_files/mythicdns.rb b/lib/symbiosis/config_files/mythicdns.rb new file mode 100644 index 0000000..ae47406 --- /dev/null +++ b/lib/symbiosis/config_files/mythicdns.rb @@ -0,0 +1,77 @@ +require 'symbiosis/config_file' +require 'symbiosis/domain/dns' +require 'tempfile' + +module Symbiosis + module ConfigFiles + class Tinydns < Symbiosis::ConfigFile + + def ok? + # + # TODO: parse the dns file and make sure it is sane. + # + true + end + + ################################################### + # + # The following methods are used in the template. + # + + # + # Return just the first IPv4. + # + def ip + ip = @domain.ipv4.first + warn "\tUsing one IP (#{ip}) where the domain has more than one configured!" if @domain.ipv4.length > 1 and $VERBOSE + raise ArgumentError, "No IPv4 addresses defined for this domain" if ip.nil? + + ip.to_s + end + + # + # Returns the domain's TTL + # + def ttl + @domain.ttl.to_s + end + + # + # Returns true if the domain has an IPv4 address configured. + # + def ipv4? + !@domain.ipv4.empty? + end + + # + # Return just the first IPv6, in the tinydns format, i.e. in full with no colons. + # + def ipv6 + ip = @domain.ipv6.first + warn "\tUsing one IP (#{ip}) where the domain has more than one configured!" if @domain.ipv6.length > 1 and $VERBOSE + raise ArgumentError, "No IPv6 addresses defined for this domain" if ip.nil? + ip.to_s + end + + # + # Returns true if the domain has an IPv6 address configured. + # + def ipv6? + !@domain.ipv6.empty? + end + + class Eruby < ::Erubis::Eruby + include Erubis::EscapeEnhancer + include Erubis::PercentLineEnhancer + + end + + self.erb = Eruby + + end + + end + +end + + diff --git a/lib/symbiosis/domain/dns.rb b/lib/symbiosis/domain/dns.rb new file mode 100644 index 0000000..1ce008f --- /dev/null +++ b/lib/symbiosis/domain/dns.rb @@ -0,0 +1,119 @@ +require 'symbiosis/domain/dkim' + +module Symbiosis + + class Domain + + # + # This now returns false as the service has been withdrawn. + # + def uses_bytemark_antispam? + false + end + + # + # Returns true if a domain has SPF enabled. + # + def spf_enabled? + spf_record.is_a?(String) + end + + alias has_spf? spf_enabled? + + def spf_record + spf = get_param("spf", self.config_dir) + spf = "v=spf1 +a +mx ?all" if spf === true + + if spf.is_a?(String) + # We encode just the first line, and remove any whitespace from the ends. + line = spf.split($/).first.strip + + tinydns_encode(line) + else + nil + end + end + + def srv_record_for(priority, weight, port, target) + data = ([priority, weight, port].pack("nnn").bytes.to_a + + target.split(".").collect{|x| [x.length, x]} + + [ 0 ]).flatten + data.collect{|x| tinydns_encode(x)}.join + end + + # + # Returns the DNS TTL as defined in config/ttl, or 300 if no TTL has been set. + # + def ttl + ttl = get_param("ttl", self.config_dir) + if ttl.is_a?(String) and ttl =~ /([0-9]+)/ + begin + ttl = Integer($1) + rescue ArgumentError + ttl = 300 + end + else + ttl = 300 + end + + if ttl < 60 + ttl = 60 + elsif ttl > 86400 + ttl = 86400 + end + + ttl + end + + def dmarc_enabled? + dmarc_record.is_a?(String) + end + + alias has_dmarc? dmarc_enabled? + + # + # Returns a DMARC record, based on various arguments in config/dmarc + # + def dmarc_record + raw_dmarc = get_param("dmarc", self.config_dir) + + return nil unless raw_dmarc + + return 'v=DMARC1; p=quarantine; sp=none' if true == raw_dmarc + + # + # Make sure we're not matching against things other than strings. + # + return nil unless raw_dmarc.is_a?(String) + + if raw_dmarc =~ /^(v=DMARC\d(;\s+\S+=[^;]+)+)/ + # Take this as a raw record + return tinydns_encode($1) + end + + puts "\tThe DMARC record looks wrong: #{raw_dmarc.inspect}" if $VERBOSE + return nil + end + + private + + # + # Encodes a given string into a format suitable for consupmtion by TinyDNS + # + def tinydns_encode(s) + s = [s].pack("c") if (s.is_a?(Integer) and 255 > s) + + s.chars.collect{|c| c =~ /[\w .=+;?-]/ ? c : c.bytes.collect{|b| "\\%03o" % b}.join}.join + end + + # + # Decodes a given string from a format suitable for consupmtion by TinyDNS + # + def tinydns_decode(s) + s.gsub(/(?:\\([0-7]{3,3})|.)/){|r| $1 ? [$1.oct].pack("c*") : r} + end + + + end + +end diff --git a/sbin/symbiosis-dns-generate b/sbin/symbiosis-dns-generate new file mode 100755 index 0000000..c3a3ebf --- /dev/null +++ b/sbin/symbiosis-dns-generate @@ -0,0 +1,258 @@ +#!/usr/bin/ruby +# +# NAME +# +# symbiosis-dns-generate - Generate DNS snippet files for Symbiosis domains. +# +# USAGE +# +# symbiosis-dns-generate [ --sleep SEC | -s SEC ] [ --template TEMPLATE | -t TEMPLATE ] +# [ --force | -f ] [ --verbose | -v ] +# [ --help | -h ] [ DOMAIN ] +# +# SYNOPSIS +# +# --sleep SEC sleep for a random amount of time before doing +# anything, up to a maximum of SEC seconds. +# +# --template TEMPLATE Specify an alternative template file to read. +# +# --force Force the re-creation of all DNS data. +# --upload Force the upload of all DNS data. +# --help Show the help information for this script. +# --verbose Show debugging information. +# +# DETAILS +# +# This script is designed to iterate over the domains hosted upon a Symbiosis +# system, and create TinyDNS snippets for each one. This can then be uploaded +# to the Bytemark content DNS service. +# +# Domains can also be specified manually on the command line, in which case +# only those domains will be processed. +# +# AUTHOR +# +# Steve Kemp +# Adapted for Mythic Beasts by Ben Charlton +# + + +require 'getoptlong' + + +# +# Entry point to the code +# +force = false +help = false +$VERBOSE = false + +# +# Do we need to re-upload the data? +# +upload=true + +# +# The root directory -- '/' by default. +# +root = "/" +dns_template = nil +sleep_for = nil + +opts = GetoptLong.new( + [ '--force', '-f', GetoptLong::NO_ARGUMENT ], + [ '--help', '-h', GetoptLong::NO_ARGUMENT ], + [ '--sleep', '-s', GetoptLong::REQUIRED_ARGUMENT ], + [ '--template', '-t', GetoptLong::REQUIRED_ARGUMENT ], + [ '--upload', '-u', GetoptLong::NO_ARGUMENT ], + [ '--verbose', '-v', GetoptLong::NO_ARGUMENT ] + ) + +opts.each do |opt, arg| + case opt + when '--help' + help = true + when '--verbose' + $VERBOSE = true + when '--template' + dns_template = arg + when '--sleep' + sleep_for = arg + when '--root' + root = arg + when '--force' + force = true + when '--upload' + upload = true + end +end + +# +# CAUTION! Here be quality kode. +# +if help + # Open the file, stripping the shebang line + lines = File.open(__FILE__){|fh| fh.readlines}[2..-1] + + lines.each do |line| + line.chomp! + break if line.empty? + puts line[2..-1].to_s + end + + exit 0 +end + +def verbose(s) + puts s if $VERBOSE +end + +require 'symbiosis/domains' +require 'symbiosis/domain' +require 'symbiosis/config_files/mythicdns' + +# +# Set the default paths. +# +dns_template = File.join(root, "/etc/symbiosis/dns.d/mythicdns.template.erb") if dns_template.nil? + +# +# Bail out if the template is missing +# +unless File.file?(dns_template) + verbose "Unable generate DNS data because the template #{dns_template.inspect} is missing." + exit 1 +end + +# +# Work out if we need to sleep. +# +unless sleep_for.nil? + sleep_for = (sleep_for =~ /(\d+)/ ? rand($1.to_i) : 0) + verbose "Sleeping for #{sleep_for}s before starting work" + sleep sleep_for +end + +# +# Any arguments on the command line specify which domains to do. +# +domains_to_configure = ARGV +string_to_hash = [] + +# +# For each domain. +# +Symbiosis::Domains.each do |domain| + + verbose "Domain: #{domain.name} " + + next unless domains_to_configure.empty? or domains_to_configure.include?(domain.name) + + begin + output = File.join(domain.config_dir, "dns", domain.name+".txt") + output_dir = File.dirname(output) + config = Symbiosis::ConfigFiles::Tinydns.new(output, "#") + config.domain = domain + config.template = dns_template + + # + # Should the snippet be created? + # + do_create = false + + if ( force ) + verbose "\tForcing re-creation of snippet due to --force." + do_create = true + + elsif config.exists? + + if config.changed? + verbose "\tNot updating snippet, as it has been edited by hand." + + elsif config.outdated? + verbose "\tRe-creating snippet as it is out of date." + do_create = true + + else + verbose "\tDomain already present and up-to date." + + end + + else + verbose "\tConfiguring site for the first time" + do_create = true + + end + + # + # + # Check the TinyDNS syntax.. TODO! + # + if do_create + if config.ok? + + verbose "\tWriting snippet to #{output}" + + # + # Create directory with the same ownership as the parent + # + domain.create_dir(output_dir) unless File.exist?(output_dir) + + # + # Write the snippet + # + config.write + + # + # Make sure the ownership is correct. + # + File.chown(domain.uid, domain.gid, config.filename) + + else + verbose "\tThe new DNS snippet is invalid -- no changes have been made." + end + end + + + # + # Rescue errors for this domain, but continue for others. + # + rescue StandardError => err + verbose "\tUnable to create DNS data for #{domain.name} because #{err.to_s}" + verbose "\t"+err.backtrace.join("\n\t") + end +end + +begin + + if upload + upload_script = "/usr/sbin/symbiosis-mythic-dns" + + verbose "Uploading using #{upload_script}" + + IO.popen("#{upload_script} 2>&1","r") do |io| + while !io.eof? do + verbose io.readline + end + end + + unless 0 == $? + raise StandardError, "#{upload_script.inspect} failed." + end + + else + verbose "No need to upload as no changes in the data have been detected." + end +rescue StandardError => err + warn "Unable to upload DNS data because #{err.to_s}" + verbose "\t"+err.backtrace.join("\n\t") + exit 1 +end + +# +# All done. +# + +exit 0 + diff --git a/sbin/symbiosis-mythic-dns b/sbin/symbiosis-mythic-dns new file mode 100755 index 0000000..004fb6a --- /dev/null +++ b/sbin/symbiosis-mythic-dns @@ -0,0 +1,110 @@ +#!/usr/bin/perl -w + +use strict; +use WWW::Mechanize; +use Getopt::Std; + +our ($opt_v, $opt_f); +getopts('vf'); + +my $domaindir = "/srv"; +my $url = 'https://dnsapi.mythic-beasts.com/'; + +sub upload_dns($$$) { + my ($domain, $dnsfile, $password) = @_; + + my $mech = WWW::Mechanize->new( autocheck => 0 ); + + my $response = $mech->post($url, + { domain => $domain, password => $password, command => 'LIST' } + ); + if (!$response->is_success()) { + warn $mech->content() ; + my $status = $response->status_line; + warn "status = $status\n"; + return 0 + } + + my %existing; + foreach my $line (split /\n/, $mech->content()) { + $line =~ s/\s+$//; + $existing{$line} = 1; + } + + my $update = 0; + open F, $dnsfile || die "Can't open $dnsfile"; + my $commands = [ domain => $domain, password => $password ]; + foreach my $record () { + chomp $record; + next if $record =~ m/^\s*\#/; + next if $record =~ m/^$/; + if (exists $existing{$record}) { + delete $existing{$record}; + } else { + print "ADD $record\n" if ($opt_v); + push @$commands, ("command", "ADD $record"); + $update++; + } + } + + foreach my $record (keys %existing) { + push @$commands, ("command", "DELETE $record"); + print "DELETE $record\n" if ($opt_v); + $update++; + } + + if ($update) { + my $response = $mech->post($url, + $commands + ); + return 1 if $response->is_success(); + warn $mech->content() ; + my $status = $response->status_line; + warn "status = $status\n"; + return undef; + } + return 1; +} + +opendir(my $dh, $domaindir) || die "can't opendir $domaindir: $!"; +while (my $d = readdir($dh)) { + + my $target = "$domaindir/$d"; + my $passwordfile = "$target/config/dns/mbpassword"; + my $lastfile = "$target/config/dns/.lastuploaded"; + my $dnsfile = "$domaindir/$d/config/dns/$d.txt"; + + # Does this look like a valid domain? + if (-d $target && -f $passwordfile) { + print "$d\n" if ($opt_v); + + # ALWAYS restrict the password file. + chmod 0600, $passwordfile; + open F, $passwordfile; + my $password = ; + close F; + chomp($password); + + # Check when the last successful upload was + my $laststamp = 0; + if (-e $lastfile) { + $laststamp = (stat($lastfile))[9]; + } + my $tstamp = (stat($dnsfile))[9]; + print "last uploaded $laststamp, last generated $tstamp\n" if ($opt_v); + # and upload if generated file is newer (or forced) + if ( ($opt_f) || ($tstamp > $laststamp)) { + print "Uploading...\n" if ($opt_v); + my $success = upload_dns($d, $dnsfile, $password); + if ($success) { + # only update lastfile on success + open F, ">", $lastfile; + close F; + utime(undef, undef, $lastfile); + } + } + + } +} +closedir $dh; +