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