diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..491f666 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +.idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e450c29 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + diff --git a/LICENSE b/LICENSE index f288702..e72bfdd 100644 --- a/LICENSE +++ b/LICENSE @@ -671,4 +671,4 @@ into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read -. +. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e2fe1e0 --- /dev/null +++ b/README.md @@ -0,0 +1,161 @@ +# PowerDNS DNS Provider + +This is a drop-in provider for [apnscp](https://apnscp.com) to enable DNS support using PowerDNS. This module may use PostgreSQL or MySQL as a backend driver. + +## Nameserver installation + +Clone the repository into the Bootstrapper addin path. Note this requires either apnscp v3.1 or apnscp v3.0.47 minimum to work. + +```bash +upcp +cd /usr/local/apnscp/resources/playbooks +git clone -b develop https://github.com/LithiumHosting/apnscp-powerdns.git addins/apnscp-powerdns +ansible-playbook addin.yml --extra-vars=addin=apnscp-powerdns --extra-vars=powerdns_driver=mysql +``` + +*PostgreSQL can be used by specifying powerdns_driver=pgsql* + +PowerDNS is now setup to accept requests on port 8081. Requests require an authorization key that can be found in `/etc/pdns/pdns.conf` + +``` +# Install jq if not already installed +yum install -y jq +grep api= /etc/pdns/pdns.conf | cut -d= -f2 +# This is your API key +curl -v -H 'X-API-Key: APIKEYABOVE' http://127.0.0.1:8081/api/v1/servers/localhost | jq . +``` + +apnscp provides a DNS-only license class that allows apnscp to run on a server without the capability to host sites. These licenses are free and may be requested via [my.apnscp.com](https://my.apnscp.com). Contact license@apnscp.com if these licenses are not available at time of writing for manual issuance. + +### Idempotently changing configuration + +PowerDNS may be configured via files in `/etc/pdns/local.d`. In addition to this location, Bootstrapper supports injecting settings via `powerdns_custom_config`. For example, + +```bash +cpcmd config:set apnscp.bootstrapper 'powerdns_custom_config' '["allow-axfr-ips":1.2.3.4,"also-notify":1.2.3.4]' +cd /usr/local/apnscp/resources/playbooks +ansible-playbook addin.yml --extra-vars=addin=apnscp-powerdns +``` + +allow-axfr-ips and also-notify directives will be set whenever the addin plays are run. + +### Restricting submission access + +In the above example, only local requests may submit DNS modifications to the server. None of the below examples affect querying; DNS queries occur over 53/UDP typically (or 53/TCP if packet size exceeds UDP limits). Depending upon infrastructure, there are a few options to securely accept record submission, *all of which require an API key for submission*. + +#### SSL + Apache + +Apache's `ProxyPass` directive send requests to the backend. Brute-force attempts are protected by [mod_evasive](https://github.com/apisnetworks/mod_evasive ) bundled with apnscp. Requests over this medium are protected by SSL, without HTTP/2 to ameliorate handshake overhead. In all but the very high volume API request environments, this will be acceptable. + +In this situation, the endpoint is https://myserver.apnscp.com/dns. Changes are made to `/etc/httpd/conf/httpd-custom.conf` within the `` bracket (with `SSLEngine On`!). After adding the below changes, `systemctl restart httpd`. + +``` + + ProxyPass http://127.0.0.1:8081 + ProxyPassReverse http://127.0.0.1:8081 + +``` + +**Downsides**: minor SSL overhead. Dependent upon Apache. +**Upsides**: easy to setup. Protected by threat deterrence. PowerDNS accessible remotely via an easily controlled URI. + +In the above example, API requests can be made via https://myserver.apnscp.com/dns, e.g. + +```bash +curl -q -H 'X-API-Key: SOMEKEY' https://myserver.apnscp.com/dns/api/v1/servers/localhost +``` + +##### Disabling brute-force throttling + +As hinted above, placing PowerDNS behind Apache confers brute-force protection by mod_evasive. By default, 10 of the same requests in 2 seconds can trigger a brute-force block. Two solutions exist, either raise the same-page request threshold or disable mod_evasive. + +Working off the example above * ... * +``` + + # Raise threshold to 30 same-page requests in 2 seconds + DOSPageCount 30 + DOSPageInterval 2 + + # Or disable entirely + DOSEnabled off + +``` + +#### Standalone server + +PowerDNS can also run by itself on a different port. In this situation, the network is configured to block all external requests to port 8081 except those whitelisted. For example, if the entire 32.12.1.1-32.12.1.255 network can be trusted and under your control, then whitelist the IP range: + +```bash +cpcmd rampart:whitelist 32.12.1.1/24 +``` + +Additionally, PowerDNS' whitelist must be updated as well. This can be quickly accomplished using the *apnscp.bootstrapper* Scope: + +``` +cpcmd config:set apnscp.bootstrapper powerdns_localonly false +cd /usr/local/apnscp/resources/playbooks +ansible-playbook addin.yml --extra-vars=addin=apnscp-powerdns +``` + +**Downsides**: requires whitelisting IP addresses for access to API server. Must run on port different than Apache. +**Upsides**: operates independently from Apache. + +The server may be accessed once the source IP has been whitelisted, + +```bash +curl -q -H 'X-API-Key: SOMEKEY' http://myserver.apnscp.com/api/v1/servers/localhost +``` + + +## apnscp DNS provider setup + +Every server that runs apnscp may delegate DNS authority to PowerDNS. This is ideal in distributed infrastructures in which coordination allows for seamless [server-to-server migrations]( ). + +Taking the **API key** from above, configure `/usr/local/apnscp/config/auth.yaml`. Configuration within this file is secret and is not exposed via apnscp's API. + +```yaml +pdns: + # This url may be different if using running PowerDNS in standalone + uri: https://myserver.apnscp.com/dns/api/v1/ + key: your_api_key_here + ns: + - ns1.yourdomain.com + - ns2.yourdomain.com + ## Optional additional nameservers +``` +* `uri` value is the hostname of your master PowerDNS server running the HTTP API webserver (with a trailing slash) +* `key` value is the **API Key** in `pdns.conf` on the master nameserver. +* `ns` value is a list of nameservers as in the example above. Put nameservers on their own lines prefixed with a hyphen and indented accordingly. There is not currently a limit for the number of nameservers you may use, 2-5 is typical and should be geographically distributed per RFC 2182. + +### Setting as default + +PowerDNS may be configured as the default provider for all sites using the `dns.default-provider` [Scope](https://gitlab.com/apisnetworks/apnscp/blob/master/docs/admin/Scopes.md). When adding a site in Nexus or [AddDomain](https://hq.apnscp.com/working-with-cli-helpers/#adddomain) the key will be replaced with "DEFAULT". This is substituted automatically on account creation. + +```bash +cpcmd config_set dns.default-provider powerdns +``` + +> Do not set dns.default-provider-key. API key is configured via `config/auth.yaml`. + +## Components + +- Module- overrides [Dns_Module](https://github.com/apisnetworks/apnscp-modules/blob/master/modules/dns.php) behavior +- Validator- service validator, checks input with AddDomain/EditDomain helpers + +### Minimal module methods + +All module methods can be overwritten. The following are the bare minimum that are overwritten for this DNS provider to work: + +- `atomicUpdate()` attempts a record modification, which must retain the original record if it fails +- `zoneAxfr()` returns all DNS records +- `add_record()` add a DNS record +- `remove_record()` removes a DNS record +- `get_hosting_nameservers()` returns nameservers for the DNS provider +- `add_zone_backend()` creates DNS zone +- `remove_zone_backend()` removes a DNS zone + +See also: [Creating a provider](https://hq.apnscp.com/apnscp-pre-alpha-technical-release/#creatingaprovider) (hq.apnscp.com) + +## Contributing + +Submit a PR and have fun! \ No newline at end of file diff --git a/plays/powerdns-authoritative-setup/defaults/main.yml b/plays/powerdns-authoritative-setup/defaults/main.yml new file mode 100644 index 0000000..34c88f4 --- /dev/null +++ b/plays/powerdns-authoritative-setup/defaults/main.yml @@ -0,0 +1,54 @@ +--- +# PowerDNS API listens for local requests only +# This can be put behind an Apache proxy or other means +# See documentation for examples +powerdns_localonly: true +# Toggle installation of PowerDNS +powerdns_enabled: true +powerdns_version: 4.2 +powerdns_repo_uri: "https://repo.powerdns.com/repo-files/centos-rec-{{ powerdns_version | regex_replace('\\.','') }}.repo" +powerdns_repo_config: /etc/yum.repos.d/powerdns.repo +powerdns_packages: + - pdns + - "pdns-backend-{{ (powerdns_driver == 'pgsql') | ternary('postgresql', powerdns_driver) }}" + +powerdns_local_dir: "/etc/pdns/local.d" +powerdns_config_file: /etc/pdns/pdns.conf +powerdns_driver: mysql +powerdns_api_port: 8081 + +# Code installation +addin_provider_name: Powerdns +addin_integration_type: "{{ (apnscp_debug | bool) | ternary('link', 'copy') }}" + +powerdns_db_password: >- + {{ lookup('pipe', 'grep -e "^\s*g' + powerdns_driver + '-password\s*=" ' + (powerdns_config_file | quote) + ' | cut -d= -f2') | default(lookup('password', '/dev/null chars=ascii_letters length=24'), true) | trim }} +powerdns_db_user: >- + {{ lookup('pipe', 'grep -e "^\s*g' + powerdns_driver + '-user\s*=" ' + (powerdns_config_file | quote) + ' | cut -d= -f2') | default('powerdns', true) | trim }} +powerdns_db_name: >- + {{ lookup('pipe', 'grep -e "^\s*g' + powerdns_driver + '-dbname\s*=" ' + (powerdns_config_file | quote) + ' | cut -d= -f2') | default('powerdns', true) | trim }} +powerdns_db_host: >- + {{ lookup('pipe', 'grep -e "^\s*g' + powerdns_driver + '-host\s*=" ' + (powerdns_config_file | quote) + ' | cut -d= -f2') | default('localhost', true) | trim }} +powerdns_api_key: >- + {{ lookup('pipe', 'grep -e "^\s*api-key\s*=" ' + (powerdns_config_file | quote) + ' | cut -d= -f2') | default(lookup('password', '/dev/null chars=ascii_letters length=24'), true) | trim }} +powerdns_config: + api: yes + "api-key": "{{ powerdns_api_key }}" + "include-dir": "{{ powerdns_local_dir }}" + webserver: "{{ powerdns_webserver_enable | default((powerdns_version is version('4.1', '>=')) | ternary('no', 'yes')) }}" + "webserver-address": >- + {{ powerdns_api_listen | default(powerdns_localonly | ternary('127.0.0.1', '0.0.0.0')) }} + "webserver-allow-from": >- + {{ powerdns_api_whitelist | default(powerdns_localonly | ternary('127.0.0.1,::1', '0.0.0.0,::/0')) }} + "webserver-port": "{{ powerdns_api_port }}" + launch: "g{{ powerdns_driver }}" + "g{{powerdns_driver}}-password": "{{ __powerdns_db_password }}" + "g{{powerdns_driver}}-user": "{{ powerdns_db_user }}" + "g{{powerdns_driver}}-dbname": "{{ powerdns_db_name }}" + "g{{powerdns_driver}}-host": "{{ powerdns_db_host }}" + version-string: anonymous + +# Inject overrides via powerdns_custom_config +# powerdns_custom_config: +# "allow-axfr-ips": 1.2.3.4 +# "also-notify": 2.3.4.5 diff --git a/plays/powerdns-authoritative-setup/files/mysql-4.0.sql b/plays/powerdns-authoritative-setup/files/mysql-4.0.sql new file mode 100644 index 0000000..002fe92 --- /dev/null +++ b/plays/powerdns-authoritative-setup/files/mysql-4.0.sql @@ -0,0 +1,90 @@ +CREATE TABLE domains ( + id INT AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + master VARCHAR(128) DEFAULT NULL, + last_check INT DEFAULT NULL, + type VARCHAR(6) NOT NULL, + notified_serial INT DEFAULT NULL, + account VARCHAR(40) DEFAULT NULL, + PRIMARY KEY (id) +) Engine=InnoDB; + +CREATE UNIQUE INDEX name_index ON domains(name); + + +CREATE TABLE records ( + id INT AUTO_INCREMENT, + domain_id INT DEFAULT NULL, + name VARCHAR(255) DEFAULT NULL, + type VARCHAR(10) DEFAULT NULL, + content VARCHAR(64000) DEFAULT NULL, + ttl INT DEFAULT NULL, + prio INT DEFAULT NULL, + change_date INT DEFAULT NULL, + disabled TINYINT(1) DEFAULT 0, + ordername VARCHAR(255) BINARY DEFAULT NULL, + auth TINYINT(1) DEFAULT 1, + PRIMARY KEY (id) +) Engine=InnoDB; + +CREATE INDEX nametype_index ON records(name,type); +CREATE INDEX domain_id ON records(domain_id); +CREATE INDEX recordorder ON records (domain_id, ordername); + + +CREATE TABLE supermasters ( + ip VARCHAR(64) NOT NULL, + nameserver VARCHAR(255) NOT NULL, + account VARCHAR(40) NOT NULL, + PRIMARY KEY (ip, nameserver) +) Engine=InnoDB; + + +CREATE TABLE comments ( + id INT AUTO_INCREMENT, + domain_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(10) NOT NULL, + modified_at INT NOT NULL, + account VARCHAR(40) NOT NULL, + comment VARCHAR(64000) NOT NULL, + PRIMARY KEY (id) +) Engine=InnoDB; + +CREATE INDEX comments_domain_id_idx ON comments (domain_id); +CREATE INDEX comments_name_type_idx ON comments (name, type); +CREATE INDEX comments_order_idx ON comments (domain_id, modified_at); + + +CREATE TABLE domainmetadata ( + id INT AUTO_INCREMENT, + domain_id INT NOT NULL, + kind VARCHAR(32), + content TEXT, + PRIMARY KEY (id) +) Engine=InnoDB; + +CREATE INDEX domainmetadata_idx ON domainmetadata (domain_id, kind); + + +CREATE TABLE cryptokeys ( + id INT AUTO_INCREMENT, + domain_id INT NOT NULL, + flags INT NOT NULL, + active BOOL, + content TEXT, + PRIMARY KEY(id) +) Engine=InnoDB; + +CREATE INDEX domainidindex ON cryptokeys(domain_id); + + +CREATE TABLE tsigkeys ( + id INT AUTO_INCREMENT, + name VARCHAR(255), + algorithm VARCHAR(50), + secret VARCHAR(255), + PRIMARY KEY (id) +) Engine=InnoDB; + +CREATE UNIQUE INDEX namealgoindex ON tsigkeys(name, algorithm); diff --git a/plays/powerdns-authoritative-setup/files/mysql-4.1.sql b/plays/powerdns-authoritative-setup/files/mysql-4.1.sql new file mode 100644 index 0000000..bf15329 --- /dev/null +++ b/plays/powerdns-authoritative-setup/files/mysql-4.1.sql @@ -0,0 +1,89 @@ +CREATE TABLE domains ( + id INT AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + master VARCHAR(128) DEFAULT NULL, + last_check INT DEFAULT NULL, + type VARCHAR(6) NOT NULL, + notified_serial INT DEFAULT NULL, + account VARCHAR(40) CHARACTER SET 'utf8' DEFAULT NULL, + PRIMARY KEY (id) +) Engine=InnoDB CHARACTER SET 'latin1'; + +CREATE UNIQUE INDEX name_index ON domains(name); + + +CREATE TABLE records ( + id BIGINT AUTO_INCREMENT, + domain_id INT DEFAULT NULL, + name VARCHAR(255) DEFAULT NULL, + type VARCHAR(10) DEFAULT NULL, + content VARCHAR(64000) DEFAULT NULL, + ttl INT DEFAULT NULL, + prio INT DEFAULT NULL, + change_date INT DEFAULT NULL, + disabled TINYINT(1) DEFAULT 0, + ordername VARCHAR(255) BINARY DEFAULT NULL, + auth TINYINT(1) DEFAULT 1, + PRIMARY KEY (id) +) Engine=InnoDB CHARACTER SET 'latin1'; + +CREATE INDEX nametype_index ON records(name,type); +CREATE INDEX domain_id ON records(domain_id); +CREATE INDEX ordername ON records (ordername); + + +CREATE TABLE supermasters ( + ip VARCHAR(64) NOT NULL, + nameserver VARCHAR(255) NOT NULL, + account VARCHAR(40) CHARACTER SET 'utf8' NOT NULL, + PRIMARY KEY (ip, nameserver) +) Engine=InnoDB CHARACTER SET 'latin1'; + + +CREATE TABLE comments ( + id INT AUTO_INCREMENT, + domain_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(10) NOT NULL, + modified_at INT NOT NULL, + account VARCHAR(40) CHARACTER SET 'utf8' DEFAULT NULL, + comment TEXT CHARACTER SET 'utf8' NOT NULL, + PRIMARY KEY (id) +) Engine=InnoDB CHARACTER SET 'latin1'; + +CREATE INDEX comments_name_type_idx ON comments (name, type); +CREATE INDEX comments_order_idx ON comments (domain_id, modified_at); + + +CREATE TABLE domainmetadata ( + id INT AUTO_INCREMENT, + domain_id INT NOT NULL, + kind VARCHAR(32), + content TEXT, + PRIMARY KEY (id) +) Engine=InnoDB CHARACTER SET 'latin1'; + +CREATE INDEX domainmetadata_idx ON domainmetadata (domain_id, kind); + + +CREATE TABLE cryptokeys ( + id INT AUTO_INCREMENT, + domain_id INT NOT NULL, + flags INT NOT NULL, + active BOOL, + content TEXT, + PRIMARY KEY(id) +) Engine=InnoDB CHARACTER SET 'latin1'; + +CREATE INDEX domainidindex ON cryptokeys(domain_id); + + +CREATE TABLE tsigkeys ( + id INT AUTO_INCREMENT, + name VARCHAR(255), + algorithm VARCHAR(50), + secret VARCHAR(255), + PRIMARY KEY (id) +) Engine=InnoDB CHARACTER SET 'latin1'; + +CREATE UNIQUE INDEX namealgoindex ON tsigkeys(name, algorithm); diff --git a/plays/powerdns-authoritative-setup/files/mysql-4.2.sql b/plays/powerdns-authoritative-setup/files/mysql-4.2.sql new file mode 100644 index 0000000..2dea1c2 --- /dev/null +++ b/plays/powerdns-authoritative-setup/files/mysql-4.2.sql @@ -0,0 +1,88 @@ +CREATE TABLE domains ( + id INT AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + master VARCHAR(128) DEFAULT NULL, + last_check INT DEFAULT NULL, + type VARCHAR(6) NOT NULL, + notified_serial INT UNSIGNED DEFAULT NULL, + account VARCHAR(40) CHARACTER SET 'utf8' DEFAULT NULL, + PRIMARY KEY (id) +) Engine=InnoDB CHARACTER SET 'latin1'; + +CREATE UNIQUE INDEX name_index ON domains(name); + + +CREATE TABLE records ( + id BIGINT AUTO_INCREMENT, + domain_id INT DEFAULT NULL, + name VARCHAR(255) DEFAULT NULL, + type VARCHAR(10) DEFAULT NULL, + content VARCHAR(64000) DEFAULT NULL, + ttl INT DEFAULT NULL, + prio INT DEFAULT NULL, + disabled TINYINT(1) DEFAULT 0, + ordername VARCHAR(255) BINARY DEFAULT NULL, + auth TINYINT(1) DEFAULT 1, + PRIMARY KEY (id) +) Engine=InnoDB CHARACTER SET 'latin1'; + +CREATE INDEX nametype_index ON records(name,type); +CREATE INDEX domain_id ON records(domain_id); +CREATE INDEX ordername ON records (ordername); + + +CREATE TABLE supermasters ( + ip VARCHAR(64) NOT NULL, + nameserver VARCHAR(255) NOT NULL, + account VARCHAR(40) CHARACTER SET 'utf8' NOT NULL, + PRIMARY KEY (ip, nameserver) +) Engine=InnoDB CHARACTER SET 'latin1'; + + +CREATE TABLE comments ( + id INT AUTO_INCREMENT, + domain_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(10) NOT NULL, + modified_at INT NOT NULL, + account VARCHAR(40) CHARACTER SET 'utf8' DEFAULT NULL, + comment TEXT CHARACTER SET 'utf8' NOT NULL, + PRIMARY KEY (id) +) Engine=InnoDB CHARACTER SET 'latin1'; + +CREATE INDEX comments_name_type_idx ON comments (name, type); +CREATE INDEX comments_order_idx ON comments (domain_id, modified_at); + + +CREATE TABLE domainmetadata ( + id INT AUTO_INCREMENT, + domain_id INT NOT NULL, + kind VARCHAR(32), + content TEXT, + PRIMARY KEY (id) +) Engine=InnoDB CHARACTER SET 'latin1'; + +CREATE INDEX domainmetadata_idx ON domainmetadata (domain_id, kind); + + +CREATE TABLE cryptokeys ( + id INT AUTO_INCREMENT, + domain_id INT NOT NULL, + flags INT NOT NULL, + active BOOL, + content TEXT, + PRIMARY KEY(id) +) Engine=InnoDB CHARACTER SET 'latin1'; + +CREATE INDEX domainidindex ON cryptokeys(domain_id); + + +CREATE TABLE tsigkeys ( + id INT AUTO_INCREMENT, + name VARCHAR(255), + algorithm VARCHAR(50), + secret VARCHAR(255), + PRIMARY KEY (id) +) Engine=InnoDB CHARACTER SET 'latin1'; + +CREATE UNIQUE INDEX namealgoindex ON tsigkeys(name, algorithm); diff --git a/plays/powerdns-authoritative-setup/files/pgsql-4.0.sql b/plays/powerdns-authoritative-setup/files/pgsql-4.0.sql new file mode 100644 index 0000000..e14395b --- /dev/null +++ b/plays/powerdns-authoritative-setup/files/pgsql-4.0.sql @@ -0,0 +1,95 @@ +CREATE TABLE domains ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + master VARCHAR(128) DEFAULT NULL, + last_check INT DEFAULT NULL, + type VARCHAR(6) NOT NULL, + notified_serial INT DEFAULT NULL, + account VARCHAR(40) DEFAULT NULL, + CONSTRAINT c_lowercase_name CHECK (((name)::TEXT = LOWER((name)::TEXT))) +); + +CREATE UNIQUE INDEX name_index ON domains(name); + + +CREATE TABLE records ( + id SERIAL PRIMARY KEY, + domain_id INT DEFAULT NULL, + name VARCHAR(255) DEFAULT NULL, + type VARCHAR(10) DEFAULT NULL, + content VARCHAR(65535) DEFAULT NULL, + ttl INT DEFAULT NULL, + prio INT DEFAULT NULL, + change_date INT DEFAULT NULL, + disabled BOOL DEFAULT 'f', + ordername VARCHAR(255), + auth BOOL DEFAULT 't', + CONSTRAINT domain_exists + FOREIGN KEY(domain_id) REFERENCES domains(id) + ON DELETE CASCADE, + CONSTRAINT c_lowercase_name CHECK (((name)::TEXT = LOWER((name)::TEXT))) +); + +CREATE INDEX rec_name_index ON records(name); +CREATE INDEX nametype_index ON records(name,type); +CREATE INDEX domain_id ON records(domain_id); +CREATE INDEX recordorder ON records (domain_id, ordername text_pattern_ops); + + +CREATE TABLE supermasters ( + ip INET NOT NULL, + nameserver VARCHAR(255) NOT NULL, + account VARCHAR(40) NOT NULL, + PRIMARY KEY(ip, nameserver) +); + + +CREATE TABLE comments ( + id SERIAL PRIMARY KEY, + domain_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(10) NOT NULL, + modified_at INT NOT NULL, + account VARCHAR(40) DEFAULT NULL, + comment VARCHAR(65535) NOT NULL, + CONSTRAINT domain_exists + FOREIGN KEY(domain_id) REFERENCES domains(id) + ON DELETE CASCADE, + CONSTRAINT c_lowercase_name CHECK (((name)::TEXT = LOWER((name)::TEXT))) +); + +CREATE INDEX comments_domain_id_idx ON comments (domain_id); +CREATE INDEX comments_name_type_idx ON comments (name, type); +CREATE INDEX comments_order_idx ON comments (domain_id, modified_at); + + +CREATE TABLE domainmetadata ( + id SERIAL PRIMARY KEY, + domain_id INT REFERENCES domains(id) ON DELETE CASCADE, + kind VARCHAR(32), + content TEXT +); + +CREATE INDEX domainidmetaindex ON domainmetadata(domain_id); + + +CREATE TABLE cryptokeys ( + id SERIAL PRIMARY KEY, + domain_id INT REFERENCES domains(id) ON DELETE CASCADE, + flags INT NOT NULL, + active BOOL, + content TEXT +); + +CREATE INDEX domainidindex ON cryptokeys(domain_id); + + +CREATE TABLE tsigkeys ( + id SERIAL PRIMARY KEY, + name VARCHAR(255), + algorithm VARCHAR(50), + secret VARCHAR(255), + CONSTRAINT c_lowercase_name CHECK (((name)::TEXT = LOWER((name)::TEXT))) +); + +CREATE UNIQUE INDEX namealgoindex ON tsigkeys(name, algorithm); diff --git a/plays/powerdns-authoritative-setup/files/pgsql-4.1.sql b/plays/powerdns-authoritative-setup/files/pgsql-4.1.sql new file mode 100644 index 0000000..b105d87 --- /dev/null +++ b/plays/powerdns-authoritative-setup/files/pgsql-4.1.sql @@ -0,0 +1,95 @@ +CREATE TABLE domains ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + master VARCHAR(128) DEFAULT NULL, + last_check INT DEFAULT NULL, + type VARCHAR(6) NOT NULL, + notified_serial INT DEFAULT NULL, + account VARCHAR(40) DEFAULT NULL, + CONSTRAINT c_lowercase_name CHECK (((name)::TEXT = LOWER((name)::TEXT))) +); + +CREATE UNIQUE INDEX name_index ON domains(name); + + +CREATE TABLE records ( + id BIGSERIAL PRIMARY KEY, + domain_id INT DEFAULT NULL, + name VARCHAR(255) DEFAULT NULL, + type VARCHAR(10) DEFAULT NULL, + content VARCHAR(65535) DEFAULT NULL, + ttl INT DEFAULT NULL, + prio INT DEFAULT NULL, + change_date INT DEFAULT NULL, + disabled BOOL DEFAULT 'f', + ordername VARCHAR(255), + auth BOOL DEFAULT 't', + CONSTRAINT domain_exists + FOREIGN KEY(domain_id) REFERENCES domains(id) + ON DELETE CASCADE, + CONSTRAINT c_lowercase_name CHECK (((name)::TEXT = LOWER((name)::TEXT))) +); + +CREATE INDEX rec_name_index ON records(name); +CREATE INDEX nametype_index ON records(name,type); +CREATE INDEX domain_id ON records(domain_id); +CREATE INDEX recordorder ON records (domain_id, ordername text_pattern_ops); + + +CREATE TABLE supermasters ( + ip INET NOT NULL, + nameserver VARCHAR(255) NOT NULL, + account VARCHAR(40) NOT NULL, + PRIMARY KEY(ip, nameserver) +); + + +CREATE TABLE comments ( + id SERIAL PRIMARY KEY, + domain_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(10) NOT NULL, + modified_at INT NOT NULL, + account VARCHAR(40) DEFAULT NULL, + comment VARCHAR(65535) NOT NULL, + CONSTRAINT domain_exists + FOREIGN KEY(domain_id) REFERENCES domains(id) + ON DELETE CASCADE, + CONSTRAINT c_lowercase_name CHECK (((name)::TEXT = LOWER((name)::TEXT))) +); + +CREATE INDEX comments_domain_id_idx ON comments (domain_id); +CREATE INDEX comments_name_type_idx ON comments (name, type); +CREATE INDEX comments_order_idx ON comments (domain_id, modified_at); + + +CREATE TABLE domainmetadata ( + id SERIAL PRIMARY KEY, + domain_id INT REFERENCES domains(id) ON DELETE CASCADE, + kind VARCHAR(32), + content TEXT +); + +CREATE INDEX domainidmetaindex ON domainmetadata(domain_id); + + +CREATE TABLE cryptokeys ( + id SERIAL PRIMARY KEY, + domain_id INT REFERENCES domains(id) ON DELETE CASCADE, + flags INT NOT NULL, + active BOOL, + content TEXT +); + +CREATE INDEX domainidindex ON cryptokeys(domain_id); + + +CREATE TABLE tsigkeys ( + id SERIAL PRIMARY KEY, + name VARCHAR(255), + algorithm VARCHAR(50), + secret VARCHAR(255), + CONSTRAINT c_lowercase_name CHECK (((name)::TEXT = LOWER((name)::TEXT))) +); + +CREATE UNIQUE INDEX namealgoindex ON tsigkeys(name, algorithm); diff --git a/plays/powerdns-authoritative-setup/files/pgsql-4.2.sql b/plays/powerdns-authoritative-setup/files/pgsql-4.2.sql new file mode 100644 index 0000000..e6c6b7c --- /dev/null +++ b/plays/powerdns-authoritative-setup/files/pgsql-4.2.sql @@ -0,0 +1,94 @@ +CREATE TABLE domains ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + master VARCHAR(128) DEFAULT NULL, + last_check INT DEFAULT NULL, + type VARCHAR(6) NOT NULL, + notified_serial INT DEFAULT NULL, + account VARCHAR(40) DEFAULT NULL, + CONSTRAINT c_lowercase_name CHECK (((name)::TEXT = LOWER((name)::TEXT))) +); + +CREATE UNIQUE INDEX name_index ON domains(name); + + +CREATE TABLE records ( + id BIGSERIAL PRIMARY KEY, + domain_id INT DEFAULT NULL, + name VARCHAR(255) DEFAULT NULL, + type VARCHAR(10) DEFAULT NULL, + content VARCHAR(65535) DEFAULT NULL, + ttl INT DEFAULT NULL, + prio INT DEFAULT NULL, + disabled BOOL DEFAULT 'f', + ordername VARCHAR(255), + auth BOOL DEFAULT 't', + CONSTRAINT domain_exists + FOREIGN KEY(domain_id) REFERENCES domains(id) + ON DELETE CASCADE, + CONSTRAINT c_lowercase_name CHECK (((name)::TEXT = LOWER((name)::TEXT))) +); + +CREATE INDEX rec_name_index ON records(name); +CREATE INDEX nametype_index ON records(name,type); +CREATE INDEX domain_id ON records(domain_id); +CREATE INDEX recordorder ON records (domain_id, ordername text_pattern_ops); + + +CREATE TABLE supermasters ( + ip INET NOT NULL, + nameserver VARCHAR(255) NOT NULL, + account VARCHAR(40) NOT NULL, + PRIMARY KEY(ip, nameserver) +); + + +CREATE TABLE comments ( + id SERIAL PRIMARY KEY, + domain_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(10) NOT NULL, + modified_at INT NOT NULL, + account VARCHAR(40) DEFAULT NULL, + comment VARCHAR(65535) NOT NULL, + CONSTRAINT domain_exists + FOREIGN KEY(domain_id) REFERENCES domains(id) + ON DELETE CASCADE, + CONSTRAINT c_lowercase_name CHECK (((name)::TEXT = LOWER((name)::TEXT))) +); + +CREATE INDEX comments_domain_id_idx ON comments (domain_id); +CREATE INDEX comments_name_type_idx ON comments (name, type); +CREATE INDEX comments_order_idx ON comments (domain_id, modified_at); + + +CREATE TABLE domainmetadata ( + id SERIAL PRIMARY KEY, + domain_id INT REFERENCES domains(id) ON DELETE CASCADE, + kind VARCHAR(32), + content TEXT +); + +CREATE INDEX domainidmetaindex ON domainmetadata(domain_id); + + +CREATE TABLE cryptokeys ( + id SERIAL PRIMARY KEY, + domain_id INT REFERENCES domains(id) ON DELETE CASCADE, + flags INT NOT NULL, + active BOOL, + content TEXT +); + +CREATE INDEX domainidindex ON cryptokeys(domain_id); + + +CREATE TABLE tsigkeys ( + id SERIAL PRIMARY KEY, + name VARCHAR(255), + algorithm VARCHAR(50), + secret VARCHAR(255), + CONSTRAINT c_lowercase_name CHECK (((name)::TEXT = LOWER((name)::TEXT))) +); + +CREATE UNIQUE INDEX namealgoindex ON tsigkeys(name, algorithm); diff --git a/plays/powerdns-authoritative-setup/handlers/main.yml b/plays/powerdns-authoritative-setup/handlers/main.yml new file mode 100644 index 0000000..132f9d0 --- /dev/null +++ b/plays/powerdns-authoritative-setup/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: Restart PowerDNS + systemd: + name: pdns + state: restarted + enabled: yes diff --git a/plays/powerdns-authoritative-setup/tasks/main.yml b/plays/powerdns-authoritative-setup/tasks/main.yml new file mode 100644 index 0000000..0472442 --- /dev/null +++ b/plays/powerdns-authoritative-setup/tasks/main.yml @@ -0,0 +1,44 @@ +# Addin play +# vim:et ts=2 sw=2 sts=2 syntax=yaml filetype=yaml +# Configure PowerDNS +--- +- assert: + that: powerdns_driver in ['mysql','pgsql'] + fail_msg: "Unknown driver {{ powerdns_driver }}" + +- set_fact: + __powerdns_db_password: "{{ powerdns_db_password }}" + +- name: Create RPM repo configuration + template: + src: templates/powerdns.repo.j2 + dest: "{{ powerdns_repo_config }}" + +- name: Install PowerDNS RPMs + yum: + name: "{{ powerdns_packages }}" + state: "{{ powerdns_enabled | ternary('present', 'absent') }}" + +- name: Setup PowerDNS + include_tasks: setup-powerdns.yml + when: powerdns_enabled | bool + +- name: "{{ powerdns_enabled | ternary('Enable', 'Disable') }} firewall ports" + include_role: name=network/setup-firewall tasks_from=add-firewall.yml + vars: + immediate: yes + service: "{{ item.service | default(None) }}" + port: "{{ item.port | default(None) }}" + state: "{{ powerdns_enabled | ternary('enabled', 'disabled') }}" + permanent: yes + with_items: + - service: dns + +- name: Install provider module + include_role: name=common/addin tasks_from="{{ powerdns_enabled | ternary('install', 'remove') }}-library.yml" + vars: + name: "{{ addin_provider_name }}" + type: "dns" + method: "{{ addin_integration_type }}" + hook: Restart apnscp + module_path: "src/" diff --git a/plays/powerdns-authoritative-setup/tasks/setup-mysql.yml b/plays/powerdns-authoritative-setup/tasks/setup-mysql.yml new file mode 100644 index 0000000..419cdb7 --- /dev/null +++ b/plays/powerdns-authoritative-setup/tasks/setup-mysql.yml @@ -0,0 +1,34 @@ +--- +- name: Verify {{ powerdns_db_name }} database exists + mysql_db: + name: "{{ powerdns_db_name }}" + state: present + register: exists + +- name: Create {{ powerdns_db_name }} database + mysql_db: + name: "{{ powerdns_db_name }}" + state: import + config_file: /root/.my.cnf + target: "{{ role_path }}/files/mysql-{{powerdns_version }}.sql" + when: exists.changed + +# MySQL-python is dumb and can't detect authentication_string usage +- name: Verify {{ powerdns_db_user }} user exists + command: mysql -u {{ powerdns_db_user | quote }} -p{{ __powerdns_db_password | quote}} + register: need_update + failed_when: false + changed_when: need_update.rc != 0 + +- name: Create {{ powerdns_db_user }} user + mysql_user: + name: "{{ powerdns_db_user }}" + password: "{{__powerdns_db_password}}" + priv: "{{powerdns_db_name}}.*:ALL" + host: localhost + state: present + login_user: root + update_password: "{{ (need_update.rc != 0) | ternary('always', 'on_create') }}" + check_implicit_admin: yes + register: user_exists + notify: Restart PowerDNS diff --git a/plays/powerdns-authoritative-setup/tasks/setup-pgsql.yml b/plays/powerdns-authoritative-setup/tasks/setup-pgsql.yml new file mode 100644 index 0000000..86a2b7f --- /dev/null +++ b/plays/powerdns-authoritative-setup/tasks/setup-pgsql.yml @@ -0,0 +1,45 @@ +--- +- name: Verify {{ appliance_user }} user exists + postgresql_user: + role_attr_flags: SUPERUSER + name: "{{ powerdns_db_user }}" + encrypted: yes + password: "{{ __powerdns_db_password}}" + state: present + register: user_exists + notify: Restart PowerDNS + become_user: postgres + become: yes + +- name: Verify {{ powerdns_db_name }} database exists + postgresql_db: + name: "{{ powerdns_db_name}}" + owner: "{{ powerdns_db_user }}" + template: template1 + state: present + register: db_exists + become_user: postgres + become: yes + +- name: Create {{ powerdns_db_name }} database + postgresql_db: + name: "{{ powerdns_db_name}}" + state: restore + target: "{{ role_path }}/files/pgsql-{{ powerdns_version }}.sql" + owner: "{{ powerdns_db_user }}" + login_password: "{{ __powerdns_db_password }}" + login_user: "{{powerdns_db_user}}" + login_host: 127.0.0.1 + when: db_exists.changed + notify: Restart PowerDNS + +- name: Verify {{powerdns_db_user}} grants + postgresql_privs: + db: template1 + privs: ALL + obj: "{{ powerdns_db_name }}" + role: "{{ powerdns_db_user }}" + type: database + state: present + become_user: postgres + become: yes diff --git a/plays/powerdns-authoritative-setup/tasks/setup-powerdns.yml b/plays/powerdns-authoritative-setup/tasks/setup-powerdns.yml new file mode 100644 index 0000000..73d7708 --- /dev/null +++ b/plays/powerdns-authoritative-setup/tasks/setup-powerdns.yml @@ -0,0 +1,33 @@ +--- +- name: Merge custom config + include_role: name=common tasks_from="implicitly-import-overrides.yml" + vars: + base: "{{ powerdns_config }}" + varname: __config + prefix: '' + name: powerdns + +- name: Update {{ powerdns_config_file }} + lineinfile: + path: "{{ powerdns_config_file }}" + regexp: '^\s*{{ item.key |quote }}\s*=' + line: '{{ item.key }}={{ item.value }}' + with_dict: "{{ __config }}" + notify: Restart PowerDNS + +- name: Create PowerDNS configuration directory + file: + path: "{{ powerdns_local_dir }}" + state: "{{ powerdns_enabled | ternary('directory', 'absent') }}" + owner: "root" + group: "root" + mode: 0700 + +- name: Setup database + include_tasks: "setup-{{ powerdns_driver }}.yml" + +- name: Enable PowerDNS + systemd: + name: pdns + state: started + enabled: yes diff --git a/plays/powerdns-authoritative-setup/templates/powerdns.repo.j2 b/plays/powerdns-authoritative-setup/templates/powerdns.repo.j2 new file mode 100644 index 0000000..0167bff --- /dev/null +++ b/plays/powerdns-authoritative-setup/templates/powerdns.repo.j2 @@ -0,0 +1,17 @@ +[powerdns-rec-{{ powerdns_version | regex_replace('\.', '')}}] +name=PowerDNS repository for PowerDNS Recursor - version {{ powerdns_version }}.X +baseurl=http://repo.powerdns.com/centos/$basearch/$releasever/rec-{{ powerdns_version | regex_replace('\.', '')}} +gpgkey=https://repo.powerdns.com/FD380FBB-pub.asc +gpgcheck=1 +enabled=1 +priority=90 +includepkg=pdns* + +[powerdns-rec-{{powerdns_version | regex_replace('\.', '')}}-debuginfo] +name=PowerDNS repository for PowerDNS Recursor - version {{ powerdns_version }}.X debug symbols +baseurl=http://repo.powerdns.com/centos/$basearch/$releasever/rec-{{ powerdns_version | regex_replace('\.', '')}}/debug +gpgkey=https://repo.powerdns.com/FD380FBB-pub.asc +gpgcheck=1 +enabled=0 +priority=90 +includepkg=pdns* diff --git a/setup.yml b/setup.yml new file mode 100644 index 0000000..8528d00 --- /dev/null +++ b/setup.yml @@ -0,0 +1,3 @@ +# Task to kickoff installation +--- +- include_role: name=addins/{{addin}}/plays/powerdns-authoritative-setup diff --git a/src/Api.php b/src/Api.php new file mode 100644 index 0000000..64f2723 --- /dev/null +++ b/src/Api.php @@ -0,0 +1,77 @@ +key = AUTH_PDNS_KEY; + $this->endpoint = AUTH_PDNS_URI; + $this->client = new \GuzzleHttp\Client([ + 'base_uri' => $this->endpoint, + ]); + } + + public function do(string $method, string $endpoint, array $params = null): array + { + $method = strtoupper($method); + if (! \in_array($method, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])) + { + error("Unknown method `%s'", $method); + + return []; + } + if ($endpoint[0] === '/') + { + warn("Stripping `/' from endpoint `%s'", $endpoint); + $endpoint = ltrim($endpoint, '/'); + } + if (strpos($endpoint, 'server') === false) + { + $endpoint = 'servers/localhost/' . $endpoint; + } + $this->lastResponse = $this->client->request($method, $endpoint, [ + 'headers' => [ + 'User-Agent' => PANEL_BRAND . " " . APNSCP_VERSION, + 'Accept' => 'application/json', + 'X-API-Key' => $this->key, + ], + 'json' => $params, + ]); + + return \json_decode($this->lastResponse->getBody()->getContents(), true) ?? []; + } + + public function getResponse(): Response + { + return $this->lastResponse; + } +} \ No newline at end of file diff --git a/src/Module.php b/src/Module.php new file mode 100644 index 0000000..64df744 --- /dev/null +++ b/src/Module.php @@ -0,0 +1,632 @@ +key = AUTH_PDNS_KEY; + $this->ns = AUTH_PDNS; + } + + /** + * Add DNS zone to service + * + * @param string $domain + * @param string $ip + * + * @return bool + */ + public function add_zone_backend(string $domain, string $ip): bool + { + $domain = rtrim($domain, '\.'); + /** + * @var Zones $api + */ + $api = $this->makeApi(); + try + { + $resp = $api->do('POST', 'servers/localhost/zones', [ + 'account' => null, + 'kind' => 'native', + 'soa_edit_api' => 'INCEPTION-INCREMENT', + 'masters' => [], + 'name' => $this->makeCanonical($domain), + 'nameservers' => [], + 'rrsets' => array_merge($this->createSOA($domain, $this->ns[0], 'hostmaster@' . $domain), $this->createNS($domain, $this->ns)), + ]); + } + catch (ClientException $e) + { + return error("Failed to add zone '%s', error: %s", $domain, $this->renderMessage($e)); + } + + return true; + } + + /** + * Create a PowerDNS API client + * + * @return Api + */ + private function makeApi(): Api + { + return new Api(); + } + + /** + * returns canonical domain (e.g. always returns root dot) + * + * @param string $name + * + * @return string + */ + private function makeCanonical($name) + { + if (empty($name)) // Sometimes the name is empty and ltrim throws a fit about it + { + return $name; + } + $name = trim($name, '.'); + if (substr($name, -1) !== '.') + { + return $name . '.'; + } + + return $name; + } + + /** + * Create SOA record for specified domain/zone + * + * @param $name + * @param $primary + * @param $soa_contact + * + * @return array + */ + protected function createSOA($name, $primary, $soa_contact) + { + $rrsets = [ + 'records' => [ + [ + 'content' => sprintf( + // primary | contact | serial | refresh | retry | expire | ttl + '%s %s %s 3600 1800 604800 600', + $this->makeCanonical($primary), + $this->makeCanonical($soa_contact), + date('Ymd') . sprintf('%02d', rand(0, 99)) + ), + 'disabled' => false, + ], + ], + 'name' => $this->makeCanonical($name), + 'ttl' => 86400, + 'type' => 'SOA', + ]; + + return [$rrsets]; + } + + /** + * Create NS records for the specified domain/zone + * + * @param $name + * @param array $nameservers + * + * @return array + */ + protected function createNS($name, array $nameservers): array + { + $rrsets = $records = []; + + foreach ($nameservers as $nameserver) + { + $records[] = [ + 'content' => $this->makeCanonical($nameserver), + 'disabled' => false, + ]; + } + + $rrsets[] = [ + 'records' => $records, + 'name' => $this->makeCanonical($name), + 'ttl' => 86400, + 'type' => 'NS', + ]; + + return $rrsets; + } + + /** + * Extract JSON message if present + * + * @param ClientException $e + * + * @return string + */ + private function renderMessage(ClientException $e): string + { + + $body = \Error_Reporter::silence(function () use ($e) { + return \json_decode($e->getResponse()->getBody()->getContents(), true); + }); + if (! $body || ! ($reason = array_get($body, 'errors.0.reason'))) + { + return $e->getMessage(); + } + + return $reason; + } + + /** + * Remove DNS zone from nameserver + * + * @param string $domain + * + * @return bool + */ + public function remove_zone_backend(string $domain): bool + { + $api = $this->makeApi(); + try + { + $api->do('DELETE', 'servers/localhost/zones' . sprintf('/%s', $domain)); + } + catch (ClientException $e) + { + return error("Failed to remove zone '%s', error: %s", $domain, $this->renderMessage($e)); + } + + return true; + } + + /** + * Creates Record with Zone Creation (not currently implemented) + * + * @param $name + * @param null $ip + * + * @return array + */ + protected function createDefaultRecords($name, $ip = null): array + { + $rrsets = []; + $cnames = $this->defaultCnames; + + if (! is_null($ip)) + { + $records[] = [ + 'content' => $ip, + 'disabled' => false, + ]; + + $rrsets[] = [ + 'records' => $records, + 'name' => $this->makeCanonical($name), + 'ttl' => 14400, + 'type' => 'A', + ]; + } + + foreach ($cnames as $cname) + { + $records = [0 => [ + 'content' => $this->makeCanonical($name), + 'disabled' => false, + ]]; + + $rrsets[] = [ + 'records' => $records, + 'name' => $this->makeFqdn($name, $cname, true), + 'ttl' => 14400, + 'type' => 'CNAME', + ]; + } + + return $rrsets; + } + + /** + * Return a complete domain name with the subdomain and zone. + * Optionally returns the canonical domain with trailing period + * + * @param $zone + * @param $subdomain + * + * @param bool $makeCanonical + * + * @return string + */ + private function makeFqdn($zone, $subdomain, $makeCanonical = false): string + { + if (strpos($subdomain, $zone) === false) + { + $subdomain = implode('.', [$subdomain, $zone]); + } + + if ($makeCanonical) + { + return $this->makeCanonical($subdomain); + } + + return $subdomain; + } + + /** + * Get raw zone data + * + * @param string $domain + * + * @return null|string + */ + protected function zoneAxfr($domain): ?string + { + $domain = rtrim($domain, '\.'); + // @todo hold records in cache and synthesize AXFR + $client = $this->makeApi(); + + try + { + $records = $client->do('GET', "zones/${domain}"); + + if (empty($records['rrsets'])) + { + // No Records Exist + return null; + } + $soa = array_get($this->get_records_external('', 'soa', $domain, $this->get_hosting_nameservers($domain)), 0, []); + + $ttldef = (int) array_get(preg_split('/\s+/', $soa['parameter'] ?? ''), 6, static::DNS_TTL); + $preamble = []; + if ($soa) + { + $preamble = [ + "${domain}.\t${ttldef}\tIN\tSOA\t${soa['parameter']}", + ]; + } + foreach ($this->get_hosting_nameservers($domain) as $ns) + { + $preamble[] = "${domain}.\t${ttldef}\tIN\tNS\t${ns}."; + } + } + catch (ClientException $e) + { + if ($e->getResponse()->getStatusCode() === 422) + { + return null; // This really shouldn't happen + } + if ($e->getResponse()->getStatusCode() === 404) + { + return null; // No zone here! + } + + error("Failed to transfer DNS records from PowerDNS - try again later. Response code: %d", $e->getResponse()->getStatusCode()); + + return null; + } + foreach ($records['rrsets'] as $r) + { + foreach ($r['records'] as $record) + { + switch ($r['type']) + { + case 'CAA': + // @XXX flags always defaults to "0" + $parameter = '0 ' . ' ' . $record['content']; + break; + case 'SRV': + $parameter = $record['content']; + break; + case 'MX': + $parameter = $record['content']; + break; + default: + $parameter = $record['content']; + } + $preamble[] = $r['name'] . "\t" . $r['ttl'] . "\tIN\t" . $r['type'] . "\t" . $parameter; + } + } + $axfrrec = implode("\n", $preamble); + + return $axfrrec; + } + + /** + * Get hosting nameservers + * + * @param string|null $domain + * + * @return array + */ + public function get_hosting_nameservers(string $domain = null): array + { + return $this->ns; + } + + /** + * Modify a DNS record + * + * @param string $zone + * @param Record $old + * @param Record $new + * + * @return bool + */ + protected function atomicUpdate(string $zone, Record $old, Record $new): bool + { + if (! $this->canonicalizeRecord($zone, $old['name'], $old['rr'], $old['parameter'], $old['ttl'])) + { + return false; + } + + $old['ttl'] = null; + + if (! $this->canonicalizeRecord($zone, $new['name'], $new['rr'], $new['parameter'], $new['ttl'])) + { + return false; + } + + try + { + $merged = clone $old; + $new = $merged->merge($new); + + $this->add_record($zone, $new['name'], $new['rr'], $new['parameter'], $new['ttl']); + $this->remove_record($zone, $old['name'], $new['rr'], $new['parameter']); + } + catch (ClientException $e) + { + return error("Failed to update record '%s' on zone '%s' (old - rr: '%s', param: '%s'; new - rr: '%s', param: '%s'): %s", + $old['name'], + $zone, + $old['rr'], + $old['parameter'], $new['name'] ?? $old['name'], $new['parameter'] ?? $old['parameter'], + $this->renderMessage($e) + ); + } + + return true; + } + + /** + * Add a DNS record + * + * @param string $zone + * @param string $subdomain + * @param string $rr + * @param string $param + * @param int $ttl + * + * @return bool + */ + public function add_record(string $zone, string $subdomain, string $rr, string $param, int $ttl = self::DNS_TTL): bool + { + if (! $this->canonicalizeRecord($zone, $subdomain, $rr, $param, $ttl)) + { + return false; + } + + $record = new Record($zone, [ + 'name' => $subdomain, + 'rr' => $rr, + 'parameter' => $param, + 'ttl' => $ttl, + ]); + + try + { + $api = $this->makeApi(); + $rrsets = $this->formatRecord($record); + $ret = $api->do('PATCH', 'zones/' . $this->makeCanonical($zone), ['rrsets' => $rrsets]); // returns empty or zero??? + } + catch (ClientException $e) + { +// info(json_encode(['rec' => $record->toArray(), 'rrsets' => $rrsets], JSON_PRETTY_PRINT)); +// info($e->getMessage()); + + return error("Failed to create record '%s': %s", (string) $record, $this->renderMessage($e)); + } + + return true; // this sucks... + } + + /** + * Format a PowerDNS record prior to sending + * + * @param Record $r + * + * @return array + */ + protected function formatRecord(Record $r): ?array + { + $type = strtoupper($r['rr']); + $ttl = $r['ttl'] ?? static::DNS_TTL; + + $content = ''; + $priority = null; + $name = null; + + switch ($type) + { + case 'A': + case 'AAAA': + case 'CNAME': + case 'TXT': + case 'NS': + case 'PTR': + $content = $r['parameter']; + break; + case 'MX': + $priority = (int) $r->getMeta('priority'); + $content = sprintf( + '%d %s', + $r->getMeta('priority'), + $this->makeCanonical($r->getMeta('data')) + ); + break; + case 'SRV': + $content = sprintf( + // protocol | service | target | priority | weight | port + '%s %s %s %d %d %d', + $r->getMeta('protocol'), + $r->getMeta('service'), + $r->getMeta('data'), + (int) $r->getMeta('priority'), + (int) $r->getMeta('weight'), + (int) $r->getMeta('port') + ); + break; + case 'CAA': + $content = sprintf( + // tag | target + '%s %s', + $r->getMeta('tag'), + $r->getMeta('data') + ); + break; + default: + fatal("Unsupported DNS RR type '%s'", $type); + } + + if ($r['name'] === '@') + { + $r['name'] = ''; + } + + $rrsets[] = [ + 'records' => [0 => [ + 'content' => $content, + 'disabled' => false, + ]], + 'name' => $name ?? $this->makeFqdn($r['zone'], $r['name'], true), + 'ttl' => $ttl, + 'type' => $type, + 'prio' => $priority ?? 0, + 'changetype' => 'REPLACE', + ]; + + return $rrsets; + } + + /** + * Remove a DNS record + * + * @param string $zone + * @param string $subdomain + * @param string $rr + * @param string|null $param + * + * @return bool + */ + public function remove_record(string $zone, string $subdomain, string $rr, string $param = null): bool + { + $zone = rtrim($zone, '\.'); + if (! $canonicalZone = $this->canonicalizeRecord($zone, $subdomain, $rr, $param)) + { + return false; + } + + $record = new Record($zone, [ + 'name' => $subdomain, + 'rr' => $rr, + 'parameter' => $param, + ]); + + if ($record['name'] === '@') + { + $name = $this->makeCanonical($zone); + } + else + { + $name = $this->makeFqdn($zone, $subdomain, true); + } + + $rrsets[] = [ + 'records' => '', + 'name' => $name, + 'changetype' => 'DELETE', + 'type' => $record['rr'], + ]; + + try + { + $api = $this->makeApi(); + $ret = $api->do('PATCH', "zones/${zone}", ['rrsets' => $rrsets]); + } + catch (ClientException $e) + { + $fqdn = $this->makeFqdn($zone, $subdomain); + + return error("Failed to delete record '%s' type %s", $fqdn, $rr); + } + + return $api->getResponse()->getStatusCode() === 200; + } + + /** + * CNAME cannot be present in root + * + * @return bool + */ + protected function hasCnameApexRestriction(): bool + { + return true; + } + + /** + * Strip the zone and trailing . from a subdomain/name entry + * + * @param $zone + * @param $subdomain + * + * @return string + */ + private function stripName($zone, $subdomain): string + { + $zone = rtrim($zone, '\.'); + $subdomain = rtrim($subdomain, '\.'); + + if (strpos($subdomain, $zone) !== false) + { + $subdomain = str_replace($zone, '', $subdomain); + } + + return rtrim($subdomain, '\.'); + } +} diff --git a/src/Validator.php b/src/Validator.php new file mode 100644 index 0000000..b0ac5d9 --- /dev/null +++ b/src/Validator.php @@ -0,0 +1,39 @@ +do('GET', '/servers'); + } + catch (RequestException $e) + { + $response = \json_decode($e->getResponse()->getBody()->getContents(), true); + $reason = array_get($response, 'errors.0.reason', "Invalid key"); + + return error("PowerDNS key failed: %s", $reason); + } + + return true; + } +}