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