-
Notifications
You must be signed in to change notification settings - Fork 11
/
Copy pathad.py
349 lines (295 loc) · 11.6 KB
/
ad.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
DOCUMENTATION = r"""
name: ad
plugin_type: inventory
author:
- Matthew Howle <matthew@howle.org>
short_description: ActiveDirectory inventory source
requirements:
- python >= 2.7
- ldap3
optional:
- gssapi
description:
- Read inventory from Active Directory
- Uses ad.(yml|yaml) YAML configuration file to configure the inventory plugin.
- If no configuration value is assigned for the server or base, it will auto-detect.
- If no username is defined, Kerberos + GSSAPI will be used to connect to the server.
options:
plugin:
description: Marks this as an instance of the 'ad' plugin.
required: true
choices: ['ad']
server:
description: Active Directory server name or list of server names.
required: false
default: null
port:
description: ActiveDirectory port. Using port 636 automatically enables SSL.
required: false
type: int
default: 389
ssl:
description: Use SSL when connecting
required: false
type: bool
default: false
starttls:
description: Use STARTTLS when connecting
required: false
type: bool
default: true
base:
description: Starting point for the search. if null, the default naming context will be used.
required: false
type: str
default: null
scope:
description: Scope of the search.
required: false
default: subtree
choices: ['base', 'level', 'subtree']
username:
description: Username to bind as. It can be the distinguishedname of the user, or "SHORTDOMAIN\user". If null, the connection will use a simple bind. Otherwise, Kerberos+GSSAPI will be used.
required: false
type: str
default: null
env:
- name: ANSIBLE_AD_INVENTORY_USERNAME
password:
description: Username's password. Must be defined if username is also defined.
required: false
type: str
default: null
env:
- name: ANSIBLE_AD_INVENTORY_PASSWORD
hostname var:
description: LDAP attribute to use as the inventory hostname
required: false
type: str
default: 'name'
filter:
description: LDAP query filter. Note "objectClass=computer" is automatically appended.
required: false
type: str
default: ''
ansible group:
description: Ansible group name to assign objects to
required: false
type: str
var attribute:
description: LDAP attribute to load as YAML for host-specific Ansible variables
required: false
type: str
default: null
use ad groups:
description: Add AD group memberships as Ansible host groups.
required: false
type: bool
default: true
"""
EXAMPLES = r"""
# Minimal example. 'server' and 'base' will be detected.
# kerberos/gssapi will be used to connect.
plugin: ad
# Example with all values assigned
plugin: ad
server: dc.example.com
port: 636
base: OU=Groups,DC=example,DC=com
username: EXAMPLE\ExampleUser # or distinguishedname
password: "SecurePassword"
filter: "(operatingSystem=Debian GNU/Linux)"
ansible group: Debian
var attribute: info
"""
import os
import socket
import struct
from ansible.errors import AnsibleError
from ansible.plugins.inventory import BaseInventoryPlugin
from ansible.parsing.yaml.objects import AnsibleSequence
import yaml
from ldap3 import BASE, Connection, DSA, LEVEL, SASL, Server, SUBTREE
from ldap3.core.exceptions import LDAPSocketOpenError
from ldap3.utils.dn import parse_dn
try:
import dns.resolver as dns_resolver
except ImportError:
dns_resolver = None
SCOPES = {
'base': BASE,
'level': LEVEL,
'subtree': SUBTREE
}
ENVIRONMENT_VAR_MAP = {
'username': 'ANSIBLE_AD_INVENTORY_USERNAME',
'password': 'ANSIBLE_AD_INVENTORY_PASSWORD',
}
class InventoryModule(BaseInventoryPlugin):
NAME = 'ad'
def __init__(self):
super(InventoryModule, self).__init__()
self._connection = None
def verify_file(self, path):
if super(InventoryModule, self).verify_file(path):
filenames = ('ad.yaml', 'ad.yml')
return any((path.endswith(filename) for filename in filenames))
return False
def parse(self, inventory, loader, path, cache=True):
super(InventoryModule, self).parse(inventory, loader, path)
self.config_data = self._read_config_data(path)
self._create_client()
self._build_inventory()
def get_option(self, option):
if option in ENVIRONMENT_VAR_MAP:
env_var = ENVIRONMENT_VAR_MAP[option]
value = os.environ.get(env_var)
if value:
return value
value = super(InventoryModule, self).get_option(option)
if value:
if option == "scope":
return SCOPES.get(value)
return value
# attempt to auto-detect option
if option == "server":
value = self._find_closest_dc()
if value:
self.set_option("server", value)
else:
value = self._get_domain()
if value:
self.set_option("server", value)
else:
raise AnsibleError("Server name could not be determined")
return value
def _get_connection_args(self):
username = self.get_option("username")
password = self.get_option("password")
if username:
if password:
return {"user": username, "password": password}
else:
raise AnsibleError("Username defined without a password")
return {"authentication": SASL, "sasl_mechanism": "GSSAPI"}
def _create_client(self):
if self._connection:
return self._connection
servers = self.get_option("server")
if servers is None:
raise AnsibleError("Server name could not be determined")
if not isinstance(servers, AnsibleSequence):
servers = [servers]
port = self.get_option("port")
base = self.get_option("base")
use_starttls = self.get_option("starttls")
use_ssl = self.get_option("ssl")
for server in servers:
sargs = {"use_ssl": True} if port == 636 or use_ssl else {}
ldap_server = Server(server, port=port, get_info=DSA, **sargs)
cargs = self._get_connection_args()
self._connection = Connection(ldap_server, **cargs)
try:
self._connection.open()
except LDAPSocketOpenError:
continue
if use_starttls:
self._connection.start_tls()
self._connection.bind()
if base is None:
result = self._connection.search(search_base="",
search_filter="(dnsHostName=%s)" % server,
search_scope=BASE, attributes=["defaultNamingContext"])
if result:
base = (self._connection.server
.info.other["defaultNamingContext"][0])
self.set_option("base", base)
break
def _get_domain(self):
fqdn = socket.getfqdn()
return fqdn[fqdn.find(".")+1:] if "." in fqdn else None
def _find_closest_dc(self):
from multiprocessing.pool import ThreadPool as Pool
def ldap_ping(args):
server, cargs = args
try:
with Connection(Server(server, get_info=DSA), **cargs) as connection:
result = connection.search(search_base="",
search_filter="(&(NtVer=\\06\\00\\00\\00)(AAC=\\00\\00\\00\\00))",
search_scope=BASE, attributes=["netlogon"])
if result:
if "netlogon" in connection.entries[0]:
data = connection.entries[0].netlogon.value
flags = struct.unpack("<i", data[4:8])[0]
if flags & 0x00000080: # DS_CLOSEST_FLAG
return server
except LDAPSocketOpenError:
return None
return None
# === ldap_ping
if dns_resolver is None:
return self._get_domain()
ldap_servers = []
for server in dns_resolver.query("_ldap._tcp", "SRV"):
ldap_servers.append(server)
if ldap_servers:
ldap_servers.sort(key=lambda x: (x.priority, x.weight))
cargs = self._get_connection_args()
pool = Pool(processes=len(ldap_servers))
results = pool.map(ldap_ping,
[(str(server.target)[:-1], cargs) for server in ldap_servers])
pool.close()
closest = ([server for server in results if server] or (None,))[0]
if closest is None:
return str(ldap_servers[0].target)[:-1]
return closest
def _set_variables(self, entity, values):
if isinstance(values, dict):
for k, v in values.items():
self.inventory.set_variable(entity, k, v)
def _build_inventory(self):
if self._connection is None:
self._create_client()
base = self.get_option("base")
user_filter = self.get_option("filter")
scope = self.get_option("scope")
hostname_var = self.get_option("hostname var")
ansible_group = self.get_option("ansible group")
use_ad_groups = self.get_option("use ad groups")
var_attribute = self.get_option("var attribute")
import_vars = var_attribute is not None
xattrib = [var_attribute] if import_vars else []
if use_ad_groups:
xattrib.append("memberOf")
qfilter = "(&(objectClass=computer)%s)" % user_filter
if ansible_group:
self.inventory.add_group(ansible_group)
results = self._connection.search(
search_base=base,
search_filter=qfilter,
search_scope=scope,
attributes=[hostname_var] + xattrib)
if results:
for entry in self._connection.entries:
info = None
if import_vars and var_attribute in entry:
try:
raw_info = entry.info.value
if raw_info:
info = yaml.safe_load(raw_info)
except yaml.scanner.ScannerError:
pass
host_name = getattr(entry, hostname_var).value.lower()
if info:
self._set_variables(host_name, info)
if ansible_group:
self.inventory.add_host(host_name, group=ansible_group)
if use_ad_groups:
for group_dn in entry.memberOf.values:
group_dn_parts = parse_dn(group_dn)
group_cn = group_dn_parts[0][1]
group = self.inventory.add_group(group_cn)
self.inventory.add_host(host_name, group=group)
self.inventory.add_host(host_name, group="all")