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