diff --git a/README.md b/README.md index 7d3c830..2748555 100644 --- a/README.md +++ b/README.md @@ -7,20 +7,29 @@ This plugin adds a new DNS provider for managing records in PowerDNS. See [How_to_Install_a_Smart-Proxy_Plugin](http://projects.theforeman.org/projects/foreman/wiki/How_to_Install_a_Smart-Proxy_Plugin) for how to install Smart Proxy plugins -This plugin is compatible with Smart Proxy 1.10 or higher. +This plugin is compatible with Smart Proxy 1.11 or higher. When installing using "gem", make sure to install the bundle file: echo "gem 'smart_proxy_dns_powerdns'" > /usr/share/foreman-proxy/bundler.d/dns_powerdns.rb +## Upgrading + +Per version 0.2.0 the backend is a required parameter. + ## Configuration To enable this DNS provider, edit `/etc/foreman-proxy/settings.d/dns.yml` and set: :use_provider: dns_powerdns -Configuration options for this plugin are in `/etc/foreman-proxy/settings.d/dns_powerdns.yml` and include: +Configuration options for this plugin are in `/etc/foreman-proxy/settings.d/dns_powerdns.yml`. + +### MySQL + +To use MySQL, set the following parameters: + :powerdns_backend: 'mysql' :powerdns_mysql_hostname: 'localhost' :powerdns_mysql_username: 'powerdns' :powerdns_mysql_password: '' diff --git a/lib/smart_proxy_dns_powerdns/backend/dummy.rb b/lib/smart_proxy_dns_powerdns/backend/dummy.rb new file mode 100644 index 0000000..a331324 --- /dev/null +++ b/lib/smart_proxy_dns_powerdns/backend/dummy.rb @@ -0,0 +1,24 @@ +module Proxy::Dns::Powerdns::Backend + class Dummy < ::Proxy::Dns::Powerdns::Record + + def initialize(a_server = nil, a_ttl = nil) + super(a_server, a_ttl) + end + + def get_zone name + { + 'id' => 1, + 'name' => name.partition('.')[2] + } + end + + def create_record domain_id, name, ttl, content, type + false + end + + def delete_record domain_id, name, type + false + end + end +end + diff --git a/lib/smart_proxy_dns_powerdns/backend/mysql.rb b/lib/smart_proxy_dns_powerdns/backend/mysql.rb new file mode 100644 index 0000000..be9a3e4 --- /dev/null +++ b/lib/smart_proxy_dns_powerdns/backend/mysql.rb @@ -0,0 +1,49 @@ +require 'mysql2' + +module Proxy::Dns::Powerdns::Backend + class Mysql < ::Proxy::Dns::Powerdns::Record + + attr_reader :hostname, :username, :password, :database + + def initialize(a_server = nil, a_ttl = nil) + @hostname = Proxy::Dns::Powerdns::Plugin.settings.powerdns_mysql_hostname || 'localhost' + @username = Proxy::Dns::Powerdns::Plugin.settings.powerdns_mysql_username + @password = Proxy::Dns::Powerdns::Plugin.settings.powerdns_mysql_password + @database = Proxy::Dns::Powerdns::Plugin.settings.powerdns_mysql_database + + super(a_server, a_ttl) + end + + def connection + @connection ||= Mysql2::Client.new(:host => hostname, :username => username, :password => password, :database => database) + end + + def get_zone name + domain = nil + + name = connection.escape(name) + connection.query("SELECT LENGTH(name) domain_length, id, name FROM domains WHERE '#{name}' LIKE CONCAT('%%.', name) ORDER BY domain_length DESC LIMIT 1").each do |row| + domain = row + end + + raise Proxy::Dns::Error, "Unable to determine zone. Zone must exist in PowerDNS." unless domain + + domain + end + + def create_record domain_id, name, ttl, content, type + name = connection.escape(name) + content = connection.escape(content) + type = connection.escape(type) + connection.query("INSERT INTO records (domain_id, name, ttl, content, type) VALUES (#{domain_id}, '#{name}', #{ttl.to_i}, '#{content}', '#{type}')") + true + end + + def delete_record domain_id, name, type + name = connection.escape(name) + type = connection.escape(type) + connection.query("DELETE FROM records WHERE domain_id=#{domain_id} AND name='#{name}' AND type='#{type}'") + true + end + end +end diff --git a/lib/smart_proxy_dns_powerdns/dependencies.rb b/lib/smart_proxy_dns_powerdns/dependencies.rb new file mode 100644 index 0000000..fb15d14 --- /dev/null +++ b/lib/smart_proxy_dns_powerdns/dependencies.rb @@ -0,0 +1,12 @@ +require 'dns_common/dependency_injection/dependencies' + +class Proxy::Dns::DependencyInjection::Dependencies + case Proxy::Dns::Powerdns::Plugin.settings.powerdns_backend + when 'mysql' + require 'smart_proxy_dns_powerdns/backend/mysql' + dependency :dns_provider, Proxy::Dns::Powerdns::Backend::Mysql + when 'dummy' + require 'smart_proxy_dns_powerdns/backend/dummy' + dependency :dns_provider, Proxy::Dns::Powerdns::Backend::Dummy + end +end diff --git a/lib/smart_proxy_dns_powerdns/dns_powerdns_configuration_validator.rb b/lib/smart_proxy_dns_powerdns/dns_powerdns_configuration_validator.rb new file mode 100644 index 0000000..309af18 --- /dev/null +++ b/lib/smart_proxy_dns_powerdns/dns_powerdns_configuration_validator.rb @@ -0,0 +1,30 @@ +require 'smart_proxy_dns_powerdns/dns_powerdns_plugin' + +module Proxy::Dns::Powerdns + class ConfigurationValidator + def validate_settings!(settings) + validate_choice(settings, :powerdns_backend, ['mysql', 'dummy']) + + case settings[:powerdns_backend] + when 'mysql' + validate_presence(settings, [:powerdns_mysql_username, :powerdns_mysql_password, :powerdns_mysql_database]) + end + end + + def validate_choice(settings, setting, choices) + value = settings[setting] + unless choices.include?(value) + raise ::Proxy::Error::ConfigurationError, "Parameter '#{setting}' is expected to be one of #{choices.join(",")}" + end + true + end + + def validate_presence(settings, names) + names.each do |name| + value = settings[name] + raise ::Proxy::Error::ConfigurationError, "Parameter '#{name}' is expected to have a non-empty value" if value.nil? || value.to_s.empty? + end + true + end + end +end diff --git a/lib/smart_proxy_dns_powerdns/dns_powerdns_main.rb b/lib/smart_proxy_dns_powerdns/dns_powerdns_main.rb index 232f4ed..887ce16 100644 --- a/lib/smart_proxy_dns_powerdns/dns_powerdns_main.rb +++ b/lib/smart_proxy_dns_powerdns/dns_powerdns_main.rb @@ -1,120 +1,73 @@ require 'dns/dns' +require 'dns_common/dns_common' require 'ipaddr' -require 'mysql2' module Proxy::Dns::Powerdns class Record < ::Proxy::Dns::Record include Proxy::Log include Proxy::Util - attr_reader :mysql_connection, :powerdns_pdnssec + attr_reader :pdnssec - def self.record(attrs = {}) - new(attrs.merge( - :powerdns_mysql_hostname => ::Proxy::Dns::Powerdns::Plugin.settings.powerdns_mysql_hostname, - :powerdns_mysql_username => ::Proxy::Dns::Powerdns::Plugin.settings.powerdns_mysql_username, - :powerdns_mysql_password => ::Proxy::Dns::Powerdns::Plugin.settings.powerdns_mysql_password, - :powerdns_mysql_database => ::Proxy::Dns::Powerdns::Plugin.settings.powerdns_mysql_database, - :powerdns_pdnssec => ::Proxy::Dns::Powerdns::Plugin.settings.powerdns_pdnssec - )) + def initialize(a_server = nil, a_ttl = nil) + @pdnssec = Proxy::Dns::Powerdns::Plugin.settings.powerdns_pdnssec + super(a_server, a_ttl || Proxy::Dns::Plugin.settings.dns_ttl) end - def initialize options = {} - raise "dns_powerdns provider needs 'powerdns_mysql_hostname' option" unless options[:powerdns_mysql_hostname] - raise "dns_powerdns provider needs 'powerdns_mysql_username' option" unless options[:powerdns_mysql_username] - raise "dns_powerdns provider needs 'powerdns_mysql_password' option" unless options[:powerdns_mysql_password] - raise "dns_powerdns provider needs 'powerdns_mysql_database' option" unless options[:powerdns_mysql_database] - @mysql_connection = Mysql2::Client.new( - :host => options[:powerdns_mysql_hostname], - :username => options[:powerdns_mysql_username], - :password => options[:powerdns_mysql_password], - :database => options[:powerdns_mysql_database] - ) - - @powerdns_pdnssec = options[:powerdns_pdnssec] || false - - # Normalize the somewhat weird PTR API spec to name / content - case options[:type] - when "PTR" - if options[:value] =~ /\.(in-addr|ip6)\.arpa$/ - @name = options[:value] - else - @name = IPAddr.new(options[:value]).reverse - end - @content = options[:fqdn] - else - @name = options[:fqdn] - @content = options[:value] + def create_a_record(fqdn, ip) + if found = dns_find(fqdn) + raise Proxy::Dns::Collision, "#{fqdn} is already in use by #{ip}" unless found == ip end - super(options) + do_create(fqdn, ip, "A") end - def create - domain_row = domain - raise Proxy::Dns::Error, "Unable to determine zone. Zone must exist in PowerDNS." unless domain_row - - if ip = dns_find(domain_row['id'], @name) - raise Proxy::Dns::Collision, "#{@name} is already in use by #{ip}" + def create_ptr_record(fqdn, ip) + if found = dns_find(ip) + raise Proxy::Dns::Collision, "#{ip} is already in use by #{found}" unless found == fqdn end - create_record(domain_row['id'], @name, @ttl, @content, @type) - - rectify_zone(domain_row['name']) + name = IPAddr.new(ip).reverse + do_create(name, fqdn, "PTR") end - def remove - domain_row = domain - raise Proxy::Dns::Error, "Unable to determine zone. Zone must exist in PowerDNS." unless domain_row - - delete_record(domain_row['id'], @name, @type) - - rectify_zone(domain_row['name']) + def do_create(name, value, type) + zone = get_zone(name) + create_record(zone['id'], name, type, value) + rectify_zone(zone['name']) end - private - def domain - domain = nil + def remove_a_record(fqdn) + do_remove(fqdn, "A") + end - name = mysql_connection.escape(@name) - mysql_connection.query("SELECT LENGTH(name) domain_length, id, name FROM domains WHERE '#{name}' LIKE CONCAT('%%.', name) ORDER BY domain_length DESC LIMIT 1").each do |row| - domain = row - end + def remove_ptr_record(ip) + name = ip # Note ip is already in-addr.arpa + do_remove(name, "PTR") + end - domain + def do_remove(name, type) + zone = get_zone(name) + delete_record(zone['id'], name, type) + rectify_zone(zone['name']) end - private - def dns_find domain_id, key - value = nil - key = mysql_connection.escape(key) - mysql_connection.query("SELECT content FROM records WHERE domain_id=#{domain_id} AND name = '#{key}' LIMIT 1").each do |row| - value = row["content"] - end - value || false + def get_zone(fqdn) + # TODO: backend specific + raise Proxy::Dns::Error, "Unable to determine zone. Zone must exist in PowerDNS." end - private - def create_record domain_id, name, ttl, content, type - name = mysql_connection.escape(name) - content = mysql_connection.escape(content) - type = mysql_connection.escape(type) - mysql_connection.query("INSERT INTO records (domain_id, name, ttl, content, type) VALUES (#{domain_id}, '#{name}', #{ttl.to_i}, '#{content}', '#{type}')") - true + def create_record(domain_id, name, type, content) + # TODO: backend specific end - private - def delete_record domain_id, name, type - name = mysql_connection.escape(name) - type = mysql_connection.escape(type) - mysql_connection.query("DELETE FROM records WHERE domain_id=#{domain_id} AND name='#{name}' AND type='#{type}'") - true + def delete_record(domain_id, name, type) + # TODO: backend specific end - private def rectify_zone domain - if @powerdns_pdnssec - %x(#{@powerdns_pdnssec} rectify-zone "#{domain}") + if @pdnssec + %x(#{@pdnssec} rectify-zone "#{domain}") $?.exitstatus == 0 else diff --git a/lib/smart_proxy_dns_powerdns/dns_powerdns_plugin.rb b/lib/smart_proxy_dns_powerdns/dns_powerdns_plugin.rb index 551b52b..5ff0650 100644 --- a/lib/smart_proxy_dns_powerdns/dns_powerdns_plugin.rb +++ b/lib/smart_proxy_dns_powerdns/dns_powerdns_plugin.rb @@ -2,13 +2,18 @@ module Proxy::Dns::Powerdns class Plugin < ::Proxy::Provider - plugin :dns_powerdns, ::Proxy::Dns::Powerdns::VERSION, - :factory => proc { |attrs| ::Proxy::Dns::Powerdns::Record.record(attrs) } + plugin :dns_powerdns, ::Proxy::Dns::Powerdns::VERSION - requires :dns, '>= 1.10' + requires :dns, '>= 1.11' + + validate_presence :powerdns_backend after_activation do + require 'smart_proxy_dns_powerdns/dns_powerdns_configuration_validator' + ::Proxy::Dns::Powerdns::ConfigurationValidator.new.validate_settings! + require 'smart_proxy_dns_powerdns/dns_powerdns_main' + require 'smart_proxy_dns_powerdns/dependencies' end end end diff --git a/test/unit/dns_powerdns_configuration_validator_test.rb b/test/unit/dns_powerdns_configuration_validator_test.rb new file mode 100644 index 0000000..d9390e8 --- /dev/null +++ b/test/unit/dns_powerdns_configuration_validator_test.rb @@ -0,0 +1,57 @@ +require 'test_helper' + +require 'smart_proxy_dns_powerdns/dns_powerdns_plugin' +require 'smart_proxy_dns_powerdns/dns_powerdns_configuration_validator' + +class DnsPowerdnsConfigurationValidatorTest < Test::Unit::TestCase + def setup + @config_validator = Proxy::Dns::Powerdns::ConfigurationValidator.new + end + + def test_initialize_missing_backend + settings = {:dns_provider => 'powerdns', :powerdns_backend => nil} + + assert_raise Proxy::Error::ConfigurationError do + @config_validator.validate_settings!(settings) + end + end + + def test_initialize_invalid_backend + settings = {:dns_provider => 'powerdns', :powerdns_backend => 'invalid'} + + assert_raise Proxy::Error::ConfigurationError do + @config_validator.validate_settings!(settings) + end + end + + def test_initialize_dummy_with_settings + settings = {:dns_provider => 'powerdns', :powerdns_backend => 'dummy'} + + assert_nothing_raised do + @config_validator.validate_settings!(settings) + end + end + + def test_initialize_mysql_without_settings + settings = {:dns_provider => 'powerdns', :powerdns_backend => 'mysql'} + + assert_raise Proxy::Error::ConfigurationError do + @config_validator.validate_settings!(settings) + end + end + + def test_initialize_mysql_with_settings + settings = { + :dns_provider => 'powerdns', + :powerdns_backend => 'mysql', + :powerdns_mysql_hostname => 'localhost', + :powerdns_mysql_username => 'username', + :powerdns_mysql_password => 'password', + :powerdns_mysql_database => 'powerdns' + } + + assert_nothing_raised do + @config_validator.validate_settings!(settings) + end + end +end diff --git a/test/unit/dns_powerdns_record_mysql_test.rb b/test/unit/dns_powerdns_record_mysql_test.rb new file mode 100644 index 0000000..f6dee88 --- /dev/null +++ b/test/unit/dns_powerdns_record_mysql_test.rb @@ -0,0 +1,28 @@ +require 'test_helper' + +require 'smart_proxy_dns_powerdns/dns_powerdns_plugin' +require 'smart_proxy_dns_powerdns/dns_powerdns_main' +require 'smart_proxy_dns_powerdns/backend/mysql' + +class DnsPowerdnsBackendMysqlTest < Test::Unit::TestCase + # Test that correct initialization works + def test_initialize_dummy_with_settings + Proxy::Dns::Powerdns::Plugin.load_test_settings( + :powerdns_mysql_hostname => 'db.example.com', + :powerdns_mysql_username => 'the_user', + :powerdns_mysql_password => 'something_secure', + :powerdns_mysql_database => 'db_pdns' + ) + provider = klass.new + assert_equal 'db.example.com', provider.hostname + assert_equal 'the_user', provider.username + assert_equal 'something_secure', provider.password + assert_equal 'db_pdns', provider.database + end + + private + + def klass + Proxy::Dns::Powerdns::Backend::Mysql + end +end diff --git a/test/unit/dns_powerdns_record_test.rb b/test/unit/dns_powerdns_record_test.rb index 9f8662b..3b785a0 100644 --- a/test/unit/dns_powerdns_record_test.rb +++ b/test/unit/dns_powerdns_record_test.rb @@ -1,103 +1,78 @@ require 'test_helper' +require 'smart_proxy_dns_powerdns/dns_powerdns_plugin' require 'smart_proxy_dns_powerdns/dns_powerdns_main' class DnsPowerdnsRecordTest < Test::Unit::TestCase - # Test that a missing :powerdns_mysql_hostname throws an error - def test_initialize_without_settings - assert_raise(RuntimeError) do - klass.new(settings.delete_if { |k,v| k == :powerdns_mysql_hostname }) - end - end - # Test that correct initialization works - def test_initialize_with_settings - assert_nothing_raised do - mock_mysql - - klass.new(settings) - end + def test_initialize_dummy_with_settings + Proxy::Dns::Powerdns::Plugin.load_test_settings(:powerdns_pdnssec => 'sudo pdnssec') + provider = klass.new + assert_equal 'sudo pdnssec', provider.pdnssec end # Test A record creation def test_create_a - mock_mysql + instance = klass.new - instance = klass.new(settings) - - instance.expects(:domain).returns({'id' => 1}) - instance.expects(:dns_find).with(1, 'test.example.com').returns(false) - instance.expects(:create_record).with(1, 'test.example.com', 84600, '10.1.1.1', 'A').returns(true) + instance.expects(:dns_find).with('test.example.com').returns(false) + instance.expects(:get_zone).with('test.example.com').returns({'id' => 1, 'name' => 'example.com'}) + instance.expects(:create_record).with(1, 'test.example.com', 'A', '10.1.1.1').returns(true) + instance.expects(:rectify_zone).with('example.com').returns(true) - assert instance.create + assert instance.create_a_record(fqdn, ip) end # Test A record creation fails if the record exists def test_create_a_conflict - mock_mysql + instance = klass.new - instance = klass.new(settings) + instance.expects(:dns_find).with('test.example.com').returns('192.168.1.1') - instance.expects(:domain).returns({'id' => 1}) - instance.expects(:dns_find).with(1, 'test.example.com').returns('192.168.1.1') - - assert_raise(Proxy::Dns::Collision) { instance.create } + assert_raise(Proxy::Dns::Collision) { instance.create_a_record(fqdn, ip) } end # Test PTR record creation def test_create_ptr - mock_mysql - - instance = klass.new(settings.merge(:type => 'PTR')) + instance = klass.new - instance.expects(:domain).returns({'id' => 1, 'name' => 'example.com'}) - instance.expects(:dns_find).with(1, '1.1.1.10.in-addr.arpa').returns(false) - instance.expects(:create_record).with(1, '1.1.1.10.in-addr.arpa', 84600, 'test.example.com', 'PTR').returns(true) - instance.expects(:rectify_zone).with('example.com').returns(true) + instance.expects(:dns_find).with('10.1.1.1').returns(false) + instance.expects(:get_zone).with('1.1.1.10.in-addr.arpa').returns({'id' => 1, 'name' => '1.1.10.in-addr.arpa'}) + instance.expects(:create_record).with(1, '1.1.1.10.in-addr.arpa', 'PTR', 'test.example.com').returns(true) + instance.expects(:rectify_zone).with('1.1.10.in-addr.arpa').returns(true) - assert instance.create + assert instance.create_ptr_record(fqdn, ip) end # Test PTR record creation fails if the record exists def test_create_ptr_conflict - mock_mysql + instance = klass.new - instance = klass.new(settings.merge(:type => 'PTR')) + instance.expects(:dns_find).with('10.1.1.1').returns('test2.example.com') - instance.expects(:domain).returns({'id' => 1, 'name' => '1.1.10.in-addr.arpa'}) - instance.expects(:dns_find).with(1, '1.1.1.10.in-addr.arpa').returns('test2.example.com') - - assert_raise(Proxy::Dns::Collision) { instance.create } + assert_raise(Proxy::Dns::Collision) { instance.create_ptr_record(fqdn, ip) } end # Test A record removal def test_remove_a - mock_mysql - - instance = klass.new(settings) + instance = klass.new - instance.expects(:domain).returns({'id' => 1, 'name' => 'example.com'}) + instance.expects(:get_zone).with('test.example.com').returns({'id' => 1, 'name' => 'example.com'}) instance.expects(:delete_record).with(1, 'test.example.com', 'A').returns(true) instance.expects(:rectify_zone).with('example.com').returns(true) - assert instance.remove + assert instance.remove_a_record(fqdn) end # Test PTR record removal def test_remove_ptr - mock_mysql - - instance = klass.new(settings.merge(:type => 'PTR')) + instance = klass.new - instance.expects(:domain).returns({'id' => 1, 'name' => '1.1.10.in-addr.arpa'}) + instance.expects(:get_zone).with('1.1.1.10.in-addr.arpa').returns({'id' => 1, 'name' => '1.1.10.in-addr.arpa'}) instance.expects(:delete_record).with(1, '1.1.1.10.in-addr.arpa', 'PTR').returns(true) instance.expects(:rectify_zone).with('1.1.10.in-addr.arpa').returns(true) - assert instance.remove - end - - def mock_mysql - Mysql2::Client.expects(:new).with(:host => 'localhost', :username => 'username', :password => 'password', :database => 'powerdns').returns(false) + assert instance.remove_ptr_record(reverse_ip) end private @@ -106,16 +81,15 @@ def klass Proxy::Dns::Powerdns::Record end - def settings - { - :powerdns_mysql_hostname => 'localhost', - :powerdns_mysql_username => 'username', - :powerdns_mysql_password => 'password', - :powerdns_mysql_database => 'powerdns', - :fqdn => 'test.example.com', - :value => '10.1.1.1', - :type => 'A', - :ttl => 84600, - } + def fqdn + 'test.example.com' + end + + def ip + '10.1.1.1' + end + + def reverse_ip + '1.1.1.10.in-addr.arpa' end end