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