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