Skip to content

Commit 557114b

Browse files
windns driver implementation
1 parent 056ac64 commit 557114b

File tree

8 files changed

+305
-1
lines changed

8 files changed

+305
-1
lines changed

CONTRIBUTORS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ Contributors
2929
- [Don S](https://github.com/donspaulding)
3030
- [Julien Demoor](https://github.com/jdkx)
3131
- [@tkalus](https://github.com/tkalus)
32+
- [Bilal Ekrem Harmansa](https://github.com/bilalekremharmansa)

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ pip3 install sewer
8383

8484
# with PowerDNS DNS Support
8585
# pip3 install sewer[powerdns]
86+
87+
# with Windows Server DNS Support
88+
# pip3 install sewer[windns]
8689
```
8790

8891
sewer(since version 0.5.0) is now python3 only. To install the (now

docs/dns-01.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ support for some features varies.
2626
| [Rackspace](https://www.rackspace.com/cloud/dns) | rackspace | ? | no | no | test coverage 69% |
2727
| [Route 53 (AWS)](https://aws.amazon.com/route53/) | route53 (1) | OK | no | no | wc+ in 0.8.2; not in CLI |
2828
| Unbound | unbound_ssh | OK | yes | no | Working demonstrator model for local unbound server |
29+
| [Windows Server DNS](https://docs.microsoft.com/en-us/powershell/module/dnsserver) | windns | OK | yes | - | |
2930

3031
- _wc+_ (wilcard plus) is specifically about a single certificate that has
3132
at least two registered names: `domain.tld` and `*.domain.tld`. This

docs/drivers/windns.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# windns - Windows DNSServer module
2+
3+
windns driver uses PowerShell DNSServer module for Let's Encrypt dns challenge.
4+
5+
Before reading further, [check the following link](https://docs.microsoft.com/en-us/powershell/module/dnsserver) to
6+
understand what this driver offers and how it works.
7+
8+
The driver uses a wrapper python library, which is called
9+
[windowsdnsserver-py](https://github.com/bilalekremharmansa/windowsdnsserver-py), to interact with PowerShell DnsServer
10+
module. Basically, the driver performs process calls to DNSServer over python subprocess module. Since commands are
11+
made on local machine (remote session is not supported), this driver has to be used on windows server
12+
where dns server is located, either using sewer as a cli and as a library.
13+
14+
# Installation
15+
16+
DNSServer has any requirements but DNSServer module, which must be installed to PowerShell on windows server.
17+
18+
Microsoft documentation:
19+
> DnsServer Module can be obtained either by installing DNS Server role or adding the DNS Server Tools part of
20+
> Remote Server Administration Tools (RSAT) feature.
21+
22+
## Usage
23+
24+
windns driver needs to know which dns zone to put TXT record for dns challenge because DNSServer module
25+
expects dns zone and dns name respectively. Nevertheless, domain should be provided to sewer as other dns drivers.
26+
27+
For instance, to start a dns challenge to "test.example.com" at zone "example.com", domain and zone should be defined
28+
below;
29+
30+
domain = test.example.com
31+
zone = example.com
32+
33+
If zone and domain are exactly same (both example.com), following definition work as well;
34+
35+
domain = example.com
36+
zone = example.com
37+
38+
### sewer-cli
39+
40+
python3 -m sewer ... --provider=windns --p_opt zone=example.com
41+
42+
### sewer as library
43+
zone = "example.com"
44+
provider = WinDNS(zone)
45+
client = client.Client(domain="...", provider=provider, ...)
46+
47+
48+
### Overriding default PowerShell path
49+
50+
windowsdnsserver-py uses the following PowerShell path to run commands;
51+
52+
'C:\Windows\syswow64\WindowsPowerShell\\v1.0\powershell.exe'
53+
54+
If you prefer sewer to use as library, you can always override this while creating dns provider instance like below;
55+
56+
overrided_power_shell_path = '...'
57+
WinDNS(zone=..., power_shell_path=overrided_power_shell_path)
58+
59+
and if you prefer sewer-cli, the following parameters should work fine;
60+
61+
python3 -m sewer ... --provider=windns --p_opt zone=example.com --p_opt power_shell_path=C:\Program Files...
62+
63+
### One thing to be kept in mind while aliasing
64+
65+
Since ```zone``` parameter is getting used by the driver to put TXT record to correct zone on DNS challenge. You should
66+
provide **the aliasing domain zone** which **CNAME points to**. Not that the domain that CNAME records live.
67+
68+
As an example; domain names are used in [sewer's aliasing](https://github.com/komuw/sewer/blob/master/docs/Aliasing.md)
69+
documentation, you need to provide the following parameters;
70+
71+
python3 -m sewer --domain name.example.com --provider=windns --p_opt zone=alias.org --p_opts alias=alias.org
72+
73+
74+
### If you installed DNSServer module and the driver is not able to initiate itself
75+
76+
If the error statement is like below,
77+
78+
DNSServer module seems it's not installed..
79+
80+
You should know that, if you are using 64 bit windows server, probably, there are two PowerShell variant on your
81+
machine. One supports 64 bit and other one support 32 bit, PowerShell (x86). The driver uses 64 bit one as default
82+
If you're comfortable to use 64 bit version, please, consider installing the DNSServer module to 64 bit one.
83+
On the other hand, if you want to keep forward with 32 bit version, I suggest you to override PowerShell path for sewer,
84+
which is mentioned in this document above.
85+
86+
----
87+
88+
**If you're problem is not related with that, you may want to create an issue about it.**

sewer/catalog.json

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,5 +260,22 @@
260260
"cls": "UnboundSsh",
261261
"features": ["alias"],
262262
"deps": []
263-
}
263+
},
264+
{
265+
"name": "windns",
266+
"desc": "interacts with Windows Server PowerShell DnsServer module for dns challenge",
267+
"chals": ["dns-01"],
268+
"args": [
269+
{
270+
"name": "zone",
271+
"req": 1
272+
},
273+
{
274+
"name": "power_shell_path"
275+
}
276+
],
277+
"path": "sewer.providers.windns",
278+
"cls": "WinDNS",
279+
"deps": ["windowsdnsserver-py"]
280+
}
264281
]

sewer/cli.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,11 @@ def get_provider(provider_name, provider_kwargs, catalog, logger):
318318
elif provider_name == "route53":
319319
raise ValueError("route53 driver can only be used programmatically at this time, sorry")
320320

321+
elif provider_name == "windns":
322+
from sewer.providers.windns import WinDNS
323+
324+
dns_class = WinDNS(**provider_kwargs)
325+
321326
else:
322327
raise ValueError("The dns provider {0} is not recognised.".format(provider_name))
323328

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
from unittest import TestCase
2+
from unittest.mock import patch
3+
4+
from sewer.lib import SewerError
5+
from sewer.providers.windns import WinDNS
6+
7+
ACME_CHALLENGE = "_acme-challenge"
8+
9+
10+
class TestWinDNS(TestCase):
11+
def test_init_with_zone_provided(self):
12+
def _fn():
13+
zone = "example.com"
14+
provider = WinDNS(zone)
15+
self.assertIsNotNone(provider)
16+
17+
run_test_with_common_mocking(_fn)
18+
19+
def test_init_with_zone_missing(self):
20+
def _fn():
21+
with self.assertRaisesRegex(
22+
ValueError, "windns requires a string value for the zone argument"
23+
):
24+
WinDNS()
25+
26+
run_test_with_common_mocking(_fn)
27+
28+
def test_init_module_is_installed(self):
29+
run_test_with_common_mocking(lambda: WinDNS("example.com"))
30+
31+
def test_init_module_is_not_installed(self):
32+
def _fn():
33+
with self.assertRaisesRegex(
34+
SewerError, "It seems that, the DNSServer module is not installed"
35+
):
36+
WinDNS("example.com")
37+
38+
run_test_with_common_mocking(_fn, False)
39+
40+
def test_validate_domain_contains_zone(self):
41+
def _fn():
42+
mock_challenge = _mock_challenge_1()
43+
mock_zone = "example.com"
44+
45+
provider = WinDNS(mock_zone)
46+
provider._validate_domain_contains_zone(mock_challenge)
47+
48+
run_test_with_common_mocking(_fn)
49+
50+
def test_assert_domain_missing_zone(self):
51+
def _fn():
52+
mock_challenge = _mock_challenge_1()
53+
mock_zone = "missing_zone"
54+
55+
provider = WinDNS(mock_zone)
56+
57+
with self.assertRaisesRegex(SewerError, "Domain must contains zone, domain"):
58+
provider._validate_domain_contains_zone(mock_challenge)
59+
60+
run_test_with_common_mocking(_fn)
61+
62+
def test_extract_sub_domain(self):
63+
def _test1():
64+
mock_challenge = _mock_challenge_1()
65+
mock_zone = "example.com"
66+
67+
provider = WinDNS(mock_zone)
68+
dns_name = provider._extract_sub_domain_from_challenge(mock_challenge)
69+
70+
self.assertEqual(dns_name, ACME_CHALLENGE)
71+
72+
def _test2():
73+
mock_challenge = _mock_challenge_2()
74+
mock_zone = "example.com"
75+
76+
provider = WinDNS(mock_zone)
77+
78+
dns_name = provider._extract_sub_domain_from_challenge(mock_challenge)
79+
80+
self.assertEqual(dns_name, "%s.test" % ACME_CHALLENGE)
81+
82+
run_test_with_common_mocking(_test1)
83+
84+
def test_alias_domain(self):
85+
def _fn():
86+
mock_challenge = _mock_challenge_1()
87+
mock_alias = "alias.com"
88+
mock_alias_zone = mock_alias
89+
90+
provider = WinDNS(mock_alias_zone, alias=mock_alias)
91+
92+
dns_name = provider._extract_sub_domain_from_challenge(mock_challenge)
93+
94+
mock_domain = mock_challenge["ident_value"]
95+
self.assertEqual(dns_name, mock_domain)
96+
97+
run_test_with_common_mocking(_fn)
98+
99+
100+
def run_test_with_common_mocking(fn, is_module_installed=True):
101+
with patch(
102+
"windowsdnsserver.dns.dnsserver.DnsServerModule.is_dns_server_module_installed"
103+
) as mock_module_installed, patch("platform.system") as mock_platform:
104+
mock_module_installed.return_value = is_module_installed
105+
mock_platform.return_value = "Windows"
106+
107+
fn()
108+
109+
110+
def _mock_challenge_1():
111+
return {"ident_value": "example.com"}
112+
113+
114+
def _mock_challenge_2():
115+
return {"ident_value": "test.example.com"}

sewer/providers/windns.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from sewer.auth import ChalListType, ErrataListType, DNSProviderBase
2+
from sewer.lib import dns_challenge, SewerError
3+
4+
from windowsdnsserver.command_runner.powershell_runner import PowerShellRunner
5+
from windowsdnsserver.dns.dnsserver import DnsServerModule
6+
7+
8+
class WinDNS(DNSProviderBase):
9+
def __init__(self, zone=None, power_shell_path=None, **kwargs):
10+
super().__init__(**kwargs)
11+
12+
if not isinstance(zone, str) or not zone:
13+
raise ValueError("windns requires a string value for the zone argument")
14+
15+
self.zone = zone
16+
17+
runner = None
18+
if power_shell_path:
19+
runner = PowerShellRunner(power_shell_path)
20+
21+
self.dns = DnsServerModule(runner)
22+
23+
if not self.dns.is_dns_server_module_installed():
24+
raise SewerError(
25+
"It seems that, the DNSServer module is not installed. Please check the windns documentation.."
26+
)
27+
28+
def setup(self, challenges: ChalListType) -> ErrataListType:
29+
for challenge in challenges:
30+
if not self.alias:
31+
self._validate_domain_contains_zone(challenge)
32+
33+
name, txt_value = self._get_dns_name_and_text_for_challenge(challenge)
34+
self.dns.add_txt_record(self.zone, name, txt_value)
35+
36+
return []
37+
38+
def unpropagated(self, challenges: ChalListType) -> ErrataListType:
39+
return []
40+
41+
def clear(self, challenges: ChalListType) -> ErrataListType:
42+
for challenge in challenges:
43+
name, txt_value = self._get_dns_name_and_text_for_challenge(challenge)
44+
self.dns.remove_txt_record(self.zone, name, txt_value)
45+
46+
return []
47+
48+
# --- Challenge ---
49+
50+
def _get_dns_name_and_text_for_challenge(self, challenge):
51+
name = self._extract_sub_domain_from_challenge(challenge)
52+
txt_value = dns_challenge(challenge["key_auth"])
53+
54+
return name, txt_value
55+
56+
def _validate_domain_contains_zone(self, challenge):
57+
domain = self.target_domain(challenge)
58+
if self.zone not in domain:
59+
raise SewerError(
60+
"Domain must contains zone, domain: [%s], zone: [%s]" % (domain, self.zone)
61+
)
62+
63+
def _extract_sub_domain_from_challenge(self, challenge):
64+
"""
65+
zone: example.com, domain: example.com ---> sub domain is ""
66+
67+
zone: example.com, domain: test.asd.example.com ---> sub domain is "test.asd"
68+
69+
zone: asd.example.com, domain: test.asd.example.com ---> sub domain is "test"
70+
"""
71+
domain = self.target_domain(challenge)
72+
73+
sub_domain_index = domain.rfind(".%s" % self.zone)
74+
return domain[0:sub_domain_index]

0 commit comments

Comments
 (0)