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