#!/usr/bin/python2
# -*- mode: python -*-

#   Prior copyright probably rmurray, troup, joey, jgg -- weasel 2008
#   Copyright (c) 2009 Stephen Gran <steve@lobefin.net>
#   Copyright (c) 2008,2009,2010 Peter Palfrader <peter@palfrader.org>
#   Copyright (c) 2008 Joerg Jaspert <joerg@debian.org>
#   Copyright (c) 2010 Helmut Grohne <helmut@subdivi.de>

from __future__ import print_function

import io
import sys
import traceback
import time
import ldap
import os
import commands
import pwd
import tempfile
import subprocess
import email
import email.parser
import binascii
import re
import functools
try:
    import spf
except ImportError:
    spf = None

from userdir_ldap.gpg import (
    gpg_check_email_sig, GPGEncrypt, ReplayCache, TemplateSubst)
from userdir_ldap.ldap import (
    GetAttr, HostBaseDn, EmailAddress, GenPass, HashPass, TemplatesDir, BaseDn,
    UserDNSDomain, FormatSSHAuth, ConfModule, DecDegree, SSH2AuthSplit,
    make_passwd_hmac, connectLDAP, PassDir, PrettyShow, HostDomain)
from userdir_ldap.exceptions import UDExecuteError, UDFormatError, UDNotAllowedError

ReplyTo = ConfModule.replyto
PingFrom = ConfModule.pingfrom
ChPassFrom = ConfModule.chpassfrom
ChangeFrom = ConfModule.changefrom
ReplayCacheFile = ConfModule.replaycachefile
SSHFingerprintFile = ConfModule.fingerprintfile
TOTPTicketDirectory = ConfModule.totpticketdirectory
WebUILocation = ConfModule.webuilocation

UUID_FORMAT = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
machine_regex = re.compile("^[0-9a-zA-Z.-]+$")

# Error codes from /usr/include/sysexits.h
EX_TEMPFAIL = 75
EX_PERMFAIL = 65      # EX_DATAERR
Error = 'Message Error'
SeenKey = 0
SeenDNS = 0
SeenDKIM = 0
mailRBL = {}
mailRHSBL = {}
mailWhitelist = {}
SeenList = {}
DNS = {}
DKIM = {}
ValidHostNames = []  # will be initialized in later

SSHFingerprint = re.compile(r'^(\d+) ([0-9a-f\:]{47}|SHA256:[0-9A-Za-z/+]{43}) (.+)$')
SSHRSA1Match = re.compile(r'^^(.* )?\d+ \d+ \d+')

ArbChanges = {"c": "..",
              "l": ".*",
              "facsimileTelephoneNumber": ".*",
              "telephoneNumber": ".*",
              "postalAddress": ".*",
              "bATVToken": ".*",
              "postalCode": ".*",
              "loginShell": ".*",
              "emailForward": "^([^<>@]+@.+)?$",
              "jabberJID": "^([^<>@]+@.+)?$",
              "ircNick": ".*",
              "icqUin": "^[0-9]*$",
              "onVacation": ".*",
              "labeledURI": ".*",
              "birthDate": "^([0-9]{4})([01][0-9])([0-3][0-9])$",
              "mailDisableMessage": ".*",
              "mailGreylisting": "^(TRUE|FALSE)$",
              "mailCallout": "^(TRUE|FALSE)$",
              "mailDefaultOptions": "^(TRUE|FALSE)$",
              "VoIP": ".*",
              "mailContentInspectionAction": "^(reject|blackhole|markup)$", }

DelItems = {"c": None,
            "l": None,
            "facsimileTelephoneNumber": None,
            "telephoneNumber": None,
            "postalAddress": None,
            "bATVToken": None,
            "postalCode": None,
            "emailForward": None,
            "ircNick": None,
            "onVacation": None,
            "labeledURI": None,
            "latitude": None,
            "longitude": None,
            "icqUin": None,
            "jabberJID": None,
            "jpegPhoto": None,
            "dnsZoneEntry": None,
            "dkimPubKey": None,
            "sshRSAAuthKey": None,
            "birthDate": None,
            "mailGreylisting": None,
            "mailCallout": None,
            "mailRBL": None,
            "mailRHSBL": None,
            "mailWhitelist": None,
            "mailDisableMessage": None,
            "mailDefaultOptions": None,
            "VoIP": None,
            "mailContentInspectionAction": None,
            }


# Decode a GPS location from some common forms
def LocDecode(Str, Dir):
    # Check for Decimal degrees, DGM, or DGMS
    if re.match(r"^[+-]?[\d.]+$", Str) is not None:
        return Str

    Deg = '0'
    Min = None
    Sec = None
    Dr = Dir[0]

    # Check for DDDxMM.MMMM where x = [nsew]
    Match = re.match(r"^(\d+)([" + Dir + r"])([\d.]+)$", Str)
    if Match:
        G = Match.groups()
        Deg = G[0]
        Min = G[2]
        Dr = G[1]

    # Check for DD.DD x
    Match = re.match(r"^([\d.]+) ?([" + Dir + r"])$", Str)
    if Match:
        G = Match.groups()
        Deg = G[0]
        Dr = G[1]

    # Check for DD:MM.MM x
    Match = re.match(r"^(\d+):([\d.]+) ?([" + Dir + "])$", Str)
    if Match:
        G = Match.groups()
        Deg = G[0]
        Min = G[1]
        Dr = G[2]

    # Check for DD:MM:SS.SS x
    Match = re.match(r"^(\d+):(\d+):([\d.]+) ?([" + Dir + "])$", Str)
    if Match:
        G = Match.groups()
        Deg = G[0]
        Min = G[1]
        Sec = G[2]
        Dr = G[3]

    # Some simple checks
    if float(Deg) > 180:
        raise UDFormatError("Bad degrees")
    if Min is not None and float(Min) > 60:
        raise UDFormatError("Bad minutes")
    if Sec is not None and float(Sec) > 60:
        raise UDFormatError("Bad seconds")

    # Pad on an extra leading 0 to disambiguate small numbers
    if len(Deg) <= 1 or Deg[1] == '.':
        Deg = '0' + Deg
    if Min is not None and (len(Min) <= 1 or Min[1] == '.'):
        Min = '0' + Min
    if Sec is not None and (len(Sec) <= 1 or Sec[1] == '.'):
        Sec = '0' + Sec

    # Construct a DGM/DGMS type value from the components.
    Res = "+"
    if Dr == Dir[1]:
        Res = "-"
    Res = Res + Deg
    if Min is not None:
        Res = Res + Min
    if Sec is not None:
        Res = Res + Sec
    return Res


# Handle changing a set of arbitary fields
#  <field>: value
def DoArbChange(Str, Attrs):
    Match = re.match("^([^ :]+): (.*)$", Str)
    if Match is None:
        return None
    G = Match.groups()

    attrName = G[0].lower()
    for i in ArbChanges.keys():
        if i.lower() == attrName:
            attrName = i
            break
    if attrName not in ArbChanges:
        return None

    value = G[1]
    if re.match(ArbChanges[attrName], value) is None:
        raise UDFormatError("Item does not match the required format" + ArbChanges[attrName])

    Attrs.append((ldap.MOD_REPLACE, attrName, value))
    return "Changed entry %s to %s" % (attrName, value)


# Handle changing a set of arbitary fields
#  <field>: value
def DoDel(Str, Attrs):
    Match = re.match("^del (.*)$", Str)
    if Match is None:
        return None
    G = Match.groups()

    attrName = G[0].lower()
    for i in DelItems.keys():
        if i.lower() == attrName:
            attrName = i
            break
    if attrName not in DelItems:
        return "Cannot erase entry %s" % (attrName,)

    Attrs.append((ldap.MOD_DELETE, attrName, None))
    return "Removed entry %s" % (attrName)


# Handle a position change message, the line format is:
#  Lat: -12412.23 Long: +12341.2342
def DoPosition(Str, Attrs):
    Match = re.match(r"^lat: ([+\-]?[\d:.ns]+(?: ?[ns])?) long: ([+\-]?[\d:.ew]+(?: ?[ew])?)$", Str.lower())
    if Match is None:
        return None

    G = Match.groups()
    try:
        sLat = LocDecode(G[0], "ns")
        sLong = LocDecode(G[1], "ew")
        Lat = DecDegree(sLat, 1)
        Long = DecDegree(sLong, 1)
    except Exception:
        raise UDFormatError("Positions were found, but they are not correctly formed")

    Attrs.append((ldap.MOD_REPLACE, "latitude", sLat))
    Attrs.append((ldap.MOD_REPLACE, "longitude", sLong))
    return "Position set to %s/%s (%s/%s decimal degrees)" % (sLat, sLong, Lat, Long)


# Load bad ssh fingerprints
def LoadBadSSH():
    f = open(SSHFingerprintFile, "r")
    bad = []
    FingerprintLine = re.compile(r'^([0-9a-f:]{47}).*$')
    for line in f.readlines():
        Match = FingerprintLine.match(line)
        if Match is not None:
            g = Match.groups()
            bad.append(g[0])
    return bad


# Handle an SSH authentication key, the line format is:
# [allowed_hosts=machine1,machine2 ][options ]ssh-rsa keybytes [comment]
def DoSSH(Str, Attrs, badkeys, uid):
    # This list should really be a constant or method somewhere
    if Str.strip() in ["ssh-rsa", "ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521"]:
        return "Key appears to have been line-wrapped. Each key must be on a single line. Using 'gpg --armor' can help avoid this issue."

    Match = SSH2AuthSplit.match(Str)
    if Match is None:
        return None
    g = Match.groups()
    typekey = g[1]
    if Match is None:
        Match = SSHRSA1Match.match(Str)
        if Match is not None:
            return "RSA1 keys not supported anymore"
        return None

    if Str.startswith("sshRSAAuthKey: "):
        return "invalid ssh key syntax; should not start with field name"

    machines = []
    if Str.startswith("allowed_hosts="):
        Str = Str.split("=", 1)[1]
        if ' ' not in Str:
            return "invalid ssh key syntax with machine specification"
        machines, Str = Str.split(' ', 1)
        machines = machines.split(",")
        for m in machines:
            if not m:
                return "empty machine specification for ssh key"
            if not machine_regex.match(m):
                return "machine specification for ssh key contains invalid characters"
            if m not in ValidHostNames:
                return "unknown machine {} used in allowed_hosts stanza for ssh keys".format(m)

    (fd, path) = tempfile.mkstemp(".pub", "sshkeytry", "/tmp")
    f = open(path, "w")
    f.write("%s\n" % (Str))
    f.close()
    cmd = "/usr/bin/ssh-keygen -l -f %s < /dev/null" % (path)
    (result, output) = commands.getstatusoutput(cmd)
    os.remove(path)
    if (result != 0):
        raise UDExecuteError("ssh-keygen -l invocation failed!\n%s\n" % (output))

    # format the string again for ldap:
    if machines:
        Str = "allowed_hosts=%s %s" % (",".join(machines), Str)

    # Head
    Date = time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.gmtime(time.time()))
    ErrReplyHead = "From: %s\nCc: %s\nReply-To: %s\nDate: %s\n" % (os.environ['SENDER'], os.environ['SENDER'], ReplyTo, Date)
    Subst = {}
    Subst["__ADMIN__"] = ReplyTo
    Subst["__USER__"] = uid

    Match = SSHFingerprint.match(output)
    if Match is None:
        return "Failed to match SSH fingerprint, has the output of ssh-keygen changed?"
    g = Match.groups()
    key_size = g[0]
    fingerprint = g[1]

    if typekey == "ssh-rsa":
        key_size_ok = (int(key_size) >= 2048)
    elif typekey in ["ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521"]:
        key_size_ok = True
    else:
        key_size_ok = False

    if not key_size_ok:
        return "SSH key fails formal criteria, not added.  We only accept RSA keys (>= 2048 bits) or ed25519 keys."
    elif fingerprint in badkeys:
        try:
            # Body
            Subst["__ERROR__"] = "SSH key with fingerprint %s known as bad key" % (g[1])
            ErrReply = TemplateSubst(Subst, open(TemplatesDir + "admin-info", "r").read())

            Child = subprocess.Popen(['/usr/sbin/sendmail', '-t'], stdin=subprocess.PIPE)
            Child.stdin.write(ErrReplyHead)
            Child.stdin.write(ErrReply)
            Child.stdin.close()
            if Child.wait() != 0:
                raise UDExecuteError("Sendmail gave a non-zero return code")
        except Exception:
            sys.exit(EX_TEMPFAIL)

        # And now break and stop processing input, which sends a reply to the user.
        raise UDFormatError("Submitted SSH key known to be bad and insecure, processing halted, NOTHING MODIFIED AT ALL")

    global SeenKey
    if SeenKey:
        Attrs.append((ldap.MOD_ADD, "sshRSAAuthKey", Str))
        return "SSH Key added: %s %s [%s]" % (key_size, fingerprint, FormatSSHAuth(Str))

    Attrs.append((ldap.MOD_REPLACE, "sshRSAAuthKey", Str))
    SeenKey = 1
    return "SSH Keys replaced with: %s %s [%s]" % (key_size, fingerprint, FormatSSHAuth(Str))


# Handle changing a dns entry
#  host IN A     12.12.12.12
#  host IN AAAA  1234::5678
#  host IN CNAME foo.bar.    <- Trailing dot is required
#  host IN MX    foo.bar.    <- Trailing dot is required
def DoDNS(Str, Attrs, DnRecord):
    prefixre = r'^([-\w._]+)\s+IN\s+'
    cnamerecord = re.match(prefixre + r'CNAME\s+([-\w.]+\.)$', Str, re.IGNORECASE)
    arecord     = re.match(prefixre + r'A\s+(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$', Str, re.IGNORECASE)  # noqa: E221
    mxrecord    = re.match(prefixre + r'MX\s+(\d{1,3})\s+([-\w.]*\.)$', Str, re.IGNORECASE)  # noqa: E221
    spfrecord   = re.match(prefixre + r'TXT\s+(v=spf1 .*)$', Str, re.IGNORECASE)  # noqa: E221
    txtrecord   = re.match(prefixre + r'TXT\s+([-\d. a-z\t<>@:=_]+)$', Str, re.IGNORECASE)  # noqa: E221
    aaaarecord  = re.match(prefixre + r'AAAA\s+([A-F0-9:]{2,39})$', Str, re.IGNORECASE)  # noqa: E221

    if cnamerecord is None and\
       arecord is None and\
       mxrecord is None and\
       spfrecord is None and\
       txtrecord is None and\
       aaaarecord is None:
        return None

    G = re.match(prefixre, Str, re.IGNORECASE)
    if G is None:
        raise UDFormatError("domainname not found although we already passed record syntax checks")
    domainname = G.group(1).lower()

    labels = domainname.split('.')
    toplabel = labels[-1]
    if toplabel == "":
        return "Names must be relative to the origin and not fully qualified domain names.  However, " + domainname + " ends with a dot."
    if UserDNSDomain:
        fqdn = "%s.%s" % (domainname, UserDNSDomain)
    else:
        fqdn = domainname
    if len(fqdn) > 253:
        return "Domain name too long: " + domainname
    for label in labels:
        # Check for punycode.  We ought to validate it before we allow it in our zone.
        if label.startswith('xn--'):
            return "Punycode not allowed: " + domainname
        elif label == "":
            return "Illegal label in: " + domainname
        elif len(label) > 63:
            return "Label portion too long: " + label

    # Check if the name (or anything below it) is already taken
    global lc
    filter = "(|(dnsZoneEntry=*.%s *)(dnsZoneEntry=%s *))" % (toplabel, toplabel)
    Rec = lc.search_s(BaseDn, ldap.SCOPE_ONELEVEL, filter, ["uid"])
    for x in Rec:
        if GetAttr(x, "uid") != GetAttr(DnRecord, "uid"):
            return "DNS entry/tree " + toplabel + " is already owned by " + GetAttr(x, "uid")

    global SeenDNS
    global DNS

    if cnamerecord:
        if domainname in DNS:
            return "CNAME and other RR types not allowed: " + Str
        else:
            DNS[domainname] = 2
    else:
        if DNS.get(domainname) == 2:
            return "CNAME and other RR types not allowed: " + Str
        else:
            DNS[domainname] = 1

    if cnamerecord is not None:
        sanitized = "%s IN CNAME %s" % (domainname, cnamerecord.group(2))
    elif spfrecord is not None:
        spf_rr = spfrecord.group(2)

        if spf is None:
            # We failed to import pyspf for some reason
            return "Unable to verify SPF record; not added"
        # Verify the syntax of the record. These values were chosen to avoid
        # being dependent on an external resource. The strictness setting is
        # possibly overkill, but a human will receive the resulting rejection
        # so should be able to work out what to do.
        spf_query = spf.query(i='127.0.0.1', s='example@example.com', h='unknown', receiver='127.0.0.1', strict=2)
        # returns: ('pass', 250, 'sender SPF authorized')
        (result, statuscode, reason) = spf_query.check(spf_rr)
        # A pass, softfail or neutral response counts as success. A record
        # containing "-all" will result in a 550, but so will various other
        # issues that we don't want to accept, so we check the text for
        # that case.
        if statuscode != 250 and result != 'fail':
            return "Invalid SPF record: %s" % (reason)
        sanitized = "%s IN TXT %s" % (domainname, spf_rr)
    elif txtrecord is not None:
        sanitized = "%s IN TXT %s" % (domainname, txtrecord.group(2))
    elif arecord is not None:
        ipaddress = arecord.group(2)
        for quad in ipaddress.split('.'):
            if not (int(quad) >= 0 and int(quad) <= 255):
                return "Invalid quad %s in IP address %s in line %s" % (quad, ipaddress, Str)
        sanitized = "%s IN A %s" % (domainname, ipaddress)
    elif mxrecord is not None:
        priority = mxrecord.group(2)
        mx = mxrecord.group(3)
        sanitized = "%s IN MX %s %s" % (domainname, priority, mx)
    elif aaaarecord is not None:
        ipv6address = aaaarecord.group(2)
        parts = ipv6address.split(':')
        if len(parts) > 8:
            return "Invalid IPv6 address (%s): too many parts" % (ipv6address)
        if len(parts) <= 2:
            return "Invalid IPv6 address (%s): too few parts" % (ipv6address)
        if parts[0] == "":
            parts.pop(0)
        if parts[-1] == "":
            parts.pop(-1)
        seenEmptypart = False
        for p in parts:
            if len(p) > 4:
                return "Invalid IPv6 address (%s): part %s is longer than 4 characters" % (ipv6address, p)
            if p == "":
                if seenEmptypart:
                    return "Invalid IPv6 address (%s): more than one :: (nothing in between colons) is not allowed" % (ipv6address)
                seenEmptypart = True
            sanitized = "%s IN AAAA %s" % (domainname, ipv6address)
    else:
        raise UDFormatError("None of the types I recognize was it.  I shouldn't be here.  confused.")

    if SeenDNS:
        Attrs.append((ldap.MOD_ADD, "dnsZoneEntry", sanitized))
        return "DNS Entry added " + sanitized

    Attrs.append((ldap.MOD_REPLACE, "dnsZoneEntry", sanitized))
    SeenDNS = 1
    return "DNS Entry replaced with " + sanitized


# Handle changing a DKIM public key
#  dkim <selector> <key>
def DoDKIM(Str, Attrs, DnRecord):
    # This rather unpleasant expression matches
    # - 'dkimPubKey:'
    # - whitespace
    # - group 1: selector name: (word characters, '.', word characters, '.user')
    # - group 2: second group of word characters from selector name (uid)
    # - whitespace
    # - group 3 (key / attributes):
    #   - either
    #     - a base64 string, optionally preceeded by 'p='
    #   - or
    #     - one of
    #       - a base64 string, preceeded by 'p='
    #       - a 'key=value' string
    #     - optionally followed by a set of space-separated
    #       - 'p=value' base64 strings
    #       - 'key=value' strings
    dkimrecord = re.match(r"^dkimPubKey:\s+([-\w]+\.([-\w]+)\.user)\s+((?:p=)?[A-Z0-9+/=]+$|(?:(?:[a-z]+=[-\w]+|p=[A-Z0-9+/=]+);?(?: (?:[a-z]+=[-\w]+|p=[A-Z0-9+/=]+);?)*)$)", Str, re.IGNORECASE)
    # - 'dkimPubKey:'
    # - whitespace
    # - group 1: selector name: (word characters, '.', word characters, '.user')
    # - group 2: second group of word characters from selector name (uid)
    # - whitespace
    # - group 3 (CNAME target)
    dkimcnamerecord = re.match(r'^dkimPubKey:\s+([-\w]+\.([-\w]+)\.user)\s+CNAME\s+([-\w.]+\.)$', Str, re.IGNORECASE)

    if dkimrecord is not None:
        (selectorname, uid, data) = dkimrecord.groups()
    elif dkimcnamerecord is not None:
        (selectorname, uid, data) = dkimcnamerecord.groups()
    else:
        return None

    # Check for punycode.  We ought to validate it before we allow it in our zone.
    if selectorname.lower().startswith('xn--'):
        return "Punycode not allowed: " + Str
    if dkimcnamerecord is not None and data.lower().startswith('xn--'):
        return "Punycode not allowed: " + Str
    if uid != GetAttr(DnRecord, "uid"):
        return "Can only set keys for user " + GetAttr(DnRecord, "uid")
    # This should essentially be a no-op loop for the CNAME case, as there
    # will be no inner whitespace or '=' symbols, so the "bare key" case
    # will match.
    dkimattrs = data.split()
    for dkimattr in dkimattrs:
        parts = dkimattr.strip(';').split('=', 1)
        if len(parts) == 1:
            # bare key
            continue
        elif parts[0] == 'h':
            if parts[1] not in ['sha256']:
                return "Unsupported DKIM hash type: " + parts[1]
        elif parts[0] == 'k':
            if parts[1] not in ['rsa', 'ed25519']:
                return "Unsupported DKIM algorithm: " + parts[1]
    if len('%s._domainkey.%s.' % (selectorname, HostDomain)) > 253:
        return "DKIM selector too long!"
    for label in selectorname.split('.'):
        if len(label) > 63:
            return "Label portion too long: " + label

    global SeenDKIM
    global DKIM

    if dkimrecord is not None:
        sanitized = "%s %s" % (selectorname.lower(), data.replace('; ', ' '))
    elif dkimcnamerecord is not None:
        sanitized = "%s CNAME %s" % (selectorname.lower(), data)
    else:
        raise UDFormatError("I shouldn't be here.  confused.")

    if SeenDKIM:
        Attrs.append((ldap.MOD_ADD, "dkimPubKey", sanitized))
        return "DKIM public key added"

    Attrs.append((ldap.MOD_REPLACE, "dkimPubKey", sanitized))
    SeenDKIM = 1
    return "DKIM public key entry replaced"


# Handle an RBL list (mailRBL, mailRHSBL, mailWhitelist)
def DoRBL(Str, Attrs):
    Match = re.compile('^mail(rbl|rhsbl|whitelist) ([-a-z0-9.]+)$').match(Str.lower())
    if Match is None:
        return None

    if Match.group(1) == "rbl":
        Key = "mailRBL"
    if Match.group(1) == "rhsbl":
        Key = "mailRHSBL"
    if Match.group(1) == "whitelist":
        Key = "mailWhitelist"
    Host = Match.group(2)

    global SeenList
    if Key in SeenList:
        Attrs.append((ldap.MOD_ADD, Key, Host))
        return "%s added %s" % (Key, Host)

    Attrs.append((ldap.MOD_REPLACE, Key, Host))
    SeenList[Key] = 1
    return "%s replaced with %s" % (Key, Host)


# Handle a ConfirmSudoPassword request
def DoConfirmSudopassword(Str, SudoPasswd):
    Match = re.compile('^confirm sudopassword (' + UUID_FORMAT + ') ([a-z0-9.,*-]+) ([0-9a-f]{40})$').match(Str)
    if Match is None:
        return None

    uuid = Match.group(1)
    hosts = Match.group(2)
    hmac = Match.group(3)

    SudoPasswd[uuid] = (hosts, hmac)
    return "got confirm for sudo password %s on host(s) %s, auth code %s" % (uuid, hosts, hmac)


def FinishConfirmSudopassword(lc, uid, Attrs, SudoPasswd):
    result = "\n"

    if len(SudoPasswd) == 0:
        return None

    res = lc.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "uid=" + uid, ['sudoPassword'])
    if len(res) != 1:
        raise UDFormatError("Not exactly one hit when searching for user")
    if 'sudoPassword' in res[0][1]:
        inldap = res[0][1]['sudoPassword']
    else:
        inldap = []

    newldap = []
    for entry in inldap:
        Match = re.compile('^(' + UUID_FORMAT + ') (confirmed:[0-9a-f]{40}|unconfirmed) ([a-z0-9.,*-]+) ([^ ]+)$').match(entry)
        if Match is None:
            raise UDFormatError("Could not parse existing sudopasswd entry")
        uuid = Match.group(1)
        status = Match.group(2)
        hosts = Match.group(3)
        cryptedpass = Match.group(4)

        if uuid in SudoPasswd:
            confirmedHosts = SudoPasswd[uuid][0]
            confirmedHmac = SudoPasswd[uuid][1]
            if status.startswith('confirmed:'):
                if status == 'confirmed:' + make_passwd_hmac('password-is-confirmed', 'sudo', uid, uuid, hosts, cryptedpass):
                    result += "Entry %s for sudo password on hosts %s already confirmed.\n" % (uuid, hosts)
                else:
                    result += "Entry %s for sudo password on hosts %s is listed as confirmed, but HMAC does not verify.\n" % (uuid, hosts)
            elif confirmedHosts != hosts:
                result += "Entry %s hostlist mismatch (%s vs. %s).\n" % (uuid, hosts, confirmedHosts)
            elif make_passwd_hmac('confirm-new-password', 'sudo', uid, uuid, hosts, cryptedpass) == confirmedHmac:
                result += "Entry %s for sudo password on hosts %s now confirmed.\n" % (uuid, hosts)
                status = 'confirmed:' + make_passwd_hmac('password-is-confirmed', 'sudo', uid, uuid, hosts, cryptedpass)
            else:
                result += "Entry %s for sudo password on hosts %s HMAC verify failed.\n" % (uuid, hosts)
            del SudoPasswd[uuid]

        newentry = " ".join([uuid, status, hosts, cryptedpass])
        if len(newldap) == 0:
            newldap.append((ldap.MOD_REPLACE, "sudoPassword", newentry))
        else:
            newldap.append((ldap.MOD_ADD, "sudoPassword", newentry))

    for entry in SudoPasswd:
        result += "Entry %s that you confirm is not listed in ldap." % (entry,)

    for entry in newldap:
        Attrs.append(entry)

    return result


def connect_to_ldap_and_check_if_locked(DnRecord):
    # Connect to the ldap server
    lc = connectLDAP()
    F = open(PassDir + "/pass-" + pwd.getpwuid(os.getuid())[0], "r")
    AccessPass = F.readline().strip().split(" ")
    F.close()
    lc.simple_bind_s("uid={},{}".format(AccessPass[0], BaseDn), AccessPass[1])

    # Check for a locked account
    Attrs = lc.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "uid=" + GetAttr(DnRecord, "uid"))
    if (GetAttr(Attrs[0], "userPassword").find("*LK*") != -1) \
       or GetAttr(Attrs[0], "userPassword").startswith("!"):
        raise UDNotAllowedError("This account is locked")
    return lc


# Handle an [almost] arbitary change
def HandleChange(DnRecord, Key):
    global PlainText
    Lines = re.split("\n *\r?", PlainText)

    Result = ""
    Attrs = []
    SudoPasswd = {}
    Show = 0
    CommitChanges = 1
    for Line in Lines:
        Line = Line.strip()
        if Line == "":
            continue

        # Try to process a command line
        Result += "> " + Line + "\n"
        try:
            if Line == "show":
                Show = 1
                Res = "OK"
            else:
                badkeys = LoadBadSSH()
                Res = DoPosition(Line, Attrs) or DoDKIM(Line, Attrs, DnRecord) or DoDNS(Line, Attrs, DnRecord) or \
                    DoArbChange(Line, Attrs) or DoSSH(Line, Attrs, badkeys, GetAttr(DnRecord, "uid")) or \
                    DoDel(Line, Attrs) or DoRBL(Line, Attrs) or DoConfirmSudopassword(Line, SudoPasswd)
        except Exception:
            Res = None
            Result += "==> %s: %s\n" % sys.exc_info()[:2]

        # Fail, if someone tries to send someone elses signed email to the
        # daemon then we want to abort ASAP.
        if Res is None:
            CommitChanges = 0
            Result = Result + "Command is not understood. Halted - no changes committed\n"
            break
        Result += Res + "\n"

    # Connect to the ldap server
    lc = connect_to_ldap_and_check_if_locked(DnRecord)

    if CommitChanges == 1 and len(SudoPasswd) > 0:  # only if we are still good to go
        try:
            Res = FinishConfirmSudopassword(lc, GetAttr(DnRecord, "uid"), Attrs, SudoPasswd)
            if Res is not None:
                Result += Res + "\n"
        except Exception as e:
            CommitChanges = 0
            Result = Result + "FinishConfirmSudopassword raised an error (%s) - no changes committed\n" % (e)

    if CommitChanges == 1 and len(Attrs) > 0:
        Dn = "uid=" + GetAttr(DnRecord, "uid") + "," + BaseDn
        lc.modify_s(Dn, Attrs)

    Attribs = ""
    if Show == 1:
        Attrs = lc.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "uid=" + GetAttr(DnRecord, "uid"))
        if len(Attrs) == 0:
            raise UDNotAllowedError("User not found")
        Attribs = GPGEncrypt(PrettyShow(Attrs[0]) + u"\n", "0x" + Key[1])

    Subst = {}
    Subst["__FROM__"] = ChangeFrom
    Subst["__EMAIL__"] = EmailAddress(DnRecord)
    Subst["__ADMIN__"] = ReplyTo
    Subst["__RESULT__"] = Result
    Subst["__ATTR__"] = Attribs

    return TemplateSubst(Subst, open(TemplatesDir + "change-reply", "r").read())


# Handle ping handles an email sent to the 'ping' address (ie this program
# called with a ping argument) It replies with a dump of the public records.
def HandlePing(DnRecord, Key):
    Subst = {}
    Subst["__FROM__"] = PingFrom
    Subst["__EMAIL__"] = EmailAddress(DnRecord)
    Subst["__LDAPFIELDS__"] = PrettyShow(DnRecord)
    Subst["__ADMIN__"] = ReplyTo

    return TemplateSubst(Subst, open(TemplatesDir + "ping-reply", "r").read())


# Handle a change password email sent to the change password address
# (this program called with the chpass argument)
def HandleChPass(DnRecord, Key):
    # Generate a random password
    Password = GenPass()
    Pass = HashPass(Password)

    # Use GPG to encrypt it
    Message = GPGEncrypt(u"Your new password is '" + Password + "'\n", "0x" + Key[1])
    Password = None

    if Message is None:
        raise UDFormatError("Unable to generate the encrypted reply, gpg failed.")

    Subst = {}
    Subst["__FROM__"] = ChPassFrom
    Subst["__EMAIL__"] = EmailAddress(DnRecord)
    Subst["__PASSWORD__"] = Message
    Subst["__ADMIN__"] = ReplyTo
    Reply = TemplateSubst(Subst, open(TemplatesDir + "passwd-changed", "r").read())

    lc = connect_to_ldap_and_check_if_locked(DnRecord)
    # Modify the password
    Rec = [(ldap.MOD_REPLACE, "userPassword", "{crypt}" + Pass),
           (ldap.MOD_REPLACE, "shadowLastChange", str(int(time.time() / (24 * 60 * 60))))]
    Dn = "uid=" + GetAttr(DnRecord, "uid") + "," + BaseDn
    lc.modify_s(Dn, Rec)

    return Reply


def HandleChTOTPSeed(DnRecord, Key):
    # Generate a random seed
    with open('/dev/urandom', 'rb') as urandom:
        seed = binascii.hexlify(urandom.read(32))
        random_id = binascii.hexlify(urandom.read(32))
    totp_file_name = "%d-%s" % (time.time(), random_id,)

    msg = GPGEncrypt(u"Please go to %s/fetch-totp-seed.cgi?id=%s\n to fetch your TOTP seed" % (WebUILocation, totp_file_name), "0x" + Key[1])

    if msg is None:
        raise UDFormatError("Unable to generate the encrypted reply, gpg failed.")

    Subst = {}
    Subst["__FROM__"] = ChPassFrom
    Subst["__EMAIL__"] = EmailAddress(DnRecord)
    Subst["__PASSWORD__"] = msg
    Subst["__ADMIN__"] = ReplyTo
    Reply = TemplateSubst(Subst, open(TemplatesDir + "totp-seed-changed", "r").read())

    lc = connect_to_ldap_and_check_if_locked(DnRecord)
    # Save the seed so the user can pick it up.
    with io.open(os.path.join(TOTPTicketDirectory, totp_file_name), 'w', encoding='ascii') as f:
        print(seed.decode('ascii'), file=f)
        print(GetAttr(DnRecord, "uid"), file=f)

    # Modify the password
    Rec = [(ldap.MOD_REPLACE, "totpSeed", seed)]
    Dn = "uid=" + GetAttr(DnRecord, "uid") + "," + BaseDn
    lc.modify_s(Dn, Rec)
    return Reply


def HandleChKrbPass(DnRecord, Key):
    # Connect to the ldap server, will throw an exception if account locked.
    connect_to_ldap_and_check_if_locked(DnRecord)

    user = GetAttr(DnRecord, "uid")
    krb_proc = subprocess.Popen(('ud-krb-reset', user), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    krb_proc.stdin.close()
    out = krb_proc.stdout.readlines()
    krb_proc.wait()
    exitcode = krb_proc.returncode

    # Use GPG to encrypt it
    m = u"Tried to reset your kerberos principal's password.\n"
    if exitcode == 0:
        m += "The exitcode of the reset script was zero, indicating that everything\n"
        m += "worked.  However, this being software who knows.  Script's output below."
    else:
        m += "The exitcode of the reset script was %d, indicating that something\n" % (exitcode,)
        m += "went terribly, terribly wrong.  Please consult the script's output below\n"
        m += "for more information.  Contact the admins if you have any questions or\n"
        m += "require assitance."

    m += "\n" + ''.join(map(lambda x: "| " + x, out))

    Message = GPGEncrypt(m, "0x" + Key[1])
    if Message is None:
        raise UDFormatError("Unable to generate the encrypted reply, gpg failed.")

    Subst = {}
    Subst["__FROM__"] = ChPassFrom
    Subst["__EMAIL__"] = EmailAddress(DnRecord)
    Subst["__PASSWORD__"] = Message
    Subst["__ADMIN__"] = ReplyTo
    Reply = TemplateSubst(Subst, open(TemplatesDir + "passwd-changed", "r").read())

    return Reply


# Handles a change mail password email sent to ud-mailgate
def HandleChMailPassword(DnRecord, Key):
    # Generate a random password
    Password = GenPass()
    Pass = HashPass(Password, hash_method="sha512")

    # Use GPG to encrypt it
    Message = GPGEncrypt(u"Your new mail password is '" + Password + "'\n", "0x" + Key[1])
    Password = None

    if Message is None:
        raise UDFormatError("Unable to generate the encrypted reply, gpg failed.")

    Subst = {}
    Subst["__FROM__"] = ChPassFrom
    Subst["__EMAIL__"] = EmailAddress(DnRecord)
    Subst["__PASSWORD__"] = Message
    Subst["__ADMIN__"] = ReplyTo
    Reply = TemplateSubst(Subst, open(TemplatesDir + "mailpassword-changed", "r").read())

    lc = connect_to_ldap_and_check_if_locked(DnRecord)
    # Modify the password
    Rec = [(ldap.MOD_REPLACE, "mailPassword", Pass)]
    Dn = "uid=" + GetAttr(DnRecord, "uid") + "," + BaseDn
    lc.modify_s(Dn, Rec)

    return Reply


# Start of main program


# Drop messages from a mailer daemon.
if not os.environ.get('SENDER'):
    sys.exit(0)

ErrMsg = "Indeterminate Error"
ErrType = EX_TEMPFAIL
try:
    # Startup the replay cache
    ErrType = EX_TEMPFAIL
    ErrMsg = "Failed to initialize the replay cache:"

    # Get the email
    ErrType = EX_PERMFAIL
    ErrMsg = "Failed to understand the email or find a signature:"
    mail = email.parser.Parser().parse(sys.stdin)

    # Check the signature
    ErrMsg = "Unable to check the signature or the signature was invalid:"
    pgp, need_mime_processing = gpg_check_email_sig(mail)

    if not pgp.ok:
        raise UDFormatError(pgp.why)

    if pgp.text is None:
        raise UDFormatError("Null signature text")

    # Extract the plain message text in the event of mime encoding
    global PlainText
    ErrMsg = "Problem stripping MIME headers from the decoded message"
    if need_mime_processing:
        e = email.parser.Parser().parsestr(pgp.text)
        PlainText = e.get_payload(decode=True)
    else:
        PlainText = pgp.text

    # Connect to the ldap server
    ErrType = EX_TEMPFAIL
    ErrMsg = "An error occurred while performing the LDAP lookup"
    global lc
    lc = connectLDAP()
    lc.simple_bind_s("", "")

    # Search for the matching key fingerprint
    Attrs = lc.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "keyFingerPrint={}".format(pgp.key_fpr))

    ErrType = EX_PERMFAIL
    if len(Attrs) == 0:
        raise UDFormatError("Key not found")
    if len(Attrs) != 1:
        raise UDFormatError("Oddly your key fingerprint is assigned to more than one account..")

    # Check the signature against the replay cache
    RC = ReplayCache(ReplayCacheFile)
    RC.process(pgp.sig_info)

    # Determine the sender address
    ErrMsg = "A problem occurred while trying to formulate the reply"
    Sender = mail.get('Reply-To', mail.get('From'))
    if not Sender:
        raise UDFormatError("Unable to determine the sender's address")

    # Formulate a reply
    Date = time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.gmtime(time.time()))

    Res = lc.search_s(HostBaseDn, ldap.SCOPE_SUBTREE, '(objectClass=debianServer)', ['hostname'])
    # Res is a list of tuples.
    # The tuples contain a dn (str) and a dictionary.
    # The dictionaries map the key "hostname" to a list.
    # These lists contain a single hostname (str).
    ValidHostNames = functools.reduce(lambda a, b: a + b, [value.get("hostname", []) for (dn, value) in Res], [])

    # Dispatch
    if sys.argv[1] == "ping":
        message = HandlePing(Attrs[0], pgp.key_info)
    elif sys.argv[1] == "chpass":
        if PlainText.strip().find("Please change my Debian password") >= 0:
            message = HandleChPass(Attrs[0], pgp.key_info)
        elif PlainText.strip().find("Please change my Tor password") >= 0:
            message = HandleChPass(Attrs[0], pgp.key_info)
        elif PlainText.strip().find("Please change my password") >= 0:
            message = HandleChPass(Attrs[0], pgp.key_info)
        elif PlainText.strip().find("Please change my Kerberos password") >= 0:
            message = HandleChKrbPass(Attrs[0], pgp.key_info)
        elif PlainText.strip().find("Please change my TOTP seed") >= 0:
            message = HandleChTOTPSeed(Attrs[0], pgp.key_info)
        elif PlainText.strip().find("Please change my mail password") >= 0:
            message = HandleChMailPassword(Attrs[0], pgp.key_info)
        else:
            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.")
    elif sys.argv[1] == "change":
        message = HandleChange(Attrs[0], pgp.key_info)
    else:
        print(sys.argv)
        raise UDFormatError("Incorrect Invocation")

    if sys.version_info[0] < 3:
        parse = email.parser.Parser().parsestr
    else:
        parse = email.parser.BytesParser().parsebytes
    Reply = parse(message.encode('utf-8'))
    Reply.add_header('To', Sender)
    Reply.add_header('Reply-To', ReplyTo)
    Reply.add_header('Date', Date)

    # Send the message through sendmail
    ErrMsg = "A problem occurred while trying to send the reply"
    Child = subprocess.Popen(['/usr/sbin/sendmail', '-t'], stdin=subprocess.PIPE)
    if sys.version_info[0] < 3:
        Child.stdin.write(Reply.as_string())
    else:
        Child.stdin.write(Reply.as_bytes())
    Child.stdin.close()
    if Child.wait() != 0:
        raise UDExecuteError("Sendmail gave a non-zero return code")

except Exception:
    # Error Reply Header
    Date = time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.gmtime(time.time()))
    ErrReplyHead = "To: %s\nReply-To: %s\nDate: %s\n" % (os.environ['SENDER'], ReplyTo, Date)

    # Error Body
    Subst = {}
    Subst["__ERROR__"] = ErrMsg
    Subst["__ADMIN__"] = ReplyTo

    Trace = "==> %s: %s\n" % sys.exc_info()[:2]
    List = traceback.extract_tb(sys.exc_info()[2])
    if len(List) > 1:
        Trace = Trace + "Python Stack Trace:\n"
        for x in List:
            Trace = Trace + "   %s %s:%u: %s\n" % (x[2], x[0], x[1], x[3])

    Subst["__TRACE__"] = Trace

    # Try to send the bounce
    try:
        ErrReply = TemplateSubst(Subst, open(TemplatesDir + "error-reply", "r").read())

        Child = subprocess.Popen(['/usr/sbin/sendmail', '-t', '-oi', '-f', ''], stdin=subprocess.PIPE)
        Child.stdin.write(ErrReplyHead)
        Child.stdin.write(ErrReply)
        Child.stdin.close()
        if Child.wait() != 0:
            raise UDExecuteError("Sendmail gave a non-zero return code")
    except Exception:
        sys.exit(EX_TEMPFAIL)

    if ErrType != EX_PERMFAIL:
        sys.exit(ErrType)
    sys.exit(0)

# vim:set et:
# vim:set ts=4:
# vim:set shiftwidth=4:
