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