diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 37e495f7..4f657042 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -29,3 +29,4 @@ Contributors - [Don S](https://github.com/donspaulding) - [Julien Demoor](https://github.com/jdkx) - [@tkalus](https://github.com/tkalus) +- [Bilal Ekrem Harmansa](https://github.com/bilalekremharmansa) diff --git a/README.md b/README.md index a08339f0..262758a3 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,9 @@ pip3 install sewer # with PowerDNS DNS Support # pip3 install sewer[powerdns] + +# with Windows Server DNS Support +# pip3 install sewer[windns] ``` sewer(since version 0.5.0) is now python3 only. To install the (now diff --git a/docs/dns-01.md b/docs/dns-01.md index f13ff20a..00a5f605 100644 --- a/docs/dns-01.md +++ b/docs/dns-01.md @@ -26,6 +26,7 @@ support for some features varies. | [Rackspace](https://www.rackspace.com/cloud/dns) | rackspace | ? | no | no | test coverage 69% | | [Route 53 (AWS)](https://aws.amazon.com/route53/) | route53 (1) | OK | no | no | wc+ in 0.8.2; not in CLI | | Unbound | unbound_ssh | OK | yes | no | Working demonstrator model for local unbound server | +| [Windows Server DNS](https://docs.microsoft.com/en-us/powershell/module/dnsserver) | windns | OK | yes | - | | - _wc+_ (wilcard plus) is specifically about a single certificate that has at least two registered names: `domain.tld` and `*.domain.tld`. This diff --git a/docs/drivers/windns.md b/docs/drivers/windns.md new file mode 100644 index 00000000..b649c7a0 --- /dev/null +++ b/docs/drivers/windns.md @@ -0,0 +1,88 @@ +# windns - Windows DNSServer module + +windns driver uses PowerShell DNSServer module for Let's Encrypt dns challenge. + +Before reading further, [check the following link](https://docs.microsoft.com/en-us/powershell/module/dnsserver) to +understand what this driver offers and how it works. + +The driver uses a wrapper python library, which is called +[windowsdnsserver-py](https://github.com/bilalekremharmansa/windowsdnsserver-py), to interact with PowerShell DnsServer +module. Basically, the driver performs process calls to DNSServer over python subprocess module. Since commands are +made on local machine (remote session is not supported), this driver has to be used on windows server +where dns server is located, either using sewer as a cli and as a library. + +# Installation + +DNSServer has any requirements but DNSServer module, which must be installed to PowerShell on windows server. + +Microsoft documentation: +> DnsServer Module can be obtained either by installing DNS Server role or adding the DNS Server Tools part of +> Remote Server Administration Tools (RSAT) feature. + +## Usage + +windns driver needs to know which dns zone to put TXT record for dns challenge because DNSServer module +expects dns zone and dns name respectively. Nevertheless, domain should be provided to sewer as other dns drivers. + +For instance, to start a dns challenge to "test.example.com" at zone "example.com", domain and zone should be defined +below; + + domain = test.example.com + zone = example.com + +If zone and domain are exactly same (both example.com), following definition work as well; + + domain = example.com + zone = example.com + +### sewer-cli + + python3 -m sewer ... --provider=windns --p_opts zone=example.com + +### sewer as library + zone = "example.com" + provider = WinDNS(zone) + client = client.Client(domain="...", provider=provider, ...) + + +### Overriding default PowerShell path + +windowsdnsserver-py uses the following PowerShell path to run commands; + + 'C:\Windows\syswow64\WindowsPowerShell\\v1.0\powershell.exe' + +If you prefer sewer to use as library, you can always override this while creating dns provider instance like below; + + overrided_power_shell_path = '...' + WinDNS(zone=..., power_shell_path=overrided_power_shell_path) + +and if you prefer sewer-cli, the following parameters should work fine; + + python3 -m sewer ... --provider=windns --p_opts zone=example.com power_shell_path=C:\Program Files... + +### One thing to be kept in mind while aliasing + +Since ```zone``` parameter is getting used by the driver to put TXT record to correct zone on DNS challenge. You should +provide **the aliasing domain zone** which **CNAME points to**. Not that the domain that CNAME records live. + +As an example; domain names are used in [sewer's aliasing](https://github.com/komuw/sewer/blob/master/docs/Aliasing.md) +documentation, you need to provide the following parameters; + + python3 -m sewer --domain name.example.com --provider=windns --p_opts zone=alias.org alias=alias.org + + +### If you installed DNSServer module and the driver is not able to initiate itself + +If the error statement is like below, + + DNSServer module seems it's not installed.. + +You should know that, if you are using 64 bit windows server, probably, there are two PowerShell variant on your +machine. One supports 64 bit and other one support 32 bit, PowerShell (x86). The driver uses 64 bit one as default +If you're comfortable to use 64 bit version, please, consider installing the DNSServer module to 64 bit one. +On the other hand, if you want to keep forward with 32 bit version, I suggest you to override PowerShell path for sewer, +which is mentioned in this document above. + +---- + +**If you're problem is not related with that, you may want to create an issue about it.** \ No newline at end of file diff --git a/sewer/catalog.json b/sewer/catalog.json index c88c5a83..e4cea1f9 100644 --- a/sewer/catalog.json +++ b/sewer/catalog.json @@ -260,5 +260,22 @@ "cls": "UnboundSsh", "features": ["alias"], "deps": [] - } + }, + { + "name": "windns", + "desc": "interacts with Windows Server PowerShell DnsServer module for dns challenge", + "chals": ["dns-01"], + "args": [ + { + "name": "zone", + "req": 1 + }, + { + "name": "power_shell_path" + } + ], + "path": "sewer.providers.windns", + "cls": "WinDNS", + "deps": ["windowsdnsserver-py"] + } ] diff --git a/sewer/cli.py b/sewer/cli.py index 1cd2da2e..e9a632db 100644 --- a/sewer/cli.py +++ b/sewer/cli.py @@ -318,6 +318,11 @@ def get_provider(provider_name, provider_kwargs, catalog, logger): elif provider_name == "route53": raise ValueError("route53 driver can only be used programmatically at this time, sorry") + elif provider_name == "windns": + from sewer.providers.windns import WinDNS + + dns_class = WinDNS(**provider_kwargs) + else: raise ValueError("The dns provider {0} is not recognised.".format(provider_name)) diff --git a/sewer/providers/tests/test_windns.py b/sewer/providers/tests/test_windns.py new file mode 100644 index 00000000..99ceaaec --- /dev/null +++ b/sewer/providers/tests/test_windns.py @@ -0,0 +1,115 @@ +from unittest import TestCase +from unittest.mock import patch + +from sewer.lib import SewerError +from sewer.providers.windns import WinDNS + +ACME_CHALLENGE = "_acme-challenge" + + +class TestWinDNS(TestCase): + def test_init_with_zone_provided(self): + def _fn(): + zone = "example.com" + provider = WinDNS(zone) + self.assertIsNotNone(provider) + + run_test_with_common_mocking(_fn) + + def test_init_with_zone_missing(self): + def _fn(): + with self.assertRaisesRegex( + ValueError, "windns requires a string value for the zone argument" + ): + WinDNS() + + run_test_with_common_mocking(_fn) + + def test_init_module_is_installed(self): + run_test_with_common_mocking(lambda: WinDNS("example.com")) + + def test_init_module_is_not_installed(self): + def _fn(): + with self.assertRaisesRegex( + SewerError, "It seems that, the DNSServer module is not installed" + ): + WinDNS("example.com") + + run_test_with_common_mocking(_fn, False) + + def test_validate_domain_contains_zone(self): + def _fn(): + mock_challenge = _mock_challenge_1() + mock_zone = "example.com" + + provider = WinDNS(mock_zone) + provider._validate_domain_contains_zone(mock_challenge) + + run_test_with_common_mocking(_fn) + + def test_assert_domain_missing_zone(self): + def _fn(): + mock_challenge = _mock_challenge_1() + mock_zone = "missing_zone" + + provider = WinDNS(mock_zone) + + with self.assertRaisesRegex(SewerError, "Domain must contains zone, domain"): + provider._validate_domain_contains_zone(mock_challenge) + + run_test_with_common_mocking(_fn) + + def test_extract_sub_domain(self): + def _test1(): + mock_challenge = _mock_challenge_1() + mock_zone = "example.com" + + provider = WinDNS(mock_zone) + dns_name = provider._extract_sub_domain_from_challenge(mock_challenge) + + self.assertEqual(dns_name, ACME_CHALLENGE) + + def _test2(): + mock_challenge = _mock_challenge_2() + mock_zone = "example.com" + + provider = WinDNS(mock_zone) + + dns_name = provider._extract_sub_domain_from_challenge(mock_challenge) + + self.assertEqual(dns_name, "%s.test" % ACME_CHALLENGE) + + run_test_with_common_mocking(_test1) + + def test_alias_domain(self): + def _fn(): + mock_challenge = _mock_challenge_1() + mock_alias = "alias.com" + mock_alias_zone = mock_alias + + provider = WinDNS(mock_alias_zone, alias=mock_alias) + + dns_name = provider._extract_sub_domain_from_challenge(mock_challenge) + + mock_domain = mock_challenge["ident_value"] + self.assertEqual(dns_name, mock_domain) + + run_test_with_common_mocking(_fn) + + +def run_test_with_common_mocking(fn, is_module_installed=True): + with patch( + "windowsdnsserver.dns.dnsserver.DnsServerModule.is_dns_server_module_installed" + ) as mock_module_installed, patch("platform.system") as mock_platform: + mock_module_installed.return_value = is_module_installed + mock_platform.return_value = "Windows" + + fn() + + +def _mock_challenge_1(): + return {"ident_value": "example.com"} + + +def _mock_challenge_2(): + return {"ident_value": "test.example.com"} diff --git a/sewer/providers/windns.py b/sewer/providers/windns.py new file mode 100755 index 00000000..43a6c016 --- /dev/null +++ b/sewer/providers/windns.py @@ -0,0 +1,74 @@ +from sewer.auth import ChalListType, ErrataListType, DNSProviderBase +from sewer.lib import dns_challenge, SewerError + +from windowsdnsserver.command_runner.powershell_runner import PowerShellRunner +from windowsdnsserver.dns.dnsserver import DnsServerModule + + +class WinDNS(DNSProviderBase): + def __init__(self, zone=None, power_shell_path=None, **kwargs): + super().__init__(**kwargs) + + if not isinstance(zone, str) or not zone: + raise ValueError("windns requires a string value for the zone argument") + + self.zone = zone + + runner = None + if power_shell_path: + runner = PowerShellRunner(power_shell_path) + + self.dns = DnsServerModule(runner) + + if not self.dns.is_dns_server_module_installed(): + raise SewerError( + "It seems that, the DNSServer module is not installed. Please check the windns documentation.." + ) + + def setup(self, challenges: ChalListType) -> ErrataListType: + for challenge in challenges: + if not self.alias: + self._validate_domain_contains_zone(challenge) + + name, txt_value = self._get_dns_name_and_text_for_challenge(challenge) + self.dns.add_txt_record(self.zone, name, txt_value) + + return [] + + def unpropagated(self, challenges: ChalListType) -> ErrataListType: + return [] + + def clear(self, challenges: ChalListType) -> ErrataListType: + for challenge in challenges: + name, txt_value = self._get_dns_name_and_text_for_challenge(challenge) + self.dns.remove_txt_record(self.zone, name, txt_value) + + return [] + + # --- Challenge --- + + def _get_dns_name_and_text_for_challenge(self, challenge): + name = self._extract_sub_domain_from_challenge(challenge) + txt_value = dns_challenge(challenge["key_auth"]) + + return name, txt_value + + def _validate_domain_contains_zone(self, challenge): + domain = self.target_domain(challenge) + if self.zone not in domain: + raise SewerError( + "Domain must contains zone, domain: [%s], zone: [%s]" % (domain, self.zone) + ) + + def _extract_sub_domain_from_challenge(self, challenge): + """ + zone: example.com, domain: example.com ---> sub domain is "" + + zone: example.com, domain: test.asd.example.com ---> sub domain is "test.asd" + + zone: asd.example.com, domain: test.asd.example.com ---> sub domain is "test" + """ + domain = self.target_domain(challenge) + + sub_domain_index = domain.rfind(".%s" % self.zone) + return domain[0:sub_domain_index]