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