gpo: Initial commit for GPO work
authorLuke Morrison <luc785@hotmail.com>
Fri, 31 Jan 2014 00:27:05 +0000 (13:27 +1300)
committerGarming Sam <garming@samba.org>
Mon, 20 Nov 2017 20:41:14 +0000 (21:41 +0100)
Enclosed is my Summer of Code 2013 patch to have vital password GPO always applied to the Samba4 Domain Controller using a GPO update service.

To try it out "make -j" your samba with the patch, apply a security password GPO and see the difference in ~20 seconds. It also takes GPO hierarchy into account.

Split from "Initial commit for GPO work done by Luke Morrison" by David Mulder

Signed-off-by: Garming Sam <garming@catalyst.net.nz>
Signed-off-by: Luke Morrison <luke@hubtrek.com>
Reviewed-by: Andrew Bartlett <abartlet@samba.org>
python/samba/gpclass.py [new file with mode: 0644]
python/samba/samdb.py
source4/scripting/bin/samba_gpoupdate [new file with mode: 0755]

diff --git a/python/samba/gpclass.py b/python/samba/gpclass.py
new file mode 100644 (file)
index 0000000..304d670
--- /dev/null
@@ -0,0 +1,315 @@
+# Reads important GPO parameters and updates Samba
+# Copyright (C) Luke Morrison <luc785@.hotmail.com> 2013
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import sys
+import os
+sys.path.insert(0, "bin/python")
+import samba.gpo as gpo
+import optparse
+import ldb
+from samba.auth import system_session
+import samba.getopt as options
+from samba.samdb import SamDB
+from samba.netcmd import gpo as gpo_user
+import codecs
+from samba import NTSTATUSError
+
+class gp_ext(object):
+    def list(self, rootpath):
+        return None
+
+    def __str__(self):
+        return "default_gp_ext"
+
+
+class inf_to_ldb(object):
+    '''This class takes the .inf file parameter (essentially a GPO file mapped to a GUID),
+    hashmaps it to the Samba parameter, which then uses an ldb object to update the
+    parameter to Samba4. Not registry oriented whatsoever.
+    '''
+
+    def __init__(self, ldb, dn, attribute, val):
+        self.ldb = ldb
+        self.dn = dn
+        self.attribute = attribute
+        self.val = val
+
+    def ch_minPwdAge(self, val):
+        self.ldb.set_minPwdAge(val)
+
+    def ch_maxPwdAge(self, val):
+        self.ldb.set_maxPwdAge(val)
+
+    def ch_minPwdLength(self, val):
+        self.ldb.set_minPwdLength(val)
+
+    def ch_pwdProperties(self, val):
+        self.ldb.set_pwdProperties(val)
+
+    def explicit(self):
+        return self.val
+
+    def nttime2unix(self):
+        seconds = 60
+        minutes = 60
+        hours = 24
+        sam_add = 10000000
+        val = (self.val)
+        val = int(val)
+        return  str(-(val * seconds * minutes * hours * sam_add))
+
+    def mapper(self):
+        '''ldap value : samba setter'''
+        return { "minPwdAge" : (self.ch_minPwdAge, self.nttime2unix),
+                 "maxPwdAge" : (self.ch_maxPwdAge, self.nttime2unix),
+                 # Could be none, but I like the method assignment in update_samba
+                 "minPwdLength" : (self.ch_minPwdLength, self.explicit),
+                 "pwdProperties" : (self.ch_pwdProperties, self.explicit),
+
+               }
+
+    def update_samba(self):
+        (upd_sam, value) = self.mapper().get(self.attribute)
+        upd_sam(value())     # or val = value() then update(val)
+
+
+class gp_sec_ext(gp_ext):
+    '''This class does the following two things:
+        1) Identifies the GPO if it has a certain kind of filepath,
+        2) Finally parses it.
+    '''
+
+    count = 0
+
+    def __str__(self):
+        return "Security GPO extension"
+
+    def list(self, rootpath):
+        path = "%s%s" % (rootpath, "MACHINE/Microsoft/Windows NT/SecEdit/GptTmpl.inf")
+        return path
+
+    def listmachpol(self, rootpath):
+        path = "%s%s" % (rootpath, "Machine/Registry.pol")
+        return path
+
+    def listuserpol(self, rootpath):
+        path = "%s%s" % (rootpath, "User/Registry.pol")
+        return path
+
+    def populate_inf(self):
+        return {"System Access": {"MinimumPasswordAge": ("minPwdAge", inf_to_ldb),
+                                  "MaximumPasswordAge": ("maxPwdAge", inf_to_ldb),
+                                  "MinimumPasswordLength": ("minPwdLength", inf_to_ldb),
+                                  "PasswordComplexity": ("pwdProperties", inf_to_ldb),
+                                 }
+               }
+
+    def read_inf(self, path, conn, attr_log):
+        ret = False
+        inftable = self.populate_inf()
+
+        policy = conn.loadfile(path).decode('utf-16')
+        current_section = None
+        LOG = open(attr_log, "a")
+        LOG.write(str(path.split('/')[2]) + '\n')
+
+        # So here we would declare a boolean,
+        # that would get changed to TRUE.
+        #
+        # If at any point in time a GPO was applied,
+        # then we return that boolean at the end.
+
+        for line in policy.splitlines():
+            line = line.strip()
+            if line[0] == '[':
+                section = line[1: -1]
+                current_section = inftable.get(section.encode('ascii', 'ignore'))
+
+            else:
+                # We must be in a section
+                if not current_section:
+                    continue
+                (key, value) = line.split("=")
+                key = key.strip()
+                if current_section.get(key):
+                    (att, setter) = current_section.get(key)
+                    value = value.encode('ascii', 'ignore')
+                    ret = True
+                    setter(self.ldb, self.dn, att, value).update_samba()
+        return ret
+
+    def parse(self, afile, ldb, conn, attr_log):
+        self.ldb = ldb
+        self.dn = ldb.get_default_basedn()
+
+        # Fixing the bug where only some Linux Boxes capitalize MACHINE
+        if afile.endswith('inf'):
+            try:
+                blist = afile.split('/')
+                idx = afile.lower().split('/').index('machine')
+                for case in [blist[idx].upper(), blist[idx].capitalize(), blist[idx].lower()]:
+                    bfile = '/'.join(blist[:idx]) + '/' + case + '/' + '/'.join(blist[idx+1:])
+                    try:
+                        return self.read_inf(bfile, conn, attr_log)
+                    except NTSTATUSError:
+                        continue
+            except ValueError:
+                try:
+                    return self.read_inf(afile, conn, attr_log)
+                except:
+                    return None
+
+
+def scan_log(sysvol_path):
+    a = open(sysvol_path, "r")
+    data = {}
+    for line in a.readlines():
+        line = line.strip()
+        (guid, version) = line.split(" ")
+        data[guid] = int(version)
+    return data
+
+
+def Reset_Defaults(test_ldb):
+    test_ldb.set_minPwdAge(str(-25920000000000))
+    test_ldb.set_maxPwdAge(str(-38016000000000))
+    test_ldb.set_minPwdLength(str(7))
+    test_ldb.set_pwdProperties(str(1))
+
+
+def check_deleted(guid_list, backloggpo):
+    if backloggpo is None:
+        return False
+    for guid in backloggpo:
+        if guid not in guid_list:
+            return True
+    return False
+
+
+# The hierarchy is as per MS http://msdn.microsoft.com/en-us/library/windows/desktop/aa374155%28v=vs.85%29.aspx
+#
+# It does not care about local GPO, because GPO and snap-ins are not made in Linux yet.
+# It follows the linking order and children GPO are last written format.
+#
+# Also, couple further testing with call scripts entitled informant and informant2.
+# They explicitly show the returned hierarchically sorted list.
+
+
+def container_indexes(GUID_LIST):
+    '''So the original list will need to be seperated into containers.
+    Returns indexed list of when the container changes after hierarchy
+    '''
+    count = 0
+    container_indexes = []
+    while count < (len(GUID_LIST)-1):
+        if GUID_LIST[count][2] != GUID_LIST[count+1][2]:
+            container_indexes.append(count+1)
+        count += 1
+    container_indexes.append(len(GUID_LIST))
+    return container_indexes
+
+
+def sort_linked(SAMDB, guid_list, start, end):
+    '''So GPO in same level need to have link level.
+    This takes a container and sorts it.
+
+    TODO:  Small small problem, it is backwards
+    '''
+    containers = gpo_user.get_gpo_containers(SAMDB, guid_list[start][0])
+    for right_container in containers:
+        if right_container.get('dn') == guid_list[start][2]:
+            break
+    gplink = str(right_container.get('gPLink'))
+    gplink_split = gplink.split('[')
+    linked_order = []
+    ret_list = []
+    for ldap_guid in gplink_split:
+        linked_order.append(str(ldap_guid[10:48]))
+    count = len(linked_order) - 1
+    while count > 0:
+        ret_list.append([linked_order[count], guid_list[start][1], guid_list[start][2]])
+        count -= 1
+    return ret_list
+
+
+def establish_hierarchy(SamDB, GUID_LIST, DC_OU, global_dn):
+    '''Takes a list of GUID from gpo, and sorts them based on OU, and realm.
+    See http://msdn.microsoft.com/en-us/library/windows/desktop/aa374155%28v=vs.85%29.aspx
+    '''
+    final_list = []
+    count_unapplied_GPO = 0
+    for GUID in GUID_LIST:
+
+        container_iteration = 0
+        # Assume first it is not applied
+        applied = False
+        # Realm only written on last call, if the GPO is linked to multiple places
+        gpo_realm = False
+
+        # A very important call. This gets all of the linked information.
+        GPO_CONTAINERS = gpo_user.get_gpo_containers(SamDB, GUID)
+        for GPO_CONTAINER in GPO_CONTAINERS:
+
+            container_iteration += 1
+
+            if DC_OU == str(GPO_CONTAINER.get('dn')):
+                applied = True
+                insert_gpo = [GUID, applied, str(GPO_CONTAINER.get('dn'))]
+                final_list.append(insert_gpo)
+                break
+
+            if global_dn == str(GPO_CONTAINER.get('dn')) and (len(GPO_CONTAINERS) == 1):
+                gpo_realm = True
+                applied = True
+
+
+            if global_dn == str(GPO_CONTAINER.get('dn')) and (len(GPO_CONTAINERS) > 1):
+                gpo_realm = True
+                applied = True
+
+
+            if container_iteration == len(GPO_CONTAINERS):
+                if gpo_realm == False:
+                    insert_dud = [GUID, applied, str(GPO_CONTAINER.get('dn'))]
+                    final_list.insert(0, insert_dud)
+                    count_unapplied_GPO += 1
+                else:
+                    REALM_GPO = [GUID, applied, str(GPO_CONTAINER.get('dn'))]
+                    final_list.insert(count_unapplied_GPO, REALM_GPO)
+
+    # After GPO are sorted into containers, let's sort the containers themselves.
+    # But first we can get the GPO that we don't care about, out of the way.
+    indexed_places = container_indexes(final_list)
+    count = 0
+    unapplied_gpo = []
+    # Sorted by container
+    sorted_gpo_list = []
+
+    # Unapplied GPO live at start of list, append them to final list
+    while final_list[0][1] == False:
+        unapplied_gpo.append(final_list[count])
+        count += 1
+    count = 0
+    sorted_gpo_list += unapplied_gpo
+
+    # A single container call gets the linked order for all GPO in container.
+    # So we need one call per container - > index of the Original list
+    indexed_places.insert(0, 0)
+    while count < (len(indexed_places)-1):
+        sorted_gpo_list += (sort_linked(SamDB, final_list, indexed_places[count], indexed_places[count+1]))
+        count += 1
+    return sorted_gpo_list
index 6fe680d30d8a219357a6ac3e89f7970511249ee9..46456290c9ef2e203ca6e3a58f73f9d401bb275c 100644 (file)
@@ -831,6 +831,24 @@ accountExpires: %u
         else:
             return res[0]["minPwdAge"][0]
 
+    def set_maxPwdAge(self, value):
+        m = ldb.Message()
+        m.dn = ldb.Dn(self, self.domain_dn())
+        m["maxPwdAge"] = ldb.MessageElement(value, ldb.FLAG_MOD_REPLACE, "maxPwdAge")
+        self.modify(m)
+
+
+    def get_maxPwdAge(self):
+        res = self.search(self.domain_dn(), scope=ldb.SCOPE_BASE, attrs=["maxPwdAge"])
+        if len(res) == 0:
+            return None
+        elif not "maxPwdAge" in res[0]:
+            return None
+        else:
+            return res[0]["maxPwdAge"][0]
+
+
+
     def set_minPwdLength(self, value):
         m = ldb.Message()
         m.dn = ldb.Dn(self, self.domain_dn())
diff --git a/source4/scripting/bin/samba_gpoupdate b/source4/scripting/bin/samba_gpoupdate
new file mode 100755 (executable)
index 0000000..ba83dcf
--- /dev/null
@@ -0,0 +1,235 @@
+#!/usr/bin/env python
+# Copyright Luke Morrison <luc785@.hotmail.com> July 2013
+# Co-Edited by Matthieu Pattou July 2013 from original August 2013
+# Edited by Garming Sam Feb. 2014
+# Edited by Luke Morrison April 2014
+
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+'''This script reads a log file of previous GPO, gets all GPO from sysvol
+and sorts them by container. Then, it applies the ones that haven't been
+applied, have changed, or is in the right container'''
+
+import os
+import fcntl
+import sys
+import tempfile
+import subprocess
+
+sys.path.insert(0, "bin/python")
+
+import samba
+import optparse
+from samba import getopt as options
+from samba.gpclass import *
+from samba.net import Net
+from samba.dcerpc import nbt
+from samba import smb
+
+
+# Finds all GPO Files ending in inf
+def gp_path_list(path):
+
+    GPO_LIST = []
+    for ext in gp_extensions:
+        GPO_LIST.append((ext, ext.list(path)))
+    return GPO_LIST
+
+
+def gpo_parser(GPO_LIST, ldb, conn, attr_log):
+    '''The API method to parse the GPO
+    :param GPO_LIST:
+    :param ldb: Live instance of an LDB object AKA Samba
+    :param conn: Live instance of a CIFS connection
+    :param attr_log: backlog path for GPO and attribute to be written
+    no return except a newly updated Samba
+    '''
+
+    ret = False
+    for entry in GPO_LIST:
+        (ext, thefile) = entry
+        if ret == False:
+            ret = ext.parse(thefile, ldb, conn, attr_log)
+        else:
+            temp = ext.parse(thefile, ldb, conn, attr_log)
+    return ret
+
+
+class GPOServiceSetup:
+    def __init__(self):
+        """Initialize all components necessary to return instances of
+        a Samba lp context (smb.conf) and Samba LDB context
+        """
+
+        self.parser = optparse.OptionParser("samba_gpoupdate [options]")
+        self.sambaopts = options.SambaOptions(self.parser)
+        self.credopts = None
+        self.opts = None
+        self.args = None
+        self.lp = None
+        self.smbconf = None
+        self.creds = None
+        self.url = None
+
+    # Setters or Initializers
+    def init_parser(self):
+        '''Get the command line options'''
+        self.parser.add_option_group(self.sambaopts)
+        self.parser.add_option_group(options.VersionOptions(self.parser))
+        self.init_credopts()
+        self.parser.add_option("-H", dest="url", help="URL for the samdb")
+        self.parser.add_option_group(self.credopts)
+
+    def init_argsopts(self):
+        '''Set the options and the arguments'''
+        (opts, args) = self.parser.parse_args()
+
+        self.opts = opts
+        self.args = args
+
+    def init_credopts(self):
+        '''Set Credential operations'''
+        self.credopts = options.CredentialsOptions(self.parser)
+
+    def init_lp(self):
+        '''Set the loadparm context'''
+        self.lp = self.sambaopts.get_loadparm()
+        self.smbconf = self.lp.configfile
+        if (not self.opts.url):
+            self.url = self.lp.samdb_url()
+        else:
+            self.url = self.opts.url
+
+    def init_session(self):
+        '''Initialize the session'''
+        self.creds = self.credopts.get_credentials(self.lp,
+            fallback_machine=True)
+        self.session = system_session()
+
+    def InitializeService(self):
+        '''Inializer for the thread'''
+        self.init_parser()
+        self.init_argsopts()
+        self.init_lp()
+        self.init_session()
+
+    # Getters
+    def Get_LDB(self):
+        '''Return a live instance of Samba'''
+        SambaDB = SamDB(self.url, session_info=self.session,
+            credentials=self.creds, lp=self.lp)
+        return SambaDB
+
+    def Get_lp_Content(self):
+        '''Return an instance of a local lp context'''
+        return self.lp
+
+    def Get_Creds(self):
+        '''Return an instance of a local creds'''
+        return self.creds
+
+
+def GetBackLog(sys_log):
+    """Reads BackLog and makes thread aware of which GPO are unchanged or empty
+    :param String sys_log: path to backLog
+    :return Dictionary previous_scanned_version: {Unedited GPO: Version Number}
+    *NOTE on Version below
+    """
+    previous_scanned_version = {}
+    if os.path.isfile(sys_log):
+        previous_scanned_version = scan_log(sys_log)
+        return previous_scanned_version
+    else:
+        return None
+
+# Set up the GPO service
+GPOService = GPOServiceSetup()
+GPOService.InitializeService()
+
+# Get the Samba Instance
+test_ldb = GPOService.Get_LDB()
+
+# Get The lp context
+lp = GPOService.Get_lp_Content()
+
+# Get the CREDS
+creds = GPOService.Get_Creds()
+
+# Read the readable backLog into a hashmap
+# then open writable backLog in same location
+BackLoggedGPO = None
+sys_log = '%s/%s' % (lp.get("path", "sysvol"), 'syslog.txt')
+attr_log = '%s/%s' % (lp.get("path", "sysvol"), 'attrlog.txt')
+BackLoggedGPO = GetBackLog(sys_log)
+
+
+BackLog = open(sys_log, "w")
+
+
+# We need to know writable DC to setup SMB connection
+net = Net(creds=creds, lp=lp)
+cldap_ret = net.finddc(domain=lp.get('realm'), flags=(nbt.NBT_SERVER_LDAP |
+    nbt.NBT_SERVER_DS))
+dc_hostname = cldap_ret.pdc_dns_name
+
+try:
+    conn = smb.SMB(dc_hostname, 'sysvol', lp=lp, creds=creds)
+except Exception, e:
+    raise Exception("Error connecting to '%s' using SMB" % dc_hostname, e)
+
+# Get the dn of the domain, and the dn of readable/writable DC
+global_dn = test_ldb.domain_dn()
+DC_OU = "OU=Domain Controllers" + ',' + global_dn
+
+# Set up a List of the GUID for all GPO's
+guid_list = [x['name'] for x in conn.list('%s/Policies' % lp.get("realm").lower())]
+SYSV_PATH = '%s/%s/%s' % (lp.get("path", "sysvol"), lp.get("realm"), 'Policies')
+
+hierarchy_gpos = establish_hierarchy(test_ldb, guid_list, DC_OU, global_dn)
+change_backlog = False
+
+# Take a local list of all current GPO list and run it against previous GPO's
+# to see if something has changed. If so reset default and re-apply GPO.
+Applicable_GPO = []
+for i in hierarchy_gpos:
+    Applicable_GPO += i
+
+# Flag gets set when
+GPO_Changed = False
+GPO_Deleted = check_deleted(Applicable_GPO, BackLoggedGPO)
+if (GPO_Deleted):
+    # Null the backlog
+    BackLoggedGPO = {}
+    # Reset defaults then overwrite them
+    Reset_Defaults(test_ldb)
+    GPO_Changed = False
+
+for guid_eval in hierarchy_gpos:
+    guid = guid_eval[0]
+    gp_extensions = [gp_sec_ext()]
+    local_path = '%s/Policies' % lp.get("realm").lower() + '/' + guid + '/'
+    version = gpo.gpo_get_sysvol_gpt_version(lp.get("path", "sysvol") + '/' + local_path)[1]
+    gpolist = gp_path_list(local_path)
+    if(version != BackLoggedGPO.get(guid)):
+        GPO_Changed = True
+    # If the GPO has a dn that is applicable to Samba
+    if guid_eval[1]:
+        # If it has a GPO file that could apply to Samba
+        if gpolist[0][1]:
+            # If it we have not read it before and is not empty
+            # Rewrite entire logfile here
+            if  (version != 0) and GPO_Changed == True:
+                change_backlog = gpo_parser(gpolist, test_ldb, conn, attr_log)
+
+    BackLog.write('%s %i\n' % (guid, version))