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