initial codebase

This commit is contained in:
DrMaxNix 2024-05-24 22:54:18 +02:00
parent ffef501736
commit 146c1d5c3a
5 changed files with 679 additions and 0 deletions

1
VERSION Normal file
View File

@ -0,0 +1 @@
1.0.0

36
lib/color Normal file
View File

@ -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

40
lib/log Normal file
View File

@ -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
}

10
lib/sudocheck Normal file
View File

@ -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
}

592
xynat Executable file
View File

@ -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