ud-mailgate: remove exception for münchen.debian.net
[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 for punycode.  We ought to validate it before we allow it in our zone.
375     if Str.lower().startswith('xn--'):
376         return "Punycode not allowed: " + Str
377
378     # Check if the name is already taken
379     G = re.match(r'^([-\w+]+)\s', Str)
380     if G is None:
381         raise UDFormatError("Hostname not found although we already passed record syntax checks")
382     hostname = G.group(1)
383
384     # Check for collisions
385     global lc
386     # [JT 20070409 - search for both tab and space suffixed hostnames
387     #  since we accept either.  It'd probably be better to parse the
388     #  incoming string in order to construct what we feed LDAP rather
389     #  than just passing it through as is.]
390     filter = "(|(dnsZoneEntry=%s        *)(dnsZoneEntry=%s *))" % (hostname, hostname)
391     Rec = lc.search_s(BaseDn, ldap.SCOPE_ONELEVEL, filter, ["uid"])
392     for x in Rec:
393         if GetAttr(x, "uid") != GetAttr(DnRecord, "uid"):
394             return "DNS entry is already owned by " + GetAttr(x, "uid")
395
396     global SeenDNS
397     global DNS
398
399     if cnamerecord:
400         if hostname in DNS:
401             return "CNAME and other RR types not allowed: " + Str
402         else:
403             DNS[hostname] = 2
404     else:
405         if DNS.get(hostname) == 2:
406             return "CNAME and other RR types not allowed: " + Str
407         else:
408             DNS[hostname] = 1
409
410     if cnamerecord is not None:
411         sanitized = "%s IN CNAME %s" % (hostname, cnamerecord.group(1))
412     elif txtrecord is not None:
413         sanitized = "%s IN TXT %s" % (hostname, txtrecord.group(1))
414     elif arecord is not None:
415         ipaddress = arecord.group(1)
416         for quad in ipaddress.split('.'):
417             if not (int(quad) >= 0 and int(quad) <= 255):
418                 return "Invalid quad %s in IP address %s in line %s" % (quad, ipaddress, Str)
419         sanitized = "%s IN A %s" % (hostname, ipaddress)
420     elif mxrecord is not None:
421         priority = mxrecord.group(1)
422         mx = mxrecord.group(2)
423         sanitized = "%s IN MX %s %s" % (hostname, priority, mx)
424     elif aaaarecord is not None:
425         ipv6address = aaaarecord.group(1)
426         parts = ipv6address.split(':')
427         if len(parts) > 8:
428             return "Invalid IPv6 address (%s): too many parts" % (ipv6address)
429         if len(parts) <= 2:
430             return "Invalid IPv6 address (%s): too few parts" % (ipv6address)
431         if parts[0] == "":
432             parts.pop(0)
433         if parts[-1] == "":
434             parts.pop(-1)
435         seenEmptypart = False
436         for p in parts:
437             if len(p) > 4:
438                 return "Invalid IPv6 address (%s): part %s is longer than 4 characters" % (ipv6address, p)
439             if p == "":
440                 if seenEmptypart:
441                     return "Invalid IPv6 address (%s): more than one :: (nothing in between colons) is not allowed" % (ipv6address)
442                 seenEmptypart = True
443             sanitized = "%s IN AAAA %s" % (hostname, ipv6address)
444     else:
445         raise UDFormatError("None of the types I recognize was it.  I shouldn't be here.  confused.")
446
447     if SeenDNS:
448         Attrs.append((ldap.MOD_ADD, "dnsZoneEntry", sanitized))
449         return "DNS Entry added " + sanitized
450
451     Attrs.append((ldap.MOD_REPLACE, "dnsZoneEntry", sanitized))
452     SeenDNS = 1
453     return "DNS Entry replaced with " + sanitized
454
455
456 # Handle an RBL list (mailRBL, mailRHSBL, mailWhitelist)
457 def DoRBL(Str, Attrs):
458     Match = re.compile('^mail(rbl|rhsbl|whitelist) ([-a-z0-9.]+)$').match(Str.lower())
459     if Match is None:
460         return None
461
462     if Match.group(1) == "rbl":
463         Key = "mailRBL"
464     if Match.group(1) == "rhsbl":
465         Key = "mailRHSBL"
466     if Match.group(1) == "whitelist":
467         Key = "mailWhitelist"
468     Host = Match.group(2)
469
470     global SeenList
471     if Key in SeenList:
472         Attrs.append((ldap.MOD_ADD, Key, Host))
473         return "%s added %s" % (Key, Host)
474
475     Attrs.append((ldap.MOD_REPLACE, Key, Host))
476     SeenList[Key] = 1
477     return "%s replaced with %s" % (Key, Host)
478
479
480 # Handle a ConfirmSudoPassword request
481 def DoConfirmSudopassword(Str, SudoPasswd):
482     Match = re.compile('^confirm sudopassword (' + UUID_FORMAT + ') ([a-z0-9.,*-]+) ([0-9a-f]{40})$').match(Str)
483     if Match is None:
484         return None
485
486     uuid = Match.group(1)
487     hosts = Match.group(2)
488     hmac = Match.group(3)
489
490     SudoPasswd[uuid] = (hosts, hmac)
491     return "got confirm for sudo password %s on host(s) %s, auth code %s" % (uuid, hosts, hmac)
492
493
494 def FinishConfirmSudopassword(lc, uid, Attrs, SudoPasswd):
495     result = "\n"
496
497     if len(SudoPasswd) == 0:
498         return None
499
500     res = lc.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "uid=" + uid, ['sudoPassword'])
501     if len(res) != 1:
502         raise UDFormatError("Not exactly one hit when searching for user")
503     if 'sudoPassword' in res[0][1]:
504         inldap = res[0][1]['sudoPassword']
505     else:
506         inldap = []
507
508     newldap = []
509     for entry in inldap:
510         Match = re.compile('^(' + UUID_FORMAT + ') (confirmed:[0-9a-f]{40}|unconfirmed) ([a-z0-9.,*-]+) ([^ ]+)$').match(entry)
511         if Match is None:
512             raise UDFormatError("Could not parse existing sudopasswd entry")
513         uuid = Match.group(1)
514         status = Match.group(2)
515         hosts = Match.group(3)
516         cryptedpass = Match.group(4)
517
518         if uuid in SudoPasswd:
519             confirmedHosts = SudoPasswd[uuid][0]
520             confirmedHmac = SudoPasswd[uuid][1]
521             if status.startswith('confirmed:'):
522                 if status == 'confirmed:' + make_passwd_hmac('password-is-confirmed', 'sudo', uid, uuid, hosts, cryptedpass):
523                     result += "Entry %s for sudo password on hosts %s already confirmed.\n" % (uuid, hosts)
524                 else:
525                     result += "Entry %s for sudo password on hosts %s is listed as confirmed, but HMAC does not verify.\n" % (uuid, hosts)
526             elif confirmedHosts != hosts:
527                 result += "Entry %s hostlist mismatch (%s vs. %s).\n" % (uuid, hosts, confirmedHosts)
528             elif make_passwd_hmac('confirm-new-password', 'sudo', uid, uuid, hosts, cryptedpass) == confirmedHmac:
529                 result += "Entry %s for sudo password on hosts %s now confirmed.\n" % (uuid, hosts)
530                 status = 'confirmed:' + make_passwd_hmac('password-is-confirmed', 'sudo', uid, uuid, hosts, cryptedpass)
531             else:
532                 result += "Entry %s for sudo password on hosts %s HMAC verify failed.\n" % (uuid, hosts)
533             del SudoPasswd[uuid]
534
535         newentry = " ".join([uuid, status, hosts, cryptedpass])
536         if len(newldap) == 0:
537             newldap.append((ldap.MOD_REPLACE, "sudoPassword", newentry))
538         else:
539             newldap.append((ldap.MOD_ADD, "sudoPassword", newentry))
540
541     for entry in SudoPasswd:
542         result += "Entry %s that you confirm is not listed in ldap." % (entry,)
543
544     for entry in newldap:
545         Attrs.append(entry)
546
547     return result
548
549
550 def connect_to_ldap_and_check_if_locked(DnRecord):
551     # Connect to the ldap server
552     lc = connectLDAP()
553     F = open(PassDir + "/pass-" + pwd.getpwuid(os.getuid())[0], "r")
554     AccessPass = F.readline().strip().split(" ")
555     F.close()
556     lc.simple_bind_s("uid={},{}".format(AccessPass[0], BaseDn), AccessPass[1])
557
558     # Check for a locked account
559     Attrs = lc.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "uid=" + GetAttr(DnRecord, "uid"))
560     if (GetAttr(Attrs[0], "userPassword").find("*LK*") != -1) \
561        or GetAttr(Attrs[0], "userPassword").startswith("!"):
562         raise UDNotAllowedError("This account is locked")
563     return lc
564
565
566 # Handle an [almost] arbitary change
567 def HandleChange(Reply, DnRecord, Key):
568     global PlainText
569     Lines = re.split("\n *\r?", PlainText)
570
571     Result = ""
572     Attrs = []
573     SudoPasswd = {}
574     Show = 0
575     CommitChanges = 1
576     for Line in Lines:
577         Line = Line.strip()
578         if Line == "":
579             continue
580
581         # Try to process a command line
582         Result += "> " + Line + "\n"
583         try:
584             if Line == "show":
585                 Show = 1
586                 Res = "OK"
587             else:
588                 badkeys = LoadBadSSH()
589                 Res = DoPosition(Line, Attrs) or DoDNS(Line, Attrs, DnRecord) or \
590                     DoArbChange(Line, Attrs) or DoSSH(Line, Attrs, badkeys, GetAttr(DnRecord, "uid")) or \
591                     DoDel(Line, Attrs) or DoRBL(Line, Attrs) or DoConfirmSudopassword(Line, SudoPasswd)
592         except Exception:
593             Res = None
594             Result += "==> %s: %s\n" % (sys.exc_type, sys.exc_value)
595
596         # Fail, if someone tries to send someone elses signed email to the
597         # daemon then we want to abort ASAP.
598         if Res is None:
599             CommitChanges = 0
600             Result = Result + "Command is not understood. Halted - no changes committed\n"
601             break
602         Result += Res + "\n"
603
604     # Connect to the ldap server
605     lc = connect_to_ldap_and_check_if_locked(DnRecord)
606
607     if CommitChanges == 1 and len(SudoPasswd) > 0:  # only if we are still good to go
608         try:
609             Res = FinishConfirmSudopassword(lc, GetAttr(DnRecord, "uid"), Attrs, SudoPasswd)
610             if Res is not None:
611                 Result += Res + "\n"
612         except Error, e:
613             CommitChanges = 0
614             Result = Result + "FinishConfirmSudopassword raised an error (%s) - no changes committed\n" % (e)
615
616     if CommitChanges == 1 and len(Attrs) > 0:
617         Dn = "uid=" + GetAttr(DnRecord, "uid") + "," + BaseDn
618         lc.modify_s(Dn, Attrs)
619
620     Attribs = ""
621     if Show == 1:
622         Attrs = lc.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "uid=" + GetAttr(DnRecord, "uid"))
623         if len(Attrs) == 0:
624             raise UDNotAllowedError("User not found")
625         Attribs = GPGEncrypt(PrettyShow(Attrs[0]) + "\n", "0x" + Key[1], Key[4])
626
627     Subst = {}
628     Subst["__FROM__"] = ChangeFrom
629     Subst["__EMAIL__"] = EmailAddress(DnRecord)
630     Subst["__ADMIN__"] = ReplyTo
631     Subst["__RESULT__"] = Result
632     Subst["__ATTR__"] = Attribs
633
634     return Reply + TemplateSubst(Subst, open(TemplatesDir + "change-reply", "r").read())
635
636
637 # Handle ping handles an email sent to the 'ping' address (ie this program
638 # called with a ping argument) It replies with a dump of the public records.
639 def HandlePing(Reply, DnRecord, Key):
640     Subst = {}
641     Subst["__FROM__"] = PingFrom
642     Subst["__EMAIL__"] = EmailAddress(DnRecord)
643     Subst["__LDAPFIELDS__"] = PrettyShow(DnRecord)
644     Subst["__ADMIN__"] = ReplyTo
645
646     return Reply + TemplateSubst(Subst, open(TemplatesDir + "ping-reply", "r").read())
647
648
649 def get_crypttype_preamble(key):
650     if (key[4] == 1):
651         type = "Your message was encrypted using PGP 2.x\ncompatibility mode."
652     else:
653         type = "Your message was encrypted using GPG (OpenPGP)\ncompatibility "\
654                "mode, without IDEA. This message cannot be decoded using PGP 2.x"
655     return type
656
657
658 # Handle a change password email sent to the change password address
659 # (this program called with the chpass argument)
660 def HandleChPass(Reply, DnRecord, Key):
661     # Generate a random password
662     Password = GenPass()
663     Pass = HashPass(Password)
664
665     # Use GPG to encrypt it
666     Message = GPGEncrypt("Your new password is '" + Password + "'\n", "0x" + Key[1], Key[4])
667     Password = None
668
669     if Message is None:
670         raise UDFormatError("Unable to generate the encrypted reply, gpg failed.")
671
672     Subst = {}
673     Subst["__FROM__"] = ChPassFrom
674     Subst["__EMAIL__"] = EmailAddress(DnRecord)
675     Subst["__CRYPTTYPE__"] = get_crypttype_preamble(Key)
676     Subst["__PASSWORD__"] = Message
677     Subst["__ADMIN__"] = ReplyTo
678     Reply = Reply + TemplateSubst(Subst, open(TemplatesDir + "passwd-changed", "r").read())
679
680     lc = connect_to_ldap_and_check_if_locked(DnRecord)
681     # Modify the password
682     Rec = [(ldap.MOD_REPLACE, "userPassword", "{crypt}" + Pass),
683            (ldap.MOD_REPLACE, "shadowLastChange", str(int(time.time() / (24 * 60 * 60))))]
684     Dn = "uid=" + GetAttr(DnRecord, "uid") + "," + BaseDn
685     lc.modify_s(Dn, Rec)
686
687     return Reply
688
689
690 def HandleChTOTPSeed(Reply, DnRecord, Key):
691     # Generate a random seed
692     seed = binascii.hexlify(open("/dev/urandom", "r").read(32))
693     random_id = binascii.hexlify(open("/dev/urandom", "r").read(32))
694     totp_file_name = "%d-%s" % (time.time(), random_id,)
695
696     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])
697
698     if msg is None:
699         raise UDFormatError("Unable to generate the encrypted reply, gpg failed.")
700
701     Subst = {}
702     Subst["__FROM__"] = ChPassFrom
703     Subst["__EMAIL__"] = EmailAddress(DnRecord)
704     Subst["__PASSWORD__"] = msg
705     Subst["__ADMIN__"] = ReplyTo
706     Reply += TemplateSubst(Subst, open(TemplatesDir + "totp-seed-changed", "r").read())
707
708     lc = connect_to_ldap_and_check_if_locked(DnRecord)
709     # Save the seed so the user can pick it up.
710     f = open(os.path.join(TOTPTicketDirectory, totp_file_name), os.O_WRONLY | os.O_CREAT)
711     print >> f, seed
712     print >> f, GetAttr(DnRecord, "uid")
713     f.close()
714
715     # Modify the password
716     Rec = [(ldap.MOD_REPLACE, "totpSeed", seed)]
717     Dn = "uid=" + GetAttr(DnRecord, "uid") + "," + BaseDn
718     lc.modify_s(Dn, Rec)
719     return Reply
720
721
722 def HandleChKrbPass(Reply, DnRecord, Key):
723     # Connect to the ldap server, will throw an exception if account locked.
724     lc = connect_to_ldap_and_check_if_locked(DnRecord)
725
726     user = GetAttr(DnRecord, "uid")
727     krb_proc = subprocess.Popen(('ud-krb-reset', user), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
728     krb_proc.stdin.close()
729     out = krb_proc.stdout.readlines()
730     krb_proc.wait()
731     exitcode = krb_proc.returncode
732
733     # Use GPG to encrypt it
734     m = "Tried to reset your kerberos principal's password.\n"
735     if exitcode == 0:
736         m += "The exitcode of the reset script was zero, indicating that everything\n"
737         m += "worked.  However, this being software who knows.  Script's output below."
738     else:
739         m += "The exitcode of the reset script was %d, indicating that something\n" % (exitcode,)
740         m += "went terribly, terribly wrong.  Please consult the script's output below\n"
741         m += "for more information.  Contact the admins if you have any questions or\n"
742         m += "require assitance."
743
744     m += "\n" + ''.join(map(lambda x: "| " + x, out))
745
746     Message = GPGEncrypt(m, "0x" + Key[1], Key[4])
747     if Message is None:
748         raise UDFormatError("Unable to generate the encrypted reply, gpg failed.")
749
750     Subst = {}
751     Subst["__FROM__"] = ChPassFrom
752     Subst["__EMAIL__"] = EmailAddress(DnRecord)
753     Subst["__CRYPTTYPE__"] = get_crypttype_preamble(Key)
754     Subst["__PASSWORD__"] = Message
755     Subst["__ADMIN__"] = ReplyTo
756     Reply += TemplateSubst(Subst, open(TemplatesDir + "passwd-changed", "r").read())
757
758     return Reply
759
760 # Start of main program
761
762
763 # Drop messages from a mailer daemon.
764 if not os.environ.get('SENDER'):
765     sys.exit(0)
766
767 ErrMsg = "Indeterminate Error"
768 ErrType = EX_TEMPFAIL
769 try:
770     # Startup the replay cache
771     ErrType = EX_TEMPFAIL
772     ErrMsg = "Failed to initialize the replay cache:"
773
774     # Get the email
775     ErrType = EX_PERMFAIL
776     ErrMsg = "Failed to understand the email or find a signature:"
777     mail = email.parser.Parser().parse(sys.stdin)
778     Msg = GetClearSig(mail)
779
780     ErrMsg = "Message is not PGP signed:"
781     if Msg[0].find("-----BEGIN PGP SIGNED MESSAGE-----") == -1 and \
782        Msg[0].find("-----BEGIN PGP MESSAGE-----") == -1:
783         raise UDFormatError("No PGP signature")
784
785     # Check the signature
786     ErrMsg = "Unable to check the signature or the signature was invalid:"
787     pgp = GPGCheckSig2(Msg[0])
788
789     if not pgp.ok:
790         raise UDFormatError(pgp.why)
791
792     if pgp.text is None:
793         raise UDFormatError("Null signature text")
794
795     # Extract the plain message text in the event of mime encoding
796     global PlainText
797     ErrMsg = "Problem stripping MIME headers from the decoded message"
798     if Msg[1] == 1:
799         e = email.parser.Parser().parsestr(pgp.text)
800         PlainText = e.get_payload(decode=True)
801     else:
802         PlainText = pgp.text
803
804     # Connect to the ldap server
805     ErrType = EX_TEMPFAIL
806     ErrMsg = "An error occured while performing the LDAP lookup"
807     global lc
808     lc = connectLDAP()
809     lc.simple_bind_s("", "")
810
811     # Search for the matching key fingerprint
812     Attrs = lc.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "keyFingerPrint={}".format(pgp.key_fpr))
813
814     ErrType = EX_PERMFAIL
815     if len(Attrs) == 0:
816         raise UDFormatError("Key not found")
817     if len(Attrs) != 1:
818         raise UDFormatError("Oddly your key fingerprint is assigned to more than one account..")
819
820     # Check the signature against the replay cache
821     RC = ReplayCache(ReplayCacheFile)
822     RC.process(pgp.sig_info)
823
824     # Determine the sender address
825     ErrMsg = "A problem occured while trying to formulate the reply"
826     Sender = mail.get('Reply-To', mail.get('From'))
827     if not Sender:
828         raise UDFormatError("Unable to determine the sender's address")
829
830     # Formulate a reply
831     Date = time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.gmtime(time.time()))
832     Reply = "To: %s\nReply-To: %s\nDate: %s\n" % (Sender, ReplyTo, Date)
833
834     Res = lc.search_s(HostBaseDn, ldap.SCOPE_SUBTREE, '(objectClass=debianServer)', ['hostname'])
835     # Res is a list of tuples.
836     # The tuples contain a dn (str) and a dictionary.
837     # The dictionaries map the key "hostname" to a list.
838     # These lists contain a single hostname (str).
839     ValidHostNames = reduce(lambda a, b: a + b, [value.get("hostname", []) for (dn, value) in Res], [])
840
841     # Dispatch
842     if sys.argv[1] == "ping":
843         Reply = HandlePing(Reply, Attrs[0], pgp.key_info)
844     elif sys.argv[1] == "chpass":
845         if PlainText.strip().find("Please change my Debian password") >= 0:
846             Reply = HandleChPass(Reply, Attrs[0], pgp.key_info)
847         elif PlainText.strip().find("Please change my Kerberos password") >= 0:
848             Reply = HandleChKrbPass(Reply, Attrs[0], pgp.key_info)
849         elif PlainText.strip().find("Please change my TOTP seed") >= 0:
850             Reply = HandleChTOTPSeed(Reply, Attrs[0], pgp.key_info)
851         else:
852             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.")
853     elif sys.argv[1] == "change":
854         Reply = HandleChange(Reply, Attrs[0], pgp.key_info)
855     else:
856         print sys.argv
857         raise UDFormatError("Incorrect Invokation")
858
859     # Send the message through sendmail
860     ErrMsg = "A problem occured while trying to send the reply"
861     Child = os.popen("/usr/sbin/sendmail -t", "w")
862     #   Child = os.popen("cat","w")
863     Child.write(Reply)
864     if Child.close() is not None:
865         raise UDExecuteError("Sendmail gave a non-zero return code")
866
867 except Exception:
868     # Error Reply Header
869     Date = time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.gmtime(time.time()))
870     ErrReplyHead = "To: %s\nReply-To: %s\nDate: %s\n" % (os.environ['SENDER'], ReplyTo, Date)
871
872     # Error Body
873     Subst = {}
874     Subst["__ERROR__"] = ErrMsg
875     Subst["__ADMIN__"] = ReplyTo
876
877     Trace = "==> %s: %s\n" % (sys.exc_type, sys.exc_value)
878     List = traceback.extract_tb(sys.exc_traceback)
879     if len(List) > 1:
880         Trace = Trace + "Python Stack Trace:\n"
881         for x in List:
882             Trace = Trace + "   %s %s:%u: %s\n" % (x[2], x[0], x[1], x[3])
883
884     Subst["__TRACE__"] = Trace
885
886     # Try to send the bounce
887     try:
888         ErrReply = TemplateSubst(Subst, open(TemplatesDir + "error-reply", "r").read())
889
890         Child = os.popen("/usr/sbin/sendmail -t -oi -f ''", "w")
891         Child.write(ErrReplyHead)
892         Child.write(ErrReply)
893         if Child.close() is not None:
894             raise UDExecuteError("Sendmail gave a non-zero return code")
895     except Exception:
896         sys.exit(EX_TEMPFAIL)
897
898     if ErrType != EX_PERMFAIL:
899         sys.exit(ErrType)
900     sys.exit(0)
901
902 # vim:set et:
903 # vim:set ts=4:
904 # vim:set shiftwidth=4: