#!/bin/sh
#
# Graphpath generates an ASCII network diagram from the route table of a Unix/Linux
# https://bsdrp.net
#
# Copyright (c) 2018, Olivier Cochard-Labbé (olivier@cochard.me)
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
# SUCH DAMAGE.


########################################################
#           ## Concept documentation ##
#
# From this host ('me'), there are mainly 2 main families diagrams:
#
# - The first model family, when source and destination are towards differents
#   interfaces:
#
# +-----+   +-----+    +-----+    +-----+
# | src |   | src |    | src |    | src |
# +-----+   +-----+    +-----+    +-----+
#    |         |          |          |
# +----+  +--------+  +--------+   +----+
# | me |  | router |  | router |   | me |
# +----+  +--------+  +--------+   +----+
#    |        |           |          |
# +-----+   +----+      +----+   +--------+
# | dst |   | me |      | me |   | router |
# +-----+   +----+      +----+   +--------+
#             |           |          |
#          +-----+    +--------+  +-----+
#          | dst |    | router |  | dst |
#          +-----+    +--------+  +-----+
#                         |
#                      +-----+
#                      | dst |
#                      +-----+
#
# - The second model family, when source and destination are towards the same
#    interface:
#
# +-----+ +-----+ +-----+ +-----+ +-----+ +-----+ +-----+ +-----+ +-----+ +-----+
# | src | | dst | | src | | dst | | src | | dst | | src | | dst | | src | | dst |
# +-----+ +-----+ +-----+ +-----+ +-----+ +-----+ +-----+ +-----+ +-----+ +-----+
#    |       |       |       |       |        |      |        |     |        |
#    |       |       |       |       |        |      |        |    -+----+---+-
#    |       |       |       |       |        |      |        |          |
#   -+--+----+-      |       |       |        |   +--------+ +--------+  |
#       |            |       |       |        |   | router | | router |  |
# +--------+     +--------+  |       | +--------+ +--------+ +--------+  |
# | router |     | router |  |       | | router |     |          |       |
# +--------+     +--------+  |       | +--------+    -+-----+----+-    +----+
#      |            |        |       |      |               |          | me |
#   +----+         -+--+-----+-     -+--+---+-            +----+       +----+
#   | me |             |                |                 | me |
#   +----+          +----+           +----+               +----+
#                   | me |           | me |
#                   +----+           +----+
#
# Looking at these diagrams, we can extract 3 type of boxes:
# - standard and simple unique box: draw_block()
#
#          |
# +----------------+
# | src/dst/router |
# | IP: address    |
# | ARP: MAC       |
# +----------------+
#         |
#
# - A dual box on the same level: draw_2blocks()
#
# +---------------+ +---------------+
# | src or router | | dst or router |
# +---------------+ +---------------+
#

# - A 'me' block: draw_me()
#
#     |
# +---------+
# | if_name |
# | if_ip   |
# | route   |
# (next only if src_int != dst_int)
# | route   |
# | if_ip   |
# | if_name |
# +---------+
#     |
#
# - About the box witdh
#
# inet4 maximum witdh size:
#| ROUTER TOWARDS DESTINATION |
#| IP:   192.168.100.100      |
#| ARP:  00:0d:b9:3c:a0:cd    |
#+----------------------------+
#123456789012345678901234567890
#
# => 30 (or 28 without line)
#
# inet6 maximum witdh size:
#| IP:   2001:0db8:0000:0000:0000:ff00:0042:8329 |
#| NDP:  00:0d:b9:3c:a0:cd                       |
#+-----------------------------------------------+
#1234567890123456789012345679012345679012345679012
#
# => 52 (or 50 without line)
#
#
########################################################

# Forcing clean script
set -eu

# Functions definitions

# A usefull function (from: http://code.google.com/p/sh-die/)
die() { echo -n "EXIT: " >&2; echo "$@" >&2; exit 1; }

usage() {
	echo "Usage:"
	echo "$0 [-v] source-IP destination-IP"
	echo "  -v: display version number"
	echo "Example:"
	echo "  $0 198.18.0.10 198.19.0.10"
	echo "  $0 2001:db8:18::10 2001:db8:19::10"
	exit 0
}

draw_lan_line () {
	# Draw a LAN line, like this one:
	# --+---+-----------------------------+---
	# This line is only used in top of 'THIS ROUTER' box

	printf '%*s--+---+' $((box_hw - 1))
	printf '%*s' $((box_w - 4)) ' ' | tr ' ' '-'
	printf '+---\n'
}

draw_connector () {
	# Draw one connector, like this one:
	#               |
	eval "printf '%s%-$((box_hw))s|%s\n' "\${l_pad}\" "\${r_pad}\""
}

draw_dual_connectors () {
	# Draw dual connectors, like this one:
	#  |                             |
	eval "printf '%-$((box_hw + 1))s|%-$((box_w))s|\n'"
}

draw_block_line () {
	# Draw box's upper or lower line, like this one:
	# +----------------------------+
	# The 'me' box didn't need left & right padding
	# because it's always put on the left side
	local ll_pad=$1
	local lr_pad=$2

	printf "%s+" "${ll_pad}"
	printf '%*s' $((box_w + 1)) ' ' | tr ' ' '-'
	printf "+%s\n" "${lr_pad}"
}

draw_2blocks_line () {
	# Draw 2 box's upper or lower line, like this one:
	# +-----------------------------+  +-----------------------------+

	printf "+"
	printf '%*s' $((box_w + 1)) ' ' | tr ' ' '-'
	printf "+  +"
	printf '%*s' $((box_w + 1)) ' ' | tr ' ' '-'
	printf "+\n"
}

draw_block () {
	# Draw one block for source&destination host and router
	#
	# $l_pad     |     $r_pad
	# $l_pad +-------+ $r_pad
	# $l_pad |$label | $r_pad
	# $l_pad |$ip    | $r_pad
	# $l_pad +-------+ $r_pad
	# $l_pad     |     $r_pad
	#
	# l_pad will draw a vertical line on the left side of the box
	# r_pad will draw a vertical line on the rigth side of the box

	local model=$1		# src or dst, used for drawing link on the upper or lower side
	local label=$2		# text to display into the block
	local ip=$3		# IP address for src & dst host (=direct for a router)
	local gateway=$4	#  IP address for a router
	local gateway_arp=$5	# ARP cache if directly connected to "me" block
	local position=$6	# left for box on left size, right other
	l_pad=""		# left padding text
	r_pad=""		# right padding text

	# There is a special case: If ip=direct, this mean this block is useless
	[ "$ip" = direct ] && return 0

	# If in second family mode, need to move or add vertical line
	if [ "${position}" = "left" ]; then
		eval "l_pad=\$(printf '%-$((box_hw + 1))s|%-$((box_hw + 5))s')"
	fi
	if [ "${position}" = "right" ]; then
		r_pad=$(printf '%*s|' $((box_hw - 1)))
	fi

	if [ "$model" = "dst" -a -z "${l_pad}" ]; then
		# Draw connector on upper side
		draw_connector
	fi

	# Draw box's upper line
	draw_block_line "${l_pad}" "${r_pad}"
	eval "
		printf '%s| %-$((box_w - 1))s |%s\n' \"\${l_pad}\" \"\${label}\" \"\${r_pad}\"
		printf '%s| IP:   %-$((box_w - 6))s|%s\n' \"\${l_pad}\" \${ip} \"\${r_pad}\"
	"

	if [ "$gateway" = "direct" ]; then
		eval "printf '%s| %s:  %-$((box_w - 6))s|%s\n' \"\${l_pad}\" \"\${ADD_RES}\" \"${gateway_arp}\" \"${r_pad}\""
	fi

	# Draw box's lower line
	draw_block_line "${l_pad}" "${r_pad}"

	# Draw block lower connectors
	if [ "$model" = "src" ]; then
		# needs a simple connectors if l_pad and r_pad are empty
		# needs dual connectors in other case
		[ -z "${l_pad}" -a -z "${r_pad}" ] && draw_connector || draw_dual_connectors
	else
		# If destination block and l_pad used, need a dual connectors
		[ -n "${l_pad}" ] && draw_dual_connectors
	fi
}

draw_2blocks () {
	# Draw two blocks on the same level
	# Second model family
	local label1=$1
	local label2=$2
	draw_2blocks_line
	eval "printf '| %-$((box_w))s|  | %-$((box_w))s|\n' \"${label1}\" \"${label2}\""
	if echo "${label1}" | grep -q 'ROUTER'; then
		eval "
			printf '| IP:   %-$((box_w - 6))s|  | IP:   %-$((box_w - 6))s|\n' \${src_gateway} \${dst_gateway}
			printf '| %s:  %-$((box_w - 6))s|  | %s:  %-$((box_w - 6))s|\n' \${ADD_RES} \${src_gateway_arp} \${ADD_RES} \${dst_gateway_arp}
		"
	else
    		eval "printf '| IP:   %-$((box_w - 6))s|  | IP:   %-$((box_w - 6))s|\n' \${src_ip} \${dst_ip}"
	fi
	if [ "${src_gateway}" = "direct" ]; then
		eval "printf '| %s:  %-$((box_w - 6))s|' \${ADD_RES} \${src_gateway_arp}"
	elif [ "${dst_gateway}" = "direct" ]; then
		eval "printf '|%-$((box_w + 1))s|'"
	fi
	if [ "${dst_gateway}" = "direct" ]; then
       		eval "printf '  | %s:  %-$((box_w - 6))s|\n' \${ADD_RES} \${dst_gateway_arp}"
	elif [ "${src_gateway}" = "direct" ]; then
		eval "printf '  | %-$((box_w))s|\n'"
	fi
	draw_2blocks_line
	draw_dual_connectors
}

draw_me () {
	# Draw this device block

	# Draw box's lower line
	draw_block_line "" ""
	eval "
		printf '| IF:   %-$((box_w - 6))s|\n' \${src_interface}
		printf '| MAC:  %-$((box_w - 6))s|\n' \${src_interface_mac}
		printf '| IP:   %-$((box_w - 6))s|\n' \${src_interface_ip}
		printf '| net:  %-$((box_w - 6))s|\n' \${src_destination}
	"
	[ -n "${src_mask}" ] && eval "printf '| mask: %-$((box_w - 6))s|\n' \${src_mask}"
	eval "
		printf '|%-$((box_w + 1))s|\n'
		printf '|%-$((box_hw - 5))s THIS %-$((box_hw + 2))s|\n' \"\" \${device}
	"
	if [ "${src_interface}" != "${dst_interface}" ]; then
		# First model type, need to draw lower part
		eval "
			printf '|%-$((box_w +1))s|\n'
			printf '| net:  %-$((box_w - 6))s|\n' \${dst_destination}
		"
		[ -n "${dst_mask}" ] && eval "printf '| mask: %-$((box_w - 6))s|\n' \${dst_mask}"
		eval "
			printf '| IP:   %-$((box_w - 6))s|\n' ${dst_interface_ip}
    		printf '| MAC:  %-$((box_w - 6))s|\n' ${dst_interface_mac}
    		printf '| IF:   %-$((box_w - 6))s|\n' ${dst_interface}
		"
	fi
	# Draw box's lower line
	draw_block_line "" ""
}

get_bsd() {
	# Extract routes data
	# Check if router enabled
	[ $(sysctl -n net.inet.ip.forwarding) -eq 0 ] && forwarding=false || forwarding=true
	[ $(sysctl -n net.inet6.ip6.forwarding) -eq  0 ] && forwarding6=false || forwarding6=true

	(${inet6}) && family="-6" || family="-4"
	tmp=$(mktemp)
	# Populate src_* and dst_* variables
	for i in src dst; do
		eval "
			# if route didn't fill _gateway, this mean its directly connected
			${i}_gateway='direct'

			# some route crossing PPP tun doesn't have _mask
			${i}_mask='255.255.255.255'

			# call route only once
			case \${OS} in
                        FreeBSD)
				route \${family} -n get \${${i}_ip} > \${tmp} || die \"Route towards \${${i}_ip} not found\"
				;;
                        *)
				route -n get \${${i}_ip} > \${tmp} || die \"Route towards \${${i}_ip} not found\"
				;;
			esac

			# Output are like this one:
			### inet4 ###
			#   route to: 2.2.2.2
			#destination: 0.0.0.0
			#       mask: 0.0.0.0
			#    gateway: 192.168.1.100
			#        fib: 0
			#  interface: bxe3
			#      flags: <UP,GATEWAY,DONE,STATIC>
			# recvpipe  sendpipe  ssthresh  rtt,msec    mtu        weight    expire
			#       0         0         0         0      1500         1         0
			### inet6 ###
			#   route to: 2001:db8:11::11
			#destination: 2001:db8:11::
			#       mask: ffff:ffff:ffff:ffff::
			#    gateway: 2001:db8:1::11
			#        fib: 0
			#  interface: bridge1
			#      flags: <UP,GATEWAY,DONE,STATIC>
			# recvpipe  sendpipe  ssthresh  rtt,msec    mtu        weight    expire
			#       0         0         0         0      1500         1         0

			while read line; do
				data=\$(echo \$line | cut -d ':' -f 1)
				case \$data in
				# Theorically we can remove for all lines, the first 13 characters
				# but the while read didn't take care of the first spaces
				\"route to\")
					${i}_routeto=\${line#??????????}
					;;
				destination)
					${i}_destination=\${line#?????????????}
					;;
				mask)
					${i}_mask=\${line#??????}
					;;
				gateway)
					${i}_gateway=\${line#?????????}
					;;
				fib)
					${i}_fib=\${line#?????}
					;;
				interface)
					${i}_interface=\${line#???????????}
					${i}_interface_mac=\$(ifconfig \${${i}_interface} | grep -E 'ether|lladdr|address' | cut -d ' ' -f 2)
					if (\${inet6}); then
						${i}_interface_ip=\$(ifconfig \${${i}_interface} | grep -w inet6 | head -1 | cut -d ' ' -f 2)
					else
						${i}_interface_ip=\$(ifconfig \${${i}_interface} | grep -w inet | cut -d ' ' -f 2)
					fi
					;;
				flags)
					${i}_flags=\${line#?????????}
					;;
				esac
			done < \${tmp}
			[ \"\${${i}_gateway}\" = \"direct\" ] && lookup=\${${i}_routeto} || lookup=\${${i}_gateway}
			if (\${inet6}); then
				${i}_gateway_arp=\$(ndp -n \${lookup} | tail -1 | tr -s ' ' | cut -d ' ' -f 2)
				# When it's empty, it return the IPv6 address between ()
				echo \${${i}_gateway_arp} | grep -q '(' && ${i}_gateway_arp='empty'
			else
				case \${OS} in
				OpenBSD)
					${i}_gateway_arp=\$(arp -n \${lookup} | grep \${lookup} | tr -s ' ' | cut -d ' ' -f 2)
					;;
				*)
					${i}_gateway_arp=\$(arp -n \${lookup} | cut -d ' ' -f 4)
					;;
				esac
			fi
			[ \"\${${i}_gateway_arp}\" = \"no\" ] && ${i}_gateway_arp='empty' || true
		"
		rm ${tmp} || die "can't delete ${tmp}"
	done
}

get_linux () {
	# Extract routes data from a Linux
	(${inet6}) && family="-6" || family="-4"
	# ip route has a non consistent output: STUPID Linux !
	# Check if router enabled
	if [ -f /proc/sys/net/ipv4/ip_forward ]; then
		[ $(cat /proc/sys/net/ipv4/ip_forward) -eq 0 ] && forwarding=false || forwarding=true
	else
		forwarding=false
	fi
	if [ -f /proc/sys/net/ipv6/conf/all/forwarding ]; then
		[ $(cat /proc/sys/net/ipv6/conf/all/forwarding) -eq  0 ] && forwarding6=false || forwarding6=true
	else
		forwarding6=false
	fi
	tmp=$(mktemp)
	# Populate src_* and dst_* variables
	for i in src dst; do
		eval "
			# if route didn't fill _gateway, this mean its directly connected
			${i}_gateway='direct'

			# No mask on linux (using a / into the destination variable)
			${i}_mask=''

			# call route only once
			ip -o route get \${${i}_ip} > \${tmp}

			# Output are like these:
			# 2.2.2.3 via 10.253.52.126 dev eth2 src 10.253.52.115 \ cache
			# 192.168.237.116 dev eth1.337 src 192.168.237.115 \    cache
			# 10.253.52.124 dev eth2 src 10.253.52.115 \    cache
			# local 127.0.0.1 dev lo src 127.0.0.1
			# 2607:f8b0:4004:80c::200e from :: via 2001:470:66:324::1 dev he-ipv6 src 2001:470:66:324::2 metric 1024  pref medium

			read ip via gateway dev if src if_ip < \${tmp}
			if [ \"\${via}\" = \"via\" ]; then
				${i}_routeto=\${${i}_ip}
				${i}_gateway=\${gateway}
				${i}_gateway_arp=\$(ip neigh show \${${i}_gateway} | cut -d ' ' -f 5)
				${i}_interface=\${if}
				${i}_interface_ip=\$(echo \${if_ip} | cut -d ' ' -f1)
			elif [ \"\${via}\" = \"dev\" ]; then
				${i}_routeto=\${${i}_ip}
				${i}_interface=\${gateway}
				${i}_interface_ip=\$(echo \${if} | cut -d ' ' -f1)
			elif [ \"\${via}\" = \"from\" ]; then
				# Inet6 route
				${i}_routeto=\${${i}_ip}
				${i}_gateway=\${if}
				# 2002:333:333::1 dev eth1 lladdr 00:12:1e:33:AA:BB router REACHABLE
				${i}_gateway_arp=\$(ip -6 neigh show \${${i}_gateway} | cut -d ' ' -f 5)
				${i}_interface=\$(echo \${if_ip} | cut -d ' ' -f1)
				${i}_interface_ip=\$(echo \${if_ip} | cut -d ' ' -f3)
			else
				echo "WARNING: Not supported condition!"
				${i}_routeto=\${via}
				${i}_interface=\${dev}
				${i}_interface_ip=\${via}
			fi
			${i}_interface_mac=\$(ip link show \${${i}_interface}| grep ether | tr -s ' ' | cut -d ' ' -f 3)

			# We still need other data (destination subnet and mask)
			# need to use another ip command to retrieve the subnet matching
			# ip route show to match 192.168.229.58
			# default via 10.253.52.126 dev eth2 onlink
			# 192.168.229.56/29 via 192.168.237.100 dev eth1.337
			${i}_destination=\$(ip -o \${family} route show to match \${${i}_ip} | grep \${if} | cut -d ' ' -f1)

			if [ \"\${${i}_gateway}\" = \"direct\" ]; then
				${i}_gateway_arp=\$(ip neigh show \${${i}_routeto} | cut -d ' ' -f 5)
				[ \"\${${i}_gateway_arp}\" = \"no\" ] && ${i}_gateway_arp='empty' || true
			fi
		"
		rm ${tmp} || die "can't delete ${tmp}"
	done
}

### Main function ###

version="1.2"

if [ "$#" -eq 1 ]; then
	if [ "$1" = "-v" ]; then
		echo "Version ${version}"
		exit 0
	else
		usage
	fi
fi
[ "$#" -ne 2 ] && usage

src_ip=$1
dst_ip=$2
device="ROUTER"
inet6=false

# Small checks
[ "${src_ip}" = "${dst_ip}" ] && die "Same source and destination IP addresses"
$(echo ${src_ip} | grep -q ':') && inet6=true

OS=$(uname)
case ${OS} in
	FreeBSD|OpenBSD|NetBSD|Darwin)
		get_bsd
		;;
	Linux)
		get_linux
		;;
	*)
		die "This script was not tested on ${OS}"
		;;
esac

if [ $forwarding = false -o $forwarding6 = false ]; then
	device="HOST  "
fi


[ "${device}" = "HOST" ] && echo "This tool is mainly designed for drawing router or firewall routing view"

### Start ASCII drawing
if (${inet6}); then
	ADD_RES="NDP"
	# inet6 addresses needs large box able to contains 50 caracters
	box_w=50
else
	ADD_RES="ARP"
	# inet4 addresses only need to contains 28 caracters
	box_w=28
fi
box_hw=$((box_w / 2 - 1))

if [ "${src_interface}" != "${dst_interface}" ]; then
	# First model family
	if [ "${src_ip}" != "${src_interface_ip}" ]; then
		draw_block src 'SOURCE HOST' "${src_ip}" "${src_gateway}" "${src_gateway_arp}" ""
		[ "${src_gateway}" != "direct" ] && draw_block src 'ROUTER TOWARDS SOURCE' "${src_gateway}" direct "${src_gateway_arp}" ""
	fi
	draw_me
	if [ "${dst_ip}" != "${dst_interface_ip}" ]; then
		[ "${dst_gateway}" != "direct" ] && draw_block dst 'ROUTER TOWARDS DESTINATION' "${dst_gateway}" direct "${dst_gateway_arp}" ""
		draw_block dst 'DESTINATION HOST' "${dst_ip}" "${dst_gateway}" "${dst_gateway_arp}" ""
	fi
else
	# Second model family
	draw_2blocks 'SOURCE HOST' 'DESTINATION HOST'
	if [ "${src_gateway}" = "${dst_gateway}" ]; then
		draw_lan_line
		eval "printf '%-$((box_hw + 1))s|\n'"
		draw_block src 'ROUTER' "${src_gateway}" direct "${src_gateway_arp}" ""
	else
		if [ "${src_gateway}" != "direct" -a "${dst_gateway}" = "direct" ]; then
			draw_block src 'ROUTER TOWARDS SOURCE' "${src_gateway}" direct "${src_gateway_arp}" "right"
		elif [ "${dst_gateway}" != "direct" -a "${src_gateway}" = "direct" ]; then
			draw_block dst 'ROUTER TOWARDS DESTINATION' "${dst_gateway}" direct "${dst_gateway_arp}" "left"
		else
			draw_2blocks 'ROUTER TOWARDS SOURCE' 'ROUTER TOWARDS DESTINATION'
		fi
		draw_lan_line
		eval "printf '%-$((box_hw + 5))s|\n'"
	fi
	draw_me
fi
