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