Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

windowsdnsserver driver implementation #215

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/dns-01.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions docs/drivers/windns.md
Original file line number Diff line number Diff line change
@@ -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.**
19 changes: 18 additions & 1 deletion sewer/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
]
5 changes: 5 additions & 0 deletions sewer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
115 changes: 115 additions & 0 deletions sewer/providers/tests/test_windns.py
Original file line number Diff line number Diff line change
@@ -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"}
74 changes: 74 additions & 0 deletions sewer/providers/windns.py
Original file line number Diff line number Diff line change
@@ -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]