diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..4546b39
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,13 @@
+# http://editorconfig.org
+root = true
+
+[*]
+indent_style = space
+indent_size = 4
+end_of_line = crlf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..18f54e8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+build/
+dist/
+ldapdomaindump.egg-info
+domainlookup.py
+*.pyc
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..4dbbfcf
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2020 Dirk-jan Mollema
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..0131a8f
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,2 @@
+include ldapdomaindump/style.css
+include LICENSE Readme.md
diff --git a/Readme.md b/Readme.md
new file mode 100644
index 0000000..2325915
--- /dev/null
+++ b/Readme.md
@@ -0,0 +1,109 @@
+# LDAPDomainDump
+Active Directory information dumper via LDAP
+
+## Introduction
+In an Active Directory domain, a lot of interesting information can be retrieved via LDAP by any authenticated user (or machine).
+This makes LDAP an interesting protocol for gathering information in the recon phase of a pentest of an internal network.
+A problem is that data from LDAP often is not available in an easy to read format.
+
+ldapdomaindump is a tool which aims to solve this problem, by collecting and parsing information available via LDAP and outputting it in a human readable HTML format, as well as machine readable json and csv/tsv/greppable files.
+
+The tool was designed with the following goals in mind:
+- Easy overview of all users/groups/computers/policies in the domain
+- Authentication both via username and password, as with NTLM hashes (requires ldap3 >=1.3.1)
+- Possibility to run the tool with an existing authenticated connection to an LDAP service, allowing for integration with relaying tools such as impackets ntlmrelayx
+
+The tool outputs several files containing an overview of objects in the domain:
+- *domain_groups*: List of groups in the domain
+- *domain_users*: List of users in the domain
+- *domain_computers*: List of computer accounts in the domain
+- *domain_policy*: Domain policy such as password requirements and lockout policy
+- *domain_trusts*: Incoming and outgoing domain trusts, and their properties
+
+As well as two grouped files:
+- *domain_users_by_group*: Domain users per group they are member of
+- *domain_computers_by_os*: Domain computers sorted by Operating System
+
+## Dependencies and installation
+Requires [ldap3](https://github.com/cannatag/ldap3) > 2.0 and [dnspython](https://github.com/rthalley/dnspython)
+
+Both can be installed with `pip install ldap3 dnspython`
+
+The ldapdomaindump package can be installed with `python setup.py install` from the git source, or for the latest release with `pip install ldapdomaindump`.
+
+## Usage
+There are 3 ways to use the tool:
+- With just the source, run `python ldapdomaindump.py`
+- After installing, by running `python -m ldapdomaindump`
+- After installing, by running `ldapdomaindump`
+
+Help can be obtained with the -h switch:
+```
+usage: ldapdomaindump.py [-h] [-u USERNAME] [-p PASSWORD] [-at {NTLM,SIMPLE}]
+                         [-o DIRECTORY] [--no-html] [--no-json] [--no-grep]
+                         [--grouped-json] [-d DELIMITER] [-r] [-n DNS_SERVER]
+                         [-m]
+                         HOSTNAME
+
+Domain information dumper via LDAP. Dumps users/computers/groups and
+OS/membership information to HTML/JSON/greppable output.
+
+Required options:
+  HOSTNAME              Hostname/ip or ldap://host:port connection string to
+                        connect to (use ldaps:// to use SSL)
+
+Main options:
+  -h, --help            show this help message and exit
+  -u USERNAME, --user USERNAME
+                        DOMAIN\username for authentication, leave empty for
+                        anonymous authentication
+  -p PASSWORD, --password PASSWORD
+                        Password or LM:NTLM hash, will prompt if not specified
+  -at {NTLM,SIMPLE}, --authtype {NTLM,SIMPLE}
+                        Authentication type (NTLM or SIMPLE, default: NTLM)
+
+Output options:
+  -o DIRECTORY, --outdir DIRECTORY
+                        Directory in which the dump will be saved (default:
+                        current)
+  --no-html             Disable HTML output
+  --no-json             Disable JSON output
+  --no-grep             Disable Greppable output
+  --grouped-json        Also write json files for grouped files (default:
+                        disabled)
+  -d DELIMITER, --delimiter DELIMITER
+                        Field delimiter for greppable output (default: tab)
+
+Misc options:
+  -r, --resolve         Resolve computer hostnames (might take a while and
+                        cause high traffic on large networks)
+  -n DNS_SERVER, --dns-server DNS_SERVER
+                        Use custom DNS resolver instead of system DNS (try a
+                        domain controller IP)
+  -m, --minimal         Only query minimal set of attributes to limit memmory
+                        usage
+```
+
+## Options
+### Authentication
+Most AD servers support NTLM authentication. In the rare case that it does not, use --authtype SIMPLE.
+
+### Output formats
+By default the tool outputs all files in HTML, JSON and tab delimited output (greppable). There are also two grouped files (users_by_group and computers_by_os) for convenience. These do not have a greppable output. JSON output for grouped files is disabled by default since it creates very large files without any data that isn't present in the other files already.
+
+### DNS resolving
+An important option is the *-r* option, which decides if a computers DNSHostName attribute should be resolved to an IPv4 address. 
+While this can be very useful, the DNSHostName attribute is not automatically updated. When the AD Domain uses subdomains for computer hostnames, the DNSHostName will often be incorrect and will not resolve. Also keep in mind that resolving every hostname in the domain might cause a high load on the domain controller.
+
+### Minimizing network and memory usage
+By default ldapdomaindump will try to dump every single attribute it can read to disk in the .json files. In large networks, this uses a lot of memory (since group relationships are currently calculated in memory before being written to disk). To dump only the minimal required attributes (the ones shown by default in the .html and .grep files), use the `--minimal` switch.
+
+## Visualizing groups with BloodHound
+LDAPDomainDump includes a utility that can be used to convert ldapdomaindumps `.json` files to CSV files suitable for BloodHound. The utility is called `ldd2bloodhound` and is added to your path upon installation. Alternatively you can run it with `python -m ldapdomaindump.convert` or with `python ldapdomaindump/convert.py` if you are running it from the source.
+The conversion tool will take the users/groups/computers/trusts `.json` file and convert those to `group_membership.csv` and `trust.csv` which you can add to BloodHound. *Note that these files are only compatible with **BloodHound 1.x** which is quite old. There are no plans to support the latest version as the [BloodHound.py project](https://github.com/fox-it/BloodHound.py) was made for this. With the DCOnly collection method this tool will also only talk to LDAP and collect more information than ldapdomaindump would*.
+
+## Visualizing dump with a pretty output like enum4linux
+LDAPDomainDump includes a utility that can be used to output ldapdomaindumps `.json` files to an enum4linux like output. The utility is called `ldd2pretty` and is added to your path upon installation. Alternatively you can run it with `python -m ldapdomaindump.pretty` or with `python ldapdomaindump/pretty.py` if you are running it from the source.
+
+## License
+MIT
diff --git a/bin/ldapdomaindump b/bin/ldapdomaindump
new file mode 100644
index 0000000..3b76994
--- /dev/null
+++ b/bin/ldapdomaindump
@@ -0,0 +1,3 @@
+#!/usr/bin/env python
+import ldapdomaindump
+ldapdomaindump.main()
\ No newline at end of file
diff --git a/bin/ldd2bloodhound b/bin/ldd2bloodhound
new file mode 100644
index 0000000..4340802
--- /dev/null
+++ b/bin/ldd2bloodhound
@@ -0,0 +1,3 @@
+#!/usr/bin/env python
+from ldapdomaindump import convert
+convert.ldd2bloodhound()
diff --git a/bin/ldd2pretty b/bin/ldd2pretty
new file mode 100644
index 0000000..82a9136
--- /dev/null
+++ b/bin/ldd2pretty
@@ -0,0 +1,3 @@
+#!/usr/bin/env python
+from ldapdomaindump import pretty
+pretty.main()
diff --git a/ldapdomaindump.py b/ldapdomaindump.py
new file mode 100644
index 0000000..c0bb795
--- /dev/null
+++ b/ldapdomaindump.py
@@ -0,0 +1,3 @@
+#!/usr/bin/env python
+import ldapdomaindump
+ldapdomaindump.main()
diff --git a/ldapdomaindump/__init__.py b/ldapdomaindump/__init__.py
new file mode 100644
index 0000000..3f3b953
--- /dev/null
+++ b/ldapdomaindump/__init__.py
@@ -0,0 +1,948 @@
+####################
+#
+# Copyright (c) 2017 Dirk-jan Mollema
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+#
+####################
+from __future__ import unicode_literals
+import sys, os, re, codecs, json, argparse, getpass, base64
+# import class and constants
+from datetime import datetime, timedelta
+try:
+    from urllib.parse import quote_plus
+except ImportError:
+    from urllib import quote_plus
+import ldap3
+from ldap3 import Server, Connection, SIMPLE, SYNC, ALL, SASL, NTLM
+from ldap3.core.exceptions import LDAPKeyError, LDAPAttributeError, LDAPCursorError, LDAPInvalidDnError
+from ldap3.abstract import attribute, attrDef
+from ldap3.utils import dn
+from ldap3.protocol.formatters.formatters import format_sid
+from builtins import str
+from future.utils import itervalues, iteritems, native_str
+
+# dnspython, for resolving hostnames
+import dns.resolver
+
+
+# User account control flags
+# From: https://blogs.technet.microsoft.com/askpfeplat/2014/01/15/understanding-the-useraccountcontrol-attribute-in-active-directory/
+uac_flags = {'ACCOUNT_DISABLED':0x00000002,
+             'ACCOUNT_LOCKED':0x00000010,
+             'PASSWD_NOTREQD':0x00000020,
+             'PASSWD_CANT_CHANGE': 0x00000040,
+             'NORMAL_ACCOUNT': 0x00000200,
+             'WORKSTATION_ACCOUNT':0x00001000,
+             'SERVER_TRUST_ACCOUNT': 0x00002000,
+             'DONT_EXPIRE_PASSWD': 0x00010000,
+             'SMARTCARD_REQUIRED': 0x00040000,
+             'TRUSTED_FOR_DELEGATION': 0x00080000,
+             'NOT_DELEGATED': 0x00100000,
+             'USE_DES_KEY_ONLY': 0x00200000,
+             'DONT_REQ_PREAUTH': 0x00400000,
+             'PASSWORD_EXPIRED': 0x00800000,
+             'TRUSTED_TO_AUTH_FOR_DELEGATION': 0x01000000,
+             'PARTIAL_SECRETS_ACCOUNT': 0x04000000
+            }
+
+# Password policy flags
+pwd_flags = {'PASSWORD_COMPLEX':0x01,
+             'PASSWORD_NO_ANON_CHANGE': 0x02,
+             'PASSWORD_NO_CLEAR_CHANGE': 0x04,
+             'LOCKOUT_ADMINS': 0x08,
+             'PASSWORD_STORE_CLEARTEXT': 0x10,
+             'REFUSE_PASSWORD_CHANGE': 0x20}
+
+# Domain trust flags
+# From: https://msdn.microsoft.com/en-us/library/cc223779.aspx
+trust_flags = {'NON_TRANSITIVE':0x00000001,
+               'UPLEVEL_ONLY':0x00000002,
+               'QUARANTINED_DOMAIN':0x00000004,
+               'FOREST_TRANSITIVE':0x00000008,
+               'CROSS_ORGANIZATION':0x00000010,
+               'WITHIN_FOREST':0x00000020,
+               'TREAT_AS_EXTERNAL':0x00000040,
+               'USES_RC4_ENCRYPTION':0x00000080,
+               'CROSS_ORGANIZATION_NO_TGT_DELEGATION':0x00000200,
+               'PIM_TRUST':0x00000400}
+
+# Domain trust direction
+# From: https://msdn.microsoft.com/en-us/library/cc223768.aspx
+trust_directions = {'INBOUND':0x01,
+                    'OUTBOUND':0x02,
+                    'BIDIRECTIONAL':0x03}
+# Domain trust types
+trust_type = {'DOWNLEVEL':0x01,
+              'UPLEVEL':0x02,
+              'MIT':0x03}
+
+# Common attribute pretty translations
+attr_translations = {'sAMAccountName':'SAM Name',
+                     'cn':'CN',
+                     'operatingSystem':'Operating System',
+                     'operatingSystemServicePack':'Service Pack',
+                     'operatingSystemVersion':'OS Version',
+                     'userAccountControl':'Flags',
+                     'objectSid':'SID',
+                     'memberOf':'Member of groups',
+                     'primaryGroupId':'Primary group',
+                     'dNSHostName':'DNS Hostname',
+                     'whenCreated':'Created on',
+                     'whenChanged':'Changed on',
+                     'IPv4':'IPv4 Address',
+                     'lockOutObservationWindow':'Lockout time window',
+                     'lockoutDuration':'Lockout Duration',
+                     'lockoutThreshold':'Lockout Threshold',
+                     'maxPwdAge':'Max password age',
+                     'minPwdAge':'Min password age',
+                     'minPwdLength':'Min password length',
+                     'pwdHistoryLength':'Password history length',
+                     'pwdProperties':'Password properties',
+                     'ms-DS-MachineAccountQuota':'Machine Account Quota',
+                     'flatName':'NETBIOS Domain name'}
+
+MINIMAL_COMPUTERATTRIBUTES = ['cn', 'sAMAccountName', 'dNSHostName', 'operatingSystem', 'operatingSystemServicePack', 'operatingSystemVersion', 'lastLogon', 'userAccountControl', 'whenCreated', 'objectSid', 'description', 'objectClass']
+MINIMAL_USERATTRIBUTES = ['cn', 'name', 'sAMAccountName', 'memberOf', 'primaryGroupId', 'whenCreated', 'whenChanged', 'lastLogon', 'userAccountControl', 'pwdLastSet', 'objectSid', 'description', 'objectClass']
+MINIMAL_GROUPATTRIBUTES = ['cn', 'name', 'sAMAccountName', 'memberOf', 'description', 'whenCreated', 'whenChanged', 'objectSid', 'distinguishedName', 'objectClass']
+
+#Class containing the default config
+class domainDumpConfig(object):
+    def __init__(self):
+        #Base path
+        self.basepath = '.'
+
+        #Output files basenames
+        self.groupsfile = 'domain_groups' #Groups
+        self.usersfile = 'domain_users' #User accounts
+        self.computersfile = 'domain_computers' #Computer accounts
+        self.policyfile = 'domain_policy' #General domain attributes
+        self.trustsfile = 'domain_trusts' #Domain trusts attributes
+
+        #Combined files basenames
+        self.users_by_group = 'domain_users_by_group' #Users sorted by group
+        self.computers_by_os = 'domain_computers_by_os' #Computers sorted by OS
+
+        #Output formats
+        self.outputhtml = True
+        self.outputjson = True
+        self.outputgrep = True
+
+        #Output json for groups
+        self.groupedjson = False
+
+        #Default field delimiter for greppable format is a tab
+        self.grepsplitchar = '\t'
+
+        #Other settings
+        self.lookuphostnames = False #Look up hostnames of computers to get their IP address
+        self.dnsserver = '' #Addres of the DNS server to use, if not specified default DNS will be used
+        self.minimal = False #Only query minimal list of attributes
+
+#Domaindumper main class
+class domainDumper(object):
+    def __init__(self, server, connection, config, root=None):
+        self.server = server
+        self.connection = connection
+        self.config = config
+        #Unless the root is specified we get it from the server
+        if root is None:
+            self.root = self.getRoot()
+        else:
+            self.root = root
+        self.users = None #Domain users
+        self.groups = None #Domain groups
+        self.computers = None #Domain computers
+        self.policy = None #Domain policy
+        self.groups_dnmap = None #CN map for group IDs to CN
+        self.groups_dict = None #Dictionary of groups by CN
+        self.trusts = None #Domain trusts
+
+    #Get the server root from the default naming context
+    def getRoot(self):
+        return self.server.info.other['defaultNamingContext'][0]
+
+    #Query the groups of the current user
+    def getCurrentUserGroups(self, username, domainsid=None):
+        self.connection.search(self.root, '(&(objectCategory=person)(objectClass=user)(sAMAccountName=%s))' % username, attributes=['cn', 'memberOf', 'primaryGroupId'])
+        try:
+            groups = self.connection.entries[0]['memberOf'].values
+            if domainsid is not None:
+                groups.append(self.getGroupDNfromID(domainsid, self.connection.entries[0]['primaryGroupId'].value))
+            return groups
+        except LDAPKeyError:
+            #No groups, probably just member of the primary group
+            if domainsid is not None:
+                primarygroup = self.getGroupDNfromID(domainsid, self.connection.entries[0]['primaryGroupId'].value)
+                return [primarygroup]
+            else:
+                return []
+        except IndexError:
+            #The username does not exist (might be a computer account)
+            return []
+
+    #Check if the user is part of the Domain Admins or Enterprise Admins group, or any of their subgroups
+    def isDomainAdmin(self, username):
+        domainsid = self.getRootSid()
+        groups = self.getCurrentUserGroups(username, domainsid)
+        #Get DA and EA group DNs
+        dagroupdn = self.getDAGroupDN(domainsid)
+        eagroupdn = self.getEAGroupDN(domainsid)
+        #First, simple checks
+        for group in groups:
+            if 'CN=Administrators' in group or 'CN=Domain Admins' in group or dagroupdn == group:
+                return True
+            #Also for enterprise admins if applicable
+            if 'CN=Enterprise Admins' in group or (eagroupdn is not False and eagroupdn == group):
+                return True
+        #Now, just do a recursive check in both groups and their subgroups using LDAP_MATCHING_RULE_IN_CHAIN
+        self.connection.search(self.root, '(&(objectCategory=person)(objectClass=user)(sAMAccountName=%s)(memberOf:1.2.840.113556.1.4.1941:=%s))' % (username, dagroupdn), attributes=['cn', 'sAMAccountName'])
+        if len(self.connection.entries) > 0:
+            return True
+        self.connection.search(self.root, '(&(objectCategory=person)(objectClass=user)(sAMAccountName=%s)(memberOf:1.2.840.113556.1.4.1941:=%s))' % (username, eagroupdn), attributes=['cn', 'sAMAccountName'])
+        if len(self.connection.entries) > 0:
+            return True
+        #At last, check the users primary group ID
+        return False
+
+    #Get all users
+    def getAllUsers(self):
+        if self.config.minimal:
+            self.connection.extend.standard.paged_search('%s' % (self.root), '(&(objectCategory=person)(objectClass=user))', attributes=MINIMAL_USERATTRIBUTES, paged_size=500, generator=False)
+        else:
+            self.connection.extend.standard.paged_search('%s' % (self.root), '(&(objectCategory=person)(objectClass=user))', attributes=ldap3.ALL_ATTRIBUTES, paged_size=500, generator=False)
+        return self.connection.entries
+
+    #Get all computers in the domain
+    def getAllComputers(self):
+        if self.config.minimal:
+            self.connection.extend.standard.paged_search('%s' % (self.root), '(&(objectClass=computer)(objectClass=user))', attributes=MINIMAL_COMPUTERATTRIBUTES, paged_size=500, generator=False)
+        else:
+            self.connection.extend.standard.paged_search('%s' % (self.root), '(&(objectClass=computer)(objectClass=user))', attributes=ldap3.ALL_ATTRIBUTES, paged_size=500, generator=False)
+        return self.connection.entries
+
+    #Get all user SPNs
+    def getAllUserSpns(self):
+        if self.config.minimal:
+            self.connection.extend.standard.paged_search('%s' % (self.root), '(&(objectCategory=person)(objectClass=user)(servicePrincipalName=*))', attributes=MINIMAL_USERATTRIBUTES, paged_size=500, generator=False)
+        else:
+            self.connection.extend.standard.paged_search('%s' % (self.root), '(&(objectCategory=person)(objectClass=user)(servicePrincipalName=*))', attributes=ldap3.ALL_ATTRIBUTES, paged_size=500, generator=False)
+        return self.connection.entries
+
+    #Get all defined groups
+    def getAllGroups(self):
+        if self.config.minimal:
+            self.connection.extend.standard.paged_search(self.root, '(objectClass=group)', attributes=MINIMAL_GROUPATTRIBUTES, paged_size=500, generator=False)
+        else:
+            self.connection.extend.standard.paged_search(self.root, '(objectClass=group)', attributes=ldap3.ALL_ATTRIBUTES, paged_size=500, generator=False)
+        return self.connection.entries
+
+    #Get the domain policies (such as lockout policy)
+    def getDomainPolicy(self):
+        self.connection.search(self.root, '(objectClass=domain)', attributes=ldap3.ALL_ATTRIBUTES)
+        return self.connection.entries
+
+    #Get domain trusts
+    def getTrusts(self):
+        self.connection.search(self.root, '(objectClass=trustedDomain)', attributes=ldap3.ALL_ATTRIBUTES)
+        return self.connection.entries
+
+    #Get all defined security groups
+    #Syntax from:
+    #https://ldapwiki.willeke.com/wiki/Active%20Directory%20Group%20Related%20Searches
+    def getAllSecurityGroups(self):
+        self.connection.search(self.root, '(groupType:1.2.840.113556.1.4.803:=2147483648)', attributes=ldap3.ALL_ATTRIBUTES)
+        return self.connection.entries
+
+    #Get the SID of the root object
+    def getRootSid(self):
+        self.connection.search(self.root, '(objectClass=domain)', attributes=['objectSid'])
+        try:
+            sid = self.connection.entries[0].objectSid
+        except (LDAPAttributeError, LDAPCursorError, IndexError):
+            return False
+        return sid
+
+    #Get group members recursively using LDAP_MATCHING_RULE_IN_CHAIN (1.2.840.113556.1.4.1941)
+    def getRecursiveGroupmembers(self, groupdn):
+        self.connection.extend.standard.paged_search(self.root, '(&(objectCategory=person)(objectClass=user)(memberOf:1.2.840.113556.1.4.1941:=%s))' % groupdn, attributes=MINIMAL_USERATTRIBUTES, paged_size=500, generator=False)
+        return self.connection.entries
+
+    #Resolve group ID to DN
+    def getGroupDNfromID(self, domainsid, gid):
+        self.connection.search(self.root, '(objectSid=%s-%d)' % (domainsid, gid), attributes=['distinguishedName'])
+        return self.connection.entries[0]['distinguishedName'].value
+
+    #Get Domain Admins group DN
+    def getDAGroupDN(self, domainsid):
+        return self.getGroupDNfromID(domainsid, 512)
+
+    #Get Enterprise Admins group DN
+    def getEAGroupDN(self, domainsid):
+        try:
+            return self.getGroupDNfromID(domainsid, 519)
+        except (LDAPAttributeError, LDAPCursorError, IndexError):
+            #This does not exist, could be in a parent domain
+            return False
+
+
+    #Lookup all computer DNS names to get their IP
+    def lookupComputerDnsNames(self):
+        dnsresolver = dns.resolver.Resolver()
+        dnsresolver.lifetime = 2
+        ipdef = attrDef.AttrDef('ipv4')
+        if self.config.dnsserver != '':
+            dnsresolver.nameservers = [self.config.dnsserver]
+        for computer in self.computers:
+            try:
+                answers = dnsresolver.query(computer.dNSHostName.values[0], 'A')
+                ip = str(answers.response.answer[0][0])
+            except dns.resolver.NXDOMAIN:
+                ip = 'error.NXDOMAIN'
+            except dns.resolver.Timeout:
+                ip = 'error.TIMEOUT'
+            except (LDAPAttributeError, LDAPCursorError):
+                ip = 'error.NOHOSTNAME'
+            #Construct a custom attribute as workaround
+            ipatt = attribute.Attribute(ipdef, computer, None)
+            ipatt.__dict__['_response'] = ip
+            ipatt.__dict__['raw_values'] = [ip]
+            ipatt.__dict__['values'] = [ip]
+            #Add the attribute to the entry's dictionary
+            computer._state.attributes['IPv4'] = ipatt
+
+    #Create a dictionary of all operating systems with the computer accounts that are associated
+    def sortComputersByOS(self, items):
+        osdict = {}
+        for computer in items:
+            try:
+                cos = computer.operatingSystem.value or 'Unknown'
+            except (LDAPAttributeError, LDAPCursorError):
+                cos = 'Unknown'
+            try:
+                osdict[cos].append(computer)
+            except KeyError:
+                #New OS
+                osdict[cos] = [computer]
+        return osdict
+
+    #Map all groups on their ID (taken from their SID) to CNs
+    #This is used for getting the primary group of a user
+    def mapGroupsIdsToDns(self):
+        dnmap = {}
+        for group in self.groups:
+            gid = int(group.objectSid.value.split('-')[-1])
+            dnmap[gid] = group.distinguishedName.values[0]
+        self.groups_dnmap = dnmap
+        return dnmap
+
+    #Create a dictionary where a groups CN returns the full object
+    def createGroupsDictByCn(self):
+        gdict = {grp.cn.values[0]:grp for grp in self.groups}
+        self.groups_dict = gdict
+        return gdict
+
+    #Get CN from DN
+    def getGroupCnFromDn(self, dnin):
+        cn = self.unescapecn(dn.parse_dn(dnin)[0][1])
+        return cn
+
+    #Unescape special DN characters from a CN (only needed if it comes from a DN)
+    def unescapecn(self, cn):
+        for c in ' "#+,;<=>\\\00':
+            cn = cn.replace('\\'+c, c)
+        return cn
+
+    #Sort users by group they belong to
+    def sortUsersByGroup(self, items):
+        groupsdict = {}
+        #Make sure the group CN mapping already exists
+        if self.groups_dnmap is None:
+            self.mapGroupsIdsToDns()
+        for user in items:
+            try:
+                ugroups = [self.getGroupCnFromDn(group) for group in user.memberOf.values]
+            #If the user is only in the default group, its memberOf property wont exist
+            except (LDAPAttributeError, LDAPCursorError):
+                ugroups = []
+            #Add the user default group
+            try:
+                ugroups.append(self.getGroupCnFromDn(self.groups_dnmap[user.primaryGroupId.value]))
+            # Sometimes we can't query this group or it doesn't exist
+            except KeyError:
+                pass
+            for group in ugroups:
+                try:
+                    groupsdict[group].append(user)
+                except KeyError:
+                    #Group is not yet in dict
+                    groupsdict[group] = [user]
+
+        #Append any groups that are members of groups
+        for group in self.groups:
+            try:
+                for parentgroup in group.memberOf.values:
+                    try:
+                        groupsdict[self.getGroupCnFromDn(parentgroup)].append(group)
+                    except KeyError:
+                        #Group is not yet in dict
+                        groupsdict[self.getGroupCnFromDn(parentgroup)] = [group]
+            #Without subgroups this attribute does not exist
+            except (LDAPAttributeError, LDAPCursorError):
+                pass
+
+        return groupsdict
+
+    #Main function
+    def domainDump(self):
+        self.users = self.getAllUsers()
+        self.computers = self.getAllComputers()
+        self.groups = self.getAllGroups()
+        if self.config.lookuphostnames:
+            self.lookupComputerDnsNames()
+        self.policy = self.getDomainPolicy()
+        self.trusts = self.getTrusts()
+        rw = reportWriter(self.config)
+        rw.generateUsersReport(self)
+        rw.generateGroupsReport(self)
+        rw.generateComputersReport(self)
+        rw.generatePolicyReport(self)
+        rw.generateTrustsReport(self)
+        rw.generateComputersByOsReport(self)
+        rw.generateUsersByGroupReport(self)
+
+class reportWriter(object):
+    def __init__(self, config):
+        self.config = config
+        self.dd = None
+        if self.config.lookuphostnames:
+            self.computerattributes = ['cn', 'sAMAccountName', 'dNSHostName', 'IPv4', 'operatingSystem', 'operatingSystemServicePack', 'operatingSystemVersion', 'lastLogon', 'userAccountControl', 'whenCreated', 'objectSid', 'description']
+        else:
+            self.computerattributes = ['cn', 'sAMAccountName', 'dNSHostName', 'operatingSystem', 'operatingSystemServicePack', 'operatingSystemVersion', 'lastLogon', 'userAccountControl', 'whenCreated', 'objectSid', 'description']
+        self.userattributes = ['cn', 'name', 'sAMAccountName', 'memberOf', 'primaryGroupId', 'whenCreated', 'whenChanged', 'lastLogon', 'userAccountControl', 'pwdLastSet', 'objectSid', 'description']
+        #In grouped view, don't include the memberOf property to reduce output size
+        self.userattributes_grouped = ['cn', 'name', 'sAMAccountName', 'whenCreated', 'whenChanged', 'lastLogon', 'userAccountControl', 'pwdLastSet', 'objectSid', 'description']
+        self.groupattributes = ['cn', 'sAMAccountName', 'memberOf', 'description', 'whenCreated', 'whenChanged', 'objectSid']
+        self.policyattributes = ['distinguishedName', 'lockOutObservationWindow', 'lockoutDuration', 'lockoutThreshold', 'maxPwdAge', 'minPwdAge', 'minPwdLength', 'pwdHistoryLength', 'pwdProperties', 'ms-DS-MachineAccountQuota']
+        self.trustattributes = ['cn', 'flatName', 'securityIdentifier', 'trustAttributes', 'trustDirection', 'trustType']
+
+    #Escape HTML special chars
+    def htmlescape(self, html):
+        return (html.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("'", "&#39;").replace('"', "&quot;"))
+
+    #Unescape special DN characters from a CN (only needed if it comes from a DN)
+    def unescapecn(self, cn):
+        for c in ' "#+,;<=>\\\00':
+            cn = cn.replace('\\'+c, c)
+        return cn
+
+    #Convert password max age (in 100 nanoseconds), to days
+    def nsToDays(self, length):
+        # ldap3 >= 2.6 returns timedelta
+        if isinstance(length, timedelta):
+            return length.total_seconds() / 86400
+        else:
+            return abs(length) * .0000001 / 86400
+
+    def nsToMinutes(self, length):
+        # ldap3 >= 2.6 returns timedelta
+        if isinstance(length, timedelta):
+            return length.total_seconds() / 60
+        else:
+            return abs(length) * .0000001 / 60
+
+    #Parse bitwise flags into a list
+    def parseFlags(self, attr, flags_def):
+        outflags = []
+        if attr is None or attr.value is None:
+            return outflags
+        for flag, val in iteritems(flags_def):
+            if attr.value & val:
+                outflags.append(flag)
+        return outflags
+
+    #Parse bitwise trust direction - only one flag applies here, 0x03 overlaps
+    def parseTrustDirection(self, attr, flags_def):
+        outflags = []
+        if attr is None:
+            return outflags
+        for flag, val in iteritems(flags_def):
+            if attr.value == val:
+                outflags.append(flag)
+        return outflags
+
+    #Generate a HTML table from a list of entries, with the specified attributes as column
+    def generateHtmlTable(self, listable, attributes, header='', firstTable=True, specialGroupsFormat=False):
+        of = []
+        #Only if this is the first table it is an actual table, the others are just bodies of the first table
+        #This makes sure that multiple tables have their columns aligned to make it less messy
+        if firstTable:
+            of.append('<table>')
+        #Table header
+        if header != '':
+            of.append('<thead><tr><td colspan="%d" id="cn_%s">%s</td></tr></thead>' % (len(attributes), self.formatId(header), header))
+        of.append('<tbody><tr>')
+        for hdr in attributes:
+            try:
+                #Print alias of this attribute if there is one
+                of.append('<th>%s</th>' % self.htmlescape(attr_translations[hdr]))
+            except KeyError:
+                of.append('<th>%s</th>' % self.htmlescape(hdr))
+        of.append('</tr>\n')
+        for li in listable:
+            #Whether we should format group objects separately
+            if specialGroupsFormat and 'group' in li['objectClass'].values:
+                #Give it an extra class and pass it to the function below to make sure the CN is a link
+                liIsGroup = True
+                of.append('<tr class="group">')
+            else:
+                liIsGroup = False
+                of.append('<tr>')
+            for att in attributes:
+                try:
+                    of.append('<td>%s</td>' % self.formatAttribute(li[att], liIsGroup))
+                except (LDAPKeyError, LDAPCursorError):
+                    of.append('<td>&nbsp;</td>')
+            of.append('</tr>\n')
+        of.append('</tbody>\n')
+        return ''.join(of)
+
+    #Generate several HTML tables for grouped reports
+    def generateGroupedHtmlTables(self, groups, attributes):
+        first = True
+        for groupname, members in iteritems(groups):
+            yield self.generateHtmlTable(members, attributes, groupname, first, specialGroupsFormat=True)
+            if first:
+                first = False
+
+    #Write generated HTML to file
+    def writeHtmlFile(self, rel_outfile, body, genfunc=None, genargs=None, closeTable=True):
+        if not os.path.exists(self.config.basepath):
+            os.makedirs(self.config.basepath)
+        outfile = os.path.join(self.config.basepath, rel_outfile)
+        with codecs.open(outfile, 'w', 'utf8') as of:
+            of.write('<!DOCTYPE html>\n<html>\n<head><meta charset="UTF-8">')
+            #Include the style
+            try:
+                with open(os.path.join(os.path.dirname(__file__), 'style.css'), 'r') as sf:
+                    of.write('<style type="text/css">')
+                    of.write(sf.read())
+                    of.write('</style>')
+            except IOError:
+                log_warn('style.css not found in package directory, styling will be skipped')
+            of.write('</head><body>')
+            #If the generator is not specified, we should write the HTML blob directly
+            if genfunc is None:
+                of.write(body)
+            else:
+                for tpart in genfunc(*genargs):
+                    of.write(tpart)
+            #Does the body contain an open table?
+            if closeTable:
+                of.write('</table>')
+            of.write('</body></html>')
+
+    #Write generated JSON to file
+    def writeJsonFile(self, rel_outfile, jsondata, genfunc=None, genargs=None):
+        if not os.path.exists(self.config.basepath):
+            os.makedirs(self.config.basepath)
+        outfile = os.path.join(self.config.basepath, rel_outfile)
+        with codecs.open(outfile, 'w', 'utf8') as of:
+            #If the generator is not specified, we should write the JSON blob directly
+            if genfunc is None:
+                of.write(jsondata)
+            else:
+                for jpart in genfunc(*genargs):
+                    of.write(jpart)
+
+    #Write generated Greppable stuff to file
+    def writeGrepFile(self, rel_outfile, body):
+        if not os.path.exists(self.config.basepath):
+            os.makedirs(self.config.basepath)
+        outfile = os.path.join(self.config.basepath, rel_outfile)
+        with codecs.open(outfile, 'w', 'utf8') as of:
+            of.write(body)
+
+    #Format a value for HTML
+    def formatString(self, value):
+        if type(value) is datetime:
+            try:
+                return value.strftime('%x %X')
+            except ValueError:
+                #Invalid date
+                return '0'
+        # Make sure it's a unicode string
+        if type(value) is bytes:
+            return value.encode('utf8')
+        if type(value) is str:
+            return value#.encode('utf8')
+        if type(value) is int:
+            return str(value)
+        if value is None:
+            return ''
+        #Other type: just return it
+        return value
+
+    #Format an attribute to a human readable format
+    def formatAttribute(self, att, formatCnAsGroup=False):
+        aname = att.key.lower()
+        #User flags
+        if aname == 'useraccountcontrol':
+            return ', '.join(self.parseFlags(att, uac_flags))
+        #List of groups
+        if aname == 'member' or aname == 'memberof' and type(att.values) is list:
+            return self.formatGroupsHtml(att.values)
+        #Primary group
+        if aname == 'primarygroupid':
+            try:
+                return self.formatGroupsHtml([self.dd.groups_dnmap[att.value]])
+            except KeyError:
+                return 'NOT FOUND!'
+        #Pwd flags
+        if aname == 'pwdproperties':
+            return ', '.join(self.parseFlags(att, pwd_flags))
+        #Domain trust flags
+        if aname == 'trustattributes':
+            return ', '.join(self.parseFlags(att, trust_flags))
+        if aname == 'trustdirection':
+            if  att.value == 0:
+                return 'DISABLED'
+            else:
+                return ', '.join(self.parseTrustDirection(att, trust_directions))
+        if aname == 'trusttype':
+            return ', '.join(self.parseFlags(att, trust_type))
+        if aname == 'securityidentifier':
+            return format_sid(att.raw_values[0])
+        if aname == 'minpwdage' or  aname == 'maxpwdage':
+            return '%.2f days' % self.nsToDays(att.value)
+        if aname == 'lockoutobservationwindow' or  aname == 'lockoutduration':
+            return '%.1f minutes' % self.nsToMinutes(att.value)
+        if aname == 'objectsid':
+            return '<abbr title="%s">%s</abbr>' % (att.value, att.value.split('-')[-1])
+        #Special case where the attribute is a CN and it should be made clear its a group
+        if aname == 'cn' and formatCnAsGroup:
+            return self.formatCnWithGroupLink(att.value)
+        #Other
+        return self.htmlescape(self.formatString(att.value))
+
+
+    def formatCnWithGroupLink(self, cn):
+        return 'Group: <a href="#cn_%s" title="%s">%s</a>' % (self.formatId(cn), self.htmlescape(cn), self.htmlescape(cn))
+
+    #Convert a CN to a valid HTML id by replacing all non-ascii characters with a _
+    def formatId(self, cn):
+        return re.sub(r'[^a-zA-Z0-9_\-]+', '_', cn)
+
+    # Fallback function for dirty DN parsing in case ldap3 functions error out
+    def parseDnFallback(self, dn):
+        try:
+            indcn = dn[3:].index(',CN=')
+            indou = dn[3:].index(',OU=')
+            if indcn < indou:
+                cn = dn[3:].split(',CN=')[0]
+            else:
+                cn = dn[3:].split(',OU=')[0]
+        except ValueError:
+            cn = dn
+        return cn
+
+    #Format groups to readable HTML
+    def formatGroupsHtml(self, grouplist):
+        outcache = []
+        for group in grouplist:
+            try:
+                cn = self.unescapecn(dn.parse_dn(group)[0][1])
+            except LDAPInvalidDnError:
+                # Parsing failed, do it manually
+                cn = self.unescapecn(self.parseDnFallback(group))
+            outcache.append('<a href="%s.html#cn_%s" title="%s">%s</a>' % (self.config.users_by_group, quote_plus(self.formatId(cn)), self.htmlescape(group), self.htmlescape(cn)))
+        return ', '.join(outcache)
+
+    #Format groups to readable HTML
+    def formatGroupsGrep(self, grouplist):
+        outcache = []
+        for group in grouplist:
+            try:
+                cn = self.unescapecn(dn.parse_dn(group)[0][1])
+            except LDAPInvalidDnError:
+                # Parsing failed, do it manually
+                cn = self.unescapecn(self.parseDnFallback(group))
+            outcache.append(cn)
+        return ', '.join(outcache)
+
+    #Format attribute for grepping
+    def formatGrepAttribute(self, att):
+        aname = att.key.lower()
+        #User flags
+        if aname == 'useraccountcontrol':
+            return ', '.join(self.parseFlags(att, uac_flags))
+        #List of groups
+        if aname == 'member' or aname == 'memberof' and type(att.values) is list:
+            return self.formatGroupsGrep(att.values)
+        if aname == 'primarygroupid':
+            try:
+                return self.formatGroupsGrep([self.dd.groups_dnmap[att.value]])
+            except KeyError:
+                return 'NOT FOUND!'
+        #Domain trust flags
+        if aname == 'trustattributes':
+            return ', '.join(self.parseFlags(att, trust_flags))
+        if aname == 'trustdirection':
+            if att.value == 0:
+                return 'DISABLED'
+            else:
+                return ', '.join(self.parseTrustDirection(att, trust_directions))
+        if aname == 'trusttype':
+            return ', '.join(self.parseFlags(att, trust_type))
+        if aname == 'securityidentifier':
+            return format_sid(att.raw_values[0])
+        #Pwd flags
+        if aname == 'pwdproperties':
+            return ', '.join(self.parseFlags(att, pwd_flags))
+        if aname == 'minpwdage' or  aname == 'maxpwdage':
+            return '%.2f days' % self.nsToDays(att.value)
+        if aname == 'lockoutobservationwindow' or  aname == 'lockoutduration':
+            return '%.1f minutes' % self.nsToMinutes(att.value)
+        return self.formatString(att.value)
+
+    #Generate grep/awk/cut-able output
+    def generateGrepList(self, entrylist, attributes):
+        hdr = self.config.grepsplitchar.join(attributes)
+        out = [hdr]
+        for entry in entrylist:
+            eo = []
+            for attr in attributes:
+                try:
+                    eo.append(self.formatGrepAttribute(entry[attr]) or '')
+                except (LDAPKeyError, LDAPCursorError):
+                    eo.append('')
+            out.append(self.config.grepsplitchar.join(eo))
+        return '\n'.join(out)
+
+    #Convert a list of entities to a JSON string
+    #String concatenation is used here since the entities have their own json generate
+    #method and converting the string back to json just to process it would be inefficient
+    def generateJsonList(self, entrylist):
+        out = '[' + ','.join([entry.entry_to_json() for entry in entrylist]) + ']'
+        return out
+
+    #Convert a group key/value pair to json
+    #Same methods as previous function are used
+    def generateJsonGroup(self, group):
+        out = '{%s:%s}' % (json.dumps(group[0]), self.generateJsonList(group[1]))
+        return out
+
+    #Convert a list of group dicts with entry lists to JSON string
+    #Same methods as previous functions are used, except that text is returned
+    #from a generator rather than allocating everything in memory
+    def generateJsonGroupedList(self, groups):
+        #Start of the list
+        yield '['
+        firstGroup = True
+        for group in iteritems(groups):
+            if not firstGroup:
+                #Separate items
+                yield ','
+            else:
+                firstGroup = False
+            yield self.generateJsonGroup(group)
+        yield ']'
+
+    #Generate report of all computers grouped by OS family
+    def generateComputersByOsReport(self, dd):
+        grouped = dd.sortComputersByOS(dd.computers)
+        if self.config.outputhtml:
+            #Use the generator approach to save memory
+            self.writeHtmlFile('%s.html' % self.config.computers_by_os, None, genfunc=self.generateGroupedHtmlTables, genargs=(grouped, self.computerattributes))
+        if self.config.outputjson and self.config.groupedjson:
+            self.writeJsonFile('%s.json' % self.config.computers_by_os, None, genfunc=self.generateJsonGroupedList, genargs=(grouped, ))
+
+    #Generate report of all groups and detailled user info
+    def generateUsersByGroupReport(self, dd):
+        grouped = dd.sortUsersByGroup(dd.users)
+        if self.config.outputhtml:
+            #Use the generator approach to save memory
+            self.writeHtmlFile('%s.html' % self.config.users_by_group, None, genfunc=self.generateGroupedHtmlTables, genargs=(grouped, self.userattributes_grouped))
+        if self.config.outputjson and self.config.groupedjson:
+            self.writeJsonFile('%s.json' % self.config.users_by_group, None, genfunc=self.generateJsonGroupedList, genargs=(grouped, ))
+
+    #Generate report with just a table of all users
+    def generateUsersReport(self, dd):
+        #Copy dd to this object, to be able to reference it
+        self.dd = dd
+        dd.mapGroupsIdsToDns()
+        if self.config.outputhtml:
+            html = self.generateHtmlTable(dd.users, self.userattributes, 'Domain users')
+            self.writeHtmlFile('%s.html' % self.config.usersfile, html)
+        if self.config.outputjson:
+            jsonout = self.generateJsonList(dd.users)
+            self.writeJsonFile('%s.json' % self.config.usersfile, jsonout)
+        if self.config.outputgrep:
+            grepout = self.generateGrepList(dd.users, self.userattributes)
+            self.writeGrepFile('%s.grep' % self.config.usersfile, grepout)
+
+    #Generate report with just a table of all computer accounts
+    def generateComputersReport(self, dd):
+        if self.config.outputhtml:
+            html = self.generateHtmlTable(dd.computers, self.computerattributes, 'Domain computer accounts')
+            self.writeHtmlFile('%s.html' % self.config.computersfile, html)
+        if self.config.outputjson:
+            jsonout = self.generateJsonList(dd.computers)
+            self.writeJsonFile('%s.json' % self.config.computersfile, jsonout)
+        if self.config.outputgrep:
+            grepout = self.generateGrepList(dd.computers, self.computerattributes)
+            self.writeGrepFile('%s.grep' % self.config.computersfile, grepout)
+
+    #Generate report with just a table of all computer accounts
+    def generateGroupsReport(self, dd):
+        if self.config.outputhtml:
+            html = self.generateHtmlTable(dd.groups, self.groupattributes, 'Domain groups')
+            self.writeHtmlFile('%s.html' % self.config.groupsfile, html)
+        if self.config.outputjson:
+            jsonout = self.generateJsonList(dd.groups)
+            self.writeJsonFile('%s.json' % self.config.groupsfile, jsonout)
+        if self.config.outputgrep:
+            grepout = self.generateGrepList(dd.groups, self.groupattributes)
+            self.writeGrepFile('%s.grep' % self.config.groupsfile, grepout)
+
+    #Generate policy report
+    def generatePolicyReport(self, dd):
+        if self.config.outputhtml:
+            html = self.generateHtmlTable(dd.policy, self.policyattributes, 'Domain policy')
+            self.writeHtmlFile('%s.html' % self.config.policyfile, html)
+        if self.config.outputjson:
+            jsonout = self.generateJsonList(dd.policy)
+            self.writeJsonFile('%s.json' % self.config.policyfile, jsonout)
+        if self.config.outputgrep:
+            grepout = self.generateGrepList(dd.policy, self.policyattributes)
+            self.writeGrepFile('%s.grep' % self.config.policyfile, grepout)
+
+    #Generate policy report
+    def generateTrustsReport(self, dd):
+        if self.config.outputhtml:
+            html = self.generateHtmlTable(dd.trusts, self.trustattributes, 'Domain trusts')
+            self.writeHtmlFile('%s.html' % self.config.trustsfile, html)
+        if self.config.outputjson:
+            jsonout = self.generateJsonList(dd.trusts)
+            self.writeJsonFile('%s.json' % self.config.trustsfile, jsonout)
+        if self.config.outputgrep:
+            grepout = self.generateGrepList(dd.trusts, self.trustattributes)
+            self.writeGrepFile('%s.grep' % self.config.trustsfile, grepout)
+
+#Some quick logging helpers
+def log_warn(text):
+    print('[!] %s' % text)
+def log_info(text):
+    print('[*] %s' % text)
+def log_success(text):
+    print('[+] %s' % text)
+
+def main():
+    parser = argparse.ArgumentParser(description='Domain information dumper via LDAP. Dumps users/computers/groups and OS/membership information to HTML/JSON/greppable output.')
+    parser._optionals.title = "Main options"
+    parser._positionals.title = "Required options"
+
+    #Main parameters
+    #maingroup = parser.add_argument_group("Main options")
+    parser.add_argument("host", type=str, metavar='HOSTNAME', help="Hostname/ip or ldap://host:port connection string to connect to (use ldaps:// to use SSL)")
+    parser.add_argument("-u", "--user", type=native_str, metavar='USERNAME', help="DOMAIN\\username for authentication, leave empty for anonymous authentication")
+    parser.add_argument("-p", "--password", type=native_str, metavar='PASSWORD', help="Password or LM:NTLM hash, will prompt if not specified")
+    parser.add_argument("-at", "--authtype", type=str, choices=['NTLM', 'SIMPLE'], default='NTLM', help="Authentication type (NTLM or SIMPLE, default: NTLM)")
+
+    #Output parameters
+    outputgroup = parser.add_argument_group("Output options")
+    outputgroup.add_argument("-o", "--outdir", type=str, metavar='DIRECTORY', help="Directory in which the dump will be saved (default: current)")
+    outputgroup.add_argument("--no-html", action='store_true', help="Disable HTML output")
+    outputgroup.add_argument("--no-json", action='store_true', help="Disable JSON output")
+    outputgroup.add_argument("--no-grep", action='store_true', help="Disable Greppable output")
+    outputgroup.add_argument("--grouped-json", action='store_true', default=False, help="Also write json files for grouped files (default: disabled)")
+    outputgroup.add_argument("-d", "--delimiter", help="Field delimiter for greppable output (default: tab)")
+
+    #Additional options
+    miscgroup = parser.add_argument_group("Misc options")
+    miscgroup.add_argument("-r", "--resolve", action='store_true', help="Resolve computer hostnames (might take a while and cause high traffic on large networks)")
+    miscgroup.add_argument("-n", "--dns-server", help="Use custom DNS resolver instead of system DNS (try a domain controller IP)")
+    miscgroup.add_argument("-m", "--minimal", action='store_true', default=False, help="Only query minimal set of attributes to limit memmory usage")
+
+    args = parser.parse_args()
+    #Create default config
+    cnf = domainDumpConfig()
+    #Dns lookups?
+    if args.resolve:
+        cnf.lookuphostnames = True
+    #Custom dns server?
+    if args.dns_server is not None:
+        cnf.dnsserver = args.dns_server
+    #Minimal attributes?
+    if args.minimal:
+        cnf.minimal = True
+    #Custom separator?
+    if args.delimiter is not None:
+        cnf.grepsplitchar = args.delimiter
+    #Disable html?
+    if args.no_html:
+        cnf.outputhtml = False
+    #Disable json?
+    if args.no_json:
+        cnf.outputjson = False
+    #Disable grep?
+    if args.no_grep:
+        cnf.outputgrep = False
+    #Custom outdir?
+    if args.outdir is not None:
+        cnf.basepath = args.outdir
+    #Do we really need grouped json files?
+    cnf.groupedjson = args.grouped_json
+
+    #Prompt for password if not set
+    authentication = None
+    if args.user is not None:
+        if args.authtype == 'SIMPLE':
+            authentication = 'SIMPLE'
+        else:
+            authentication = NTLM
+        if not '\\' in args.user:
+            log_warn('Username must include a domain, use: DOMAIN\\username')
+            sys.exit(1)
+        if args.password is None:
+            args.password = getpass.getpass()
+    else:
+        log_info('Connecting as anonymous user, dumping will probably fail. Consider specifying a username/password to login with')
+    # define the server and the connection
+    s = Server(args.host, get_info=ALL)
+    log_info('Connecting to host...')
+
+    c = Connection(s, user=args.user, password=args.password, authentication=authentication)
+    log_info('Binding to host')
+    # perform the Bind operation
+    if not c.bind():
+        log_warn('Could not bind with specified credentials')
+        log_warn(c.result)
+        sys.exit(1)
+    log_success('Bind OK')
+    log_info('Starting domain dump')
+    #Create domaindumper object
+    dd = domainDumper(s, c, cnf)
+
+    #Do the actual dumping
+    dd.domainDump()
+    log_success('Domain dump finished')
+
+if __name__ == '__main__':
+    main()
diff --git a/ldapdomaindump/__main__.py b/ldapdomaindump/__main__.py
new file mode 100644
index 0000000..c0bb795
--- /dev/null
+++ b/ldapdomaindump/__main__.py
@@ -0,0 +1,3 @@
+#!/usr/bin/env python
+import ldapdomaindump
+ldapdomaindump.main()
diff --git a/ldapdomaindump/convert.py b/ldapdomaindump/convert.py
new file mode 100644
index 0000000..14ebac1
--- /dev/null
+++ b/ldapdomaindump/convert.py
@@ -0,0 +1,196 @@
+from __future__ import unicode_literals
+import argparse
+import os
+import logging
+import json
+import codecs
+import re
+from ldapdomaindump import trust_flags, trust_directions
+from builtins import str
+from future.utils import itervalues, iteritems
+
+logging.basicConfig()
+logger = logging.getLogger('ldd2bloodhound')
+
+class Utils(object):
+    @staticmethod
+    def ldap_to_domain(ldap):
+        return re.sub(',DC=', '.', ldap[ldap.find('DC='):], flags=re.I)[3:]
+
+    @staticmethod
+    def get_group_object(groupo, domain):
+        return {
+            'dn': groupo['dn'],
+            'sid': groupo['attributes']['objectSid'][0],
+            'type': 'group',
+            'principal': '%s@%s' % (groupo['attributes']['sAMAccountName'][0].upper(), domain.upper()),
+            'memberOf': groupo['attributes']['memberOf'] if 'memberOf' in groupo['attributes'] else []
+        }
+
+class BloodHoundConverter(object):
+    def __init__(self):
+        # Input files
+        self.computers_files = []
+        self.trust_files = []
+        self.group_files = []
+        self.user_files = []
+
+        # Caches
+        self.groups_by_dn = {}
+        self.groups_by_sid = {}
+        self.domaincache = {}
+
+    # Get domain from sid and dn - use cache if possible
+    def get_domain(self, sid, dn):
+        dsid = sid.rsplit('-', 1)[0]
+        try:
+            return self.domaincache[dsid]
+        except KeyError:
+            self.domaincache[dsid] = Utils.ldap_to_domain(dn)
+            return self.domaincache[dsid]
+
+    def build_mappings(self):
+        # Parse groups, build DN and SID index
+        for file in self.group_files:
+            with codecs.open(file, 'r', 'utf-8') as infile:
+                data = json.load(infile)
+            # data is now a list of groups (objects)
+            for group in data:
+                groupattrs = Utils.get_group_object(group, self.get_domain(group['attributes']['objectSid'][0], group['dn']))
+                self.groups_by_dn[group['dn']] = groupattrs
+                self.groups_by_sid[group['attributes']['objectSid'][0]] = groupattrs
+        return
+
+    def write_users(self):
+        # Read user mapping - write to csv
+        with codecs.open('group_membership.csv', 'w', 'utf-8') as outfile:
+            outfile.write('GroupName,AccountName,AccountType\n')
+            for file in self.user_files:
+                with codecs.open(file, 'r', 'utf-8') as infile:
+                    data = json.load(infile)
+                # data is now a list of users (objects)
+                for user in data:
+                    self.write_entry_memberships(user, outfile)
+
+    def write_computers(self):
+        # Read computer mapping - write to csv
+        # file is already created here, we just append
+        with codecs.open('group_membership.csv', 'a', 'utf-8') as outfile:
+            for file in self.computers_files:
+                with codecs.open(file, 'r', 'utf-8') as infile:
+                    data = json.load(infile)
+                # data is now a list of computers (objects)
+                for computer in data:
+                    self.write_entry_memberships(computer, outfile, 'computer')
+
+    def write_groups(self):
+        # Read group mapping - write to csv
+        # file is already created here, we just append
+        with codecs.open('group_membership.csv', 'a', 'utf-8') as outfile:
+            for group in itervalues(self.groups_by_dn):
+                for membergroup in group['memberOf']:
+                    try:
+                        outfile.write('%s,%s,%s\n' % (self.groups_by_dn[membergroup]['principal'], group['principal'], 'group'))
+                    except KeyError:
+                        logger.warning('Unknown group %s. Not found in groups cache!', membergroup)
+
+    def write_trusts(self):
+        direction_map = {flag:meaning.capitalize() for meaning, flag in trust_directions.items()}
+        # open output file first
+        with codecs.open('trusts.csv', 'w', 'utf-8') as outfile:
+            outfile.write('SourceDomain,TargetDomain,TrustDirection,TrustType,Transitive\n')
+            for file in self.trust_files:
+                # load the trusts from file
+                with codecs.open(file, 'r', 'utf-8') as infile:
+                    data = json.load(infile)
+                # data is now a list of trusts (objects)
+                for trust in data:
+                    # process flags similar to BloodHound.py
+                    flags = trust['attributes']['trustAttributes'][0]
+                    if flags & trust_flags['WITHIN_FOREST']:
+                        trustType = 'ParentChild'
+                    else:
+                        trustType = 'External'
+                    if flags & trust_flags['NON_TRANSITIVE']:
+                        isTransitive = False
+                    else:
+                        isTransitive = True
+                    out = [
+                        Utils.ldap_to_domain(trust['dn']),
+                        trust['attributes']['name'][0],
+                        direction_map[trust['attributes']['trustDirection'][0]],
+                        trustType,
+                        str(isTransitive)
+                    ]
+                    outfile.write(','.join(out) + '\n')
+
+    def write_entry_memberships(self, entry, outfile, entry_type='user'):
+        domain = self.get_domain(entry['attributes']['objectSid'][0], entry['dn'])
+        if entry_type == 'user':
+            principal = '%s@%s' % (entry['attributes']['sAMAccountName'][0].upper(), domain.upper())
+        else:
+            principal = '%s.%s' % (entry['attributes']['sAMAccountName'][0][:-1].upper(), domain.upper())
+        if 'memberOf' in entry['attributes']:
+            for group in entry['attributes']['memberOf']:
+                try:
+                    rgroup = self.groups_by_dn[group]
+                    outfile.write('%s,%s,%s\n' % (rgroup['principal'], principal, entry_type))
+                except KeyError:
+                    logger.warning('Unknown group %s. Not found in groups cache!', group)
+        # Now process primary group id
+        dsid = entry['attributes']['objectSid'][0].rsplit('-', 1)[0]
+        try:
+            rgroup = self.groups_by_sid['%s-%d' % (dsid, entry['attributes']['primaryGroupID'][0])]
+            outfile.write('%s,%s,%s\n' % (rgroup['principal'], principal, entry_type))
+        except KeyError:
+            logger.warning('Unknown rid %d. Not found in groups cache!', entry['attributes']['primaryGroupID'][0])
+
+    def parse_files(self, infiles):
+        filemap = {
+            'domain_users.json': self.user_files,
+            'domain_groups.json': self.group_files,
+            'domain_trusts.json': self.trust_files,
+            'domain_computers.json': self.computers_files,
+        }
+        for file in infiles:
+            # Get the filename
+            filename = file.split(os.sep)[-1]
+            try:
+                filemap[filename.lower()].append(file)
+            except KeyError:
+                logger.debug('Unknown input file: %s', filename)
+        return
+
+def ldd2bloodhound():
+    parser = argparse.ArgumentParser(description='LDAPDomainDump to BloodHound CSV converter utility. Supports users/computers/trusts conversion.')
+
+    #Main parameters
+    parser.add_argument("files", type=str, nargs='+', metavar='FILENAME', help="The ldapdomaindump json files to load. Required files: domain_users.json and domain_groups.json")
+    parser.add_argument("-d", "--debug", action='store_true', help="Enable debug logger")
+
+    args = parser.parse_args()
+    if args.debug:
+        logger.setLevel(logging.DEBUG)
+
+    converter = BloodHoundConverter()
+    converter.parse_files(args.files)
+    if len(converter.group_files) == 0:
+        logger.error('No domain_groups.json files were specified. Need at least one to perform conversion.')
+        return
+    if len(converter.user_files) == 0:
+        logger.error('No domain_users.json files were specified. Need at least one to perform conversion.')
+        return
+    logger.debug('Mapping groups...')
+    converter.build_mappings()
+    logger.debug('Processing users')
+    converter.write_users()
+    logger.debug('Processing groups')
+    converter.write_groups()
+    logger.debug('Processing computers')
+    converter.write_computers()
+    logger.debug('Processing trusts')
+    converter.write_trusts()
+    print('Done!')
+
+if __name__ == '__main__':
+    ldd2bloodhound()
diff --git a/ldapdomaindump/pretty.py b/ldapdomaindump/pretty.py
new file mode 100644
index 0000000..285f05a
--- /dev/null
+++ b/ldapdomaindump/pretty.py
@@ -0,0 +1,152 @@
+from __future__ import print_function
+from builtins import str
+import argparse
+import json
+import os.path
+import re
+from time import strftime, gmtime
+
+class PrettyOuput(object):
+
+    def d2b(self, a):
+        tbin = []
+        while a:
+            tbin.append(a % 2)
+            a //= 2
+
+        t2bin = tbin[::-1]
+        if len(t2bin) != 8:
+            for x in range(6 - len(t2bin)):
+                t2bin.insert(0, 0)
+        return ''.join([str(g) for g in t2bin])
+
+    def convert(self, time):
+        if isinstance(time, str):
+            return time
+        if time == 0:
+            return "None"
+        if time == -9223372036854775808:
+            return "Not Set"
+        sec = abs(time) // 10000000
+        days = sec // 86400
+        sec -= 86400*days
+        hrs = sec // 3600
+        sec -= 3600*hrs
+        mins = sec // 60
+        sec -= 60*mins
+        result = ""
+        if days > 1:
+            result += "{0} days ".format(days)
+        elif days == 1:
+            result += "{0} day ".format(days)
+        if hrs > 1:
+            result += "{0} hours ".format(hrs)
+        elif hrs == 1:
+            result += "{0} hour ".format(hrs)
+        if mins > 1:
+            result += "{0} minutes ".format(mins)
+        elif mins == 1:
+            result += "{0} minute ".format(mins)
+        return result
+
+    def password_complexity(self, data):
+
+        print('''
+    +-----------------------------------------+
+    | Password Policy Information             |
+    +-----------------------------------------+
+    ''')
+
+        print("[+] Password Info for Domain:", data[0]['attributes']['dc'][0].upper())
+        print("\t[+] Minimum password length: ", data[0]['attributes']['instanceType'][0])
+        print("\t[+] Password history length:", data[0]['attributes']['pwdHistoryLength'][0])
+
+        password_properties = self.d2b(data[0]['attributes']['pwdProperties'][0])
+        print("\t[+] Password Complexity Flags:", password_properties)
+        print("")
+        print("\t\t[+] Domain Refuse Password Change:", password_properties[0])
+        print("\t\t[+] Domain Password Store Cleartext:", password_properties[1])
+        print("\t\t[+] Domain Password Lockout Admins:", password_properties[2])
+        print("\t\t[+] Domain Password No Clear Change:", password_properties[3])
+        print("\t\t[+] Domain Password No Anon Change:", password_properties[4])
+        print("\t\t[+] Domain Password Complex:", password_properties[5])
+        print("")
+        print("\t[+] Maximum password age:", self.convert(data[0]['attributes']['maxPwdAge'][0]))
+        print("\t[+] Minimum password age:", self.convert(data[0]['attributes']['minPwdAge'][0]))
+        print("\t[+] Reset Account Lockout Counter:", self.convert(data[0]['attributes']['lockoutDuration'][0]))
+        print("\t[+] Account Lockout Threshold:", data[0]['attributes']['lockoutThreshold'][0])
+        print("\t[+] Forced Log off Time:", self.convert(data[0]['attributes']['forceLogoff'][0]))
+
+    def domain_info(self, data):
+        print('''
+    +--------------------------------------+
+    | Getting Domain Sid For               |
+    +--------------------------------------+
+    ''')
+        print('[+] Domain Name:', data[0]['attributes']['dc'][0])
+        print('Domain Sid:', data[0]['attributes']['objectSid'][0])
+        print('')
+        return data[0]['attributes']['dc'][0]
+
+    def user_info(self, users, dc):
+        print('''
+    +------------------------+
+    | Users Infos            |
+    +------------------------+
+    ''')
+        for user in users:
+            desc = user['attributes'].get('description')[0] if user['attributes'].get('description') else "(null)"
+            print("Account: " + dc + "\\" + user['attributes']['sAMAccountName'][0] + "\tName: " + user['attributes']['name'][0] + "\tDesc: " + desc)
+
+        print("")
+        for user in users:
+            print("user:[" + user['attributes']['sAMAccountName'][0] + "]")
+        print("")
+
+    def groups_info(self, groups, dc):
+        print('''
+    +------------------------+import os.path
+    | Groups Infos           |
+    +------------------------+
+    ''')
+        for group in groups:
+            print("group:[" + group['attributes']['name'][0] + "]")
+
+        for group in groups:
+            if group['attributes'].get('member'):
+                users = re.findall(r"^CN=([\w\s\-\_\{\}\.\$\#]+)", '\n'.join(group['attributes']['member']), re.M)
+                if users:
+                    print("\n[+] Getting domain group memberships:")
+                    for user in users:
+                        if user == "S-1-5-11":
+                            user = "NT AUTHORITY\\Authenticated Users"
+                        elif user == "S-1-5-4":
+                            user = "NT AUTHORITY\\INTERACTIVE"
+                        elif user == "S-1-5-17":
+                            user = "NT AUTHORITY\\IUSR"
+                        print("Group '" + group['attributes']['name'][0] + "' has member: " + dc + "\\" + user)
+
+def main():
+    parser = argparse.ArgumentParser(description='LDAPDomainDump to pretty output like enum4linux.')
+
+    #Main parameters
+    parser.add_argument("-d", "--directory", help="The ldapdomaindump directory where the json files are saved. Required files: domain_users.json, domain_groups.json and domain_policy.json")
+    args = parser.parse_args()
+
+    if args.directory:
+        with open(args.directory + '/domain_policy.json') as f:
+            domain = json.load(f)
+        with open(args.directory + '/domain_users.json') as f:
+            users = json.load(f)
+        with open(args.directory + '/domain_groups.json') as f:
+            groups = json.load(f)
+        pretty = PrettyOuput()
+        dc = pretty.domain_info(domain)
+        pretty.password_complexity(domain)
+        pretty.user_info(users, dc.upper())
+        pretty.groups_info(groups, dc.upper())
+    else:
+        print("Missing parameter --directory /output/")
+
+if __name__ == "__main__":
+    main()
diff --git a/ldapdomaindump/style.css b/ldapdomaindump/style.css
new file mode 100644
index 0000000..3daa5b5
--- /dev/null
+++ b/ldapdomaindump/style.css
@@ -0,0 +1,28 @@
+tbody th {
+    border: 1px solid #000;
+}
+tbody td {
+    border: 1px solid #ababab;
+    border-spacing: 0px;
+    padding: 4px;
+    border-collapse: collapse;
+}
+body {
+    font-family: verdana;
+}
+table {
+    font-size: 13px;
+    border-collapse: collapse;
+    width: 100%;
+}
+tbody tr:nth-child(odd) td {
+    background-color: #eee;
+}
+tbody tr:hover td {
+    background-color: lightblue;
+}
+thead td {
+    font-size: 19px;
+    font-weight: bold;
+    padding: 10px 0px;
+}
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..8a1b0e7
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,13 @@
+from setuptools import setup
+setup(name='ldapdomaindump',
+      version='0.9.3',
+      description='Active Directory information dumper via LDAP',
+      author='Dirk-jan Mollema',
+      author_email='dirkjan@sanoweb.nl',
+      url='https://github.com/dirkjanm/ldapdomaindump/',
+      packages=['ldapdomaindump'],
+      install_requires=['dnspython', 'ldap3>=2.5,!=2.5.2,!=2.5.0,!=2.6', 'future'],
+      package_data={'ldapdomaindump': ['style.css']},
+      include_package_data=True,
+      scripts=['bin/ldapdomaindump', 'bin/ldd2bloodhound', 'bin/ldd2pretty']
+      )