#!/usr/bin/env python
# -*- 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 userdir_gpg
import userdir_ldap
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
try:
    import spf
except ImportError:
    spf = None

from userdir_gpg import *
from userdir_ldap import *
from userdir_exceptions import *

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:
#  [options] 1024 35 13188913666680[..] [comment]
# maybe it really should be:
# [allowed_hosts=machine1,machine2 ][options ]ssh-rsa keybytes [comment]
def DoSSH(Str, Attrs, badkeys, uid):
    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

    # lines can now be prepended with "allowed_hosts=machine1,machine2 "
    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 == "rsa":
        key_size_ok = (int(key_size) >= 2048)
    elif typekey == "ed25519":
        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)
    mxrecord    = re.match(prefixre + r'MX\s+(\d{1,3})\s+([-\w.]*\.)$', Str, re.IGNORECASE)
    spfrecord   = re.match(prefixre + r'TXT\s+(v=spf1 .*)$', Str, re.IGNORECASE)
    txtrecord   = re.match(prefixre + r'TXT\s+([-\d. a-z\t<>@:=]+)$', Str, re.IGNORECASE)
    aaaarecord  = re.match(prefixre + r'AAAA\s+([A-F0-9:]{2,39})$', Str, re.IGNORECASE)

    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 not (statuscode == '250' or 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(Reply, 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_type, sys.exc_value)

        # 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 Error, 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]) + "\n", "0x" + Key[1], Key[4])

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

    return Reply + 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(Reply, DnRecord, Key):
    Subst = {}
    Subst["__FROM__"] = PingFrom
    Subst["__EMAIL__"] = EmailAddress(DnRecord)
    Subst["__LDAPFIELDS__"] = PrettyShow(DnRecord)
    Subst["__ADMIN__"] = ReplyTo

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


def get_crypttype_preamble(key):
    if (key[4] == 1):
        type = "Your message was encrypted using PGP 2.x\ncompatibility mode."
    else:
        type = "Your message was encrypted using GPG (OpenPGP)\ncompatibility "\
               "mode, without IDEA. This message cannot be decoded using PGP 2.x"
    return type


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

    # Use GPG to encrypt it
    Message = GPGEncrypt("Your new password is '" + Password + "'\n", "0x" + Key[1], Key[4])
    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["__CRYPTTYPE__"] = get_crypttype_preamble(Key)
    Subst["__PASSWORD__"] = Message
    Subst["__ADMIN__"] = ReplyTo
    Reply = 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(Reply, DnRecord, Key):
    # Generate a random seed
    seed = binascii.hexlify(open("/dev/urandom", "r").read(32))
    random_id = binascii.hexlify(open("/dev/urandom", "r").read(32))
    totp_file_name = "%d-%s" % (time.time(), random_id,)

    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])

    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.
    f = open(os.path.join(TOTPTicketDirectory, totp_file_name), os.O_WRONLY | os.O_CREAT)
    print(seed, file=f)
    print(GetAttr(DnRecord, "uid"), file=f)
    f.close()

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


def HandleChKrbPass(Reply, DnRecord, Key):
    # Connect to the ldap server, will throw an exception if account locked.
    lc = 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 = "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], Key[4])
    if Message is None:
        raise UDFormatError("Unable to generate the encrypted reply, gpg failed.")

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

    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)
    Msg = GetClearSig(mail)

    ErrMsg = "Message is not PGP signed:"
    if Msg[0].find("-----BEGIN PGP SIGNED MESSAGE-----") == -1 and \
       Msg[0].find("-----BEGIN PGP MESSAGE-----") == -1:
        raise UDFormatError("No PGP signature")

    # Check the signature
    ErrMsg = "Unable to check the signature or the signature was invalid:"
    pgp = GPGCheckSig2(Msg[0])

    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 Msg[1] == 1:
        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()))
    Reply = "To: %s\nReply-To: %s\nDate: %s\n" % (Sender, ReplyTo, Date)

    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 = reduce(lambda a, b: a + b, [value.get("hostname", []) for (dn, value) in Res], [])

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

    # 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)
    Child.stdin.write(Reply)
    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_type, sys.exc_value)
    List = traceback.extract_tb(sys.exc_traceback)
    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:
