add voipPassword
[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 # Generate the voipPassword list
394 def GenVoipPassword(accounts, File):
395    F = None
396    try:
397       OldMask = os.umask(0077)
398       F = open(File, "w", 0600)
399       os.umask(OldMask)
400
401       for a in accounts:
402          if not 'voipPassword' in a: continue
403          if not a.pw_active(): continue
404
405          Pass = str(a['voipPassword'])
406          Line = "<user id=\"%s\">\n <params>\n  <param name=\"password\" value=\"%s\"/>\n <params />\n</user>" % (a['uid'], Pass)
407          Line = Sanitize(Line) + "\n"
408          F.write("%s" % (Line))
409
410    except:
411       Die(File, None, F)
412       raise
413
414 def GenSSHtarballs(global_dir, userlist, ssh_userkeys, grouprevmap, target, current_host):
415    OldMask = os.umask(0077)
416    tf = tarfile.open(name=os.path.join(global_dir, 'ssh-keys-%s.tar.gz' % current_host), mode='w:gz')
417    os.umask(OldMask)
418    for f in userlist:
419       if f not in ssh_userkeys:
420          continue
421       # If we're not exporting their primary group, don't export
422       # the key and warn
423       grname = None
424       if userlist[f] in grouprevmap.keys():
425          grname = grouprevmap[userlist[f]]
426       else:
427          try:
428             if int(userlist[f]) <= 100:
429                # In these cases, look it up in the normal way so we
430                # deal with cases where, for instance, users are in group
431                # users as their primary group.
432                grname = grp.getgrgid(userlist[f])[0]
433          except Exception, e:
434             pass
435
436       if grname is None:
437          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])
438          continue
439
440       lines = []
441       for line in ssh_userkeys[f]:
442          if line.startswith("allowed_hosts=") and ' ' in line:
443             machines, line = line.split('=', 1)[1].split(' ', 1)
444             if current_host not in machines.split(','):
445                continue # skip this key
446          lines.append(line)
447       if not lines:
448          continue # no keys for this host
449       contents = "\n".join(lines) + "\n"
450
451       to = tarfile.TarInfo(name=f)
452       # These will only be used where the username doesn't
453       # exist on the target system for some reason; hence,
454       # in those cases, the safest thing is for the file to
455       # be owned by root but group nobody.  This deals with
456       # the bloody obscure case where the group fails to exist
457       # whilst the user does (in which case we want to avoid
458       # ending up with a file which is owned user:root to avoid
459       # a fairly obvious attack vector)
460       to.uid = 0
461       to.gid = 65534
462       # Using the username / groupname fields avoids any need
463       # to give a shit^W^W^Wcare about the UIDoffset stuff.
464       to.uname = f
465       to.gname = grname
466       to.mode  = 0400
467       to.mtime = int(time.time())
468       to.size = len(contents)
469
470       tf.addfile(to, StringIO(contents))
471
472    tf.close()
473    os.rename(os.path.join(global_dir, 'ssh-keys-%s.tar.gz' % current_host), target)
474
475 # add a list of groups to existing groups,
476 # including all subgroups thereof, recursively.
477 # basically this proceduces the transitive hull of the groups in
478 # addgroups.
479 def addGroups(existingGroups, newGroups, uid, current_host):
480    for group in newGroups:
481       # if it's a <group>@host, split it and verify it's on the current host.
482       s = group.split('@', 1)
483       if len(s) == 2 and s[1] != current_host:
484          continue
485       group = s[0]
486
487       # let's see if we handled this group already
488       if group in existingGroups:
489          continue
490
491       if not GroupIDMap.has_key(group):
492          print "Group", group, "does not exist but", uid, "is in it"
493          continue
494
495       existingGroups.append(group)
496
497       if SubGroupMap.has_key(group):
498          addGroups(existingGroups, SubGroupMap[group], uid, current_host)
499
500 # Generate the group list
501 def GenGroup(accounts, File, current_host):
502    grouprevmap = {}
503    F = None
504    try:
505       F = open(File + ".tdb.tmp", "w")
506      
507       # Generate the GroupMap
508       GroupMap = {}
509       for x in GroupIDMap:
510          GroupMap[x] = []
511       GroupHasPrimaryMembers = {}
512
513       # Sort them into a list of groups having a set of users
514       for a in accounts:
515          GroupHasPrimaryMembers[ a['gidNumber'] ] = True
516          if not 'supplementaryGid' in a: continue
517
518          supgroups=[]
519          addGroups(supgroups, a['supplementaryGid'], a['uid'], current_host)
520          for g in supgroups:
521             GroupMap[g].append(a['uid'])
522
523       # Output the group file.
524       J = 0
525       for x in GroupMap.keys():
526          if not x in GroupIDMap:
527             continue
528
529          if len(GroupMap[x]) == 0 and GroupIDMap[x] not in GroupHasPrimaryMembers:
530             continue
531
532          grouprevmap[GroupIDMap[x]] = x
533
534          Line = "%s:x:%u:" % (x, GroupIDMap[x])
535          Comma = ''
536          for I in GroupMap[x]:
537             Line = Line + ("%s%s" % (Comma, I))
538             Comma = ','
539          Line = Sanitize(Line) + "\n"
540          F.write("0%u %s" % (J, Line))
541          F.write(".%s %s" % (x, Line))
542          F.write("=%u %s" % (GroupIDMap[x], Line))
543          J = J + 1
544   
545    # Oops, something unspeakable happened.
546    except:
547       Die(File, None, F)
548       raise
549    Done(File, None, F)
550   
551    return grouprevmap
552
553 def CheckForward(accounts):
554    for a in accounts:
555       if not 'emailForward' in a: continue
556
557       delete = False
558
559       # Do not allow people to try to buffer overflow busted parsers
560       if len(a['emailForward']) > 200: delete = True
561       # Check the forwarding address
562       elif EmailCheck.match(a['emailForward']) is None: delete = True
563
564       if delete:
565          a.delete_mailforward()
566
567 # Generate the email forwarding list
568 def GenForward(accounts, File):
569    F = None
570    try:
571       OldMask = os.umask(0022)
572       F = open(File + ".tmp", "w", 0644)
573       os.umask(OldMask)
574
575       for a in accounts:
576          if not 'emailForward' in a: continue
577          Line = "%s: %s" % (a['uid'], a['emailForward'])
578          Line = Sanitize(Line) + "\n"
579          F.write(Line)
580
581    # Oops, something unspeakable happened.
582    except:
583       Die(File, F, None)
584       raise
585    Done(File, F, None)
586
587 def GenCDB(accounts, File, key):
588    Fdb = None
589    try:
590       OldMask = os.umask(0022)
591       # nothing else does the fsync stuff, so why do it here?
592       prefix = "/usr/bin/eatmydata " if os.path.exists('/usr/bin/eatmydata') else ''
593       Fdb = os.popen("%scdbmake %s %s.tmp"%(prefix, 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, lockfilename=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                clientcommand='rsync --server --sender -pr . /var/cache/userdir-ldap/hosts/%s'%(Host)
977                clientcommand="flock -s %s -c '%s'"%(lockfilename, clientcommand)
978                Line = 'command="%s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,from="%s" %s' % (clientcommand, ",".join(hosts), I)
979             else:
980                Line = "%s %s" %(",".join(HostNames + HostToIP(x, False)), I)
981             Line = Sanitize(Line) + "\n"
982             F.write(Line)
983    # Oops, something unspeakable happened.
984    except:
985       Die(File, F, None)
986       raise
987    Done(File, F, None)
988
989 # Generate the debianhosts file (list of all IP addresses)
990 def GenHosts(host_attrs, File):
991    F = None
992    try:
993       OldMask = os.umask(0022)
994       F = open(File + ".tmp", "w", 0644)
995       os.umask(OldMask)
996      
997       seen = set()
998
999       for x in host_attrs:
1000
1001          if IsDebianHost.match(GetAttr(x, "hostname")) is None:
1002             continue
1003
1004          if not 'ipHostNumber' in x[1]:
1005             continue
1006
1007          addrs = x[1]["ipHostNumber"]
1008          for addr in addrs:
1009             if addr not in seen:
1010                seen.add(addr)
1011                addr = Sanitize(addr) + "\n"
1012                F.write(addr)
1013
1014    # Oops, something unspeakable happened.
1015    except:
1016       Die(File, F, None)
1017       raise
1018    Done(File, F, None)
1019
1020 def replaceTree(src, dst_basedir):
1021    bn = os.path.basename(src)
1022    dst = os.path.join(dst_basedir, bn)
1023    safe_rmtree(dst)
1024    shutil.copytree(src, dst)
1025
1026 def GenKeyrings(OutDir):
1027    for k in Keyrings:
1028       if os.path.isdir(k):
1029          replaceTree(k, OutDir)
1030       else:
1031          shutil.copy(k, OutDir)
1032
1033
1034 def get_accounts(ldap_conn):
1035    # Fetch all the users
1036    passwd_attrs = ldap_conn.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "(&(uid=*)(!(uidNumber=0))(objectClass=shadowAccount))",\
1037                    ["uid", "uidNumber", "gidNumber", "supplementaryGid",\
1038                     "gecos", "loginShell", "userPassword", "shadowLastChange",\
1039                     "shadowMin", "shadowMax", "shadowWarning", "shadowInactive",
1040                     "shadowExpire", "emailForward", "latitude", "longitude",\
1041                     "allowedHost", "sshRSAAuthKey", "dnsZoneEntry", "cn", "sn",\
1042                     "keyFingerPrint", "privateSub", "mailDisableMessage",\
1043                     "mailGreylisting", "mailCallout", "mailRBL", "mailRHSBL",\
1044                     "mailWhitelist", "sudoPassword", "objectClass", "accountStatus",\
1045                     "mailContentInspectionAction", "webPassword", "voipPassword"])
1046
1047    if passwd_attrs is None:
1048       raise UDEmptyList, "No Users"
1049    accounts = map(lambda x: UDLdap.Account(x[0], x[1]), passwd_attrs)
1050    accounts.sort(lambda x,y: cmp(x['uid'].lower(), y['uid'].lower()))
1051
1052    return accounts
1053
1054 def get_hosts(ldap_conn):
1055    # Fetch all the hosts
1056    HostAttrs    = ldap_conn.search_s(HostBaseDn, ldap.SCOPE_ONELEVEL, "objectClass=debianServer",\
1057                    ["hostname", "sshRSAHostKey", "purpose", "allowedGroups", "exportOptions",\
1058                     "mXRecord", "ipHostNumber", "dnsTTL", "machine", "architecture"])
1059
1060    if HostAttrs == None:
1061       raise UDEmptyList, "No Hosts"
1062
1063    HostAttrs.sort(lambda x, y: cmp((GetAttr(x, "hostname")).lower(), (GetAttr(y, "hostname")).lower()))
1064
1065    return HostAttrs
1066
1067
1068 def make_ldap_conn():
1069    # Connect to the ldap server
1070    l = connectLDAP()
1071    # for testing purposes it's sometimes useful to pass username/password
1072    # via the environment
1073    if 'UD_CREDENTIALS' in os.environ:
1074       Pass = os.environ['UD_CREDENTIALS'].split()
1075    else:
1076       F = open(PassDir + "/pass-" + pwd.getpwuid(os.getuid())[0], "r")
1077       Pass = F.readline().strip().split(" ")
1078       F.close()
1079    l.simple_bind_s("uid=" + Pass[0] + "," + BaseDn, Pass[1])
1080
1081    return l
1082
1083
1084
1085 def setup_group_maps(l):
1086    # Fetch all the groups
1087    group_id_map = {}
1088    subgroup_map = {}
1089    attrs = l.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "gid=*",\
1090                      ["gid", "gidNumber", "subGroup"])
1091
1092    # Generate the subgroup_map and group_id_map
1093    for x in attrs:
1094       if x[1].has_key("accountStatus") and x[1]['accountStatus'] == "disabled":
1095          continue
1096       if x[1].has_key("gidNumber") == 0:
1097          continue
1098       group_id_map[x[1]["gid"][0]] = int(x[1]["gidNumber"][0])
1099       if x[1].has_key("subGroup") != 0:
1100          subgroup_map.setdefault(x[1]["gid"][0], []).extend(x[1]["subGroup"])
1101
1102    global SubGroupMap
1103    global GroupIDMap
1104    SubGroupMap = subgroup_map
1105    GroupIDMap = group_id_map
1106
1107 def generate_all(global_dir, ldap_conn):
1108    accounts = get_accounts(ldap_conn)
1109    host_attrs = get_hosts(ldap_conn)
1110
1111    global_dir += '/'
1112    # Generate global things
1113    accounts_disabled = GenDisabledAccounts(accounts, global_dir + "disabled-accounts")
1114
1115    accounts = filter(lambda x: not IsRetired(x), accounts)
1116    #accounts_DDs = filter(lambda x: IsGidDebian(x), accounts)
1117
1118    CheckForward(accounts)
1119
1120    GenMailDisable(accounts, global_dir + "mail-disable")
1121    GenCDB(accounts, global_dir + "mail-forward.cdb", 'emailForward')
1122    GenCDB(accounts, global_dir + "mail-contentinspectionaction.cdb", 'mailContentInspectionAction')
1123    GenPrivate(accounts, global_dir + "debian-private")
1124    GenSSHKnown(host_attrs, global_dir+"authorized_keys", 'authorized_keys', global_dir+'ud-generate.lock')
1125    GenMailBool(accounts, global_dir + "mail-greylist", "mailGreylisting")
1126    GenMailBool(accounts, global_dir + "mail-callout", "mailCallout")
1127    GenMailList(accounts, global_dir + "mail-rbl", "mailRBL")
1128    GenMailList(accounts, global_dir + "mail-rhsbl", "mailRHSBL")
1129    GenMailList(accounts, global_dir + "mail-whitelist", "mailWhitelist")
1130    GenWebPassword(accounts, global_dir + "web-passwords")
1131    GenVoipPassword(accounts, global_dir + "voip-passwords")
1132    GenKeyrings(global_dir)
1133
1134    # Compatibility.
1135    GenForward(accounts, global_dir + "forward-alias")
1136
1137    GenAllUsers(accounts, global_dir + 'all-accounts.json')
1138    accounts = filter(lambda a: not a in accounts_disabled, accounts)
1139
1140    ssh_userkeys = GenSSHShadow(global_dir, accounts)
1141    GenMarkers(accounts, global_dir + "markers")
1142    GenSSHKnown(host_attrs, global_dir + "ssh_known_hosts")
1143    GenHosts(host_attrs, global_dir + "debianhosts")
1144    GenSSHGitolite(accounts, global_dir + "ssh-gitolite")
1145
1146    GenDNS(accounts, global_dir + "dns-zone")
1147    GenZoneRecords(host_attrs, global_dir + "dns-sshfp")
1148
1149    setup_group_maps(ldap_conn)
1150
1151    for host in host_attrs:
1152       if not "hostname" in host[1]:
1153          continue
1154       generate_host(host, global_dir, accounts, ssh_userkeys)
1155
1156 def generate_host(host, global_dir, accounts, ssh_userkeys):
1157    current_host = host[1]['hostname'][0]
1158    OutDir = global_dir + current_host + '/'
1159    if not os.path.isdir(OutDir):
1160       os.mkdir(OutDir)
1161
1162    # Get the group list and convert any named groups to numerics
1163    GroupList = {}
1164    for groupname in AllowedGroupsPreload.strip().split(" "):
1165       GroupList[groupname] = True
1166    if 'allowedGroups' in host[1]:
1167       for groupname in host[1]['allowedGroups']:
1168          GroupList[groupname] = True
1169    for groupname in GroupList.keys():
1170       if groupname in GroupIDMap:
1171          GroupList[str(GroupIDMap[groupname])] = True
1172
1173    ExtraList = {}
1174    if 'exportOptions' in host[1]:
1175       for extra in host[1]['exportOptions']:
1176          ExtraList[extra.upper()] = True
1177
1178    if GroupList != {}:
1179       accounts = filter(lambda x: IsInGroup(x, GroupList, current_host), accounts)
1180
1181    DoLink(global_dir, OutDir, "debianhosts")
1182    DoLink(global_dir, OutDir, "ssh_known_hosts")
1183    DoLink(global_dir, OutDir, "disabled-accounts")
1184
1185    sys.stdout.flush()
1186    if 'NOPASSWD' in ExtraList:
1187       userlist = GenPasswd(accounts, OutDir + "passwd", HomePrefix, "*")
1188    else:
1189       userlist = GenPasswd(accounts, OutDir + "passwd", HomePrefix, "x")
1190    sys.stdout.flush()
1191    grouprevmap = GenGroup(accounts, OutDir + "group", current_host)
1192    GenShadowSudo(accounts, OutDir + "sudo-passwd", ('UNTRUSTED' in ExtraList) or ('NOPASSWD' in ExtraList), current_host)
1193
1194    # Now we know who we're allowing on the machine, export
1195    # the relevant ssh keys
1196    GenSSHtarballs(global_dir, userlist, ssh_userkeys, grouprevmap, os.path.join(OutDir, 'ssh-keys.tar.gz'), current_host)
1197
1198    if not 'NOPASSWD' in ExtraList:
1199       GenShadow(accounts, OutDir + "shadow")
1200
1201    # Link in global things
1202    if not 'NOMARKERS' in ExtraList:
1203       DoLink(global_dir, OutDir, "markers")
1204    DoLink(global_dir, OutDir, "mail-forward.cdb")
1205    DoLink(global_dir, OutDir, "mail-contentinspectionaction.cdb")
1206    DoLink(global_dir, OutDir, "mail-disable")
1207    DoLink(global_dir, OutDir, "mail-greylist")
1208    DoLink(global_dir, OutDir, "mail-callout")
1209    DoLink(global_dir, OutDir, "mail-rbl")
1210    DoLink(global_dir, OutDir, "mail-rhsbl")
1211    DoLink(global_dir, OutDir, "mail-whitelist")
1212    DoLink(global_dir, OutDir, "all-accounts.json")
1213    GenCDB(accounts, OutDir + "user-forward.cdb", 'emailForward')
1214    GenCDB(accounts, OutDir + "batv-tokens.cdb", 'bATVToken')
1215    GenCDB(accounts, OutDir + "default-mail-options.cdb", 'mailDefaultOptions')
1216
1217    # Compatibility.
1218    DoLink(global_dir, OutDir, "forward-alias")
1219
1220    if 'DNS' in ExtraList:
1221       DoLink(global_dir, OutDir, "dns-zone")
1222       DoLink(global_dir, OutDir, "dns-sshfp")
1223
1224    if 'AUTHKEYS' in ExtraList:
1225       DoLink(global_dir, OutDir, "authorized_keys")
1226
1227    if 'BSMTP' in ExtraList:
1228       GenBSMTP(accounts, OutDir + "bsmtp", HomePrefix)
1229
1230    if 'PRIVATE' in ExtraList:
1231       DoLink(global_dir, OutDir, "debian-private")
1232
1233    if 'GITOLITE' in ExtraList:
1234       DoLink(global_dir, OutDir, "ssh-gitolite")
1235
1236    if 'WEB-PASSWORDS' in ExtraList:
1237       DoLink(global_dir, OutDir, "web-passwords")
1238
1239    if 'KEYRING' in ExtraList:
1240       for k in Keyrings:
1241          bn = os.path.basename(k)
1242          if os.path.isdir(k):
1243             src = os.path.join(global_dir, bn)
1244             replaceTree(src, OutDir)
1245          else:
1246             DoLink(global_dir, OutDir, bn)
1247    else:
1248       for k in Keyrings:
1249          try:
1250             bn = os.path.basename(k)
1251             target = os.path.join(OutDir, bn)
1252             if os.path.isdir(target):
1253                safe_rmtree(dst)
1254             else:
1255                posix.remove(target)
1256          except:
1257             pass
1258    DoLink(global_dir, OutDir, "last_update.trace")
1259
1260
1261 def getLastLDAPChangeTime(l):
1262    mods = l.search_s('cn=log',
1263          ldap.SCOPE_ONELEVEL,
1264          '(&(&(!(reqMod=activity-from*))(!(reqMod=activity-pgp*)))(|(reqType=add)(reqType=delete)(reqType=modify)(reqType=modrdn)))',
1265          ['reqEnd'])
1266
1267    last = 0
1268
1269    # Sort the list by reqEnd
1270    sorted_mods = sorted(mods, key=lambda mod: mod[1]['reqEnd'][0].split('.')[0])
1271    # Take the last element in the array
1272    last = sorted_mods[-1][1]['reqEnd'][0].split('.')[0]
1273
1274    return last
1275
1276 def getLastBuildTime(gdir):
1277    cache_last_mod = 0
1278
1279    try:
1280       fd = open(os.path.join(gdir, "last_update.trace"), "r")
1281       cache_last_mod=fd.read().split()
1282       try:
1283          cache_last_mod = cache_last_mod[0]
1284       except IndexError:
1285          pass
1286       fd.close()
1287    except IOError, e:
1288       if e.errno == errno.ENOENT:
1289          pass
1290       else:
1291          raise e
1292
1293    return cache_last_mod
1294
1295
1296 def ud_generate():
1297    parser = optparse.OptionParser()
1298    parser.add_option("-g", "--generatedir", dest="generatedir", metavar="DIR",
1299      help="Output directory.")
1300    parser.add_option("-f", "--force", dest="force", action="store_true",
1301      help="Force generation, even if not update to LDAP has happened.")
1302
1303    (options, args) = parser.parse_args()
1304    if len(args) > 0:
1305       parser.print_help()
1306       sys.exit(1)
1307
1308    if options.generatedir is not None:
1309       generate_dir = os.environ['UD_GENERATEDIR']
1310    elif 'UD_GENERATEDIR' in os.environ:
1311       generate_dir = os.environ['UD_GENERATEDIR']
1312    else:
1313       generate_dir = GenerateDir
1314
1315
1316    lockf = os.path.join(generate_dir, 'ud-generate.lock')
1317    lock = get_lock( lockf )
1318    if lock is None:
1319       sys.stderr.write("Could not acquire lock %s.\n"%(lockf))
1320       sys.exit(1)
1321
1322    l = make_ldap_conn()
1323
1324    ldap_last_mod = getLastLDAPChangeTime(l)
1325    cache_last_mod = getLastBuildTime(generate_dir)
1326    need_update = ldap_last_mod > cache_last_mod
1327
1328    if not options.force and not need_update:
1329       fd = open(os.path.join(generate_dir, "last_update.trace"), "w")
1330       fd.write("%s\n%s\n" % (ldap_last_mod, int(time.time())))
1331       fd.close()
1332       sys.exit(0)
1333
1334    tracefd = open(os.path.join(generate_dir, "last_update.trace"), "w")
1335    generate_all(generate_dir, l)
1336    tracefd.write("%s\n%s\n" % (ldap_last_mod, int(time.time())))
1337    tracefd.close()
1338
1339
1340 if __name__ == "__main__":
1341    if 'UD_PROFILE' in os.environ:
1342       import cProfile
1343       import pstats
1344       cProfile.run('ud_generate()', "udg_prof")
1345       p = pstats.Stats('udg_prof')
1346       ##p.sort_stats('time').print_stats()
1347       p.sort_stats('cumulative').print_stats()
1348    else:
1349       ud_generate()
1350
1351 # vim:set et:
1352 # vim:set ts=3:
1353 # vim:set shiftwidth=3: