release 0.3.92
[mirror/userdir-ldap.git] / ud-mailgate
1 #!/usr/bin/env python
2 # -*- mode: python -*-
3
4 #   Prior copyright probably rmurray, troup, joey, jgg -- weasel 2008
5 #   Copyright (c) 2009 Stephen Gran <steve@lobefin.net>
6 #   Copyright (c) 2008,2009,2010 Peter Palfrader <peter@palfrader.org>
7 #   Copyright (c) 2008 Joerg Jaspert <joerg@debian.org>
8 #   Copyright (c) 2010 Helmut Grohne <helmut@subdivi.de>
9
10 import userdir_gpg, userdir_ldap, sys, traceback, time, ldap, os, commands
11 import pwd, tempfile
12 import subprocess
13 import email, email.parser
14 import binascii
15
16 from userdir_gpg import *
17 from userdir_ldap import *
18 from userdir_exceptions import *
19
20 # Error codes from /usr/include/sysexits.h
21 ReplyTo = ConfModule.replyto;
22 PingFrom = ConfModule.pingfrom;
23 ChPassFrom = ConfModule.chpassfrom;
24 ChangeFrom = ConfModule.changefrom;
25 ReplayCacheFile = ConfModule.replaycachefile;
26 SSHFingerprintFile = ConfModule.fingerprintfile
27
28 UUID_FORMAT = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
29
30 EX_TEMPFAIL = 75;
31 EX_PERMFAIL = 65;      # EX_DATAERR
32 Error = 'Message Error';
33 SeenKey = 0;
34 SeenDNS = 0;
35 mailRBL = {}
36 mailRHSBL = {}
37 mailWhitelist = {}
38 SeenList = {}
39 DNS = {}
40 ValidHostNames = [] # will be initialized in later
41
42 SSHFingerprint = re.compile('^(\d+) ([0-9a-f\:]{47}|SHA256:[0-9A-Za-z/+]{43}) (.+)$')
43 SSHRSA1Match = re.compile('^^(.* )?\d+ \d+ \d+')
44
45 GenderTable = {"male": '1',
46                "1": '1',
47                "female": '2',
48                "2": '2',
49                "unspecified": '9',
50                "9": '9',
51 };
52
53 ArbChanges = {"c": "..",
54               "l": ".*",
55               "facsimileTelephoneNumber": ".*",
56               "telephoneNumber": ".*",
57               "postalAddress": ".*",
58          "bATVToken": ".*",
59               "postalCode": ".*",
60               "loginShell": ".*",
61               "emailForward": "^([^<>@]+@.+)?$",
62               "jabberJID": "^([^<>@]+@.+)?$",
63               "ircNick": ".*",
64               "icqUin": "^[0-9]*$",
65               "onVacation": ".*",
66               "labeledURI": ".*",
67               "birthDate": "^([0-9]{4})([01][0-9])([0-3][0-9])$",
68               "mailDisableMessage": ".*",
69               "mailGreylisting": "^(TRUE|FALSE)$",
70               "mailCallout": "^(TRUE|FALSE)$",
71               "mailDefaultOptions": "^(TRUE|FALSE)$",
72               "VoIP": ".*",
73               "gender": "^(1|2|9|male|female|unspecified)$",
74          "mailContentInspectionAction": "^(reject|blackhole|markup)$",
75 };
76
77 DelItems = {"c": None,
78             "l": None,
79             "facsimileTelephoneNumber": None,
80             "telephoneNumber": None,
81             "postalAddress": None,
82             "bATVToken": None,
83             "postalCode": None,
84             "emailForward": None,
85             "ircNick": None,
86             "onVacation": None,
87             "labeledURI": None,
88             "latitude": None,
89             "longitude": None,
90             "icqUin": None,
91             "jabberJID": None,
92             "jpegPhoto": None,
93             "dnsZoneEntry": None,
94             "sshRSAAuthKey": None,
95             "birthDate" : None,
96             "mailGreylisting": None,
97             "mailCallout": None,
98             "mailRBL": None,
99             "mailRHSBL": None,
100             "mailWhitelist": None,
101             "mailDisableMessage": None,
102             "mailDefaultOptions": None,
103             "VoIP": None,
104             "mailContentInspectionAction": None,
105             };
106
107
108 # Decode a GPS location from some common forms
109 def LocDecode(Str,Dir):
110    # Check for Decimal degrees, DGM, or DGMS
111    if re.match("^[+-]?[\d.]+$",Str) != None:
112       return Str;
113
114    Deg = '0'; Min = None; Sec = None; Dr = Dir[0];
115    
116    # Check for DDDxMM.MMMM where x = [nsew]
117    Match = re.match("^(\d+)(["+Dir+"])([\d.]+)$",Str);
118    if Match != None:
119       G = Match.groups();
120       Deg = G[0]; Min = G[2]; Dr = G[1];
121
122    # Check for DD.DD x 
123    Match = re.match("^([\d.]+) ?(["+Dir+"])$",Str);
124    if Match != None:
125       G = Match.groups();
126       Deg = G[0]; Dr = G[1];
127
128    # Check for DD:MM.MM x 
129    Match = re.match("^(\d+):([\d.]+) ?(["+Dir+"])$",Str);
130    if Match != None:
131       G = Match.groups();
132       Deg = G[0]; Min = G[1]; Dr = G[2];
133
134    # Check for DD:MM:SS.SS x
135    Match = re.match("^(\d+):(\d+):([\d.]+) ?(["+Dir+"])$",Str);
136    if Match != None:
137       G = Match.groups();
138       Deg = G[0]; Min = G[1]; Sec = G[2]; Dr = G[3];
139       
140    # Some simple checks
141    if float(Deg) > 180:
142       raise UDFormatError, "Bad degrees";
143    if Min != None and float(Min) > 60:
144       raise UDFormatError, "Bad minutes";
145    if Sec != None and float(Sec) > 60:
146       raise UDFormatError, "Bad seconds";
147       
148    # Pad on an extra leading 0 to disambiguate small numbers
149    if len(Deg) <= 1 or Deg[1] == '.':
150       Deg = '0' + Deg;
151    if Min != None and (len(Min) <= 1 or Min[1] == '.'):
152       Min = '0' + Min;
153    if Sec != None and (len(Sec) <= 1 or Sec[1] == '.'):
154       Sec = '0' + Sec;
155    
156    # Construct a DGM/DGMS type value from the components.
157    Res = "+"
158    if Dr == Dir[1]:
159       Res = "-";
160    Res = Res + Deg;
161    if Min != None:
162       Res = Res + Min;
163    if Sec != None:
164       Res = Res + Sec;
165    return Res;
166               
167 # Handle changing a set of arbitary fields
168 #  <field>: value
169 def DoArbChange(Str,Attrs):
170    Match = re.match("^([^ :]+): (.*)$",Str);
171    if Match == None:
172       return None;
173    G = Match.groups();
174
175    attrName = G[0].lower();
176    for i in ArbChanges.keys():
177       if i.lower() == attrName:
178          attrName = i;
179          break;
180    if ArbChanges.has_key(attrName) == 0:
181       return None;
182
183    if re.match(ArbChanges[attrName],G[1]) == None:
184       raise UDFormatError, "Item does not match the required format"+ArbChanges[attrName];
185
186    value = G[1];
187    if attrName == 'gender':
188       if G[1] not in GenderTable:
189          raise UDFormatError, "Gender not found in table"
190       value = GenderTable[G[1]]
191
192 #   if attrName == 'birthDate':
193 #      (re.match("^([0-9]{4})([01][0-9])([0-3][0-9])$",G[1]) {
194 #    $bd_yr = $1; $bd_mo = $2; $bd_day = $3;
195 #    if ($bd_mo > 0 and $bd_mo <= 12 and $bd_day > 0) {
196 #      if ($bd_mo == 2) {
197 #        if ($bd_day == 29 and ($bd_yr == 0 or ($bd_yr % 4 == 0 && ($bd_yr % 100 != 0 || $bd_yr % 400 == 0)))) {
198 #          $bd_ok = 1;
199 #        } elsif ($bd_day <= 28) {
200 #          $bd_ok = 1;
201 #        }
202 #      } elsif ($bd_mo == 4 or $bd_mo == 6 or $bd_mo == 9 or $bd_mo == 11) {
203 #       if ($bd_day <= 30) {
204 #         $bd_ok = 1;
205 #       }
206 #      } else {
207 #       if ($bd_day <= 31) {
208 #         $bd_ok = 1;
209 #       }
210 #      }
211 #    }
212 #  } elsif (not defined($query->param('birthdate')) or $query->param('birthdate') =~ /^\s*$/) {
213 #    $bd_ok = 1;
214 #  }
215    Attrs.append((ldap.MOD_REPLACE,attrName,value));
216    return "Changed entry %s to %s"%(attrName,value);
217
218 # Handle changing a set of arbitary fields
219 #  <field>: value
220 def DoDel(Str,Attrs):
221    Match = re.match("^del (.*)$",Str);
222    if Match == None:
223       return None;
224    G = Match.groups();
225
226    attrName = G[0].lower();
227    for i in DelItems.keys():
228       if i.lower() == attrName:
229          attrName = i;
230          break;
231    if DelItems.has_key(attrName) == 0:
232       return "Cannot erase entry %s"%(attrName);
233
234    Attrs.append((ldap.MOD_DELETE,attrName,None));
235    return "Removed entry %s"%(attrName);
236
237 # Handle a position change message, the line format is:
238 #  Lat: -12412.23 Long: +12341.2342
239 def DoPosition(Str,Attrs):
240    Match = re.match("^lat: ([+\-]?[\d:.ns]+(?: ?[ns])?) long: ([+\-]?[\d:.ew]+(?: ?[ew])?)$", Str.lower())
241    if Match == None:
242       return None;
243
244    G = Match.groups();
245    try:
246       sLat = LocDecode(G[0],"ns");
247       sLong = LocDecode(G[1],"ew");
248       Lat = DecDegree(sLat,1);
249       Long = DecDegree(sLong,1);
250    except:
251       raise UDFormatError, "Positions were found, but they are not correctly formed";
252
253    Attrs.append((ldap.MOD_REPLACE,"latitude",sLat));
254    Attrs.append((ldap.MOD_REPLACE,"longitude",sLong));
255    return "Position set to %s/%s (%s/%s decimal degrees)"%(sLat,sLong,Lat,Long);
256
257 # Load bad ssh fingerprints
258 def LoadBadSSH():
259    f = open(SSHFingerprintFile, "r")
260    bad = []
261    FingerprintLine = re.compile('^([0-9a-f\:]{47}).*$')
262    for line in f.readlines():
263       Match = FingerprintLine.match(line)
264       if Match is not None:
265          g = Match.groups()
266          bad.append(g[0])
267    return bad
268
269 # Handle an SSH authentication key, the line format is:
270 #  [options] 1024 35 13188913666680[..] [comment]
271 # maybe it really should be:
272 # [allowed_hosts=machine1,machine2 ][options ]ssh-rsa keybytes [comment]
273 machine_regex = re.compile("^[0-9a-zA-Z.-]+$")
274 def DoSSH(Str, Attrs, badkeys, uid):
275    Match = SSH2AuthSplit.match(Str);
276    if Match == None:
277       return None;
278    g = Match.groups()
279    typekey = g[1]
280    if Match == None:
281       Match = SSHRSA1Match.match(Str)
282       if Match is not None:
283          return "RSA1 keys not supported anymore"
284       return None;
285
286    # lines can now be prepended with "allowed_hosts=machine1,machine2 "
287    machines = []
288    if Str.startswith("allowed_hosts="):
289       Str = Str.split("=", 1)[1]
290       if ' ' not in Str:
291          return "invalid ssh key syntax with machine specification"
292       machines, Str = Str.split(' ', 1)
293       machines = machines.split(",")
294       for m in machines:
295          if not m:
296             return "empty machine specification for ssh key"
297          if not machine_regex.match(m):
298             return "machine specification for ssh key contains invalid characters"
299          if m not in ValidHostNames:
300             return "unknown machine used in allowed_hosts stanza for ssh keys"
301
302    (fd, path) = tempfile.mkstemp(".pub", "sshkeytry", "/tmp")
303    f = open(path, "w")
304    f.write("%s\n" % (Str))
305    f.close()
306    cmd = "/usr/bin/ssh-keygen -l -f %s < /dev/null" % (path)
307    (result, output) = commands.getstatusoutput(cmd)
308    os.remove(path)
309    if (result != 0):
310       raise UDExecuteError, "ssh-keygen -l invocation failed!\n%s\n" % (output)
311
312    # format the string again for ldap:
313    if machines:
314       Str = "allowed_hosts=%s %s" % (",".join(machines), Str)
315
316
317    # Head
318    Date = time.strftime("%a, %d %b %Y %H:%M:%S +0000",time.gmtime(time.time()))
319    ErrReplyHead = "From: %s\nCc: %s\nReply-To: %s\nDate: %s\n" % (os.environ['SENDER'],os.environ['SENDER'],ReplyTo,Date)
320    Subst = {}
321    Subst["__ADMIN__"] = ReplyTo
322    Subst["__USER__"] = uid
323
324    Match = SSHFingerprint.match(output)
325    if Match is None:
326       return "Failed to match SSH fingerprint, has the output of ssh-keygen changed?"
327    g = Match.groups()
328    key_size = g[0]
329    fingerprint = g[1]
330
331    if typekey == "rsa":
332       key_size_ok = (int(key_size) >= 2048)
333    elif typekey == "ed25519":
334      key_size_ok = True
335    else:
336      key_size_ok = False
337
338    if not key_size_ok:
339       return "SSH key fails formal criteria, not added.  We only accept RSA keys (>= 2048 bits) or ed25519 keys."
340    elif fingerprint in badkeys:
341       try:
342          # Body
343          Subst["__ERROR__"] = "SSH key with fingerprint %s known as bad key" % (g[1])
344          ErrReply = TemplateSubst(Subst,open(TemplatesDir+"admin-info","r").read())
345
346          Child = os.popen("/usr/sbin/sendmail -t","w")
347          Child.write(ErrReplyHead)
348          Child.write(ErrReply)
349          if Child.close() != None:
350             raise UDExecuteError, "Sendmail gave a non-zero return code"
351       except:
352          sys.exit(EX_TEMPFAIL)
353
354       # And now break and stop processing input, which sends a reply to the user.
355       raise UDFormatError, "Submitted SSH Key known to be bad and insecure, processing halted, NOTHING MODIFIED AT ALL"
356
357    global SeenKey;
358    if SeenKey:
359      Attrs.append((ldap.MOD_ADD,"sshRSAAuthKey",Str));
360      return "SSH Key added: %s %s [%s]"%(key_size, fingerprint, FormatSSHAuth(Str))
361
362    Attrs.append((ldap.MOD_REPLACE,"sshRSAAuthKey",Str));
363    SeenKey = 1;
364    return "SSH Keys replaced with: %s %s [%s]"%(key_size, fingerprint, FormatSSHAuth(Str))
365
366 # Handle changing a dns entry
367 #  host IN A     12.12.12.12
368 #  host IN AAAA  1234::5678
369 #  host IN CNAME foo.bar.    <- Trailing dot is required
370 #  host IN MX    foo.bar.    <- Trailing dot is required
371 def DoDNS(Str,Attrs,DnRecord):
372    cnamerecord = re.match("^[-\w]+\s+IN\s+CNAME\s+([-\w.]+\.)$",Str,re.IGNORECASE)
373    arecord     = re.match('^[-\w]+\s+IN\s+A\s+(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$',Str,re.IGNORECASE)
374    mxrecord    = re.match("^[-\w]+\s+IN\s+MX\s+(\d{1,3})\s+([-\w.]+\.)$",Str,re.IGNORECASE)
375    txtrecord   = re.match("^[-\w]+\s+IN\s+TXT\s+([-\d. a-z\t<>@:]+)", Str, re.IGNORECASE)
376    #aaaarecord  = re.match('^[-\w]+\s+IN\s+AAAA\s+((?:[0-9a-f]{1,4})(?::[0-9a-f]{1,4})*(?::(?:(?::[0-9a-f]{1,4})*|:))?)$',Str,re.IGNORECASE)
377    aaaarecord  = re.match('^[-\w]+\s+IN\s+AAAA\s+([A-F0-9:]{2,39})$',Str,re.IGNORECASE)
378
379    if cnamerecord is None and\
380       arecord is None and\
381       mxrecord is None and\
382       txtrecord is None and\
383       aaaarecord is None:
384      return None;
385
386    # Check if the name is already taken
387    G = re.match('^([-\w+]+)\s',Str)
388    if G is None:
389      raise UDFormatError, "Hostname not found although we already passed record syntax checks"
390    hostname = G.group(1)
391
392    # Check for collisions
393    global l;
394    # [JT 20070409 - search for both tab and space suffixed hostnames
395    #  since we accept either.  It'd probably be better to parse the
396    #  incoming string in order to construct what we feed LDAP rather
397    #  than just passing it through as is.]
398    filter = "(|(dnsZoneEntry=%s *)(dnsZoneEntry=%s *))" % (hostname, hostname)
399    Rec = l.search_s(BaseDn,ldap.SCOPE_ONELEVEL,filter,["uid"]);
400    for x in Rec:
401       if GetAttr(x,"uid") != GetAttr(DnRecord,"uid"):
402          return "DNS entry is already owned by " + GetAttr(x,"uid")
403
404    global SeenDNS;
405    global DNS;
406
407    if cnamerecord:
408      if DNS.has_key(hostname):
409        return "CNAME and other RR types not allowed: "+Str
410      else:
411        DNS[hostname] = 2
412    else:
413      if DNS.has_key(hostname) and DNS[hostname] == 2:
414        return "CNAME and other RR types not allowed: "+Str
415      else:
416        DNS[hostname] = 1
417
418    if cnamerecord is not None:
419      sanitized = "%s IN CNAME %s" % (hostname, cnamerecord.group(1))
420    elif txtrecord is not None:
421       sanitized = "%s IN TXT %s" % (hostname, txtrecord.group(1))
422    elif arecord is not None:
423      ipaddress = arecord.group(1)
424      for quad in ipaddress.split('.'):
425        if not (int(quad) >=0 and int(quad) <= 255):
426          return "Invalid quad %s in IP address %s in line %s" %(quad, ipaddress, Str)
427      sanitized = "%s IN A %s"% (hostname, ipaddress)
428    elif mxrecord is not None:
429      priority = mxrecord.group(1)
430      mx = mxrecord.group(2)
431      sanitized = "%s IN MX %s %s" % (hostname, priority, mx)
432    elif aaaarecord is not None:
433      ipv6address = aaaarecord.group(1)
434      parts = ipv6address.split(':')
435      if len(parts) > 8:
436        return "Invalid IPv6 address (%s): too many parts"%(ipv6address)
437      if len(parts) <= 2:
438        return "Invalid IPv6 address (%s): too few parts"%(ipv6address)
439      if parts[0] == "":
440        parts.pop(0)
441      if parts[-1] == "":
442        parts.pop(-1)
443      seenEmptypart = False
444      for p in parts:
445        if len(p) > 4:
446          return "Invalid IPv6 address (%s): part %s is longer than 4 characters"%(ipv6address, p)
447        if p == "":
448          if seenEmptypart:
449            return "Invalid IPv6 address (%s): more than one :: (nothing in between colons) is not allowed"%(ipv6address)
450          seenEmptypart = True
451      sanitized = "%s IN AAAA %s" % (hostname, ipv6address)
452    else:
453      raise UDFormatError, "None of the types I recognize was it.  I shouldn't be here.  confused."
454
455    if SeenDNS:
456      Attrs.append((ldap.MOD_ADD,"dnsZoneEntry",sanitized));
457      return "DNS Entry added "+sanitized;
458
459    Attrs.append((ldap.MOD_REPLACE,"dnsZoneEntry",sanitized));
460    SeenDNS = 1;
461    return "DNS Entry replaced with "+sanitized;
462
463 # Handle an RBL list (mailRBL, mailRHSBL, mailWhitelist)
464 def DoRBL(Str,Attrs):
465    Match = re.compile('^mail(rbl|rhsbl|whitelist) ([-a-z0-9.]+)$').match(Str.lower())
466    if Match == None:
467       return None
468    
469    if Match.group(1) == "rbl":
470       Key = "mailRBL"
471    if Match.group(1) == "rhsbl":
472       Key = "mailRHSBL"
473    if Match.group(1) == "whitelist":
474       Key = "mailWhitelist"
475    Host = Match.group(2)
476
477    global SeenList
478    if SeenList.has_key(Key):
479      Attrs.append((ldap.MOD_ADD,Key,Host))
480      return "%s added %s" % (Key,Host)
481       
482    Attrs.append((ldap.MOD_REPLACE,Key,Host))
483    SeenList[Key] = 1;
484    return "%s replaced with %s" % (Key,Host)
485
486 # Handle a ConfirmSudoPassword request
487 def DoConfirmSudopassword(Str, SudoPasswd):
488    Match = re.compile('^confirm sudopassword ('+UUID_FORMAT+') ([a-z0-9.,*-]+) ([0-9a-f]{40})$').match(Str)
489    if Match == None:
490       return None
491
492    uuid = Match.group(1)
493    hosts = Match.group(2)
494    hmac = Match.group(3)
495
496    SudoPasswd[uuid] = (hosts, hmac)
497    return "got confirm for sudo password %s on host(s) %s, auth code %s" % (uuid,hosts, hmac)
498
499 def FinishConfirmSudopassword(l, uid, Attrs, SudoPasswd):
500    result = "\n"
501
502    if len(SudoPasswd) == 0:
503        return None
504
505    res = l.search_s(BaseDn,ldap.SCOPE_ONELEVEL,"uid="+uid, ['sudoPassword']);
506    if len(res) != 1:
507       raise UDFormatError, "Not exactly one hit when searching for user"
508    if res[0][1].has_key('sudoPassword'):
509       inldap = res[0][1]['sudoPassword']
510    else:
511       inldap = []
512
513    newldap = []
514    for entry in inldap:
515       Match = re.compile('^('+UUID_FORMAT+') (confirmed:[0-9a-f]{40}|unconfirmed) ([a-z0-9.,*-]+) ([^ ]+)$').match(entry)
516       if Match == None:
517          raise UDFormatError, "Could not parse existing sudopasswd entry"
518       uuid = Match.group(1)
519       status = Match.group(2)
520       hosts = Match.group(3)
521       cryptedpass = Match.group(4)
522
523       if SudoPasswd.has_key(uuid):
524          confirmedHosts = SudoPasswd[uuid][0]
525          confirmedHmac = SudoPasswd[uuid][1]
526          if status.startswith('confirmed:'):
527             if status == 'confirmed:'+make_passwd_hmac('password-is-confirmed', 'sudo', uid, uuid, hosts, cryptedpass):
528                result = result + "Entry %s for sudo password on hosts %s already confirmed.\n"%(uuid, hosts)
529             else:
530                result = result + "Entry %s for sudo password on hosts %s is listed as confirmed, but HMAC does not verify.\n"%(uuid, hosts)
531          elif confirmedHosts != hosts:
532             result = result + "Entry %s hostlist mismatch (%s vs. %s).\n"%(uuid, hosts, confirmedHosts)
533          elif make_passwd_hmac('confirm-new-password', 'sudo', uid, uuid, hosts, cryptedpass) == confirmedHmac:
534             result = result + "Entry %s for sudo password on hosts %s now confirmed.\n"%(uuid, hosts)
535             status = 'confirmed:'+make_passwd_hmac('password-is-confirmed', 'sudo', uid, uuid, hosts, cryptedpass)
536          else:
537             result = result + "Entry %s for sudo password on hosts %s HMAC verify failed.\n"%(uuid, hosts)
538          del SudoPasswd[uuid]
539
540       newentry = " ".join([uuid, status, hosts, cryptedpass])
541       if len(newldap) == 0:
542          newldap.append((ldap.MOD_REPLACE,"sudoPassword",newentry))
543       else:
544          newldap.append((ldap.MOD_ADD,"sudoPassword",newentry))
545
546    for entry in SudoPasswd:
547       result = result + "Entry %s that you confirm is not listed in ldap."%(entry)
548
549    for entry in newldap:
550       Attrs.append(entry)
551
552    return result
553
554 def connect_to_ldap_and_check_if_locked(DnRecord):
555    # Connect to the ldap server
556    l = connectLDAP()
557    F = open(PassDir+"/pass-"+pwd.getpwuid(os.getuid())[0],"r");
558    AccessPass = F.readline().strip().split(" ")
559    F.close();
560    l.simple_bind_s("uid="+AccessPass[0]+","+BaseDn,AccessPass[1]);
561
562    # Check for a locked account
563    Attrs = l.search_s(BaseDn,ldap.SCOPE_ONELEVEL,"uid="+GetAttr(DnRecord,"uid"));
564    if (GetAttr(Attrs[0],"userPassword").find("*LK*") != -1) \
565              or GetAttr(Attrs[0],"userPassword").startswith("!"):
566       raise UDNotAllowedError, "This account is locked";
567
568    return l
569
570 # Handle an [almost] arbitary change
571 def HandleChange(Reply,DnRecord,Key):
572    global PlainText;
573    Lines = re.split("\n *\r?",PlainText);
574
575    Result = "";
576    Attrs = [];
577    SudoPasswd = {}
578    Show = 0;
579    CommitChanges = 1
580    for Line in Lines: 
581       Line = Line.strip()
582       if Line == "":
583          continue;
584
585       # Try to process a command line
586       Result = Result + "> "+Line+"\n";
587       try:
588          if Line == "show":
589             Show = 1;
590             Res = "OK";
591          else:
592             badkeys = LoadBadSSH()
593             Res = DoPosition(Line,Attrs) or DoDNS(Line,Attrs,DnRecord) or \
594                   DoArbChange(Line,Attrs) or DoSSH(Line,Attrs,badkeys,GetAttr(DnRecord,"uid")) or \
595                   DoDel(Line,Attrs) or DoRBL(Line,Attrs) or DoConfirmSudopassword(Line, SudoPasswd)
596       except:
597          Res = None;
598          Result = Result + "==> %s: %s\n" %(sys.exc_type,sys.exc_value);
599
600       # Fail, if someone tries to send someone elses signed email to the
601       # daemon then we want to abort ASAP.
602       if Res == None:
603          CommitChanges = 0
604          Result = Result + "Command is not understood. Halted - no changes committed\n";
605          break;
606       Result = Result + Res + "\n";
607
608    # Connect to the ldap server
609    l = connect_to_ldap_and_check_if_locked(DnRecord)
610
611    if CommitChanges == 1 and len(SudoPasswd) > 0: # only if we are still good to go
612       try:
613          Res = FinishConfirmSudopassword(l, GetAttr(DnRecord,"uid"), Attrs, SudoPasswd)
614          if not Res is None:
615             Result = Result + Res + "\n";
616       except Error, e:
617          CommitChanges = 0
618          Result = Result + "FinishConfirmSudopassword raised an error (%s) - no changes committed\n"%(e);
619
620    if CommitChanges == 1 and len(Attrs) > 0:
621       Dn = "uid=" + GetAttr(DnRecord,"uid") + "," + BaseDn;
622       l.modify_s(Dn,Attrs);
623
624    Attribs = "";
625    if Show == 1:
626       Attrs = l.search_s(BaseDn,ldap.SCOPE_ONELEVEL,"uid="+GetAttr(DnRecord,"uid"));
627       if len(Attrs) == 0:
628          raise UDNotAllowedError, "User not found"
629       Attribs = GPGEncrypt(PrettyShow(Attrs[0])+"\n","0x"+Key[1],Key[4]);
630
631    Subst = {};
632    Subst["__FROM__"] = ChangeFrom;
633    Subst["__EMAIL__"] = EmailAddress(DnRecord);
634    Subst["__ADMIN__"] = ReplyTo;
635    Subst["__RESULT__"] = Result;
636    Subst["__ATTR__"] = Attribs;
637
638    return Reply + TemplateSubst(Subst,open(TemplatesDir+"change-reply","r").read());
639    
640 # Handle ping handles an email sent to the 'ping' address (ie this program
641 # called with a ping argument) It replies with a dump of the public records.
642 def HandlePing(Reply,DnRecord,Key):
643    Subst = {};
644    Subst["__FROM__"] = PingFrom;
645    Subst["__EMAIL__"] = EmailAddress(DnRecord);
646    Subst["__LDAPFIELDS__"] = PrettyShow(DnRecord);
647    Subst["__ADMIN__"] = ReplyTo;
648
649    return Reply + TemplateSubst(Subst,open(TemplatesDir+"ping-reply","r").read());
650
651
652
653 def get_crypttype_preamble(key):
654    if (key[4] == 1):
655       type = "Your message was encrypted using PGP 2.x\ncompatibility mode.";
656    else:
657       type = "Your message was encrypted using GPG (OpenPGP)\ncompatibility "\
658              "mode, without IDEA. This message cannot be decoded using PGP 2.x";
659    return type
660
661 # Handle a change password email sent to the change password address
662 # (this program called with the chpass argument)
663 def HandleChPass(Reply,DnRecord,Key):
664    # Generate a random password
665    Password = GenPass();
666    Pass = HashPass(Password);
667
668    # Use GPG to encrypt it      
669    Message = GPGEncrypt("Your new password is '" + Password + "'\n",\
670                         "0x"+Key[1],Key[4]);
671    Password = None;
672
673    if Message == None:
674       raise UDFormatError, "Unable to generate the encrypted reply, gpg failed.";
675
676    Subst = {};
677    Subst["__FROM__"] = ChPassFrom;
678    Subst["__EMAIL__"] = EmailAddress(DnRecord);
679    Subst["__CRYPTTYPE__"] = get_crypttype_preamble(Key)
680    Subst["__PASSWORD__"] = Message;
681    Subst["__ADMIN__"] = ReplyTo;
682    Reply = Reply + TemplateSubst(Subst,open(TemplatesDir+"passwd-changed","r").read());
683
684    l = connect_to_ldap_and_check_if_locked(DnRecord)
685    # Modify the password
686    Rec = [(ldap.MOD_REPLACE,"userPassword","{crypt}"+Pass),
687           (ldap.MOD_REPLACE,"shadowLastChange",str(int(time.time()/24/60/60)))];
688    Dn = "uid=" + GetAttr(DnRecord,"uid") + "," + BaseDn;
689    l.modify_s(Dn,Rec);
690
691    return Reply;
692
693 def HandleChTOTPSeed(Reply, DnRecord, Key):
694    # Generate a random seed
695    seed = binascii.hexlify(open("/dev/urandom", "r").read(32))
696    msg = GPGEncrypt("Your new TOTP seed is '%s'\n" % (seed,), "0x"+Key[1],Key[4]);
697
698    if msg is None:
699       raise UDFormatError, "Unable to generate the encrypted reply, gpg failed.";
700
701    Subst = {};
702    Subst["__FROM__"] = ChPassFrom
703    Subst["__EMAIL__"] = EmailAddress(DnRecord)
704    Subst["__PASSWORD__"] = msg
705    Subst["__ADMIN__"] = ReplyTo
706    Reply = Reply + TemplateSubst(Subst, open(TemplatesDir+"totp-seed-changed", "r").read())
707
708    l = connect_to_ldap_and_check_if_locked(DnRecord)
709    # Modify the password
710    Rec = [(ldap.MOD_REPLACE, "totpSeed", seed)]
711    Dn = "uid=" + GetAttr(DnRecord,"uid") + "," + BaseDn
712    l.modify_s(Dn,Rec)
713    return Reply;
714
715 def HandleChKrbPass(Reply,DnRecord,Key):
716    # Connect to the ldap server, will throw an exception if account locked.
717    l = connect_to_ldap_and_check_if_locked(DnRecord)
718
719    user = GetAttr(DnRecord,"uid")
720    krb_proc = subprocess.Popen( ('ud-krb-reset', user), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
721    krb_proc.stdin.close()
722    out = krb_proc.stdout.readlines()
723    krb_proc.wait()
724    exitcode = krb_proc.returncode
725
726    # Use GPG to encrypt it
727    m = "Tried to reset your kerberos principal's password.\n"
728    if exitcode == 0:
729       m += "The exitcode of the reset script was zero, indicating that everything\n"
730       m += "worked.  However, this being software who knows.  Script's output below."
731    else:
732       m += "The exitcode of the reset script was %d, indicating that something\n"%(exitcode)
733       m += "went terribly, terribly wrong.  Please consult the script's output below\n"
734       m += "for more information.  Contact the admins if you have any questions or\n"
735       m += "require assitance."
736
737    m += "\n"+''.join( map(lambda x: "| "+x, out)  )
738
739    Message = GPGEncrypt(m, "0x"+Key[1],Key[4]);
740    if Message == None:
741       raise UDFormatError, "Unable to generate the encrypted reply, gpg failed.";
742
743    Subst = {};
744    Subst["__FROM__"] = ChPassFrom;
745    Subst["__EMAIL__"] = EmailAddress(DnRecord);
746    Subst["__CRYPTTYPE__"] = get_crypttype_preamble(Key)
747    Subst["__PASSWORD__"] = Message;
748    Subst["__ADMIN__"] = ReplyTo;
749    Reply = Reply + TemplateSubst(Subst,open(TemplatesDir+"passwd-changed","r").read());
750
751    return Reply;
752
753 # Start of main program
754
755 # Drop messages from a mailer daemon.
756 if os.environ.has_key('SENDER') == 0 or len(os.environ['SENDER']) == 0:
757    sys.exit(0);
758
759 ErrMsg = "Indeterminate Error";
760 ErrType = EX_TEMPFAIL;
761 try:
762    # Startup the replay cache
763    ErrType = EX_TEMPFAIL;
764    ErrMsg = "Failed to initialize the replay cache:";
765
766    # Get the email 
767    ErrType = EX_PERMFAIL;
768    ErrMsg = "Failed to understand the email or find a signature:";
769    mail = email.parser.Parser().parse(sys.stdin);
770    Msg = GetClearSig(mail);
771
772    ErrMsg = "Message is not PGP signed:"
773    if Msg[0].find("-----BEGIN PGP SIGNED MESSAGE-----") == -1 and \
774       Msg[0].find("-----BEGIN PGP MESSAGE-----") == -1:
775       raise UDFormatError, "No PGP signature";
776    
777    # Check the signature
778    ErrMsg = "Unable to check the signature or the signature was invalid:";
779    pgp = GPGCheckSig2(Msg[0])
780
781    if not pgp.ok:
782       raise UDFormatError, pgp.why
783       
784    if pgp.text is None:
785       raise UDFormatError, "Null signature text"
786
787    # Extract the plain message text in the event of mime encoding
788    global PlainText;
789    ErrMsg = "Problem stripping MIME headers from the decoded message"
790    if Msg[1] == 1:
791       e = email.parser.Parser().parsestr(pgp.text)
792       PlainText = e.get_payload(decode=True)
793    else:
794       PlainText = pgp.text
795
796    # Connect to the ldap server
797    ErrType = EX_TEMPFAIL;
798    ErrMsg = "An error occured while performing the LDAP lookup";
799    global l;
800    l = connectLDAP()
801    l.simple_bind_s("","");
802
803    # Search for the matching key fingerprint
804    Attrs = l.search_s(BaseDn,ldap.SCOPE_ONELEVEL,"keyFingerPrint=" + pgp.key_fpr)
805
806    ErrType = EX_PERMFAIL;
807    if len(Attrs) == 0:
808       raise UDFormatError, "Key not found"
809    if len(Attrs) != 1:
810       raise UDFormatError, "Oddly your key fingerprint is assigned to more than one account.."
811
812
813    # Check the signature against the replay cache
814    RC = ReplayCache(ReplayCacheFile);
815    RC.process(pgp.sig_info)
816
817    # Determine the sender address
818    ErrMsg = "A problem occured while trying to formulate the reply";
819    Sender = mail['Reply-To']
820    if not Sender: Sender = mail['From']
821    if not Sender: raise UDFormatError, "Unable to determine the sender's address";
822
823    # Formulate a reply
824    Date = time.strftime("%a, %d %b %Y %H:%M:%S +0000",time.gmtime(time.time()));
825    Reply = "To: %s\nReply-To: %s\nDate: %s\n" % (Sender,ReplyTo,Date);
826
827    Res = l.search_s(HostBaseDn, ldap.SCOPE_SUBTREE, '(objectClass=debianServer)', ['hostname'] )
828    # Res is a list of tuples.
829    # The tuples contain a dn (str) and a dictionary.
830    # The dictionaries map the key "hostname" to a list.
831    # These lists contain a single hostname (str).
832    ValidHostNames = reduce(lambda a,b: a+b, [value.get("hostname", []) for (dn, value) in Res], [])
833
834    # Dispatch
835    if sys.argv[1] == "ping":
836       Reply = HandlePing(Reply,Attrs[0],pgp.key_info);
837    elif sys.argv[1] == "chpass":
838       if PlainText.strip().find("Please change my Debian password") >= 0:
839          Reply = HandleChPass(Reply,Attrs[0],pgp.key_info);
840       elif PlainText.strip().find("Please change my Kerberos password") >= 0:
841          Reply = HandleChKrbPass(Reply,Attrs[0],pgp.key_info);
842       elif PlainText.strip().find("Please change my TOTP seed") >= 0:
843          Reply = HandleChTOTPSeed(Reply, Attrs[0], pgp.key_info)
844       else:
845          raise UDFormatError,"Please send a signed message where the first line of text is the string 'Please change my Debian password' or some other string we accept here.";
846    elif sys.argv[1] == "change":
847       Reply = HandleChange(Reply,Attrs[0],pgp.key_info);
848    else:
849       print sys.argv;
850       raise UDFormatError, "Incorrect Invokation";
851
852    # Send the message through sendmail      
853    ErrMsg = "A problem occured while trying to send the reply";
854    Child = os.popen("/usr/sbin/sendmail -t","w");
855 #   Child = os.popen("cat","w");
856    Child.write(Reply);
857    if Child.close() != None:
858       raise UDExecuteError, "Sendmail gave a non-zero return code";
859
860 except:
861    # Error Reply Header
862    Date = time.strftime("%a, %d %b %Y %H:%M:%S +0000",time.gmtime(time.time()));
863    ErrReplyHead = "To: %s\nReply-To: %s\nDate: %s\n" % (os.environ['SENDER'],ReplyTo,Date);
864
865    # Error Body
866    Subst = {};
867    Subst["__ERROR__"] = ErrMsg;
868    Subst["__ADMIN__"] = ReplyTo;
869
870    Trace = "==> %s: %s\n" %(sys.exc_type,sys.exc_value);
871    List = traceback.extract_tb(sys.exc_traceback);
872    if len(List) > 1:
873       Trace = Trace + "Python Stack Trace:\n";
874       for x in List:
875          Trace = Trace +  "   %s %s:%u: %s\n" %(x[2],x[0],x[1],x[3]);
876
877    Subst["__TRACE__"] = Trace;
878
879    # Try to send the bounce
880    try:
881       ErrReply = TemplateSubst(Subst,open(TemplatesDir+"error-reply","r").read());
882
883       Child = os.popen("/usr/sbin/sendmail -t -oi -f ''","w");
884       Child.write(ErrReplyHead);
885       Child.write(ErrReply);
886       if Child.close() != None:
887          raise UDExecuteError, "Sendmail gave a non-zero return code";
888    except:
889       sys.exit(EX_TEMPFAIL);
890       
891    if ErrType != EX_PERMFAIL:
892       sys.exit(ErrType);
893    sys.exit(0);
894
895 # vim:set et:
896 # vim:set ts=3:
897 # vim:set shiftwidth=3: