Make ud-generate pull the last modification time out of ldap and only
[mirror/userdir-ldap.git] / ud-generate
1 #!/usr/bin/env python
2 # -*- mode: python -*-
3 # Generates passwd, shadow and group files from the ldap directory.
4
5 #   Copyright (c) 2000-2001  Jason Gunthorpe <jgg@debian.org>
6 #   Copyright (c) 2003-2004  James Troup <troup@debian.org>
7 #   Copyright (c) 2004-2005,7  Joey Schulze <joey@infodrom.org>
8 #   Copyright (c) 2001-2007  Ryan Murray <rmurray@debian.org>
9 #   Copyright (c) 2008,2009,2010,2011 Peter Palfrader <peter@palfrader.org>
10 #   Copyright (c) 2008 Andreas Barth <aba@not.so.argh.org>
11 #   Copyright (c) 2008 Mark Hymers <mhy@debian.org>
12 #   Copyright (c) 2008 Luk Claes <luk@debian.org>
13 #   Copyright (c) 2008 Thomas Viehmann <tv@beamnet.de>
14 #   Copyright (c) 2009 Stephen Gran <steve@lobefin.net>
15 #   Copyright (c) 2010 Helmut Grohne <helmut@subdivi.de>
16 #
17 #   This program is free software; you can redistribute it and/or modify
18 #   it under the terms of the GNU General Public License as published by
19 #   the Free Software Foundation; either version 2 of the License, or
20 #   (at your option) any later version.
21 #
22 #   This program is distributed in the hope that it will be useful,
23 #   but WITHOUT ANY WARRANTY; without even the implied warranty of
24 #   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
25 #   GNU General Public License for more details.
26 #
27 #   You should have received a copy of the GNU General Public License
28 #   along with this program; if not, write to the Free Software
29 #   Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
30
31 import string, re, time, ldap, getopt, sys, os, pwd, posix, socket, base64, hashlib, shutil, errno, tarfile, grp
32 import lockfile
33 from userdir_ldap import *
34 from userdir_exceptions import *
35 import UDLdap
36 try:
37    from cStringIO import StringIO
38 except ImportError:
39    from StringIO import StringIO
40 try:
41    import simplejson as json
42 except ImportError:
43    import json
44    if not '__author__' in json.__dict__:
45       sys.stderr.write("Warning: This is probably the wrong json module.  We want python 2.6's json\n")
46       sys.stderr.write("module, or simplejson on pytyon 2.5.  Let's see if/how stuff blows up.\n")
47
48 if os.getuid() == 0:
49    sys.stderr.write("You should probably not run ud-generate as root.\n")
50    sys.exit(1)
51
52
53 #
54 # GLOBAL STATE
55 #
56 GroupIDMap = {}
57 SubGroupMap = {}
58 Allowed = None
59 CurrentHost = ""
60
61
62
63 UUID_FORMAT = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
64
65 EmailCheck = re.compile("^([^ <>@]+@[^ ,<>@]+)?$")
66 BSMTPCheck = re.compile(".*mx 0 (master)\.debian\.org\..*",re.DOTALL)
67 PurposeHostField = re.compile(r".*\[\[([\*\-]?[a-z0-9.\-]*)(?:\|.*)?\]\]")
68 IsV6Addr = re.compile("^[a-fA-F0-9:]+$")
69 IsDebianHost = re.compile(ConfModule.dns_hostmatch)
70 isSSHFP = re.compile("^\s*IN\s+SSHFP")
71 DNSZone = ".debian.net"
72 Keyrings = ConfModule.sync_keyrings.split(":")
73
74 def safe_makedirs(dir):
75    try:
76       os.makedirs(dir)
77    except OSError, e:
78       if e.errno == errno.EEXIST:
79          pass
80       else:
81          raise e
82
83 def safe_rmtree(dir):
84    try:
85       shutil.rmtree(dir)
86    except OSError, e:
87       if e.errno == errno.ENOENT:
88          pass
89       else:
90          raise e
91
92 def get_lock(fn, wait=5*60, max_age=3600*6):
93    try:
94       stat = os.stat(fn)
95       if stat[ST_MTIME] < time.time() - max_age:
96          sys.stderr.write("Removing stale lock %s"%(fn))
97          os.unlink(fn)
98    except OSError, error:
99       if error.errno == errno.ENOENT:
100          pass
101       else:
102          raise
103
104    lock = lockfile.FileLock(fn)
105    try:
106       lock.acquire(timeout=wait)
107    except lockfile.LockTimeout:
108       return None
109
110    return lock
111
112
113 def Sanitize(Str):
114    return Str.translate(string.maketrans("\n\r\t", "$$$"))
115
116 def DoLink(From, To, File):
117    try: 
118       posix.remove(To + File)
119    except: 
120       pass
121    posix.link(From + File, To + File)
122
123 def IsRetired(account):
124    """
125    Looks for accountStatus in the LDAP record and tries to
126    match it against one of the known retired statuses
127    """
128
129    status = account['accountStatus']
130
131    line = status.split()
132    status = line[0]
133
134    if status == "inactive":
135       return True
136
137    elif status == "memorial":
138       return True
139
140    elif status == "retiring":
141       # We'll give them a few extra days over what we said
142       age = 6 * 31 * 24 * 60 * 60
143       try:
144          return (time.time() - time.mktime(time.strptime(line[1], "%Y-%m-%d"))) > age
145       except IndexError:
146          return False
147       except ValueError:
148          return False
149
150    return False
151
152 #def IsGidDebian(account):
153 #   return account['gidNumber'] == 800
154
155 # See if this user is in the group list
156 def IsInGroup(account):
157   if Allowed is None:
158      return True
159
160   # See if the primary group is in the list
161   if str(account['gidNumber']) in Allowed: return True
162
163   # Check the host based ACL
164   if account.is_allowed_by_hostacl(CurrentHost): return True
165
166   # See if there are supplementary groups
167   if not 'supplementaryGid' in account: return False
168
169   supgroups=[]
170   addGroups(supgroups, account['supplementaryGid'], account['uid'])
171   for g in supgroups:
172      if Allowed.has_key(g):
173         return True
174   return False
175
176 def Die(File, F, Fdb):
177    if F != None:
178       F.close()
179    if Fdb != None:
180       Fdb.close()
181    try: 
182       os.remove(File + ".tmp")
183    except:
184       pass
185    try: 
186       os.remove(File + ".tdb.tmp")
187    except: 
188       pass
189
190 def Done(File, F, Fdb):
191    if F != None:
192       F.close()
193       os.rename(File + ".tmp", File)
194    if Fdb != None:
195       Fdb.close()
196       os.rename(File + ".tdb.tmp", File + ".tdb")
197
198 # Generate the password list
199 def GenPasswd(accounts, File, HomePrefix, PwdMarker):
200    F = None
201    try:
202       F = open(File + ".tdb.tmp", "w")
203
204       userlist = {}
205       i = 0
206       for a in accounts:
207          if not IsInGroup(a): continue
208
209          # Do not let people try to buffer overflow some busted passwd parser.
210          if len(a['gecos']) > 100 or len(a['loginShell']) > 50: continue
211
212          userlist[a['uid']] = a['gidNumber']
213          line = "%s:%s:%d:%d:%s:%s%s:%s" % (
214                  a['uid'],
215                  PwdMarker,
216                  a['uidNumber'],
217                  a['gidNumber'],
218                  a['gecos'],
219                  HomePrefix, a['uid'],
220                  a['loginShell'])
221          line = Sanitize(line) + "\n"
222          F.write("0%u %s" % (i, line))
223          F.write(".%s %s" % (a['uid'], line))
224          F.write("=%d %s" % (a['uidNumber'], line))
225          i = i + 1
226
227    # Oops, something unspeakable happened.
228    except:
229       Die(File, None, F)
230       raise
231    Done(File, None, F)
232
233    # Return the list of users so we know which keys to export
234    return userlist
235
236 def GenAllUsers(accounts, file):
237    f = None
238    try:
239       OldMask = os.umask(0022)
240       f = open(file + ".tmp", "w", 0644)
241       os.umask(OldMask)
242
243       all = []
244       for a in accounts:
245          all.append( { 'uid': a['uid'],
246                        'uidNumber': a['uidNumber'],
247                        'active': a.pw_active() and a.shadow_active() } )
248       json.dump(all, f)
249
250    # Oops, something unspeakable happened.
251    except:
252       Die(file, f, None)
253       raise
254    Done(file, f, None)
255
256 # Generate the shadow list
257 def GenShadow(accounts, File):
258    F = None
259    try:
260       OldMask = os.umask(0077)
261       F = open(File + ".tdb.tmp", "w", 0600)
262       os.umask(OldMask)
263
264       i = 0
265       for a in accounts:
266          Pass = '*'
267          if not IsInGroup(a): continue
268
269          # If the account is locked, mark it as such in shadow
270          # See Debian Bug #308229 for why we set it to 1 instead of 0
271          if not a.pw_active():     ShadowExpire = '1'
272          elif 'shadowExpire' in a: ShadowExpire = str(a['shadowExpire'])
273          else:                     ShadowExpire = ''
274
275          values = []
276          values.append(a['uid'])
277          values.append(a.get_password())
278          for key in 'shadowLastChange', 'shadowMin', 'shadowMax', 'shadowWarning', 'shadowInactive':
279             if key in a: values.append(a[key])
280             else:        values.append('')
281          values.append(ShadowExpire)
282          line = ':'.join(values)+':'
283          line = Sanitize(line) + "\n"
284          F.write("0%u %s" % (i, line))
285          F.write(".%s %s" % (a['uid'], line))
286          i = i + 1
287
288    # Oops, something unspeakable happened.
289    except:
290       Die(File, None, F)
291       raise
292    Done(File, None, F)
293
294 # Generate the sudo passwd file
295 def GenShadowSudo(accounts, File, untrusted):
296    F = None
297    try:
298       OldMask = os.umask(0077)
299       F = open(File + ".tmp", "w", 0600)
300       os.umask(OldMask)
301
302       for a in accounts:
303          Pass = '*'
304          if not IsInGroup(a): continue
305      
306          if 'sudoPassword' in a:
307             for entry in a['sudoPassword']:
308                Match = re.compile('^('+UUID_FORMAT+') (confirmed:[0-9a-f]{40}|unconfirmed) ([a-z0-9.,*]+) ([^ ]+)$').match(entry)
309                if Match == None:
310                   continue
311                uuid = Match.group(1)
312                status = Match.group(2)
313                hosts = Match.group(3)
314                cryptedpass = Match.group(4)
315      
316                if status != 'confirmed:'+make_passwd_hmac('password-is-confirmed', 'sudo', a['uid'], uuid, hosts, cryptedpass):
317                   continue
318                for_all = hosts == "*"
319                for_this_host = CurrentHost in hosts.split(',')
320                if not (for_all or for_this_host):
321                   continue
322                # ignore * passwords for untrusted hosts, but copy host specific passwords
323                if for_all and untrusted:
324                   continue
325                Pass = cryptedpass
326                if for_this_host: # this makes sure we take a per-host entry over the for-all entry
327                   break
328             if len(Pass) > 50:
329                Pass = '*'
330      
331          Line = "%s:%s" % (a['uid'], Pass)
332          Line = Sanitize(Line) + "\n"
333          F.write("%s" % (Line))
334   
335    # Oops, something unspeakable happened.
336    except:
337       Die(File, F, None)
338       raise
339    Done(File, F, None)
340
341 # Generate the shadow list
342 def GenSSHShadow(global_dir, accounts):
343    # Fetch all the users
344    userfiles = []
345
346    safe_rmtree(os.path.join(global_dir, 'userkeys'))
347    safe_makedirs(os.path.join(global_dir, 'userkeys'))
348
349    for a in accounts:
350       if not 'sshRSAAuthKey' in a: continue
351
352       F = None
353       try:
354          OldMask = os.umask(0077)
355          File = os.path.join(global_dir, 'userkeys', a['uid'])
356          F = open(File + ".tmp", "w", 0600)
357          os.umask(OldMask)
358
359          for I in a['sshRSAAuthKey']:
360             MultipleLine = "%s" % I
361             MultipleLine = Sanitize(MultipleLine) + "\n"
362             F.write(MultipleLine)
363
364          Done(File, F, None)
365          userfiles.append(os.path.basename(File))
366
367       # Oops, something unspeakable happened.
368       except IOError:
369          Die(File, F, None)
370          # As neither masterFileName nor masterFile are defined at any point
371          # this will raise a NameError.
372          Die(masterFileName, masterFile, None)
373          raise
374
375    return userfiles
376
377 def GenSSHtarballs(global_dir, userlist, SSHFiles, grouprevmap, target):
378    OldMask = os.umask(0077)
379    tf = tarfile.open(name=os.path.join(global_dir, 'ssh-keys-%s.tar.gz' % CurrentHost), mode='w:gz')
380    os.umask(OldMask)
381    for f in userlist.keys():
382       if f not in SSHFiles:
383          continue
384       # If we're not exporting their primary group, don't export
385       # the key and warn
386       grname = None
387       if userlist[f] in grouprevmap.keys():
388          grname = grouprevmap[userlist[f]]
389       else:
390          try:
391             if int(userlist[f]) <= 100:
392                # In these cases, look it up in the normal way so we
393                # deal with cases where, for instance, users are in group
394                # users as their primary group.
395                grname = grp.getgrgid(userlist[f])[0]
396          except Exception, e:
397             pass
398
399       if grname is None:
400          print "User %s is supposed to have their key exported to host %s but their primary group (gid: %d) isn't in LDAP" % (f, CurrentHost, userlist[f])
401          continue
402
403       to = tf.gettarinfo(os.path.join(global_dir, 'userkeys', f), f)
404       # These will only be used where the username doesn't
405       # exist on the target system for some reason; hence,
406       # in those cases, the safest thing is for the file to
407       # be owned by root but group nobody.  This deals with
408       # the bloody obscure case where the group fails to exist
409       # whilst the user does (in which case we want to avoid
410       # ending up with a file which is owned user:root to avoid
411       # a fairly obvious attack vector)
412       to.uid = 0
413       to.gid = 65534
414       # Using the username / groupname fields avoids any need
415       # to give a shit^W^W^Wcare about the UIDoffset stuff.
416       to.uname = f
417       to.gname = grname
418       to.mode  = 0400
419
420       contents = file(os.path.join(global_dir, 'userkeys', f)).read()
421       lines = []
422       for line in contents.splitlines():
423          if line.startswith("allowed_hosts=") and ' ' in line:
424             machines, line = line.split('=', 1)[1].split(' ', 1)
425             if CurrentHost not in machines.split(','):
426                continue # skip this key
427          lines.append(line)
428       if not lines:
429          continue # no keys for this host
430       contents = "\n".join(lines) + "\n"
431       to.size = len(contents)
432       tf.addfile(to, StringIO(contents))
433
434    tf.close()
435    os.rename(os.path.join(global_dir, 'ssh-keys-%s.tar.gz' % CurrentHost), target)
436
437 # add a list of groups to existing groups,
438 # including all subgroups thereof, recursively.
439 # basically this proceduces the transitive hull of the groups in
440 # addgroups.
441 def addGroups(existingGroups, newGroups, uid):
442    for group in newGroups:
443       # if it's a <group>@host, split it and verify it's on the current host.
444       s = group.split('@', 1)
445       if len(s) == 2 and s[1] != CurrentHost:
446          continue
447       group = s[0]
448
449       # let's see if we handled this group already
450       if group in existingGroups:
451          continue
452
453       if not GroupIDMap.has_key(group):
454          print "Group", group, "does not exist but", uid, "is in it"
455          continue
456
457       existingGroups.append(group)
458
459       if SubGroupMap.has_key(group):
460          addGroups(existingGroups, SubGroupMap[group], uid)
461
462 # Generate the group list
463 def GenGroup(accounts, File):
464    grouprevmap = {}
465    F = None
466    try:
467       F = open(File + ".tdb.tmp", "w")
468      
469       # Generate the GroupMap
470       GroupMap = {}
471       for x in GroupIDMap.keys():
472          GroupMap[x] = []
473       GroupHasPrimaryMembers = {}
474
475       # Sort them into a list of groups having a set of users
476       for a in accounts:
477          GroupHasPrimaryMembers[ a['gidNumber'] ] = True
478          if not IsInGroup(a): continue
479          if not 'supplementaryGid' in a: continue
480
481          supgroups=[]
482          addGroups(supgroups, a['supplementaryGid'], a['uid'])
483          for g in supgroups:
484             GroupMap[g].append(a['uid'])
485
486       # Output the group file.
487       J = 0
488       for x in GroupMap.keys():
489          if GroupIDMap.has_key(x) == 0:
490             continue
491
492          if len(GroupMap[x]) == 0 and GroupIDMap[x] not in GroupHasPrimaryMembers:
493             continue
494
495          grouprevmap[GroupIDMap[x]] = x
496
497          Line = "%s:x:%u:" % (x, GroupIDMap[x])
498          Comma = ''
499          for I in GroupMap[x]:
500             Line = Line + ("%s%s" % (Comma, I))
501             Comma = ','
502          Line = Sanitize(Line) + "\n"
503          F.write("0%u %s" % (J, Line))
504          F.write(".%s %s" % (x, Line))
505          F.write("=%u %s" % (GroupIDMap[x], Line))
506          J = J + 1
507   
508    # Oops, something unspeakable happened.
509    except:
510       Die(File, None, F)
511       raise
512    Done(File, None, F)
513   
514    return grouprevmap
515
516 def CheckForward(accounts):
517    for a in accounts:
518       if not 'emailForward' in a: continue
519
520
521       delete = False
522
523       if not IsInGroup(a): delete = True
524       # Do not allow people to try to buffer overflow busted parsers
525       elif len(a['emailForward']) > 200: delete = True
526       # Check the forwarding address
527       elif EmailCheck.match(a['emailForward']) is None: delete = True
528
529       if delete:
530          a.delete_mailforward()
531
532 # Generate the email forwarding list
533 def GenForward(accounts, File):
534    F = None
535    try:
536       OldMask = os.umask(0022)
537       F = open(File + ".tmp", "w", 0644)
538       os.umask(OldMask)
539
540       for a in accounts:
541          if not 'emailForward' in a: continue
542          Line = "%s: %s" % (a['uid'], a['emailForward'])
543          Line = Sanitize(Line) + "\n"
544          F.write(Line)
545
546    # Oops, something unspeakable happened.
547    except:
548       Die(File, F, None)
549       raise
550    Done(File, F, None)
551
552 def GenCDB(accounts, File, key):
553    Fdb = None
554    try:
555       OldMask = os.umask(0022)
556       Fdb = os.popen("cdbmake %s %s.tmp"%(File, File), "w")
557       os.umask(OldMask)
558
559       # Write out the email address for each user
560       for a in accounts:
561          if not key in a: continue
562          value = a[key]
563          user = a['uid']
564          Fdb.write("+%d,%d:%s->%s\n" % (len(user), len(value), user, value))
565
566       Fdb.write("\n")
567    # Oops, something unspeakable happened.
568    except:
569       Fdb.close()
570       raise
571    if Fdb.close() != None:
572       raise "cdbmake gave an error"
573
574 # Generate the anon XEarth marker file
575 def GenMarkers(accounts, File):
576    F = None
577    try:
578       F = open(File + ".tmp", "w")
579
580       # Write out the position for each user
581       for a in accounts:
582          if not ('latitude' in a and 'longitude' in a): continue
583          try:
584             Line = "%8s %8s \"\""%(a.latitude_dec(True), a.longitude_dec(True))
585             Line = Sanitize(Line) + "\n"
586             F.write(Line)
587          except:
588             pass
589   
590    # Oops, something unspeakable happened.
591    except:
592       Die(File, F, None)
593       raise
594    Done(File, F, None)
595
596 # Generate the debian-private subscription list
597 def GenPrivate(accounts, File):
598    F = None
599    try:
600       F = open(File + ".tmp", "w")
601
602       # Write out the position for each user
603       for a in accounts:
604          if not a.is_active_user(): continue
605          if not 'privateSub' in a: continue
606          try:
607             Line = "%s"%(a['privateSub'])
608             Line = Sanitize(Line) + "\n"
609             F.write(Line)
610          except:
611             pass
612   
613    # Oops, something unspeakable happened.
614    except:
615       Die(File, F, None)
616       raise
617    Done(File, F, None)
618
619 # Generate a list of locked accounts
620 def GenDisabledAccounts(accounts, File):
621    F = None
622    try:
623       F = open(File + ".tmp", "w")
624       disabled_accounts = []
625
626       # Fetch all the users
627       for a in accounts:
628          if a.pw_active(): continue
629          Line = "%s:%s" % (a['uid'], "Account is locked")
630          disabled_accounts.append(a)
631          F.write(Sanitize(Line) + "\n")
632
633    # Oops, something unspeakable happened.
634    except:
635       Die(File, F, None)
636       raise
637    Done(File, F, None)
638    return disabled_accounts
639
640 # Generate the list of local addresses that refuse all mail
641 def GenMailDisable(accounts, File):
642    F = None
643    try:
644       F = open(File + ".tmp", "w")
645
646       for a in accounts:
647          if not 'mailDisableMessage' in a: continue
648          Line = "%s: %s"%(a['uid'], a['mailDisableMessage'])
649          Line = Sanitize(Line) + "\n"
650          F.write(Line)
651
652    # Oops, something unspeakable happened.
653    except:
654       Die(File, F, None)
655       raise
656    Done(File, F, None)
657
658 # Generate a list of uids that should have boolean affects applied
659 def GenMailBool(accounts, File, key):
660    F = None
661    try:
662       F = open(File + ".tmp", "w")
663
664       for a in accounts:
665          if not key in a: continue
666          if not a[key] == 'TRUE': continue
667          Line = "%s"%(a['uid'])
668          Line = Sanitize(Line) + "\n"
669          F.write(Line)
670
671    # Oops, something unspeakable happened.
672    except:
673       Die(File, F, None)
674       raise
675    Done(File, F, None)
676
677 # Generate a list of hosts for RBL or whitelist purposes.
678 def GenMailList(accounts, File, key):
679    F = None
680    try:
681       F = open(File + ".tmp", "w")
682
683       if key == "mailWhitelist": validregex = re.compile('^[-\w.]+(/[\d]+)?$')
684       else:                      validregex = re.compile('^[-\w.]+$')
685
686       for a in accounts:
687          if not key in a: continue
688
689          filtered = filter(lambda z: validregex.match(z), a[key])
690          if len(filtered) == 0: continue
691          if key == "mailRHSBL": filtered = map(lambda z: z+"/$sender_address_domain", filtered)
692          line = a['uid'] + ': ' + ' : '.join(filtered)
693          line = Sanitize(line) + "\n"
694          F.write(line)
695
696    # Oops, something unspeakable happened.
697    except:
698       Die(File, F, None)
699       raise
700    Done(File, F, None)
701
702 def isRoleAccount(account):
703    return 'debianRoleAccount' in account['objectClass']
704
705 # Generate the DNS Zone file
706 def GenDNS(accounts, File):
707    F = None
708    try:
709       F = open(File + ".tmp", "w")
710
711       # Fetch all the users
712       RRs = {}
713
714       # Write out the zone file entry for each user
715       for a in accounts:
716          if not 'dnsZoneEntry' in a: continue
717          if not a.is_active_user() and not isRoleAccount(a): continue
718
719          try:
720             F.write("; %s\n"%(a.email_address()))
721             for z in a["dnsZoneEntry"]:
722                Split = z.lower().split()
723                if Split[1].lower() == 'in':
724                   for y in range(0, len(Split)):
725                      if Split[y] == "$":
726                         Split[y] = "\n\t"
727                   Line = " ".join(Split) + "\n"
728                   F.write(Line)
729      
730                   Host = Split[0] + DNSZone
731                   if BSMTPCheck.match(Line) != None:
732                      F.write("; Has BSMTP\n")
733      
734                   # Write some identification information
735                   if not RRs.has_key(Host):
736                      if Split[2].lower() in ["a", "aaaa"]:
737                         Line = "%s IN TXT \"%s\"\n"%(Split[0], a.email_address())
738                         for y in a["keyFingerPrint"]:
739                            Line = Line + "%s IN TXT \"PGP %s\"\n"%(Split[0], FormatPGPKey(y))
740                            F.write(Line)
741                         RRs[Host] = 1
742                else:
743                   Line = "; Err %s"%(str(Split))
744                   F.write(Line)
745      
746             F.write("\n")
747          except Exception, e:
748             F.write("; Errors:\n")
749             for line in str(e).split("\n"):
750                F.write("; %s\n"%(line))
751             pass
752   
753    # Oops, something unspeakable happened.
754    except:
755       Die(File, F, None)
756       raise
757    Done(File, F, None)
758
759 def ExtractDNSInfo(x):
760
761    TTLprefix="\t"
762    if 'dnsTTL' in x[1]:
763       TTLprefix="%s\t"%(x[1]["dnsTTL"][0])
764
765    DNSInfo = []
766    if x[1].has_key("ipHostNumber"):
767       for I in x[1]["ipHostNumber"]:
768          if IsV6Addr.match(I) != None:
769             DNSInfo.append("%sIN\tAAAA\t%s" % (TTLprefix, I))
770          else:
771             DNSInfo.append("%sIN\tA\t%s" % (TTLprefix, I))
772
773    Algorithm = None
774
775    if 'sshRSAHostKey' in x[1]:
776       for I in x[1]["sshRSAHostKey"]:
777          Split = I.split()
778          if Split[0] == 'ssh-rsa':
779             Algorithm = 1
780          if Split[0] == 'ssh-dss':
781             Algorithm = 2
782          if Algorithm == None:
783             continue
784          Fingerprint = hashlib.new('sha1', base64.decodestring(Split[1])).hexdigest()
785          DNSInfo.append("%sIN\tSSHFP\t%u 1 %s" % (TTLprefix, Algorithm, Fingerprint))
786
787    if 'architecture' in x[1]:
788       Arch = GetAttr(x, "architecture")
789       Mach = ""
790       if x[1].has_key("machine"):
791          Mach = " " + GetAttr(x, "machine")
792       DNSInfo.append("%sIN\tHINFO\t\"%s%s\" \"%s\"" % (TTLprefix, Arch, Mach, "Debian GNU/Linux"))
793
794    if x[1].has_key("mXRecord"):
795       for I in x[1]["mXRecord"]:
796          DNSInfo.append("%sIN\tMX\t%s" % (TTLprefix, I))
797
798    return DNSInfo
799
800 # Generate the DNS records
801 def GenZoneRecords(host_attrs, File):
802    F = None
803    try:
804       F = open(File + ".tmp", "w")
805
806       # Fetch all the hosts
807       for x in host_attrs:
808          if x[1].has_key("hostname") == 0:
809             continue
810
811          if IsDebianHost.match(GetAttr(x, "hostname")) is None:
812             continue
813
814          DNSInfo = ExtractDNSInfo(x)
815          start = True
816          for Line in DNSInfo:
817             if start == True:
818                Line = "%s.\t%s" % (GetAttr(x, "hostname"), Line)
819                start = False
820             else:
821                Line = "\t\t\t%s" % (Line)
822
823             F.write(Line + "\n")
824
825         # this would write sshfp lines for services on machines
826         # but we can't yet, since some are cnames and we'll make
827         # an invalid zonefile
828         #
829         # for i in x[1].get("purpose", []):
830         #    m = PurposeHostField.match(i)
831         #    if m:
832         #       m = m.group(1)
833         #       # we ignore [[*..]] entries
834         #       if m.startswith('*'):
835         #          continue
836         #       if m.startswith('-'):
837         #          m = m[1:]
838         #       if m:
839         #          if not m.endswith(HostDomain):
840         #             continue
841         #          if not m.endswith('.'):
842         #             m = m + "."
843         #          for Line in DNSInfo:
844         #             if isSSHFP.match(Line):
845         #                Line = "%s\t%s" % (m, Line)
846         #                F.write(Line + "\n")
847
848    # Oops, something unspeakable happened.
849    except:
850       Die(File, F, None)
851       raise
852    Done(File, F, None)
853
854 # Generate the BSMTP file
855 def GenBSMTP(accounts, File, HomePrefix):
856    F = None
857    try:
858       F = open(File + ".tmp", "w")
859      
860       # Write out the zone file entry for each user
861       for a in accounts:
862          if not 'dnsZoneEntry' in a: continue
863          if not a.is_active_user(): continue
864
865          try:
866             for z in a["dnsZoneEntry"]:
867                Split = z.lower().split()
868                if Split[1].lower() == 'in':
869                   for y in range(0, len(Split)):
870                      if Split[y] == "$":
871                         Split[y] = "\n\t"
872                   Line = " ".join(Split) + "\n"
873      
874                   Host = Split[0] + DNSZone
875                   if BSMTPCheck.match(Line) != None:
876                       F.write("%s: user=%s group=Debian file=%s%s/bsmtp/%s\n"%(Host,
877                                   a['uid'], HomePrefix, a['uid'], Host))
878      
879          except:
880             F.write("; Errors\n")
881             pass
882   
883    # Oops, something unspeakable happened.
884    except:
885       Die(File, F, None)
886       raise
887    Done(File, F, None)
888   
889 def HostToIP(Host, mapped=True):
890
891    IPAdresses = []
892
893    if Host[1].has_key("ipHostNumber"):
894       for addr in Host[1]["ipHostNumber"]:
895          IPAdresses.append(addr)
896          if IsV6Addr.match(addr) is None and mapped == "True":
897             IPAdresses.append("::ffff:"+addr)
898
899    return IPAdresses
900
901 # Generate the ssh known hosts file
902 def GenSSHKnown(host_attrs, File, mode=None):
903    F = None
904    try:
905       OldMask = os.umask(0022)
906       F = open(File + ".tmp", "w", 0644)
907       os.umask(OldMask)
908      
909       for x in host_attrs:
910          if x[1].has_key("hostname") == 0 or \
911             x[1].has_key("sshRSAHostKey") == 0:
912             continue
913          Host = GetAttr(x, "hostname")
914          HostNames = [ Host ]
915          if Host.endswith(HostDomain):
916             HostNames.append(Host[:-(len(HostDomain) + 1)])
917      
918          # in the purpose field [[host|some other text]] (where some other text is optional)
919          # makes a hyperlink on the web thing. we now also add these hosts to the ssh known_hosts
920          # file.  But so that we don't have to add everything we link we can add an asterisk
921          # and say [[*... to ignore it.  In order to be able to add stuff to ssh without
922          # http linking it we also support [[-hostname]] entries.
923          for i in x[1].get("purpose", []):
924             m = PurposeHostField.match(i)
925             if m:
926                m = m.group(1)
927                # we ignore [[*..]] entries
928                if m.startswith('*'):
929                   continue
930                if m.startswith('-'):
931                   m = m[1:]
932                if m:
933                   HostNames.append(m)
934                   if m.endswith(HostDomain):
935                      HostNames.append(m[:-(len(HostDomain) + 1)])
936      
937          for I in x[1]["sshRSAHostKey"]:
938             if mode and mode == 'authorized_keys':
939                hosts = HostToIP(x)
940                if 'sshdistAuthKeysHost' in x[1]:
941                   hosts += x[1]['sshdistAuthKeysHost']
942                Line = 'command="rsync --server --sender -pr . /var/cache/userdir-ldap/hosts/%s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,from="%s" %s' % (Host, ",".join(hosts), I)
943             else:
944                Line = "%s %s" %(",".join(HostNames + HostToIP(x, False)), I)
945             Line = Sanitize(Line) + "\n"
946             F.write(Line)
947    # Oops, something unspeakable happened.
948    except:
949       Die(File, F, None)
950       raise
951    Done(File, F, None)
952
953 # Generate the debianhosts file (list of all IP addresses)
954 def GenHosts(host_attrs, File):
955    F = None
956    try:
957       OldMask = os.umask(0022)
958       F = open(File + ".tmp", "w", 0644)
959       os.umask(OldMask)
960      
961       seen = set()
962
963       for x in host_attrs:
964
965          if IsDebianHost.match(GetAttr(x, "hostname")) is None:
966             continue
967
968          if not 'ipHostNumber' in x[1]:
969             continue
970
971          addrs = x[1]["ipHostNumber"]
972          for addr in addrs:
973             if addr not in seen:
974                seen.add(addr)
975                addr = Sanitize(addr) + "\n"
976                F.write(addr)
977
978    # Oops, something unspeakable happened.
979    except:
980       Die(File, F, None)
981       raise
982    Done(File, F, None)
983
984 def replaceTree(src, dst_basedir):
985    bn = os.path.basename(src)
986    dst = os.path.join(dst_basedir, bn)
987    safe_rmtree(dst)
988    shutil.copytree(src, dst)
989
990 def GenKeyrings(OutDir):
991    for k in Keyrings:
992       if os.path.isdir(k):
993          replaceTree(k, OutDir)
994       else:
995          shutil.copy(k, OutDir)
996
997
998 def get_accounts(ldap_conn):
999    # Fetch all the users
1000    passwd_attrs = ldap_conn.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "(&(uid=*)(!(uidNumber=0))(objectClass=shadowAccount))",\
1001                    ["uid", "uidNumber", "gidNumber", "supplementaryGid",\
1002                     "gecos", "loginShell", "userPassword", "shadowLastChange",\
1003                     "shadowMin", "shadowMax", "shadowWarning", "shadowInactive",
1004                     "shadowExpire", "emailForward", "latitude", "longitude",\
1005                     "allowedHost", "sshRSAAuthKey", "dnsZoneEntry", "cn", "sn",\
1006                     "keyFingerPrint", "privateSub", "mailDisableMessage",\
1007                     "mailGreylisting", "mailCallout", "mailRBL", "mailRHSBL",\
1008                     "mailWhitelist", "sudoPassword", "objectClass", "accountStatus",\
1009                     "mailContentInspectionAction"])
1010
1011    if passwd_attrs is None:
1012       raise UDEmptyList, "No Users"
1013    accounts = map(lambda x: UDLdap.Account(x[0], x[1]), passwd_attrs)
1014    accounts.sort(lambda x,y: cmp(x['uid'].lower(), y['uid'].lower()))
1015
1016    return accounts
1017
1018 def get_hosts(ldap_conn):
1019    # Fetch all the hosts
1020    HostAttrs    = ldap_conn.search_s(HostBaseDn, ldap.SCOPE_ONELEVEL, "objectClass=debianServer",\
1021                    ["hostname", "sshRSAHostKey", "purpose", "allowedGroups", "exportOptions",\
1022                     "mXRecord", "ipHostNumber", "dnsTTL", "machine", "architecture"])
1023
1024    if HostAttrs == None:
1025       raise UDEmptyList, "No Hosts"
1026
1027    HostAttrs.sort(lambda x, y: cmp((GetAttr(x, "hostname")).lower(), (GetAttr(y, "hostname")).lower()))
1028
1029    return HostAttrs
1030
1031
1032 def make_ldap_conn():
1033    # Connect to the ldap server
1034    l = connectLDAP()
1035    # for testing purposes it's sometimes useful to pass username/password
1036    # via the environment
1037    if 'UD_CREDENTIALS' in os.environ:
1038       Pass = os.environ['UD_CREDENTIALS'].split()
1039    else:
1040       F = open(PassDir + "/pass-" + pwd.getpwuid(os.getuid())[0], "r")
1041       Pass = F.readline().strip().split(" ")
1042       F.close()
1043    l.simple_bind_s("uid=" + Pass[0] + "," + BaseDn, Pass[1])
1044
1045    return l
1046
1047 def generate_all(global_dir, ldap_conn):
1048    accounts = get_accounts(ldap_conn)
1049    host_attrs = get_hosts(ldap_conn)
1050
1051    global_dir += '/'
1052    # Generate global things
1053    accounts_disabled = GenDisabledAccounts(accounts, global_dir + "disabled-accounts")
1054
1055    accounts = filter(lambda x: not IsRetired(x), accounts)
1056    #accounts_DDs = filter(lambda x: IsGidDebian(x), accounts)
1057
1058    CheckForward(accounts)
1059
1060    GenMailDisable(accounts, global_dir + "mail-disable")
1061    GenCDB(accounts, global_dir + "mail-forward.cdb", 'emailForward')
1062    GenCDB(accounts, global_dir + "mail-contentinspectionaction.cdb", 'mailContentInspectionAction')
1063    GenPrivate(accounts, global_dir + "debian-private")
1064    GenSSHKnown(host_attrs, global_dir+"authorized_keys", 'authorized_keys')
1065    GenMailBool(accounts, global_dir + "mail-greylist", "mailGreylisting")
1066    GenMailBool(accounts, global_dir + "mail-callout", "mailCallout")
1067    GenMailList(accounts, global_dir + "mail-rbl", "mailRBL")
1068    GenMailList(accounts, global_dir + "mail-rhsbl", "mailRHSBL")
1069    GenMailList(accounts, global_dir + "mail-whitelist", "mailWhitelist")
1070    GenKeyrings(global_dir)
1071
1072    # Compatibility.
1073    GenForward(accounts, global_dir + "forward-alias")
1074
1075    GenAllUsers(accounts, global_dir + 'all-accounts.json')
1076    accounts = filter(lambda a: not a in accounts_disabled, accounts)
1077
1078    ssh_files = GenSSHShadow(global_dir, accounts)
1079    GenMarkers(accounts, global_dir + "markers")
1080    GenSSHKnown(host_attrs, global_dir + "ssh_known_hosts")
1081    GenHosts(host_attrs, global_dir + "debianhosts")
1082
1083    GenDNS(accounts, global_dir + "dns-zone")
1084    GenZoneRecords(host_attrs, global_dir + "dns-sshfp")
1085
1086    for host in host_attrs:
1087       if not "hostname" in host[1]:
1088          continue
1089       generate_host(host, global_dir, accounts, ssh_files)
1090
1091 def generate_host(host, global_dir, accounts, ssh_files):
1092    global CurrentHost
1093
1094    CurrentHost = host[1]['hostname'][0]
1095    OutDir = global_dir + CurrentHost + '/'
1096    try:
1097       os.mkdir(OutDir)
1098    except:
1099       pass
1100
1101    # Get the group list and convert any named groups to numerics
1102    GroupList = {}
1103    for groupname in AllowedGroupsPreload.strip().split(" "):
1104       GroupList[groupname] = True
1105    if 'allowedGroups' in host[1]:
1106       for groupname in host[1]['allowedGroups']:
1107          GroupList[groupname] = True
1108    for groupname in GroupList.keys():
1109       if groupname in GroupIDMap:
1110          GroupList[str(GroupIDMap[groupname])] = True
1111
1112    ExtraList = {}
1113    if 'exportOptions' in host[1]:
1114       for extra in host[1]['exportOptions']:
1115          ExtraList[extra.upper()] = True
1116
1117    global Allowed
1118    Allowed = GroupList
1119    if Allowed == {}:
1120       Allowed = None
1121
1122    DoLink(global_dir, OutDir, "debianhosts")
1123    DoLink(global_dir, OutDir, "ssh_known_hosts")
1124    DoLink(global_dir, OutDir, "disabled-accounts")
1125
1126    sys.stdout.flush()
1127    if 'NOPASSWD' in ExtraList:
1128       userlist = GenPasswd(accounts, OutDir + "passwd", HomePrefix, "*")
1129    else:
1130       userlist = GenPasswd(accounts, OutDir + "passwd", HomePrefix, "x")
1131    sys.stdout.flush()
1132    grouprevmap = GenGroup(accounts, OutDir + "group")
1133    GenShadowSudo(accounts, OutDir + "sudo-passwd", ('UNTRUSTED' in ExtraList) or ('NOPASSWD' in ExtraList))
1134
1135    # Now we know who we're allowing on the machine, export
1136    # the relevant ssh keys
1137    GenSSHtarballs(global_dir, userlist, ssh_files, grouprevmap, os.path.join(OutDir, 'ssh-keys.tar.gz'))
1138
1139    if not 'NOPASSWD' in ExtraList:
1140       GenShadow(accounts, OutDir + "shadow")
1141
1142    # Link in global things
1143    if not 'NOMARKERS' in ExtraList:
1144       DoLink(global_dir, OutDir, "markers")
1145    DoLink(global_dir, OutDir, "mail-forward.cdb")
1146    DoLink(global_dir, OutDir, "mail-contentinspectionaction.cdb")
1147    DoLink(global_dir, OutDir, "mail-disable")
1148    DoLink(global_dir, OutDir, "mail-greylist")
1149    DoLink(global_dir, OutDir, "mail-callout")
1150    DoLink(global_dir, OutDir, "mail-rbl")
1151    DoLink(global_dir, OutDir, "mail-rhsbl")
1152    DoLink(global_dir, OutDir, "mail-whitelist")
1153    DoLink(global_dir, OutDir, "all-accounts.json")
1154    GenCDB(filter(lambda x: IsInGroup(x), accounts), OutDir + "user-forward.cdb", 'emailForward')
1155    GenCDB(filter(lambda x: IsInGroup(x), accounts), OutDir + "batv-tokens.cdb", 'bATVToken')
1156    GenCDB(filter(lambda x: IsInGroup(x), accounts), OutDir + "default-mail-options.cdb", 'mailDefaultOptions')
1157
1158    # Compatibility.
1159    DoLink(global_dir, OutDir, "forward-alias")
1160
1161    if 'DNS' in ExtraList:
1162       DoLink(global_dir, OutDir, "dns-zone")
1163       DoLink(global_dir, OutDir, "dns-sshfp")
1164
1165    if 'AUTHKEYS' in ExtraList:
1166       DoLink(global_dir, OutDir, "authorized_keys")
1167
1168    if 'BSMTP' in ExtraList:
1169       GenBSMTP(accounts, OutDir + "bsmtp", HomePrefix)
1170
1171    if 'PRIVATE' in ExtraList:
1172       DoLink(global_dir, OutDir, "debian-private")
1173
1174    if 'KEYRING' in ExtraList:
1175       for k in Keyrings:
1176          bn = os.path.basename(k)
1177          if os.path.isdir(k):
1178             src = os.path.join(global_dir, bn)
1179             replaceTree(src, OutDir)
1180          else:
1181             DoLink(global_dir, OutDir, bn)
1182    else:
1183       for k in Keyrings:
1184          try:
1185             bn = os.path.basename(k)
1186             target = os.path.join(OutDir, bn)
1187             if os.path.isdir(target):
1188                safe_rmtree(dst)
1189             else:
1190                posix.remove(target)
1191          except:
1192             pass
1193
1194 l = make_ldap_conn()
1195
1196 mods = l.search_s('cn=log',
1197       ldap.SCOPE_ONELEVEL,
1198       '(&(&(!(reqMod=activity-from*))(!(reqMod=activity-pgp*)))(|(reqType=add)(reqType=delete)(reqType=modify)(reqType=modrdn)))',
1199       ['reqEnd'])
1200
1201 last = 0
1202
1203 # Sort the list by reqEnd
1204 sorted_mods = sorted(mods, key=lambda mod: mod[1]['reqEnd'][0].split('.')[0])
1205 # Take the last element in the array
1206 last = sorted_mods[-1][1]['reqEnd'][0].split('.')[0]
1207
1208 # override globaldir for testing
1209 if 'UD_GENERATEDIR' in os.environ:
1210    GenerateDir = os.environ['UD_GENERATEDIR']
1211
1212 cache_last_mod = 0
1213
1214 try:
1215    fd = open(os.path.join(GenerateDir, "last_update.trace"), "r")
1216    cache_last_mod=fd.read().strip()
1217    fd.close()
1218 except IOError, e:
1219    if e.errno == errno.ENOENT:
1220       pass
1221    else:
1222       raise e
1223 if cache_last_mod >= last:
1224    sys.exit(0)
1225
1226 fd = open(os.path.join(GenerateDir, "last_update.trace"), "w")
1227 fd.write(last)
1228 fd.close()
1229
1230 # Fetch all the groups
1231 GroupIDMap = {}
1232 attrs = l.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "gid=*",\
1233                   ["gid", "gidNumber", "subGroup"])
1234
1235 # Generate the SubGroupMap and GroupIDMap
1236 for x in attrs:
1237    if x[1].has_key("accountStatus") and x[1]['accountStatus'] == "disabled":
1238       continue
1239    if x[1].has_key("gidNumber") == 0:
1240       continue
1241    GroupIDMap[x[1]["gid"][0]] = int(x[1]["gidNumber"][0])
1242    if x[1].has_key("subGroup") != 0:
1243       SubGroupMap.setdefault(x[1]["gid"][0], []).extend(x[1]["subGroup"])
1244
1245 try:
1246    lock = get_lock( os.path.join(GenerateDir, 'ud-generate.lock') )
1247    if lock is None:
1248       sys.stderr.write("Could not acquire lock %s.\n"%(fn))
1249       sys.exit(1)
1250
1251    generate_all(GenerateDir, l)
1252
1253 finally:
1254    if not lock is None:
1255       lock.release()
1256
1257 # vim:set et:
1258 # vim:set ts=3:
1259 # vim:set shiftwidth=3: