gpo: Move implementation from samba_gpoupdate
[nivanova/samba-autobuild/.git] / python / samba / gpclass.py
1 # Reads important GPO parameters and updates Samba
2 # Copyright (C) Luke Morrison <luc785@.hotmail.com> 2013
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 3 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17
18 import sys
19 import os
20 import tdb
21 sys.path.insert(0, "bin/python")
22 from samba import NTSTATUSError
23 from ConfigParser import ConfigParser
24 from StringIO import StringIO
25 from abc import ABCMeta, abstractmethod
26 import xml.etree.ElementTree as etree
27 import re
28 from samba.net import Net
29 from samba.dcerpc import nbt
30 from samba import smb
31 import samba.gpo as gpo
32 import chardet
33
34 try:
35     from enum import Enum
36     GPOSTATE = Enum('GPOSTATE', 'APPLY ENFORCE UNAPPLY')
37 except ImportError:
38     class GPOSTATE:
39         APPLY = 1
40         ENFORCE = 2
41         UNAPPLY = 3
42
43 class gp_log:
44     ''' Log settings overwritten by gpo apply
45     The gp_log is an xml file that stores a history of gpo changes (and the
46     original setting value).
47
48     The log is organized like so:
49
50 <gp>
51     <user name="KDC-1$">
52         <applylog>
53             <guid count="0" value="{31B2F340-016D-11D2-945F-00C04FB984F9}" />
54         </applylog>
55         <guid value="{31B2F340-016D-11D2-945F-00C04FB984F9}">
56             <gp_ext name="System Access">
57                 <attribute name="minPwdAge">-864000000000</attribute>
58                 <attribute name="maxPwdAge">-36288000000000</attribute>
59                 <attribute name="minPwdLength">7</attribute>
60                 <attribute name="pwdProperties">1</attribute>
61             </gp_ext>
62             <gp_ext name="Kerberos Policy">
63                 <attribute name="ticket_lifetime">1d</attribute>
64                 <attribute name="renew_lifetime" />
65                 <attribute name="clockskew">300</attribute>
66             </gp_ext>
67         </guid>
68     </user>
69 </gp>
70
71     Each guid value contains a list of extensions, which contain a list of
72     attributes. The guid value represents a GPO. The attributes are the values
73     of those settings prior to the application of the GPO.
74     The list of guids is enclosed within a user name, which represents the user
75     the settings were applied to. This user may be the samaccountname of the
76     local computer, which implies that these are machine policies.
77     The applylog keeps track of the order in which the GPOs were applied, so
78     that they can be rolled back in reverse, returning the machine to the state
79     prior to policy application.
80     '''
81     def __init__(self, user, gpostore, db_log=None):
82         ''' Initialize the gp_log
83         param user          - the username (or machine name) that policies are
84                               being applied to
85         param gpostore      - the GPOStorage obj which references the tdb which
86                               contains gp_logs
87         param db_log        - (optional) a string to initialize the gp_log
88         '''
89         self._state = GPOSTATE.APPLY
90         self.gpostore = gpostore
91         self.username = user
92         if db_log:
93             self.gpdb = etree.fromstring(db_log)
94         else:
95             self.gpdb = etree.Element('gp')
96         self.user = user
97         user_obj = self.gpdb.find('user[@name="%s"]' % user)
98         if user_obj is None:
99             user_obj = etree.SubElement(self.gpdb, 'user')
100             user_obj.attrib['name'] = user
101
102     def state(self, value):
103         ''' Policy application state
104         param value         - APPLY, ENFORCE, or UNAPPLY
105
106         The behavior of the gp_log depends on whether we are applying policy,
107         enforcing policy, or unapplying policy. During an apply, old settings
108         are recorded in the log. During an enforce, settings are being applied
109         but the gp_log does not change. During an unapply, additions to the log
110         should be ignored (since function calls to apply settings are actually
111         reverting policy), but removals from the log are allowed.
112         '''
113         # If we're enforcing, but we've unapplied, apply instead
114         if value == GPOSTATE.ENFORCE:
115             user_obj = self.gpdb.find('user[@name="%s"]' % self.user)
116             apply_log = user_obj.find('applylog')
117             if apply_log is None or len(apply_log) == 0:
118                 self._state = GPOSTATE.APPLY
119             else:
120                 self._state = value
121         else:
122             self._state = value
123
124     def set_guid(self, guid):
125         ''' Log to a different GPO guid
126         param guid          - guid value of the GPO from which we're applying
127                               policy
128         '''
129         self.guid = guid
130         user_obj = self.gpdb.find('user[@name="%s"]' % self.user)
131         obj = user_obj.find('guid[@value="%s"]' % guid)
132         if obj is None:
133             obj = etree.SubElement(user_obj, 'guid')
134             obj.attrib['value'] = guid
135         if self._state == GPOSTATE.APPLY:
136             apply_log = user_obj.find('applylog')
137             if apply_log is None:
138                 apply_log = etree.SubElement(user_obj, 'applylog')
139             item = etree.SubElement(apply_log, 'guid')
140             item.attrib['count'] = '%d' % (len(apply_log)-1)
141             item.attrib['value'] = guid
142
143     def apply_log_pop(self):
144         ''' Pop a GPO guid from the applylog
145         return              - last applied GPO guid
146
147         Removes the GPO guid last added to the list, which is the most recently
148         applied GPO.
149         '''
150         user_obj = self.gpdb.find('user[@name="%s"]' % self.user)
151         apply_log = user_obj.find('applylog')
152         if apply_log is not None:
153             ret = apply_log.find('guid[@count="%d"]' % (len(apply_log)-1))
154             if ret is not None:
155                 apply_log.remove(ret)
156                 return ret.attrib['value']
157             if len(apply_log) == 0 and apply_log in user_obj:
158                 user_obj.remove(apply_log)
159         return None
160
161     def store(self, gp_ext_name, attribute, old_val):
162         ''' Store an attribute in the gp_log
163         param gp_ext_name   - Name of the extension applying policy
164         param attribute     - The attribute being modified
165         param old_val       - The value of the attribute prior to policy
166                               application
167         '''
168         if self._state == GPOSTATE.UNAPPLY or self._state == GPOSTATE.ENFORCE:
169             return None
170         user_obj = self.gpdb.find('user[@name="%s"]' % self.user)
171         guid_obj = user_obj.find('guid[@value="%s"]' % self.guid)
172         assert guid_obj is not None, "gpo guid was not set"
173         ext = guid_obj.find('gp_ext[@name="%s"]' % gp_ext_name)
174         if ext is None:
175             ext = etree.SubElement(guid_obj, 'gp_ext')
176             ext.attrib['name'] = gp_ext_name
177         attr = ext.find('attribute[@name="%s"]' % attribute)
178         if attr is None:
179             attr = etree.SubElement(ext, 'attribute')
180             attr.attrib['name'] = attribute
181             attr.text = old_val
182
183     def retrieve(self, gp_ext_name, attribute):
184         ''' Retrieve a stored attribute from the gp_log
185         param gp_ext_name   - Name of the extension which applied policy
186         param attribute     - The attribute being retrieved
187         return              - The value of the attribute prior to policy
188                               application
189         '''
190         user_obj = self.gpdb.find('user[@name="%s"]' % self.user)
191         guid_obj = user_obj.find('guid[@value="%s"]' % self.guid)
192         assert guid_obj is not None, "gpo guid was not set"
193         ext = guid_obj.find('gp_ext[@name="%s"]' % gp_ext_name)
194         if ext is not None:
195             attr = ext.find('attribute[@name="%s"]' % attribute)
196             if attr is not None:
197                 return attr.text
198         return None
199
200     def list(self, gp_extensions):
201         ''' Return a list of attributes, their previous values, and functions
202             to set them
203         param gp_extensions - list of extension objects, for retrieving attr to
204                               func mappings
205         return              - list of (attr, value, apply_func) tuples for
206                               unapplying policy
207         '''
208         user_obj = self.gpdb.find('user[@name="%s"]' % self.user)
209         guid_obj = user_obj.find('guid[@value="%s"]' % self.guid)
210         assert guid_obj is not None, "gpo guid was not set"
211         ret = []
212         data_maps = {}
213         for gp_ext in gp_extensions:
214             data_maps.update(gp_ext.apply_map())
215         exts = guid_obj.findall('gp_ext')
216         if exts is not None:
217             for ext in exts:
218                 attrs = ext.findall('attribute')
219                 for attr in attrs:
220                     func = None
221                     if attr.attrib['name'] in data_maps[ext.attrib['name']]:
222                         func = data_maps[ext.attrib['name']]\
223                                [attr.attrib['name']][-1]
224                     else:
225                         for dmap in data_maps[ext.attrib['name']].keys():
226                             if data_maps[ext.attrib['name']][dmap][0] == \
227                                attr.attrib['name']:
228                                 func = data_maps[ext.attrib['name']][dmap][-1]
229                                 break
230                     ret.append((attr.attrib['name'], attr.text, func))
231         return ret
232
233     def delete(self, gp_ext_name, attribute):
234         ''' Remove an attribute from the gp_log
235         param gp_ext_name   - name of extension from which to remove the
236                               attribute
237         param attribute     - attribute to remove
238         '''
239         user_obj = self.gpdb.find('user[@name="%s"]' % self.user)
240         guid_obj = user_obj.find('guid[@value="%s"]' % self.guid)
241         assert guid_obj is not None, "gpo guid was not set"
242         ext = guid_obj.find('gp_ext[@name="%s"]' % gp_ext_name)
243         if ext is not None:
244             attr = ext.find('attribute[@name="%s"]' % attribute)
245             if attr is not None:
246                 ext.remove(attr)
247                 if len(ext) == 0:
248                     guid_obj.remove(ext)
249
250     def commit(self):
251         ''' Write gp_log changes to disk '''
252         self.gpostore.store(self.username, etree.tostring(self.gpdb, 'utf-8'))
253
254 class GPOStorage:
255     def __init__(self, log_file):
256         if os.path.isfile(log_file):
257             self.log = tdb.open(log_file)
258         else:
259             self.log = tdb.Tdb(log_file, 0, tdb.DEFAULT, os.O_CREAT|os.O_RDWR)
260
261     def start(self):
262         self.log.transaction_start()
263
264     def get_int(self, key):
265         try:
266             return int(self.log.get(key))
267         except TypeError:
268             return None
269
270     def get(self, key):
271         return self.log.get(key)
272
273     def get_gplog(self, user):
274         return gp_log(user, self, self.log.get(user))
275
276     def store(self, key, val):
277         self.log.store(key, val)
278
279     def cancel(self):
280         self.log.transaction_cancel()
281
282     def delete(self, key):
283         self.log.delete(key)
284
285     def commit(self):
286         self.log.transaction_commit()
287
288     def __del__(self):
289         self.log.close()
290
291 class gp_ext(object):
292     __metaclass__ = ABCMeta
293
294     def __init__(self, logger):
295         self.logger = logger
296
297     @abstractmethod
298     def list(self, rootpath):
299         pass
300
301     @abstractmethod
302     def apply_map(self):
303         pass
304
305     @abstractmethod
306     def read(self, policy):
307         pass
308
309     def parse(self, afile, ldb, conn, gp_db, lp):
310         self.ldb = ldb
311         self.gp_db = gp_db
312         self.lp = lp
313
314         # Fixing the bug where only some Linux Boxes capitalize MACHINE
315         try:
316             blist = afile.split('/')
317             idx = afile.lower().split('/').index('machine')
318             for case in [
319                             blist[idx].upper(),
320                             blist[idx].capitalize(),
321                             blist[idx].lower()
322                         ]:
323                 bfile = '/'.join(blist[:idx]) + '/' + case + '/' + \
324                     '/'.join(blist[idx+1:])
325                 try:
326                     return self.read(conn.loadfile(bfile.replace('/', '\\')))
327                 except NTSTATUSError:
328                     continue
329         except ValueError:
330             try:
331                 return self.read(conn.loadfile(afile.replace('/', '\\')))
332             except Exception as e:
333                 self.logger.error(str(e))
334                 return None
335
336     @abstractmethod
337     def __str__(self):
338         pass
339
340 class gp_ext_setter():
341     __metaclass__ = ABCMeta
342
343     def __init__(self, logger, ldb, gp_db, lp, attribute, val):
344         self.logger = logger
345         self.ldb = ldb
346         self.attribute = attribute
347         self.val = val
348         self.lp = lp
349         self.gp_db = gp_db
350
351     def explicit(self):
352         return self.val
353
354     def update_samba(self):
355         (upd_sam, value) = self.mapper().get(self.attribute)
356         upd_sam(value())
357
358     @abstractmethod
359     def mapper(self):
360         pass
361
362     @abstractmethod
363     def __str__(self):
364         pass
365
366 class inf_to_kdc_tdb(gp_ext_setter):
367     def mins_to_hours(self):
368         return '%d' % (int(self.val)/60)
369
370     def days_to_hours(self):
371         return '%d' % (int(self.val)*24)
372
373     def set_kdc_tdb(self, val):
374         old_val = self.gp_db.gpostore.get(self.attribute)
375         self.logger.info('%s was changed from %s to %s' % (self.attribute,
376                                                            old_val, val))
377         if val is not None:
378             self.gp_db.gpostore.store(self.attribute, val)
379             self.gp_db.store(str(self), self.attribute, old_val)
380         else:
381             self.gp_db.gpostore.delete(self.attribute)
382             self.gp_db.delete(str(self), self.attribute)
383
384     def mapper(self):
385         return { 'kdc:user_ticket_lifetime': (self.set_kdc_tdb, self.explicit),
386                  'kdc:service_ticket_lifetime': (self.set_kdc_tdb,
387                                                  self.mins_to_hours),
388                  'kdc:renewal_lifetime': (self.set_kdc_tdb,
389                                           self.days_to_hours),
390                }
391
392     def __str__(self):
393         return 'Kerberos Policy'
394
395 class inf_to_ldb(gp_ext_setter):
396     '''This class takes the .inf file parameter (essentially a GPO file mapped
397     to a GUID), hashmaps it to the Samba parameter, which then uses an ldb
398     object to update the parameter to Samba4. Not registry oriented whatsoever.
399     '''
400
401     def ch_minPwdAge(self, val):
402         old_val = self.ldb.get_minPwdAge()
403         self.logger.info('KDC Minimum Password age was changed from %s to %s' \
404                          % (old_val, val))
405         self.gp_db.store(str(self), self.attribute, old_val)
406         self.ldb.set_minPwdAge(val)
407
408     def ch_maxPwdAge(self, val):
409         old_val = self.ldb.get_maxPwdAge()
410         self.logger.info('KDC Maximum Password age was changed from %s to %s' \
411                          % (old_val, val))
412         self.gp_db.store(str(self), self.attribute, old_val)
413         self.ldb.set_maxPwdAge(val)
414
415     def ch_minPwdLength(self, val):
416         old_val = self.ldb.get_minPwdLength()
417         self.logger.info(
418             'KDC Minimum Password length was changed from %s to %s' \
419              % (old_val, val))
420         self.gp_db.store(str(self), self.attribute, old_val)
421         self.ldb.set_minPwdLength(val)
422
423     def ch_pwdProperties(self, val):
424         old_val = self.ldb.get_pwdProperties()
425         self.logger.info('KDC Password Properties were changed from %s to %s' \
426                          % (old_val, val))
427         self.gp_db.store(str(self), self.attribute, old_val)
428         self.ldb.set_pwdProperties(val)
429
430     def days2rel_nttime(self):
431         seconds = 60
432         minutes = 60
433         hours = 24
434         sam_add = 10000000
435         val = (self.val)
436         val = int(val)
437         return  str(-(val * seconds * minutes * hours * sam_add))
438
439     def mapper(self):
440         '''ldap value : samba setter'''
441         return { "minPwdAge" : (self.ch_minPwdAge, self.days2rel_nttime),
442                  "maxPwdAge" : (self.ch_maxPwdAge, self.days2rel_nttime),
443                  # Could be none, but I like the method assignment in
444                  # update_samba
445                  "minPwdLength" : (self.ch_minPwdLength, self.explicit),
446                  "pwdProperties" : (self.ch_pwdProperties, self.explicit),
447
448                }
449
450     def __str__(self):
451         return 'System Access'
452
453
454 class gp_inf_ext(gp_ext):
455     @abstractmethod
456     def list(self, rootpath):
457         pass
458
459     @abstractmethod
460     def apply_map(self):
461         pass
462
463     def read(self, policy):
464         ret = False
465         inftable = self.apply_map()
466
467         current_section = None
468
469         # So here we would declare a boolean,
470         # that would get changed to TRUE.
471         #
472         # If at any point in time a GPO was applied,
473         # then we return that boolean at the end.
474
475         inf_conf = ConfigParser()
476         inf_conf.optionxform=str
477         try:
478             inf_conf.readfp(StringIO(policy))
479         except:
480             inf_conf.readfp(StringIO(policy.decode('utf-16')))
481
482         for section in inf_conf.sections():
483             current_section = inftable.get(section)
484             if not current_section:
485                 continue
486             for key, value in inf_conf.items(section):
487                 if current_section.get(key):
488                     (att, setter) = current_section.get(key)
489                     value = value.encode('ascii', 'ignore')
490                     ret = True
491                     setter(self.logger, self.ldb, self.gp_db, self.lp, att,
492                            value).update_samba()
493                     self.gp_db.commit()
494         return ret
495
496     @abstractmethod
497     def __str__(self):
498         pass
499
500 class gp_sec_ext(gp_inf_ext):
501     '''This class does the following two things:
502         1) Identifies the GPO if it has a certain kind of filepath,
503         2) Finally parses it.
504     '''
505
506     count = 0
507
508     def __str__(self):
509         return "Security GPO extension"
510
511     def list(self, rootpath):
512         return os.path.join(rootpath,
513                             "MACHINE/Microsoft/Windows NT/SecEdit/GptTmpl.inf")
514
515     def listmachpol(self, rootpath):
516         return os.path.join(rootpath, "Machine/Registry.pol")
517
518     def listuserpol(self, rootpath):
519         return os.path.join(rootpath, "User/Registry.pol")
520
521     def apply_map(self):
522         return {"System Access": {"MinimumPasswordAge": ("minPwdAge",
523                                                          inf_to_ldb),
524                                   "MaximumPasswordAge": ("maxPwdAge",
525                                                          inf_to_ldb),
526                                   "MinimumPasswordLength": ("minPwdLength",
527                                                             inf_to_ldb),
528                                   "PasswordComplexity": ("pwdProperties",
529                                                          inf_to_ldb),
530                                  },
531                 "Kerberos Policy": {"MaxTicketAge": (
532                                         "kdc:user_ticket_lifetime",
533                                         inf_to_kdc_tdb
534                                     ),
535                                     "MaxServiceAge": (
536                                         "kdc:service_ticket_lifetime",
537                                         inf_to_kdc_tdb
538                                     ),
539                                     "MaxRenewAge": (
540                                         "kdc:renewal_lifetime",
541                                         inf_to_kdc_tdb
542                                     ),
543                                    }
544                }
545
546 ''' Fetch the hostname of a writable DC '''
547 def get_dc_hostname(creds, lp):
548     net = Net(creds=creds, lp=lp)
549     cldap_ret = net.finddc(domain=lp.get('realm'), flags=(nbt.NBT_SERVER_LDAP |
550         nbt.NBT_SERVER_DS))
551     return cldap_ret.pdc_dns_name
552
553 ''' Fetch a list of GUIDs for applicable GPOs '''
554 def get_gpo_list(dc_hostname, creds, lp):
555     gpos = []
556     ads = gpo.ADS_STRUCT(dc_hostname, lp, creds)
557     if ads.connect():
558         gpos = ads.get_gpo_list(creds.get_username())
559     return gpos
560
561 def apply_gp(lp, creds, test_ldb, logger, store, gp_extensions):
562     gp_db = store.get_gplog(creds.get_username())
563     dc_hostname = get_dc_hostname(creds, lp)
564     try:
565         conn =  smb.SMB(dc_hostname, 'sysvol', lp=lp, creds=creds)
566     except:
567         logger.error('Error connecting to \'%s\' using SMB' % dc_hostname)
568         raise
569     gpos = get_gpo_list(dc_hostname, creds, lp)
570
571     for gpo_obj in gpos:
572         guid = gpo_obj.name
573         if guid == 'Local Policy':
574             continue
575         path = os.path.join(lp.get('realm').lower(), 'Policies', guid)
576         local_path = os.path.join(lp.get("path", "sysvol"), path)
577         version = int(gpo.gpo_get_sysvol_gpt_version(local_path)[1])
578         if version != store.get_int(guid):
579             logger.info('GPO %s has changed' % guid)
580             gp_db.state(GPOSTATE.APPLY)
581         else:
582             gp_db.state(GPOSTATE.ENFORCE)
583         gp_db.set_guid(guid)
584         store.start()
585         for ext in gp_extensions:
586             try:
587                 ext.parse(ext.list(path), test_ldb, conn, gp_db, lp)
588             except Exception as e:
589                 logger.error('Failed to parse gpo %s for extension %s' % \
590                     (guid, str(ext)))
591                 logger.error('Message was: ' + str(e))
592                 store.cancel()
593                 continue
594         store.store(guid, '%i' % version)
595         store.commit()
596
597 def unapply_log(gp_db):
598     while True:
599         item = gp_db.apply_log_pop()
600         if item:
601             yield item
602         else:
603             break
604
605 def unapply_gp(lp, creds, test_ldb, logger, store, gp_extensions):
606     gp_db = store.get_gplog(creds.get_username())
607     gp_db.state(GPOSTATE.UNAPPLY)
608     for gpo_guid in unapply_log(gp_db):
609         gp_db.set_guid(gpo_guid)
610         unapply_attributes = gp_db.list(gp_extensions)
611         for attr in unapply_attributes:
612             attr_obj = attr[-1](logger, test_ldb, gp_db, lp, attr[0], attr[1])
613             attr_obj.mapper()[attr[0]][0](attr[1]) # Set the old value
614             gp_db.delete(str(attr_obj), attr[0])
615         gp_db.commit()
616