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