#!/bin/bash

# Copyright 2020 The gVisor Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# Run a packetimpact test.  Two docker containers are made, one for the
# Device-Under-Test (DUT) and one for the test bench.  Each is attached with
# two networks, one for control packets that aid the test and one for test
# packets which are sent as part of the test and observed for correctness.

set -euxo pipefail

function failure() {
  local lineno=$1
  local msg=$2
  local filename="$0"
  echo "FAIL: $filename:$lineno: $msg"
}
trap 'failure ${LINENO} "$BASH_COMMAND"' ERR

declare -r LONGOPTS="dut_platform:,posix_server_binary:,testbench_binary:,runtime:,tshark,extra_test_arg:,expect_failure"

# Don't use declare below so that the error from getopt will end the script.
PARSED=$(getopt --options "" --longoptions=$LONGOPTS --name "$0" -- "$@")

eval set -- "$PARSED"

declare -a EXTRA_TEST_ARGS

while true; do
  case "$1" in
    --dut_platform)
      # Either "linux" or "netstack".
      declare -r DUT_PLATFORM="$2"
      shift 2
      ;;
    --posix_server_binary)
      declare -r POSIX_SERVER_BINARY="$2"
      shift 2
      ;;
    --testbench_binary)
      declare -r TESTBENCH_BINARY="$2"
      shift 2
      ;;
    --runtime)
      # Not readonly because there might be multiple --runtime arguments and we
      # want to use just the last one.  Only used if --dut_platform is
      # "netstack".
      declare RUNTIME="$2"
      shift 2
      ;;
    --tshark)
      declare -r TSHARK="1"
      shift 1
      ;;
    --extra_test_arg)
      EXTRA_TEST_ARGS+="$2"
      shift 2
      ;;
    --expect_failure)
      declare -r EXPECT_FAILURE="1"
      shift 1
      ;;
    --)
      shift
      break
      ;;
    *)
      echo "Programming error"
      exit 3
  esac
done

# All the other arguments are scripts.
declare -r scripts="$@"

# Check that the required flags are defined in a way that is safe for "set -u".
if [[ "${DUT_PLATFORM-}" == "netstack" ]]; then
  if [[ -z "${RUNTIME-}" ]]; then
    echo "FAIL: Missing --runtime argument: ${RUNTIME-}"
    exit 2
  fi
  declare -r RUNTIME_ARG="--runtime ${RUNTIME}"
elif [[ "${DUT_PLATFORM-}" == "linux" ]]; then
  declare -r RUNTIME_ARG=""
else
  echo "FAIL: Bad or missing --dut_platform argument: ${DUT_PLATFORM-}"
  exit 2
fi
if [[ ! -f "${POSIX_SERVER_BINARY-}" ]]; then
  echo "FAIL: Bad or missing --posix_server_binary: ${POSIX_SERVER-}"
  exit 2
fi
if [[ ! -f "${TESTBENCH_BINARY-}" ]]; then
  echo "FAIL: Bad or missing --testbench_binary: ${TESTBENCH_BINARY-}"
  exit 2
fi

function new_net_prefix() {
  # Class C, 192.0.0.0 to 223.255.255.255, transitionally has mask 24.
  echo "$(shuf -i 192-223 -n 1).$(shuf -i 0-255 -n 1).$(shuf -i 0-255 -n 1)"
}

# Variables specific to the control network and interface start with CTRL_.
# Variables specific to the test network and interface start with TEST_.
# Variables specific to the DUT start with DUT_.
# Variables specific to the test bench start with TESTBENCH_.
# Use random numbers so that test networks don't collide.
declare CTRL_NET="ctrl_net-${RANDOM}${RANDOM}"
declare CTRL_NET_PREFIX=$(new_net_prefix)
declare TEST_NET="test_net-${RANDOM}${RANDOM}"
declare TEST_NET_PREFIX=$(new_net_prefix)
# On both DUT and test bench, testing packets are on the eth2 interface.
declare -r TEST_DEVICE="eth2"
# Number of bits in the *_NET_PREFIX variables.
declare -r NET_MASK="24"
# Last bits of the DUT's IP address.
declare -r DUT_NET_SUFFIX=".10"
# Control port.
declare -r CTRL_PORT="40000"
# Last bits of the test bench's IP address.
declare -r TESTBENCH_NET_SUFFIX=".20"
declare -r TIMEOUT="60"
declare -r IMAGE_TAG="gcr.io/gvisor-presubmit/packetimpact"

# Make sure that docker is installed.
docker --version

function finish {
  local cleanup_success=1

  if [[ -z "${TSHARK-}" ]]; then
    # Kill tcpdump so that it will flush output.
    docker exec -t "${TESTBENCH}" \
      killall tcpdump || \
      cleanup_success=0
  else
    # Kill tshark so that it will flush output.
    docker exec -t "${TESTBENCH}" \
      killall tshark || \
      cleanup_success=0
  fi

  for net in "${CTRL_NET}" "${TEST_NET}"; do
    # Kill all processes attached to ${net}.
    for docker_command in "kill" "rm"; do
      (docker network inspect "${net}" \
        --format '{{range $key, $value := .Containers}}{{$key}} {{end}}' \
        | xargs -r docker "${docker_command}") || \
        cleanup_success=0
    done
    # Remove the network.
    docker network rm "${net}" || \
      cleanup_success=0
  done

  if ((!$cleanup_success)); then
    echo "FAIL: Cleanup command failed"
    exit 4
  fi
}
trap finish EXIT

# Subnet for control packets between test bench and DUT.
while ! docker network create \
  "--subnet=${CTRL_NET_PREFIX}.0/${NET_MASK}" "${CTRL_NET}"; do
  sleep 0.1
  CTRL_NET_PREFIX=$(new_net_prefix)
  CTRL_NET="ctrl_net-${RANDOM}${RANDOM}"
done

# Subnet for the packets that are part of the test.
while ! docker network create \
  "--subnet=${TEST_NET_PREFIX}.0/${NET_MASK}" "${TEST_NET}"; do
  sleep 0.1
  TEST_NET_PREFIX=$(new_net_prefix)
  TEST_NET="test_net-${RANDOM}${RANDOM}"
done

docker pull "${IMAGE_TAG}"

# Create the DUT container and connect to network.
DUT=$(docker create ${RUNTIME_ARG} --privileged --rm \
  --cap-add NET_ADMIN \
  --sysctl net.ipv6.conf.all.disable_ipv6=0 \
  --stop-timeout ${TIMEOUT} -it ${IMAGE_TAG})
docker network connect "${CTRL_NET}" \
  --ip "${CTRL_NET_PREFIX}${DUT_NET_SUFFIX}" "${DUT}" \
  || (docker kill ${DUT}; docker rm ${DUT}; false)
docker network connect "${TEST_NET}" \
  --ip "${TEST_NET_PREFIX}${DUT_NET_SUFFIX}" "${DUT}" \
  || (docker kill ${DUT}; docker rm ${DUT}; false)
docker start "${DUT}"

# Create the test bench container and connect to network.
TESTBENCH=$(docker create --privileged --rm \
  --cap-add NET_ADMIN \
  --sysctl net.ipv6.conf.all.disable_ipv6=0 \
  --stop-timeout ${TIMEOUT} -it ${IMAGE_TAG})
docker network connect "${CTRL_NET}" \
  --ip "${CTRL_NET_PREFIX}${TESTBENCH_NET_SUFFIX}" "${TESTBENCH}" \
  || (docker kill ${TESTBENCH}; docker rm ${TESTBENCH}; false)
docker network connect "${TEST_NET}" \
  --ip "${TEST_NET_PREFIX}${TESTBENCH_NET_SUFFIX}" "${TESTBENCH}" \
  || (docker kill ${TESTBENCH}; docker rm ${TESTBENCH}; false)
docker start "${TESTBENCH}"

# Start the posix_server in the DUT.
declare -r DOCKER_POSIX_SERVER_BINARY="/$(basename ${POSIX_SERVER_BINARY})"
docker cp -L ${POSIX_SERVER_BINARY} "${DUT}:${DOCKER_POSIX_SERVER_BINARY}"

docker exec -t "${DUT}" \
  /bin/bash -c "${DOCKER_POSIX_SERVER_BINARY} \
  --ip ${CTRL_NET_PREFIX}${DUT_NET_SUFFIX} \
  --port ${CTRL_PORT}" &

# Because the Linux kernel receives the SYN-ACK but didn't send the SYN it will
# issue a RST. To prevent this IPtables can be used to filter those out.
docker exec "${TESTBENCH}" \
  iptables -A INPUT -i ${TEST_DEVICE} -j DROP

# Wait for the DUT server to come up.  Attempt to connect to it from the test
# bench every 100 milliseconds until success.
while ! docker exec "${TESTBENCH}" \
  nc -zv "${CTRL_NET_PREFIX}${DUT_NET_SUFFIX}" "${CTRL_PORT}"; do
  sleep 0.1
done

declare -r REMOTE_MAC=$(docker exec -t "${DUT}" ip link show \
  "${TEST_DEVICE}" | tail -1 | cut -d' ' -f6)
declare -r LOCAL_MAC=$(docker exec -t "${TESTBENCH}" ip link show \
  "${TEST_DEVICE}" | tail -1 | cut -d' ' -f6)
declare REMOTE_IPV6=$(docker exec -t "${DUT}" ip addr show scope link \
  "${TEST_DEVICE}" | grep inet6 | cut -d' ' -f6 | cut -d'/' -f1)
declare -r LOCAL_IPV6=$(docker exec -t "${TESTBENCH}" ip addr show scope link \
  "${TEST_DEVICE}" | grep inet6 | cut -d' ' -f6 | cut -d'/' -f1)

# Netstack as DUT doesn't assign IPv6 addresses automatically so do it if
# needed.  Convert the MAC address to an IPv6 link local address as described in
# RFC 4291 page 20: https://tools.ietf.org/html/rfc4291#page-20
if [[ -z "${REMOTE_IPV6}" ]]; then
  # Split the octets of the MAC into an array of strings.
  IFS=":" read -a REMOTE_OCTETS <<< "${REMOTE_MAC}"
  # Flip the global bit.
  REMOTE_OCTETS[0]=$(printf '%x' "$((0x${REMOTE_OCTETS[0]} ^ 2))")
  # Add the IPv6 address.
  docker exec "${DUT}" \
    ip addr add $(printf 'fe80::%02x%02x:%02xff:fe%02x:%02x%02x/64' \
    "0x${REMOTE_OCTETS[0]}" "0x${REMOTE_OCTETS[1]}" "0x${REMOTE_OCTETS[2]}" \
    "0x${REMOTE_OCTETS[3]}" "0x${REMOTE_OCTETS[4]}" "0x${REMOTE_OCTETS[5]}") \
    scope link \
    dev "${TEST_DEVICE}"
  # Re-extract the IPv6 address.
  # TODO(eyalsoha): Add "scope link" below when netstack supports correctly
  # creating link-local IPv6 addresses.
  REMOTE_IPV6=$(docker exec -t "${DUT}" ip addr show \
    "${TEST_DEVICE}" | grep inet6 | cut -d' ' -f6 | cut -d'/' -f1)
fi

declare -r DOCKER_TESTBENCH_BINARY="/$(basename ${TESTBENCH_BINARY})"
docker cp -L "${TESTBENCH_BINARY}" "${TESTBENCH}:${DOCKER_TESTBENCH_BINARY}"

if [[ -z "${TSHARK-}" ]]; then
  # Run tcpdump in the test bench unbuffered, without dns resolution, just on
  # the interface with the test packets.
  docker exec -t "${TESTBENCH}" \
    tcpdump -S -vvv -U -n -i "${TEST_DEVICE}" \
    net "${TEST_NET_PREFIX}/24" or \
    host "${REMOTE_IPV6}" or \
    host "${LOCAL_IPV6}" &
else
  # Run tshark in the test bench unbuffered, without dns resolution, just on the
  # interface with the test packets.
  docker exec -t "${TESTBENCH}" \
    tshark -V -l -n -i "${TEST_DEVICE}" \
    -o tcp.check_checksum:TRUE \
    -o udp.check_checksum:TRUE \
    net "${TEST_NET_PREFIX}/24" or \
    host "${REMOTE_IPV6}" or \
    host "${LOCAL_IPV6}" &
fi

# tcpdump and tshark take time to startup
sleep 3

# Start a packetimpact test on the test bench.  The packetimpact test sends and
# receives packets and also sends POSIX socket commands to the posix_server to
# be executed on the DUT.
docker exec \
  -e XML_OUTPUT_FILE="/test.xml" \
  -e TEST_TARGET \
  -t "${TESTBENCH}" \
  /bin/bash -c "${DOCKER_TESTBENCH_BINARY} \
  ${EXTRA_TEST_ARGS[@]-} \
  --posix_server_ip=${CTRL_NET_PREFIX}${DUT_NET_SUFFIX} \
  --posix_server_port=${CTRL_PORT} \
  --remote_ipv4=${TEST_NET_PREFIX}${DUT_NET_SUFFIX} \
  --local_ipv4=${TEST_NET_PREFIX}${TESTBENCH_NET_SUFFIX} \
  --remote_ipv6=${REMOTE_IPV6} \
  --local_ipv6=${LOCAL_IPV6} \
  --remote_mac=${REMOTE_MAC} \
  --local_mac=${LOCAL_MAC} \
  --device=${TEST_DEVICE}" && true
declare -r TEST_RESULT="${?}"
if [[ -z "${EXPECT_FAILURE-}" && "${TEST_RESULT}" != 0 ]]; then
  echo 'FAIL: This test was expected to pass.'
  exit ${TEST_RESULT}
fi
if [[ ! -z "${EXPECT_FAILURE-}" && "${TEST_RESULT}" == 0 ]]; then
  echo 'FAIL: This test was expected to fail but passed.  Enable the test and' \
    'mark the corresponding bug as fixed.'
  exit 1
fi
echo PASS: No errors.