#!/usr/bin/python
# -*- coding: utf-8 -*-
'''
Author: Volker Schwicking, vs@heg.com

Salt tcpdumper to visualize whats happening network-wise on
the salt-master. It uses pcapy and inspects all incoming networks
packets and filters only the ones relevant to salt-communication.

based on: http://oss.coresecurity.com/projects/pcapy.html

$ salt-tcpdump.py -n 2

Will print the overall tcp-status of tcp-connections to salts
default ports in a two second interval.


$ salt-tcpdump.py -I -n 2

Will print the number of IPs making new connections to salts
default ports.


$ salt-tcpdump.py -I -n 2 -i eth1

Same as before but on eth1 instead of the default eth0.

Rough equivalents to this script could be:

For Port 4505
tcpdump "tcp[tcpflags] & tcp-syn != 0" and port 4505 and "tcp[tcpflags] & tcp-ack == 0"

For Port 4506
tcpdump "tcp[tcpflags] & tcp-syn != 0" and port 4506 and "tcp[tcpflags] & tcp-ack == 0"
'''

# Import Python Libs
from __future__ import absolute_import, print_function
import socket
from struct import unpack
import pcapy
import sys
import argparse  # pylint: disable=minimum-python-version
import time


class ArgParser(object):
    '''
    Simple Argument-Parser class
    '''
    def __init__(self):
        '''
        Init the Parser
        '''
        self.main_parser = argparse.ArgumentParser()
        self.add_args()

    def add_args(self):
        '''
        Add new arguments
        '''

        self.main_parser.add_argument('-i',
                                      type=str,
                                      default='eth0',
                                      dest='iface',
                                      required=False,
                                      help=('the interface to dump the'
                                            'master runs on(default:eth0)'))

        self.main_parser.add_argument('-n',
                                      type=int,
                                      default=5,
                                      dest='ival',
                                      required=False,
                                      help=('interval for printing stats '
                                            '(default:5)'))

        self.main_parser.add_argument('-I',
                                      type=bool,
                                      default=False,
                                      const=True,
                                      nargs='?',
                                      dest='only_ip',
                                      required=False,
                                      help=('print unique IPs making new '
                                            'connections with SYN set'))

    def parse_args(self):
        '''
        parses and returns the given arguments in a namespace object
        '''
        return self.main_parser.parse_args()


class PCAPParser(object):
    '''
    parses a network packet on given device and
    returns source, target, source_port and dest_port
    '''

    def __init__(self, iface):
        self.iface = iface

    def run(self):
        '''
        main loop for the packet-parser
        '''
        # open device
        # Arguments here are:
        #   device
        #   snaplen (maximum number of bytes to capture _per_packet_)
        #   promiscious mode (1 for true)
        #   timeout (in milliseconds)
        cap = pcapy.open_live(self.iface, 65536, 1, 0)

        count = 0
        l_time = None

        while 1:

            packet_data = {
                           'ip': {},
                           'tcp': {}
                          }

            (header, packet) = cap.next()  # pylint: disable=incompatible-py3-code

            eth_length, eth_protocol = self.parse_ether(packet)

            # Parse IP packets, IP Protocol number = 8
            if eth_protocol == 8:
                # Parse IP header
                # take first 20 characters for the ip header
                version_ihl, version, ihl, iph_length, ttl, protocol, s_addr, d_addr = self.parse_ip(packet, eth_length)
                packet_data['ip']['s_addr'] = s_addr
                packet_data['ip']['d_addr'] = d_addr

                # TCP protocol
                if protocol == 6:

                    source_port, dest_port, flags, data = self.parse_tcp(packet, iph_length, eth_length)
                    packet_data['tcp']['d_port'] = dest_port
                    packet_data['tcp']['s_port'] = source_port
                    packet_data['tcp']['flags'] = flags
                    packet_data['tcp']['data'] = data
                    yield packet_data

    def parse_ether(self, packet):
        '''
        parse ethernet_header and return size and protocol
        '''
        eth_length = 14

        eth_header = packet[:eth_length]
        eth = unpack('!6s6sH', eth_header)
        eth_protocol = socket.ntohs(eth[2])
        return eth_length, eth_protocol

    def parse_ip(self, packet, eth_length):
        '''
        parse ip_header and return all ip data fields
        '''
        # Parse IP header
        # take first 20 characters for the ip header
        ip_header = packet[eth_length:20+eth_length]

        # now unpack them:)
        iph = unpack('!BBHHHBBH4s4s', ip_header)

        version_ihl = iph[0]
        version = version_ihl >> 4
        ihl = version_ihl & 0xF

        iph_length = ihl * 4

        ttl = iph[5]
        protocol = iph[6]
        s_addr = socket.inet_ntoa(iph[8])
        d_addr = socket.inet_ntoa(iph[9])

        return [version_ihl,
                version,
                ihl,
                iph_length,
                ttl,
                protocol,
                s_addr,
                d_addr]

    def parse_tcp(self, packet, iph_length, eth_length):
        '''
        parse tcp_data and return source_port,
        dest_port and actual packet data
        '''
        p_len = iph_length + eth_length
        tcp_header = packet[p_len:p_len+20]

        # now unpack them:)
        tcph = unpack('!H HLLBBHHH', tcp_header)
        #  H     H     L   L   B   B      H   H   H
        #  2b    2b    4b  4b  1b  1b     2b  2b  2b
        #  sport dport seq ack res flags  win chk up
        # (22,   36513, 3701969065, 2346113113, 128, 24, 330, 33745, 0)
        source_port = tcph[0]

        dest_port = tcph[1]
        sequence = tcph[2]
        acknowledgement = tcph[3]
        doff_reserved = tcph[4]
        tcph_length = doff_reserved >> 4
        tcp_flags = tcph[5]

        h_size = eth_length + iph_length + tcph_length * 4
        data_size = len(packet) - h_size

        data = packet[h_size:]

        return source_port, dest_port, tcp_flags, data


class SaltNetstat(object):
    '''
    Reads /proc/net/tcp and returns all connections
    '''

    def proc_tcp(self):
        '''
        Read the table of tcp connections & remove header
        '''
        with open('/proc/net/tcp', 'r') as tcp_f:
            content = tcp_f.readlines()
            content.pop(0)
        return content

    def hex2dec(self, hex_s):
        '''
        convert hex to dezimal
        '''
        return str(int(hex_s, 16))

    def ip(self, hex_s):
        '''
        convert into readable ip
        '''
        ip = [(self.hex2dec(hex_s[6:8])),
              (self.hex2dec(hex_s[4:6])),
              (self.hex2dec(hex_s[2:4])),
              (self.hex2dec(hex_s[0:2]))]
        return '.'.join(ip)

    def remove_empty(self, array):
        '''
        create new list without empty entries
        '''
        return [x for x in array if x != '']

    def convert_ip_port(self, array):
        '''
        hex_ip:hex_port to str_ip:str_port
        '''
        host, port = array.split(':')
        return self.ip(host), self.hex2dec(port)

    def run(self):
        '''
        main loop for netstat
        '''
        while 1:
            ips = {
                    'ips/4505': {},
                    'ips/4506': {}
                  }
            content = self.proc_tcp()

            for line in content:
                line_array = self.remove_empty(line.split(' '))
                l_host, l_port = self.convert_ip_port(line_array[1])
                r_host, r_port = self.convert_ip_port(line_array[2])
                if l_port == '4505':
                    if r_host not in ips['ips/4505']:
                        ips['ips/4505'][r_host] = 0
                    ips['ips/4505'][r_host] += 1
                if l_port == '4506':
                    if r_host not in ips['ips/4506']:
                        ips['ips/4506'][r_host] = 0
                    ips['ips/4506'][r_host] += 1

            yield (len(ips['ips/4505']), len(ips['ips/4506']))
            time.sleep(0.5)


def filter_new_cons(packet):
    '''
    filter packets by there tcp-state and
    returns codes for specific states
    '''
    flags = []
    TCP_FIN = 0x01
    TCP_SYN = 0x02
    TCP_RST = 0x04
    TCP_PSH = 0x08
    TCP_ACK = 0x10
    TCP_URG = 0x20
    TCP_ECE = 0x40
    TCP_CWK = 0x80

    if packet['tcp']['flags'] & TCP_FIN:
        flags.append('FIN')
    elif packet['tcp']['flags'] & TCP_SYN:
        flags.append('SYN')
    elif packet['tcp']['flags'] & TCP_RST:
        flags.append('RST')
    elif packet['tcp']['flags'] & TCP_PSH:
        flags.append('PSH')
    elif packet['tcp']['flags'] & TCP_ACK:
        flags.append('ACK')
    elif packet['tcp']['flags'] & TCP_URG:
        flags.append('URG')
    elif packet['tcp']['flags'] & TCP_ECE:
        flags.append('ECE')
    elif packet['tcp']['flags'] & TCP_CWK:
        flags.append('CWK')
    else:
        print("UNKNOWN PACKET")

    if packet['tcp']['d_port'] == 4505:
        # track new connections
        if 'SYN' in flags and len(flags) == 1:
            return 10
        # track closing connections
        elif 'FIN' in flags:
            return 12

    elif packet['tcp']['d_port'] == 4506:
        # track new connections
        if 'SYN' in flags and len(flags) == 1:
            return 100
        # track closing connections
        elif 'FIN' in flags:
            return 120
    # packet does not match requirements
    else:
        return None


def main():
    '''
    main loop for whole script
    '''
    # passed parameters
    args = vars(ArgParser().parse_args())

    # reference timer for printing in intervals
    r_time = 0

    # the ports we want to monitor
    ports = [4505, 4506]

    print("Sniffing device {0}".format(args['iface']))

    stat = {
              '4506/new': 0,
              '4506/est': 0,
              '4506/fin': 0,
              '4505/new': 0,
              '4505/est': 0,
              '4505/fin': 0,
              'ips/4505': 0,
              'ips/4506': 0
            }

    if args['only_ip']:
        print (
               'IPs making new connections '
               '(ports:{0}, interval:{1})'.format(ports,
                                                  args['ival'])
              )
    else:
        print (
               'Salt-Master Network Status '
               '(ports:{0}, interval:{1})'.format(ports,
                                                  args['ival'])
              )
    try:
        while 1:
            s_time = int(time.time())

            packet = next(PCAPParser(args['iface']).run())

            p_state = filter_new_cons(packet)

            ips_auth = []
            ips_push = []

            # new connection to 4505
            if p_state == 10:
                stat['4505/new'] += 1
                if packet['ip']['s_addr'] not in ips_auth:
                    ips_auth.append(packet['ip']['s_addr'])
            # closing connection to 4505
            elif p_state == 12:
                stat['4505/fin'] += 1

            # new connection to 4506
            elif p_state == 100:
                stat['4506/new'] += 1
                if packet['ip']['s_addr'] not in ips_push:
                    ips_push.append(packet['ip']['s_addr'])
            # closing connection to 4506
            elif p_state == 120:
                stat['4506/fin'] += 1

            # get the established connections to 4505 and 4506
            # these would only show up in tcpdump if data is transferred
            # but then with different flags (PSH, etc.)
            stat['4505/est'], stat['4506/est'] = next(SaltNetstat().run())

            # print only in intervals
            if (s_time % args['ival']) == 0:
                # prevent printing within the same second
                if r_time != s_time:
                    if args['only_ip']:
                        msg = 'IPs/4505: {0}, IPs/4506: {1}'.format(len(ips_auth),
                                                                    len(ips_push))
                    else:
                        msg = "4505=>[ est: {0}, ".format(stat['4505/est'])
                        msg += "new: {0}/s, ".format(stat['4505/new'] / args['ival'])
                        msg += "fin: {0}/s ] ".format(stat['4505/fin'] / args['ival'])

                        msg += " 4506=>[ est: {0}, ".format(stat['4506/est'])
                        msg += "new: {0}/s, ".format(stat['4506/new'] / args['ival'])
                        msg += "fin: {0}/s ]".format(stat['4506/fin'] / args['ival'])

                    print(msg)

                    # reset the so far collected stats
                    for item in stat:
                        stat[item] = 0
                    r_time = s_time

    except KeyboardInterrupt:
        sys.exit(1)

if __name__ == "__main__":
    main()
