# This file is part of Scapy
# See http://www.secdev.org/projects/scapy for more information
# Copyright (C) Philippe Biondi <phil@secdev.org>
# This program is published under a GPLv2 license

"""
Clone of p0f passive OS fingerprinting
"""

from __future__ import absolute_import
from __future__ import print_function
import time
import struct
import os
import socket
import random

from scapy.data import KnowledgeBase
from scapy.config import conf
from scapy.compat import raw
from scapy.layers.inet import IP, TCP, TCPOptions
from scapy.packet import NoPayload, Packet
from scapy.error import warning, Scapy_Exception, log_runtime
from scapy.volatile import RandInt, RandByte, RandNum, RandShort, RandString
from scapy.sendrecv import sniff
from scapy.modules import six
from scapy.modules.six.moves import map, range
if conf.route is None:
    # unused import, only to initialize conf.route
    import scapy.route  # noqa: F401

conf.p0f_base = "/etc/p0f/p0f.fp"
conf.p0fa_base = "/etc/p0f/p0fa.fp"
conf.p0fr_base = "/etc/p0f/p0fr.fp"
conf.p0fo_base = "/etc/p0f/p0fo.fp"


###############
#  p0f stuff  #
###############

# File format (according to p0f.fp) :
#
# wwww:ttt:D:ss:OOO...:QQ:OS:Details
#
# wwww    - window size
# ttt     - initial TTL
# D       - don't fragment bit  (0=unset, 1=set)
# ss      - overall SYN packet size
# OOO     - option value and order specification
# QQ      - quirks list
# OS      - OS genre
# details - OS description

class p0fKnowledgeBase(KnowledgeBase):
    def __init__(self, filename):
        KnowledgeBase.__init__(self, filename)
        # self.ttl_range=[255]

    def lazy_init(self):
        try:
            f = open(self.filename)
        except IOError:
            warning("Can't open base %s", self.filename)
            return
        try:
            self.base = []
            for line in f:
                if line[0] in ["#", "\n"]:
                    continue
                line = tuple(line.split(":"))
                if len(line) < 8:
                    continue

                def a2i(x):
                    if x.isdigit():
                        return int(x)
                    return x
                li = [a2i(e) for e in line[1:4]]
                # if li[0] not in self.ttl_range:
                #    self.ttl_range.append(li[0])
                #    self.ttl_range.sort()
                self.base.append((line[0], li[0], li[1], li[2], line[4],
                                  line[5], line[6], line[7][:-1]))
        except Exception:
            warning("Can't parse p0f database (new p0f version ?)")
            self.base = None
        f.close()


p0f_kdb, p0fa_kdb, p0fr_kdb, p0fo_kdb = None, None, None, None


def p0f_load_knowledgebases():
    global p0f_kdb, p0fa_kdb, p0fr_kdb, p0fo_kdb
    p0f_kdb = p0fKnowledgeBase(conf.p0f_base)
    p0fa_kdb = p0fKnowledgeBase(conf.p0fa_base)
    p0fr_kdb = p0fKnowledgeBase(conf.p0fr_base)
    p0fo_kdb = p0fKnowledgeBase(conf.p0fo_base)


p0f_load_knowledgebases()


def p0f_selectdb(flags):
    # tested flags: S, R, A
    if flags & 0x16 == 0x2:
        # SYN
        return p0f_kdb
    elif flags & 0x16 == 0x12:
        # SYN/ACK
        return p0fa_kdb
    elif flags & 0x16 in [0x4, 0x14]:
        # RST RST/ACK
        return p0fr_kdb
    elif flags & 0x16 == 0x10:
        # ACK
        return p0fo_kdb
    else:
        return None


def packet2p0f(pkt):
    pkt = pkt.copy()
    pkt = pkt.__class__(raw(pkt))
    while pkt.haslayer(IP) and pkt.haslayer(TCP):
        pkt = pkt.getlayer(IP)
        if isinstance(pkt.payload, TCP):
            break
        pkt = pkt.payload

    if not isinstance(pkt, IP) or not isinstance(pkt.payload, TCP):
        raise TypeError("Not a TCP/IP packet")
    # if pkt.payload.flags & 0x7 != 0x02: #S,!F,!R
    #    raise TypeError("Not a SYN or SYN/ACK packet")

    db = p0f_selectdb(pkt.payload.flags)

    # t = p0f_kdb.ttl_range[:]
    # t += [pkt.ttl]
    # t.sort()
    # ttl=t[t.index(pkt.ttl)+1]
    ttl = pkt.ttl

    ss = len(pkt)
    # from p0f/config.h : PACKET_BIG = 100
    if ss > 100:
        if db == p0fr_kdb:
            # p0fr.fp: "Packet size may be wildcarded. The meaning of
            #           wildcard is, however, hardcoded as 'size >
            #           PACKET_BIG'"
            ss = '*'
        else:
            ss = 0
    if db == p0fo_kdb:
        # p0fo.fp: "Packet size MUST be wildcarded."
        ss = '*'

    ooo = ""
    mss = -1
    qqT = False
    qqP = False
    # qqBroken = False
    ilen = (pkt.payload.dataofs << 2) - 20  # from p0f.c
    for option in pkt.payload.options:
        ilen -= 1
        if option[0] == "MSS":
            ooo += "M" + str(option[1]) + ","
            mss = option[1]
            # FIXME: qqBroken
            ilen -= 3
        elif option[0] == "WScale":
            ooo += "W" + str(option[1]) + ","
            # FIXME: qqBroken
            ilen -= 2
        elif option[0] == "Timestamp":
            if option[1][0] == 0:
                ooo += "T0,"
            else:
                ooo += "T,"
            if option[1][1] != 0:
                qqT = True
            ilen -= 9
        elif option[0] == "SAckOK":
            ooo += "S,"
            ilen -= 1
        elif option[0] == "NOP":
            ooo += "N,"
        elif option[0] == "EOL":
            ooo += "E,"
            if ilen > 0:
                qqP = True
        else:
            if isinstance(option[0], str):
                ooo += "?%i," % TCPOptions[1][option[0]]
            else:
                ooo += "?%i," % option[0]
            # FIXME: ilen
    ooo = ooo[:-1]
    if ooo == "":
        ooo = "."

    win = pkt.payload.window
    if mss != -1:
        if mss != 0 and win % mss == 0:
            win = "S" + str(win / mss)
        elif win % (mss + 40) == 0:
            win = "T" + str(win / (mss + 40))
    win = str(win)

    qq = ""

    if db == p0fr_kdb:
        if pkt.payload.flags & 0x10 == 0x10:
            # p0fr.fp: "A new quirk, 'K', is introduced to denote
            #           RST+ACK packets"
            qq += "K"
    # The two next cases should also be only for p0f*r*, but although
    # it's not documented (or I have not noticed), p0f seems to
    # support the '0' and 'Q' quirks on any databases (or at the least
    # "classical" p0f.fp).
    if pkt.payload.seq == pkt.payload.ack:
        # p0fr.fp: "A new quirk, 'Q', is used to denote SEQ number
        #           equal to ACK number."
        qq += "Q"
    if pkt.payload.seq == 0:
        # p0fr.fp: "A new quirk, '0', is used to denote packets
        #           with SEQ number set to 0."
        qq += "0"
    if qqP:
        qq += "P"
    if pkt.id == 0:
        qq += "Z"
    if pkt.options != []:
        qq += "I"
    if pkt.payload.urgptr != 0:
        qq += "U"
    if pkt.payload.reserved != 0:
        qq += "X"
    if pkt.payload.ack != 0:
        qq += "A"
    if qqT:
        qq += "T"
    if db == p0fo_kdb:
        if pkt.payload.flags & 0x20 != 0:
            # U
            # p0fo.fp: "PUSH flag is excluded from 'F' quirk checks"
            qq += "F"
    else:
        if pkt.payload.flags & 0x28 != 0:
            # U or P
            qq += "F"
    if db != p0fo_kdb and not isinstance(pkt.payload.payload, NoPayload):
        # p0fo.fp: "'D' quirk is not checked for."
        qq += "D"
    # FIXME : "!" - broken options segment: not handled yet

    if qq == "":
        qq = "."

    return (db, (win, ttl, pkt.flags.DF, ss, ooo, qq))


def p0f_correl(x, y):
    d = 0
    # wwww can be "*" or "%nn". "Tnn" and "Snn" should work fine with
    # the x[0] == y[0] test.
    d += (x[0] == y[0] or y[0] == "*" or (y[0][0] == "%" and x[0].isdigit() and (int(x[0]) % int(y[0][1:])) == 0))  # noqa: E501
    # ttl
    d += (y[1] >= x[1] and y[1] - x[1] < 32)
    for i in [2, 5]:
        d += (x[i] == y[i] or y[i] == '*')
    # '*' has a special meaning for ss
    d += x[3] == y[3]
    xopt = x[4].split(",")
    yopt = y[4].split(",")
    if len(xopt) == len(yopt):
        same = True
        for i in range(len(xopt)):
            if not (xopt[i] == yopt[i] or
                    (len(yopt[i]) == 2 and len(xopt[i]) > 1 and
                     yopt[i][1] == "*" and xopt[i][0] == yopt[i][0]) or
                    (len(yopt[i]) > 2 and len(xopt[i]) > 1 and
                     yopt[i][1] == "%" and xopt[i][0] == yopt[i][0] and
                     int(xopt[i][1:]) % int(yopt[i][2:]) == 0)):
                same = False
                break
        if same:
            d += len(xopt)
    return d


@conf.commands.register
def p0f(pkt):
    """Passive OS fingerprinting: which OS emitted this TCP packet ?
p0f(packet) -> accuracy, [list of guesses]
"""
    db, sig = packet2p0f(pkt)
    if db:
        pb = db.get_base()
    else:
        pb = []
    if not pb:
        warning("p0f base empty.")
        return []
    # s = len(pb[0][0])
    r = []
    max = len(sig[4].split(",")) + 5
    for b in pb:
        d = p0f_correl(sig, b)
        if d == max:
            r.append((b[6], b[7], b[1] - pkt[IP].ttl))
    return r


def prnp0f(pkt):
    """Calls p0f and returns a user-friendly output"""
    # we should print which DB we use
    try:
        r = p0f(pkt)
    except Exception:
        return
    if r == []:
        r = ("UNKNOWN", "[" + ":".join(map(str, packet2p0f(pkt)[1])) + ":?:?]", None)  # noqa: E501
    else:
        r = r[0]
    uptime = None
    try:
        uptime = pkt2uptime(pkt)
    except Exception:
        pass
    if uptime == 0:
        uptime = None
    res = pkt.sprintf("%IP.src%:%TCP.sport% - " + r[0] + " " + r[1])
    if uptime is not None:
        res += pkt.sprintf(" (up: " + str(uptime / 3600) + " hrs)\n  -> %IP.dst%:%TCP.dport% (%TCP.flags%)")  # noqa: E501
    else:
        res += pkt.sprintf("\n  -> %IP.dst%:%TCP.dport% (%TCP.flags%)")
    if r[2] is not None:
        res += " (distance " + str(r[2]) + ")"
    print(res)


@conf.commands.register
def pkt2uptime(pkt, HZ=100):
    """Calculate the date the machine which emitted the packet booted using TCP timestamp  # noqa: E501
pkt2uptime(pkt, [HZ=100])"""
    if not isinstance(pkt, Packet):
        raise TypeError("Not a TCP packet")
    if isinstance(pkt, NoPayload):
        raise TypeError("Not a TCP packet")
    if not isinstance(pkt, TCP):
        return pkt2uptime(pkt.payload)
    for opt in pkt.options:
        if opt[0] == "Timestamp":
            # t = pkt.time - opt[1][0] * 1.0/HZ
            # return time.ctime(t)
            t = opt[1][0] / HZ
            return t
    raise TypeError("No timestamp option")


def p0f_impersonate(pkt, osgenre=None, osdetails=None, signature=None,
                    extrahops=0, mtu=1500, uptime=None):
    """Modifies pkt so that p0f will think it has been sent by a
specific OS.  If osdetails is None, then we randomly pick up a
personality matching osgenre. If osgenre and signature are also None,
we use a local signature (using p0f_getlocalsigs). If signature is
specified (as a tuple), we use the signature.

For now, only TCP Syn packets are supported.
Some specifications of the p0f.fp file are not (yet) implemented."""
    pkt = pkt.copy()
    # pkt = pkt.__class__(raw(pkt))
    while pkt.haslayer(IP) and pkt.haslayer(TCP):
        pkt = pkt.getlayer(IP)
        if isinstance(pkt.payload, TCP):
            break
        pkt = pkt.payload

    if not isinstance(pkt, IP) or not isinstance(pkt.payload, TCP):
        raise TypeError("Not a TCP/IP packet")

    db = p0f_selectdb(pkt.payload.flags)
    if osgenre:
        pb = db.get_base()
        if pb is None:
            pb = []
        pb = [x for x in pb if x[6] == osgenre]
        if osdetails:
            pb = [x for x in pb if x[7] == osdetails]
    elif signature:
        pb = [signature]
    else:
        pb = p0f_getlocalsigs()[db]
    if db == p0fr_kdb:
        # 'K' quirk <=> RST+ACK
        if pkt.payload.flags & 0x4 == 0x4:
            pb = [x for x in pb if 'K' in x[5]]
        else:
            pb = [x for x in pb if 'K' not in x[5]]
    if not pb:
        raise Scapy_Exception("No match in the p0f database")
    pers = pb[random.randint(0, len(pb) - 1)]

    # options (we start with options because of MSS)
    # Take the options already set as "hints" to use in the new packet if we
    # can. MSS, WScale and Timestamp can all be wildcarded in a signature, so
    # we'll use the already-set values if they're valid integers.
    orig_opts = dict(pkt.payload.options)
    int_only = lambda val: val if isinstance(val, six.integer_types) else None
    mss_hint = int_only(orig_opts.get('MSS'))
    wscale_hint = int_only(orig_opts.get('WScale'))
    ts_hint = [int_only(o) for o in orig_opts.get('Timestamp', (None, None))]

    options = []
    if pers[4] != '.':
        for opt in pers[4].split(','):
            if opt[0] == 'M':
                # MSS might have a maximum size because of window size
                # specification
                if pers[0][0] == 'S':
                    maxmss = (2**16 - 1) // int(pers[0][1:])
                else:
                    maxmss = (2**16 - 1)
                # disregard hint if out of range
                if mss_hint and not 0 <= mss_hint <= maxmss:
                    mss_hint = None
                # If we have to randomly pick up a value, we cannot use
                # scapy RandXXX() functions, because the value has to be
                # set in case we need it for the window size value. That's
                # why we use random.randint()
                if opt[1:] == '*':
                    if mss_hint is not None:
                        options.append(('MSS', mss_hint))
                    else:
                        options.append(('MSS', random.randint(1, maxmss)))
                elif opt[1] == '%':
                    coef = int(opt[2:])
                    if mss_hint is not None and mss_hint % coef == 0:
                        options.append(('MSS', mss_hint))
                    else:
                        options.append((
                            'MSS', coef * random.randint(1, maxmss // coef)))
                else:
                    options.append(('MSS', int(opt[1:])))
            elif opt[0] == 'W':
                if wscale_hint and not 0 <= wscale_hint < 2**8:
                    wscale_hint = None
                if opt[1:] == '*':
                    if wscale_hint is not None:
                        options.append(('WScale', wscale_hint))
                    else:
                        options.append(('WScale', RandByte()))
                elif opt[1] == '%':
                    coef = int(opt[2:])
                    if wscale_hint is not None and wscale_hint % coef == 0:
                        options.append(('WScale', wscale_hint))
                    else:
                        options.append((
                            'WScale', coef * RandNum(min=1, max=(2**8 - 1) // coef)))  # noqa: E501
                else:
                    options.append(('WScale', int(opt[1:])))
            elif opt == 'T0':
                options.append(('Timestamp', (0, 0)))
            elif opt == 'T':
                # Determine first timestamp.
                if uptime is not None:
                    ts_a = uptime
                elif ts_hint[0] and 0 < ts_hint[0] < 2**32:
                    # Note: if first ts is 0, p0f registers it as "T0" not "T",
                    # hence we don't want to use the hint if it was 0.
                    ts_a = ts_hint[0]
                else:
                    ts_a = random.randint(120, 100 * 60 * 60 * 24 * 365)
                # Determine second timestamp.
                if 'T' not in pers[5]:
                    ts_b = 0
                elif ts_hint[1] and 0 < ts_hint[1] < 2**32:
                    ts_b = ts_hint[1]
                else:
                    # FIXME: RandInt() here does not work (bug (?) in
                    # TCPOptionsField.m2i often raises "OverflowError:
                    # long int too large to convert to int" in:
                    #    oval = struct.pack(ofmt, *oval)"
                    # Actually, this is enough to often raise the error:
                    #    struct.pack('I', RandInt())
                    ts_b = random.randint(1, 2**32 - 1)
                options.append(('Timestamp', (ts_a, ts_b)))
            elif opt == 'S':
                options.append(('SAckOK', ''))
            elif opt == 'N':
                options.append(('NOP', None))
            elif opt == 'E':
                options.append(('EOL', None))
            elif opt[0] == '?':
                if int(opt[1:]) in TCPOptions[0]:
                    optname = TCPOptions[0][int(opt[1:])][0]
                    optstruct = TCPOptions[0][int(opt[1:])][1]
                    options.append((optname,
                                    struct.unpack(optstruct,
                                                  RandString(struct.calcsize(optstruct))._fix())))  # noqa: E501
                else:
                    options.append((int(opt[1:]), ''))
            # FIXME: qqP not handled
            else:
                warning("unhandled TCP option " + opt)
            pkt.payload.options = options

    # window size
    if pers[0] == '*':
        pkt.payload.window = RandShort()
    elif pers[0].isdigit():
        pkt.payload.window = int(pers[0])
    elif pers[0][0] == '%':
        coef = int(pers[0][1:])
        pkt.payload.window = coef * RandNum(min=1, max=(2**16 - 1) // coef)
    elif pers[0][0] == 'T':
        pkt.payload.window = mtu * int(pers[0][1:])
    elif pers[0][0] == 'S':
        # needs MSS set
        mss = [x for x in options if x[0] == 'MSS']
        if not mss:
            raise Scapy_Exception("TCP window value requires MSS, and MSS option not set")  # noqa: E501
        pkt.payload.window = mss[0][1] * int(pers[0][1:])
    else:
        raise Scapy_Exception('Unhandled window size specification')

    # ttl
    pkt.ttl = pers[1] - extrahops
    # DF flag
    pkt.flags |= (2 * pers[2])
    # FIXME: ss (packet size) not handled (how ? may be with D quirk
    # if present)
    # Quirks
    if pers[5] != '.':
        for qq in pers[5]:
            # FIXME: not handled: P, I, X, !
            # T handled with the Timestamp option
            if qq == 'Z':
                pkt.id = 0
            elif qq == 'U':
                pkt.payload.urgptr = RandShort()
            elif qq == 'A':
                pkt.payload.ack = RandInt()
            elif qq == 'F':
                if db == p0fo_kdb:
                    pkt.payload.flags |= 0x20  # U
                else:
                    pkt.payload.flags |= random.choice([8, 32, 40])  # P/U/PU
            elif qq == 'D' and db != p0fo_kdb:
                pkt /= conf.raw_layer(load=RandString(random.randint(1, 10)))  # XXX p0fo.fp  # noqa: E501
            elif qq == 'Q':
                pkt.payload.seq = pkt.payload.ack
            # elif qq == '0': pkt.payload.seq = 0
        # if db == p0fr_kdb:
        # '0' quirk is actually not only for p0fr.fp (see
        # packet2p0f())
    if '0' in pers[5]:
        pkt.payload.seq = 0
    elif pkt.payload.seq == 0:
        pkt.payload.seq = RandInt()

    while pkt.underlayer:
        pkt = pkt.underlayer
    return pkt


def p0f_getlocalsigs():
    """This function returns a dictionary of signatures indexed by p0f
db (e.g., p0f_kdb, p0fa_kdb, ...) for the local TCP/IP stack.

You need to have your firewall at least accepting the TCP packets
from/to a high port (30000 <= x <= 40000) on your loopback interface.

Please note that the generated signatures come from the loopback
interface and may (are likely to) be different than those generated on
"normal" interfaces."""
    pid = os.fork()
    port = random.randint(30000, 40000)
    if pid > 0:
        # parent: sniff
        result = {}

        def addresult(res):
            # TODO: wildcard window size in some cases? and maybe some
            # other values?
            if res[0] not in result:
                result[res[0]] = [res[1]]
            else:
                if res[1] not in result[res[0]]:
                    result[res[0]].append(res[1])
        # XXX could we try with a "normal" interface using other hosts
        iface = conf.route.route('127.0.0.1')[0]
        # each packet is seen twice: S + RA, S + SA + A + FA + A
        # XXX are the packets also seen twice on non Linux systems ?
        count = 14
        pl = sniff(iface=iface, filter='tcp and port ' + str(port), count=count, timeout=3)  # noqa: E501
        for pkt in pl:
            for elt in packet2p0f(pkt):
                addresult(elt)
        os.waitpid(pid, 0)
    elif pid < 0:
        log_runtime.error("fork error")
    else:
        # child: send
        # XXX erk
        time.sleep(1)
        s1 = socket.socket(socket.AF_INET, type=socket.SOCK_STREAM)
        # S & RA
        try:
            s1.connect(('127.0.0.1', port))
        except socket.error:
            pass
        # S, SA, A, FA, A
        s1.bind(('127.0.0.1', port))
        s1.connect(('127.0.0.1', port))
        # howto: get an RST w/o ACK packet
        s1.close()
        os._exit(0)
    return result
