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