0d6d330212a14d9fc29ae4c6fc82eaa8e51294da
[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.cgi?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     print >> f, seed
708     print >> f, GetAttr(DnRecord, "uid")
709     f.close()
710
711     # Modify the password
712     Rec = [(ldap.MOD_REPLACE, "totpSeed", seed)]
713     Dn = "uid=" + GetAttr(DnRecord, "uid") + "," + BaseDn
714     lc.modify_s(Dn, Rec)
715     return Reply
716
717
718 def HandleChKrbPass(Reply, DnRecord, Key):
719     # Connect to the ldap server, will throw an exception if account locked.
720     lc = connect_to_ldap_and_check_if_locked(DnRecord)
721
722     user = GetAttr(DnRecord, "uid")
723     krb_proc = subprocess.Popen(('ud-krb-reset', user), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
724     krb_proc.stdin.close()
725     out = krb_proc.stdout.readlines()
726     krb_proc.wait()
727     exitcode = krb_proc.returncode
728
729     # Use GPG to encrypt it
730     m = "Tried to reset your kerberos principal's password.\n"
731     if exitcode == 0:
732         m += "The exitcode of the reset script was zero, indicating that everything\n"
733         m += "worked.  However, this being software who knows.  Script's output below."
734     else:
735         m += "The exitcode of the reset script was %d, indicating that something\n" % (exitcode,)
736         m += "went terribly, terribly wrong.  Please consult the script's output below\n"
737         m += "for more information.  Contact the admins if you have any questions or\n"
738         m += "require assitance."
739
740     m += "\n" + ''.join(map(lambda x: "| " + x, out))
741
742     Message = GPGEncrypt(m, "0x" + Key[1], Key[4])
743     if Message is None:
744         raise UDFormatError("Unable to generate the encrypted reply, gpg failed.")
745
746     Subst = {}
747     Subst["__FROM__"] = ChPassFrom
748     Subst["__EMAIL__"] = EmailAddress(DnRecord)
749     Subst["__CRYPTTYPE__"] = get_crypttype_preamble(Key)
750     Subst["__PASSWORD__"] = Message
751     Subst["__ADMIN__"] = ReplyTo
752     Reply += TemplateSubst(Subst, open(TemplatesDir + "passwd-changed", "r").read())
753
754     return Reply
755
756 # Start of main program
757
758
759 # Drop messages from a mailer daemon.
760 if not os.environ.get('SENDER'):
761     sys.exit(0)
762
763 ErrMsg = "Indeterminate Error"
764 ErrType = EX_TEMPFAIL
765 try:
766     # Startup the replay cache
767     ErrType = EX_TEMPFAIL
768     ErrMsg = "Failed to initialize the replay cache:"
769
770     # Get the email
771     ErrType = EX_PERMFAIL
772     ErrMsg = "Failed to understand the email or find a signature:"
773     mail = email.parser.Parser().parse(sys.stdin)
774     Msg = GetClearSig(mail)
775
776     ErrMsg = "Message is not PGP signed:"
777     if Msg[0].find("-----BEGIN PGP SIGNED MESSAGE-----") == -1 and \
778        Msg[0].find("-----BEGIN PGP MESSAGE-----") == -1:
779         raise UDFormatError("No PGP signature")
780
781     # Check the signature
782     ErrMsg = "Unable to check the signature or the signature was invalid:"
783     pgp = GPGCheckSig2(Msg[0])
784
785     if not pgp.ok:
786         raise UDFormatError(pgp.why)
787
788     if pgp.text is None:
789         raise UDFormatError("Null signature text")
790
791     # Extract the plain message text in the event of mime encoding
792     global PlainText
793     ErrMsg = "Problem stripping MIME headers from the decoded message"
794     if Msg[1] == 1:
795         e = email.parser.Parser().parsestr(pgp.text)
796         PlainText = e.get_payload(decode=True)
797     else:
798         PlainText = pgp.text
799
800     # Connect to the ldap server
801     ErrType = EX_TEMPFAIL
802     ErrMsg = "An error occured while performing the LDAP lookup"
803     global lc
804     lc = connectLDAP()
805     lc.simple_bind_s("", "")
806
807     # Search for the matching key fingerprint
808     Attrs = lc.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "keyFingerPrint={}".format(pgp.key_fpr))
809
810     ErrType = EX_PERMFAIL
811     if len(Attrs) == 0:
812         raise UDFormatError("Key not found")
813     if len(Attrs) != 1:
814         raise UDFormatError("Oddly your key fingerprint is assigned to more than one account..")
815
816     # Check the signature against the replay cache
817     RC = ReplayCache(ReplayCacheFile)
818     RC.process(pgp.sig_info)
819
820     # Determine the sender address
821     ErrMsg = "A problem occured while trying to formulate the reply"
822     Sender = mail.get('Reply-To', mail.get('From'))
823     if not Sender:
824         raise UDFormatError("Unable to determine the sender's address")
825
826     # Formulate a reply
827     Date = time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.gmtime(time.time()))
828     Reply = "To: %s\nReply-To: %s\nDate: %s\n" % (Sender, ReplyTo, Date)
829
830     Res = lc.search_s(HostBaseDn, ldap.SCOPE_SUBTREE, '(objectClass=debianServer)', ['hostname'])
831     # Res is a list of tuples.
832     # The tuples contain a dn (str) and a dictionary.
833     # The dictionaries map the key "hostname" to a list.
834     # These lists contain a single hostname (str).
835     ValidHostNames = reduce(lambda a, b: a + b, [value.get("hostname", []) for (dn, value) in Res], [])
836
837     # Dispatch
838     if sys.argv[1] == "ping":
839         Reply = HandlePing(Reply, Attrs[0], pgp.key_info)
840     elif sys.argv[1] == "chpass":
841         if PlainText.strip().find("Please change my Debian password") >= 0:
842             Reply = HandleChPass(Reply, Attrs[0], pgp.key_info)
843         elif PlainText.strip().find("Please change my Kerberos password") >= 0:
844             Reply = HandleChKrbPass(Reply, Attrs[0], pgp.key_info)
845         elif PlainText.strip().find("Please change my TOTP seed") >= 0:
846             Reply = HandleChTOTPSeed(Reply, Attrs[0], pgp.key_info)
847         else:
848             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.")
849     elif sys.argv[1] == "change":
850         Reply = HandleChange(Reply, Attrs[0], pgp.key_info)
851     else:
852         print sys.argv
853         raise UDFormatError("Incorrect Invokation")
854
855     # Send the message through sendmail
856     ErrMsg = "A problem occured while trying to send the reply"
857     Child = os.popen("/usr/sbin/sendmail -t", "w")
858     #   Child = os.popen("cat","w")
859     Child.write(Reply)
860     if Child.close() is not None:
861         raise UDExecuteError("Sendmail gave a non-zero return code")
862
863 except Exception:
864     # Error Reply Header
865     Date = time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.gmtime(time.time()))
866     ErrReplyHead = "To: %s\nReply-To: %s\nDate: %s\n" % (os.environ['SENDER'], ReplyTo, Date)
867
868     # Error Body
869     Subst = {}
870     Subst["__ERROR__"] = ErrMsg
871     Subst["__ADMIN__"] = ReplyTo
872
873     Trace = "==> %s: %s\n" % (sys.exc_type, sys.exc_value)
874     List = traceback.extract_tb(sys.exc_traceback)
875     if len(List) > 1:
876         Trace = Trace + "Python Stack Trace:\n"
877         for x in List:
878             Trace = Trace + "   %s %s:%u: %s\n" % (x[2], x[0], x[1], x[3])
879
880     Subst["__TRACE__"] = Trace
881
882     # Try to send the bounce
883     try:
884         ErrReply = TemplateSubst(Subst, open(TemplatesDir + "error-reply", "r").read())
885
886         Child = os.popen("/usr/sbin/sendmail -t -oi -f ''", "w")
887         Child.write(ErrReplyHead)
888         Child.write(ErrReply)
889         if Child.close() is not None:
890             raise UDExecuteError("Sendmail gave a non-zero return code")
891     except Exception:
892         sys.exit(EX_TEMPFAIL)
893
894     if ErrType != EX_PERMFAIL:
895         sys.exit(ErrType)
896     sys.exit(0)
897
898 # vim:set et:
899 # vim:set ts=4:
900 # vim:set shiftwidth=4: