51437b24f32e5541e490643f034734f605932150
[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, lockfilename=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                clientcommand='rsync --server --sender -pr . /var/cache/userdir-ldap/hosts/%s'%(Host)
956                clientcommand="flock -s %s -c '%s'"%(lockfilename, clientcommand)
957                Line = 'command="%s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,from="%s" %s' % (clientcommand, ",".join(hosts), I)
958             else:
959                Line = "%s %s" %(",".join(HostNames + HostToIP(x, False)), I)
960             Line = Sanitize(Line) + "\n"
961             F.write(Line)
962    # Oops, something unspeakable happened.
963    except:
964       Die(File, F, None)
965       raise
966    Done(File, F, None)
967
968 # Generate the debianhosts file (list of all IP addresses)
969 def GenHosts(host_attrs, File):
970    F = None
971    try:
972       OldMask = os.umask(0022)
973       F = open(File + ".tmp", "w", 0644)
974       os.umask(OldMask)
975      
976       seen = set()
977
978       for x in host_attrs:
979
980          if IsDebianHost.match(GetAttr(x, "hostname")) is None:
981             continue
982
983          if not 'ipHostNumber' in x[1]:
984             continue
985
986          addrs = x[1]["ipHostNumber"]
987          for addr in addrs:
988             if addr not in seen:
989                seen.add(addr)
990                addr = Sanitize(addr) + "\n"
991                F.write(addr)
992
993    # Oops, something unspeakable happened.
994    except:
995       Die(File, F, None)
996       raise
997    Done(File, F, None)
998
999 def replaceTree(src, dst_basedir):
1000    bn = os.path.basename(src)
1001    dst = os.path.join(dst_basedir, bn)
1002    safe_rmtree(dst)
1003    shutil.copytree(src, dst)
1004
1005 def GenKeyrings(OutDir):
1006    for k in Keyrings:
1007       if os.path.isdir(k):
1008          replaceTree(k, OutDir)
1009       else:
1010          shutil.copy(k, OutDir)
1011
1012
1013 def get_accounts(ldap_conn):
1014    # Fetch all the users
1015    passwd_attrs = ldap_conn.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "(&(uid=*)(!(uidNumber=0))(objectClass=shadowAccount))",\
1016                    ["uid", "uidNumber", "gidNumber", "supplementaryGid",\
1017                     "gecos", "loginShell", "userPassword", "shadowLastChange",\
1018                     "shadowMin", "shadowMax", "shadowWarning", "shadowInactive",
1019                     "shadowExpire", "emailForward", "latitude", "longitude",\
1020                     "allowedHost", "sshRSAAuthKey", "dnsZoneEntry", "cn", "sn",\
1021                     "keyFingerPrint", "privateSub", "mailDisableMessage",\
1022                     "mailGreylisting", "mailCallout", "mailRBL", "mailRHSBL",\
1023                     "mailWhitelist", "sudoPassword", "objectClass", "accountStatus",\
1024                     "mailContentInspectionAction", "webPassword"])
1025
1026    if passwd_attrs is None:
1027       raise UDEmptyList, "No Users"
1028    accounts = map(lambda x: UDLdap.Account(x[0], x[1]), passwd_attrs)
1029    accounts.sort(lambda x,y: cmp(x['uid'].lower(), y['uid'].lower()))
1030
1031    return accounts
1032
1033 def get_hosts(ldap_conn):
1034    # Fetch all the hosts
1035    HostAttrs    = ldap_conn.search_s(HostBaseDn, ldap.SCOPE_ONELEVEL, "objectClass=debianServer",\
1036                    ["hostname", "sshRSAHostKey", "purpose", "allowedGroups", "exportOptions",\
1037                     "mXRecord", "ipHostNumber", "dnsTTL", "machine", "architecture"])
1038
1039    if HostAttrs == None:
1040       raise UDEmptyList, "No Hosts"
1041
1042    HostAttrs.sort(lambda x, y: cmp((GetAttr(x, "hostname")).lower(), (GetAttr(y, "hostname")).lower()))
1043
1044    return HostAttrs
1045
1046
1047 def make_ldap_conn():
1048    # Connect to the ldap server
1049    l = connectLDAP()
1050    # for testing purposes it's sometimes useful to pass username/password
1051    # via the environment
1052    if 'UD_CREDENTIALS' in os.environ:
1053       Pass = os.environ['UD_CREDENTIALS'].split()
1054    else:
1055       F = open(PassDir + "/pass-" + pwd.getpwuid(os.getuid())[0], "r")
1056       Pass = F.readline().strip().split(" ")
1057       F.close()
1058    l.simple_bind_s("uid=" + Pass[0] + "," + BaseDn, Pass[1])
1059
1060    return l
1061
1062
1063
1064 def setup_group_maps(l):
1065    # Fetch all the groups
1066    group_id_map = {}
1067    subgroup_map = {}
1068    attrs = l.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "gid=*",\
1069                      ["gid", "gidNumber", "subGroup"])
1070
1071    # Generate the subgroup_map and group_id_map
1072    for x in attrs:
1073       if x[1].has_key("accountStatus") and x[1]['accountStatus'] == "disabled":
1074          continue
1075       if x[1].has_key("gidNumber") == 0:
1076          continue
1077       group_id_map[x[1]["gid"][0]] = int(x[1]["gidNumber"][0])
1078       if x[1].has_key("subGroup") != 0:
1079          subgroup_map.setdefault(x[1]["gid"][0], []).extend(x[1]["subGroup"])
1080
1081    global SubGroupMap
1082    global GroupIDMap
1083    SubGroupMap = subgroup_map
1084    GroupIDMap = group_id_map
1085
1086 def generate_all(global_dir, ldap_conn):
1087    accounts = get_accounts(ldap_conn)
1088    host_attrs = get_hosts(ldap_conn)
1089
1090    global_dir += '/'
1091    # Generate global things
1092    accounts_disabled = GenDisabledAccounts(accounts, global_dir + "disabled-accounts")
1093
1094    accounts = filter(lambda x: not IsRetired(x), accounts)
1095    #accounts_DDs = filter(lambda x: IsGidDebian(x), accounts)
1096
1097    CheckForward(accounts)
1098
1099    GenMailDisable(accounts, global_dir + "mail-disable")
1100    GenCDB(accounts, global_dir + "mail-forward.cdb", 'emailForward')
1101    GenCDB(accounts, global_dir + "mail-contentinspectionaction.cdb", 'mailContentInspectionAction')
1102    GenPrivate(accounts, global_dir + "debian-private")
1103    GenSSHKnown(host_attrs, global_dir+"authorized_keys", 'authorized_keys', global_dir+'ud-generate.lock')
1104    GenMailBool(accounts, global_dir + "mail-greylist", "mailGreylisting")
1105    GenMailBool(accounts, global_dir + "mail-callout", "mailCallout")
1106    GenMailList(accounts, global_dir + "mail-rbl", "mailRBL")
1107    GenMailList(accounts, global_dir + "mail-rhsbl", "mailRHSBL")
1108    GenMailList(accounts, global_dir + "mail-whitelist", "mailWhitelist")
1109    GenWebPassword(accounts, global_dir + "web-passwords")
1110    GenKeyrings(global_dir)
1111
1112    # Compatibility.
1113    GenForward(accounts, global_dir + "forward-alias")
1114
1115    GenAllUsers(accounts, global_dir + 'all-accounts.json')
1116    accounts = filter(lambda a: not a in accounts_disabled, accounts)
1117
1118    ssh_userkeys = GenSSHShadow(global_dir, accounts)
1119    GenMarkers(accounts, global_dir + "markers")
1120    GenSSHKnown(host_attrs, global_dir + "ssh_known_hosts")
1121    GenHosts(host_attrs, global_dir + "debianhosts")
1122    GenSSHGitolite(accounts, global_dir + "ssh-gitolite")
1123
1124    GenDNS(accounts, global_dir + "dns-zone")
1125    GenZoneRecords(host_attrs, global_dir + "dns-sshfp")
1126
1127    setup_group_maps(ldap_conn)
1128
1129    for host in host_attrs:
1130       if not "hostname" in host[1]:
1131          continue
1132       generate_host(host, global_dir, accounts, ssh_userkeys)
1133
1134 def generate_host(host, global_dir, accounts, ssh_userkeys):
1135    current_host = host[1]['hostname'][0]
1136    OutDir = global_dir + current_host + '/'
1137    if not os.path.isdir(OutDir):
1138       os.mkdir(OutDir)
1139
1140    # Get the group list and convert any named groups to numerics
1141    GroupList = {}
1142    for groupname in AllowedGroupsPreload.strip().split(" "):
1143       GroupList[groupname] = True
1144    if 'allowedGroups' in host[1]:
1145       for groupname in host[1]['allowedGroups']:
1146          GroupList[groupname] = True
1147    for groupname in GroupList.keys():
1148       if groupname in GroupIDMap:
1149          GroupList[str(GroupIDMap[groupname])] = True
1150
1151    ExtraList = {}
1152    if 'exportOptions' in host[1]:
1153       for extra in host[1]['exportOptions']:
1154          ExtraList[extra.upper()] = True
1155
1156    if GroupList != {}:
1157       accounts = filter(lambda x: IsInGroup(x, GroupList, current_host), accounts)
1158
1159    DoLink(global_dir, OutDir, "debianhosts")
1160    DoLink(global_dir, OutDir, "ssh_known_hosts")
1161    DoLink(global_dir, OutDir, "disabled-accounts")
1162
1163    sys.stdout.flush()
1164    if 'NOPASSWD' in ExtraList:
1165       userlist = GenPasswd(accounts, OutDir + "passwd", HomePrefix, "*")
1166    else:
1167       userlist = GenPasswd(accounts, OutDir + "passwd", HomePrefix, "x")
1168    sys.stdout.flush()
1169    grouprevmap = GenGroup(accounts, OutDir + "group", current_host)
1170    GenShadowSudo(accounts, OutDir + "sudo-passwd", ('UNTRUSTED' in ExtraList) or ('NOPASSWD' in ExtraList), current_host)
1171
1172    # Now we know who we're allowing on the machine, export
1173    # the relevant ssh keys
1174    GenSSHtarballs(global_dir, userlist, ssh_userkeys, grouprevmap, os.path.join(OutDir, 'ssh-keys.tar.gz'), current_host)
1175
1176    if not 'NOPASSWD' in ExtraList:
1177       GenShadow(accounts, OutDir + "shadow")
1178
1179    # Link in global things
1180    if not 'NOMARKERS' in ExtraList:
1181       DoLink(global_dir, OutDir, "markers")
1182    DoLink(global_dir, OutDir, "mail-forward.cdb")
1183    DoLink(global_dir, OutDir, "mail-contentinspectionaction.cdb")
1184    DoLink(global_dir, OutDir, "mail-disable")
1185    DoLink(global_dir, OutDir, "mail-greylist")
1186    DoLink(global_dir, OutDir, "mail-callout")
1187    DoLink(global_dir, OutDir, "mail-rbl")
1188    DoLink(global_dir, OutDir, "mail-rhsbl")
1189    DoLink(global_dir, OutDir, "mail-whitelist")
1190    DoLink(global_dir, OutDir, "all-accounts.json")
1191    GenCDB(accounts, OutDir + "user-forward.cdb", 'emailForward')
1192    GenCDB(accounts, OutDir + "batv-tokens.cdb", 'bATVToken')
1193    GenCDB(accounts, OutDir + "default-mail-options.cdb", 'mailDefaultOptions')
1194
1195    # Compatibility.
1196    DoLink(global_dir, OutDir, "forward-alias")
1197
1198    if 'DNS' in ExtraList:
1199       DoLink(global_dir, OutDir, "dns-zone")
1200       DoLink(global_dir, OutDir, "dns-sshfp")
1201
1202    if 'AUTHKEYS' in ExtraList:
1203       DoLink(global_dir, OutDir, "authorized_keys")
1204
1205    if 'BSMTP' in ExtraList:
1206       GenBSMTP(accounts, OutDir + "bsmtp", HomePrefix)
1207
1208    if 'PRIVATE' in ExtraList:
1209       DoLink(global_dir, OutDir, "debian-private")
1210
1211    if 'GITOLITE' in ExtraList:
1212       DoLink(global_dir, OutDir, "ssh-gitolite")
1213
1214    if 'WEB-PASSWORDS' in ExtraList:
1215       DoLink(global_dir, OutDir, "web-passwords")
1216
1217    if 'KEYRING' in ExtraList:
1218       for k in Keyrings:
1219          bn = os.path.basename(k)
1220          if os.path.isdir(k):
1221             src = os.path.join(global_dir, bn)
1222             replaceTree(src, OutDir)
1223          else:
1224             DoLink(global_dir, OutDir, bn)
1225    else:
1226       for k in Keyrings:
1227          try:
1228             bn = os.path.basename(k)
1229             target = os.path.join(OutDir, bn)
1230             if os.path.isdir(target):
1231                safe_rmtree(dst)
1232             else:
1233                posix.remove(target)
1234          except:
1235             pass
1236    DoLink(global_dir, OutDir, "last_update.trace")
1237
1238
1239 def getLastLDAPChangeTime(l):
1240    mods = l.search_s('cn=log',
1241          ldap.SCOPE_ONELEVEL,
1242          '(&(&(!(reqMod=activity-from*))(!(reqMod=activity-pgp*)))(|(reqType=add)(reqType=delete)(reqType=modify)(reqType=modrdn)))',
1243          ['reqEnd'])
1244
1245    last = 0
1246
1247    # Sort the list by reqEnd
1248    sorted_mods = sorted(mods, key=lambda mod: mod[1]['reqEnd'][0].split('.')[0])
1249    # Take the last element in the array
1250    last = sorted_mods[-1][1]['reqEnd'][0].split('.')[0]
1251
1252    return last
1253
1254 def getLastBuildTime(gdir):
1255    cache_last_mod = 0
1256
1257    try:
1258       fd = open(os.path.join(gdir, "last_update.trace"), "r")
1259       cache_last_mod=fd.read().split()
1260       try:
1261          cache_last_mod = cache_last_mod[0]
1262       except IndexError:
1263          pass
1264       fd.close()
1265    except IOError, e:
1266       if e.errno == errno.ENOENT:
1267          pass
1268       else:
1269          raise e
1270
1271    return cache_last_mod
1272
1273
1274 def ud_generate():
1275    parser = optparse.OptionParser()
1276    parser.add_option("-g", "--generatedir", dest="generatedir", metavar="DIR",
1277      help="Output directory.")
1278    parser.add_option("-f", "--force", dest="force", action="store_true",
1279      help="Force generation, even if not update to LDAP has happened.")
1280
1281    (options, args) = parser.parse_args()
1282    if len(args) > 0:
1283       parser.print_help()
1284       sys.exit(1)
1285
1286    if options.generatedir is not None:
1287       generate_dir = os.environ['UD_GENERATEDIR']
1288    elif 'UD_GENERATEDIR' in os.environ:
1289       generate_dir = os.environ['UD_GENERATEDIR']
1290    else:
1291       generate_dir = GenerateDir
1292
1293
1294    lockf = os.path.join(generate_dir, 'ud-generate.lock')
1295    lock = get_lock( lockf )
1296    if lock is None:
1297       sys.stderr.write("Could not acquire lock %s.\n"%(lockf))
1298       sys.exit(1)
1299
1300    l = make_ldap_conn()
1301
1302    ldap_last_mod = getLastLDAPChangeTime(l)
1303    cache_last_mod = getLastBuildTime(generate_dir)
1304    need_update = ldap_last_mod > cache_last_mod
1305
1306    if not options.force and not need_update:
1307       fd = open(os.path.join(generate_dir, "last_update.trace"), "w")
1308       fd.write("%s\n%s\n" % (ldap_last_mod, int(time.time())))
1309       fd.close()
1310       sys.exit(0)
1311
1312    tracefd = open(os.path.join(generate_dir, "last_update.trace"), "w")
1313    generate_all(generate_dir, l)
1314    tracefd.write("%s\n%s\n" % (ldap_last_mod, int(time.time())))
1315    tracefd.close()
1316
1317
1318 if __name__ == "__main__":
1319    if 'UD_PROFILE' in os.environ:
1320       import cProfile
1321       import pstats
1322       cProfile.run('ud_generate()', "udg_prof")
1323       p = pstats.Stats('udg_prof')
1324       ##p.sort_stats('time').print_stats()
1325       p.sort_stats('cumulative').print_stats()
1326    else:
1327       ud_generate()
1328
1329 # vim:set et:
1330 # vim:set ts=3:
1331 # vim:set shiftwidth=3: