diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/lib/color b/lib/color new file mode 100644 index 0000000..4ed3a92 --- /dev/null +++ b/lib/color @@ -0,0 +1,36 @@ +#!/bin/bash + +## EMPTY DEFAULTS ## +color_bold="" +color_underline="" +color_standout="" +color_normal="" +color_black="" +color_red="" +color_green="" +color_yellow="" +color_blue="" +color_magenta="" +color_cyan="" +color_white="" + + +## ONLY IF PRINTING TO A TERMINAL ## +if test -t 1; then + # check count of supported colors + color_count=$(tput colors) + if [[ ! -z "$color_count" && "$color_count" -ge 8 ]]; then + color_bold="$(tput bold)" + color_underline="$(tput smul)" + color_standout="$(tput smso)" + color_normal="$(tput sgr0)" + color_black="$(tput setaf 0)" + color_red="$(tput setaf 1)" + color_green="$(tput setaf 2)" + color_yellow="$(tput setaf 3)" + color_blue="$(tput setaf 4)" + color_magenta="$(tput setaf 5)" + color_cyan="$(tput setaf 6)" + color_white="$(tput setaf 7)" + fi +fi diff --git a/lib/log b/lib/log new file mode 100644 index 0000000..4c2d597 --- /dev/null +++ b/lib/log @@ -0,0 +1,40 @@ +#!/bin/bash + +# +# Log a line with debug level. +# +log_debug(){ + echo -ne "${color_green}[DEBUG]${color_normal} " 1>&2 + echo "$1" 1>&2 +} + + + +# +# Log a line with info level. +# +log_info(){ + echo -ne "${color_blue}[INFO ]${color_normal} " 1>&2 + echo "$1" 1>&2 +} + + + +# +# Log a line with warning level. +# +log_warn(){ + echo -ne "${color_yellow}[WARN ]${color_normal} " 1>&2 + echo "$1" 1>&2 +} + + + +# +# Log a line with error level and exit. +# +log_error(){ + echo -ne "${color_red}[ERROR]${color_normal} " 1>&2 + echo "$1" 1>&2 + exit 1 +} diff --git a/lib/sudocheck b/lib/sudocheck new file mode 100644 index 0000000..33332b2 --- /dev/null +++ b/lib/sudocheck @@ -0,0 +1,10 @@ +#!/bin/bash + +# +# Make sure we have sudo permissions. +# +sudocheck(){ + if [[ "$EUID" -ne 0 ]]; then + log_error "This script needs sudo permissions" + fi +} diff --git a/xynat b/xynat new file mode 100755 index 0000000..c05302c --- /dev/null +++ b/xynat @@ -0,0 +1,592 @@ +#!/bin/bash +set -euo pipefail + +## INIT ## +# get script base directory +SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}") +SCRIPT_DIR=$(dirname -- "$SCRIPT_PATH") + +# store arguments globally +ARG_LIST=("$@") + +# get version number +VERSION=$(cat ${SCRIPT_DIR}/VERSION | xargs) + +# load libraries +source "${SCRIPT_DIR}/lib/color" +source "${SCRIPT_DIR}/lib/log" +#source "${SCRIPT_DIR}/lib/toolcheck" +source "${SCRIPT_DIR}/lib/sudocheck" + +# regex definitions +regex_ip_address="^([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])){3}$" +regex_ip_or_net="^([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])){3}(\/([0-9]|[1-2][0-9]|3[0-2]))?$" +regex_local_address="^(10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168\.).*$" + +# subnet list +subnet_list_special_use="0.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 192.0.0.0/24 192.0.0.0/24 192.88.99.0/24 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 233.252.0.0/24 240.0.0.0/4 255.255.255.255/32" +subnet_list_local="10.0.0.0/8 172.16.0.0/12 192.168.0.0/16" + + + +# +# HELPER: Remove a firewall. +# +xynat_fw_remove(){ + ## REMOVE CHAINS ## + # flush chains + iptables -F "${chain_id}_IN" 2> /dev/null || true + iptables -F "${chain_id}_OUT" 2> /dev/null || true + iptables -F "${chain_id}_FWI" 2> /dev/null || true + iptables -F "${chain_id}_FWO" 2> /dev/null || true + iptables -t nat -F "${chain_id}_NI" 2> /dev/null || true + iptables -t nat -F "${chain_id}_NO" 2> /dev/null || true + + # remove ipv4 references + iptables -D INPUT -i "$arg_iface" -j "${chain_id}_IN" 2> /dev/null || true + iptables -D OUTPUT -o "$arg_iface" -j "${chain_id}_OUT" 2> /dev/null || true + iptables -D FORWARD -i "$arg_iface" -j "${chain_id}_FWI" 2> /dev/null || true + iptables -D FORWARD -o "$arg_iface" -j "${chain_id}_FWO" 2> /dev/null || true + iptables -t nat -D PREROUTING -j "${chain_id}_NI" 2> /dev/null || true + iptables -t nat -D POSTROUTING -j "${chain_id}_NO" 2> /dev/null || true + + # remove ipv6 references + ip6tables -D INPUT -i "$arg_iface" -j REJECT --reject-with icmp6-no-route 2> /dev/null || true + ip6tables -D OUTPUT -o "$arg_iface" -j REJECT --reject-with icmp6-no-route 2> /dev/null || true + ip6tables -D FORWARD -i "$arg_iface" -j REJECT --reject-with icmp6-no-route 2> /dev/null || true + ip6tables -D FORWARD -o "$arg_iface" -j REJECT --reject-with icmp6-no-route 2> /dev/null || true + + # delete chains + iptables -X "${chain_id}_IN" 2> /dev/null || true + iptables -X "${chain_id}_OUT" 2> /dev/null || true + iptables -X "${chain_id}_FWI" 2> /dev/null || true + iptables -X "${chain_id}_FWO" 2> /dev/null || true + iptables -t nat -X "${chain_id}_NI" 2> /dev/null || true + iptables -t nat -X "${chain_id}_NO" 2> /dev/null || true +} + + + +# +# HELPER: Add a firewall. +# +xynat_fw_add(){ + ## RESET ## + # remove references + iptables -D INPUT -i "$arg_iface" -j "${chain_id}_IN" 2> /dev/null || true + iptables -D OUTPUT -o "$arg_iface" -j "${chain_id}_OUT" 2> /dev/null || true + iptables -D FORWARD -i "$arg_iface" -j "${chain_id}_FWI" 2> /dev/null || true + iptables -D FORWARD -o "$arg_iface" -j "${chain_id}_FWO" 2> /dev/null || true + iptables -t nat -D PREROUTING -j "${chain_id}_NI" 2> /dev/null || true + iptables -t nat -D POSTROUTING -j "${chain_id}_NO" 2> /dev/null || true + + # remove ipv6 references + ip6tables -D INPUT -i "$arg_iface" -j REJECT --reject-with icmp6-no-route 2> /dev/null || true + ip6tables -D OUTPUT -o "$arg_iface" -j REJECT --reject-with icmp6-no-route 2> /dev/null || true + ip6tables -D FORWARD -i "$arg_iface" -j REJECT --reject-with icmp6-no-route 2> /dev/null || true + ip6tables -D FORWARD -o "$arg_iface" -j REJECT --reject-with icmp6-no-route 2> /dev/null || true + + + ## ADD CHAINS ## + # create chains + iptables -N "${chain_id}_IN" 2> /dev/null || true + iptables -N "${chain_id}_OUT" 2> /dev/null || true + iptables -N "${chain_id}_FWI" 2> /dev/null || true + iptables -N "${chain_id}_FWO" 2> /dev/null || true + iptables -t nat -N "${chain_id}_NI" 2> /dev/null || true + iptables -t nat -N "${chain_id}_NO" 2> /dev/null || true + + # insert references + iptables -I INPUT -i "$arg_iface" -j "${chain_id}_IN" + iptables -I OUTPUT -o "$arg_iface" -j "${chain_id}_OUT" + iptables -I FORWARD -i "$arg_iface" -j "${chain_id}_FWI" + iptables -I FORWARD -o "$arg_iface" -j "${chain_id}_FWO" + iptables -t nat -I PREROUTING -j "${chain_id}_NI" + iptables -t nat -I POSTROUTING -j "${chain_id}_NO" + + # block ipv6 + ip6tables -I INPUT -i "$arg_iface" -j REJECT --reject-with icmp6-no-route + ip6tables -I OUTPUT -o "$arg_iface" -j REJECT --reject-with icmp6-no-route + ip6tables -I FORWARD -i "$arg_iface" -j REJECT --reject-with icmp6-no-route + ip6tables -I FORWARD -o "$arg_iface" -j REJECT --reject-with icmp6-no-route +} + + + +# +# HELPER: Update all rulesets. +# +xynat_ruleset_update(){ + xynat_ruleset_update_in + xynat_ruleset_update_out + xynat_ruleset_update_fwi + xynat_ruleset_update_fwo + xynat_ruleset_update_ni + xynat_ruleset_update_no +} + + + +# +# HELPER: Update ruleset: `in`. +# +xynat_ruleset_update_in(){ + ## FLUSH CHAIN ## + iptables -F "${chain_id}_IN" + + + ## ADD RULES ## + # reject all packets + iptables -A "${chain_id}_IN" -j REJECT --reject-with icmp-host-unreachable + + # default: return + iptables -A "${chain_id}_IN" -j RETURN +} + + + +# +# HELPER: Update ruleset: `out`. +# +xynat_ruleset_update_out(){ + ## FLUSH CHAIN ## + iptables -F "${chain_id}_OUT" + + + ## ADD RULES ## + # allow icmp messages + iptables -A "${chain_id}_OUT" -p icmp -j RETURN + + # reject all other packets + iptables -A "${chain_id}_OUT" -j REJECT --reject-with icmp-host-unreachable + + # default: return + iptables -A "${chain_id}_OUT" -j RETURN +} + + + +# +# HELPER: Update ruleset: `fwi`. +# +xynat_ruleset_update_fwi(){ + ## FLUSH CHAIN ## + iptables -F "${chain_id}_FWI" + + + ## ADD RULES ## + # enforce correct vm address + iptables -A "${chain_id}_FWI" ! -s "$arg_vm_address" -j REJECT --reject-with icmp-admin-prohibited + + # ignore allowed local addresses + for a in ${arg_allow:-""}; do + iptables -A "${chain_id}_FWI" -d "$a" -j RETURN + done + + # ignore allowed incomming local addresses + for a in ${arg_allow_in:-""}; do + iptables -A "${chain_id}_FWI" -d "$a" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN + done + + # reject filtered packets + for a in $subnet_list_special_use $subnet_list_local; do + iptables -A "${chain_id}_FWI" -d "$a" -j REJECT --reject-with icmp-net-unreachable + done + + # default: return + iptables -A "${chain_id}_FWI" -j RETURN +} + + + +# +# HELPER: Update ruleset: `fwo`. +# +xynat_ruleset_update_fwo(){ + ## FLUSH CHAIN ## + iptables -F "${chain_id}_FWO" + + + ## ADD RULES ## + # enforce correct vm address + iptables -A "${chain_id}_FWO" ! -d "$arg_vm_address" -j REJECT --reject-with icmp-admin-prohibited + + # ignore allowed local addresses + for a in ${arg_allow:-""} ${arg_allow_in:-""}; do + iptables -A "${chain_id}_FWO" -s "$a" -j RETURN + done + + # reject filtered packets + for a in $subnet_list_special_use $subnet_list_local; do + iptables -A "${chain_id}_FWO" -s "$a" -j REJECT --reject-with icmp-net-unreachable + done + + # default: return + iptables -A "${chain_id}_FWO" -j RETURN +} + + + +# +# HELPER: Update ruleset: `ni`. +# +xynat_ruleset_update_ni(){ + ## FLUSH CHAIN ## + iptables -t nat -F "${chain_id}_NI" + + + ## ADD RULES ## + # filter for vm public ip + iptables -t nat -A "${chain_id}_NI" ! -d "$arg_public_ip" -j RETURN # TODO + + # translate destination address (forward to vm) + iptables -t nat -A "${chain_id}_NI" -j DNAT --to-destination "$arg_vm_address" +} + + + +# +# HELPER: Update ruleset: `no`. +# +xynat_ruleset_update_no(){ + ## FLUSH CHAIN ## + iptables -t nat -F "${chain_id}_NO" + + + ## ADD RULES ## + # filter for vm ip address + iptables -t nat -A "${chain_id}_NO" ! -s "$arg_vm_address" -j RETURN + + # translate source address (forward to internet) + iptables -t nat -A "${chain_id}_NO" -j SNAT --to-source "$arg_public_ip" # TODO +} + + + +# +# Explain usage of script. +# +xynat_help(){ + echo "XYNat v${VERSION}" + echo + echo "Usage: $0 [OPTIONS]" + echo + echo "Options:" + echo " -a, --add - Add new firewall (default, fallback: MODE=start)" + echo " -r, --remove - Remove existing firewall (fallback: MODE=stop)" + echo + echo " -i, --iface=iface - Interface name for virtual switch (required, fallback: IFACE)" + echo " -s, --vm-address=ip - IP address of virtual machine (required)" + echo " -p, --public-ip=ip - IP address to use for outgoing traffic and DNAT" + echo + echo " -w, --allow=ip-or-net - Allow address(es) for incomming and outgoing connections (multi-use allowed)" + echo " -x, --allow-in=ip-or-net - Allow address(es) for incomming connections only (multi-use allowed)" + echo " -y, --allow-host - Allow local host for incomming and outgoing connections" + echo " -z, --allow-host-in - Allow local host for incomming connections only" + echo + echo " -h, --help - Display this help message and exit" + echo " -v, --version - Display version information and exit" +} + + + +# +# Display version information. +# +xynat_version(){ + echo "XYNat v${VERSION}" + echo "(c) 2024 DrMaxNix" +} + + + +# +# VALIDATOR: `iface`. +# +xynat_validate_iface(){ + ## CHECK IF VALID NAME ## + local iface_list="$(xynat_iface_list)" + local found="no" + for i in $iface_list; do + if [[ "$i" == "$1" ]]; then + found="yes" + fi + done + if [[ "$found" != "yes" ]]; then + log_warn "Unknown iface name '$1'" + fi +} + + + +# +# VALIDATOR: `vm-address`. +# +xynat_validate_vm_address(){ + ## CHECK FORMAT ## + if [[ ! "$1" =~ $regex_ip_address ]]; then + log_error "Malformed vm-address '$1'" + fi +} + + + +# +# VALIDATOR: `public-ip`. +# +xynat_validate_public_ip(){ + ## CHECK FORMAT ## + if [[ ! "$1" =~ $regex_ip_address ]]; then + log_error "Malformed vm-address '$1'" + fi + + + ## CHECK IP IFACE ASSIGNMENT ## + # make sure this is not a primary ip address + local primary_ip_list="$(xynat_primary_ip_list)" + for p in $primary_ip_list; do + if [[ "$p" == "$1" ]]; then + log_error "Virtual machine's public-ip is in use as primary host address" + fi + done + + # check whether it exists as a secondary address + local secondary_ip_list="$(xynat_secondary_ip_list)" + local found="no" + for s in $secondary_ip_list; do + if [[ "$s" == "$1" ]]; then + found="yes" + fi + done + if [[ "$found" != "yes" ]]; then + log_warn "Virtual machine's public-ip '$1' is not assigned to any host interface" + fi +} + + + +# +# VALIDATOR: `allow`. +# +xynat_validate_allow(){ + for a in $1; do + ## VALIDATE SYNTAX ## + if [[ ! "$a" =~ $regex_ip_or_net ]]; then + log_error "Malformed ip address or subnet in allowlist: '$a'" + fi + + + ## CHECK FOR KNOWN LOCAL PREFIX ## + if [[ ! "$a" =~ $regex_local_address ]]; then + log_warn "Allowlist entry does not look like a local address: '$a'" + fi + done +} + + + +# +# HELPER: List names of all interfaces. +# +xynat_iface_list(){ + ip link show | grep -Pe "^[0-9]+: (.*): <.*$" | sed -E "s/^[0-9]+: (.*): <.*$/\1/g" +} + + + +# +# HELPER: List all primary ip addresses of the host. +# +xynat_primary_ip_list(){ + ip address show | grep -Pe "^\s+inet ([0-9\.]+)\/[0-9]+.*$" | grep -v "\ssecondary\s" | sed -E "s/^\s+inet ([0-9\.]+)\/[0-9]+.*$/\1/g" +} + + + +# +# HELPER: List all secondary ip addresses of the host. +# +xynat_secondary_ip_list(){ + ip address show | grep -Pe "^\s+inet ([0-9\.]+)\/[0-9]+.*$" | grep "\ssecondary\s" | sed -E "s/^\s+inet ([0-9\.]+)\/[0-9]+.*$/\1/g" +} + + + +## PARSE ARGUMENTS ## +while [[ "$#" -gt 0 ]]; do + case "$1" in + # help + -h|--help) + xynat_help + exit 0 + ;; + + # version + -v|--version) + xynat_version + exit 0 + ;; + + # add + -a|--add) + if [[ "${arg_mode:+x}" ]]; then log_error "Cannot set mode twice; already set to '$arg_mode'"; fi + arg_mode="add" + shift + ;; + + # remove + -r|--remove) + if [[ "${arg_mode:+x}" ]]; then log_error "Cannot set mode twice; already set to '$arg_mode'"; fi + arg_mode="remove" + shift + ;; + + # iface + -i|--iface|-i=*|--iface=*) + if [[ "${arg_iface:+x}" ]]; then log_error "Cannot set iface twice; already set to '$arg_iface'"; fi + if [[ "$1" =~ ^[a-z\-]+=(.+)$ ]]; then + arg_iface=$(echo $1 | sed -E "s/^[a-z\-]+=(.*)$/\1/g") + shift; continue; fi + + shift; if [[ $# -gt 0 ]]; then + arg_iface="$1" + else log_error "Value expected for parameter 'iface'"; fi; shift + ;; + + # vm-address + -s|--vm-address|-s=*|--vm-address=*) + if [[ "${arg_vm_address:+x}" ]]; then log_error "Cannot set vm-address twice; already set to '$arg_vm_address'"; fi + if [[ "$1" =~ ^[a-z\-]+=(.+)$ ]]; then + arg_vm_address=$(echo $1 | sed -E "s/^[a-z\-]+=(.*)$/\1/g") + shift; continue; fi + + shift; if [[ $# -gt 0 ]]; then + arg_vm_address="$1" + else log_error "Value expected for parameter 'vm-address'"; fi; shift + ;; + + # public-ip + -p|--public-ip|-p=*|--public-ip=*) + if [[ "${arg_public_ip:+x}" ]]; then log_error "Cannot set public-ip twice; already set to '$arg_public_ip'"; fi + if [[ "$1" =~ ^[a-z\-]+=(.+)$ ]]; then + arg_public_ip=$(echo $1 | sed -E "s/^[a-z\-]+=(.*)$/\1/g") + shift; continue; fi + + shift; if [[ $# -gt 0 ]]; then + arg_public_ip="$1" + else log_error "Value expected for parameter 'public-ip'"; fi; shift + ;; + + # allow + -w|--allow|-w=*|--allow=*) + if [[ "$1" =~ ^[a-z\-]+=(.+)$ ]]; then + arg_allow=(${arg_allow[@]:-""} "$(echo $1 | sed -E "s/^[a-z\-]+=(.*)$/\1/g")") + shift; continue; fi + + shift; if [[ $# -gt 0 ]]; then + arg_allow=(${arg_allow[@]:-""} "$1") + else log_error "Value expected for parameter 'allow'"; fi; shift + ;; + + # allow-in + -x|--allow-in|-x=*|--allow-in=*) + if [[ "$1" =~ ^[a-z\-]+=(.+)$ ]]; then + arg_allow_in=(${arg_allow_in[@]:-""} "$(echo $1 | sed -E "s/^[a-z\-]+=(.*)$/\1/g")") + shift; continue; fi + + shift; if [[ $# -gt 0 ]]; then + arg_allow_in=(${arg_allow_in[@]:-""} "$1") + else log_error "Value expected for parameter 'allow-in'"; fi; shift + ;; + + # allow-host + -y|--allow-host|-y=*|--allow-host=*) + if [[ "${arg_allow_host_mode:+x}" ]]; then log_error "Cannot set allow-host mode twice; already set to '$arg_allow_host_mode'"; fi + arg_allow_host_mode="in_out" + shift + ;; + + # allow-host-in + -z|--allow-host-in|-z=*|--allow-host-in=*) + if [[ "${arg_allow_host_mode:+x}" ]]; then log_error "Cannot set allow-host mode twice; already set to '$arg_allow_host_mode'"; fi + arg_allow_host_mode="in" + shift + ;; + + # unknown + *) + log_error "Unexpected argument '$1'" + ;; + esac +done + + +## MAKE SURE WE HAVE SUDO PERMISSION ## +sudocheck + + +## ARGUMENT FALLBACKS AND VALIDATION ## +# mode +if [[ -z "${arg_mode:+x}" ]]; then + case "${MODE:-""}" in + "stop") arg_mode="remove";; + "start"|*) arg_mode="add";; + esac +fi + +# iface +if [[ -z "${arg_iface:+x}" ]]; then + if [[ -z "${IFACE:+x}" ]]; then log_error "Missing required argument 'iface'; See '$0 --help' for usage information"; fi + arg_iface="$IFACE" +fi +xynat_validate_iface "$arg_iface" + +# vm-address +if [[ -z "${arg_vm_address:+x}" ]]; then + log_error "Missing required argument 'vm-address'; See '$0 --help' for usage information" +fi +xynat_validate_vm_address "$arg_vm_address" + +# public-ip +if [[ "${arg_public_ip:+x}" ]]; then + xynat_validate_public_ip "$arg_public_ip" +fi + +# allow +xynat_validate_allow "${arg_allow[*]:-""}" + +# allow-in +xynat_validate_allow "${arg_allow_in[*]:-""}" + + +## BUILD MISSING VALUES ## +# firewall chain id +chain_id="XYNAT_$(echo "$arg_iface" | tr a-z A-Z | tr - _)" + + +## EXECUTE ACTION ## +case "$arg_mode" in + # add + "add") + xynat_fw_add + xynat_ruleset_update + ;; + + # remove + "remove") + xynat_fw_remove + ;; + + # should not happen + *) + log_error "Invalid mode '$arg_mode'" + ;; +esac + + + + + +# TODO: Refine icmp filter to only allow related packets +# TODO: Wire up public-ip being empty (SNAT/DNAT) +# TODO: Wire up allow-host and allow-host-in