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