xynat/xynat
2024-08-16 23:16:44 +02:00

635 lines
18 KiB
Bash
Executable File

#!/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 ##
# enforce correct vm address
iptables -A "${chain_id}_IN" ! -s "$arg_vm_address" -j REJECT --reject-with icmp-admin-prohibited
# maybe allow host access
if [[ "${arg_allow_host_mode:-}" == "in_out" ]]; then
iptables -A "${chain_id}_IN" -j ACCEPT
elif [[ "${arg_allow_host_mode:-}" == "in" ]]; then
iptables -A "${chain_id}_IN" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
fi
# reject all packets
iptables -A "${chain_id}_IN" -j REJECT --reject-with icmp-host-unreachable
}
#
# HELPER: Update ruleset: `out`.
#
xynat_ruleset_update_out(){
## FLUSH CHAIN ##
iptables -F "${chain_id}_OUT"
## ADD RULES ##
# allow related icmp messages
iptables -A "${chain_id}_OUT" -p icmp -m conntrack --ctstate RELATED -j ACCEPT
# enforce correct vm address
iptables -A "${chain_id}_OUT" ! -d "$arg_vm_address" -j REJECT --reject-with icmp-admin-prohibited
# maybe allow host access
if [[ "${arg_allow_host_mode:-}" == "in_out" || "${arg_allow_host_mode:-}" == "in" ]]; then
iptables -A "${chain_id}_OUT" -j ACCEPT
fi
# reject all other packets
iptables -A "${chain_id}_OUT" -j REJECT --reject-with icmp-host-unreachable
}
#
# 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
# reject packets for blocked address(es)
for a in ${arg_block[*]:-}; do
iptables -A "${chain_id}_FWI" -d "$a" -j REJECT --reject-with icmp-net-unreachable
done
# accept allowed local addresses
for a in ${arg_allow[*]:-}; do
iptables -A "${chain_id}_FWI" -d "$a" -j ACCEPT
done
# accept allowed incomming local addresses
for a in ${arg_allow_in[*]:-}; do
iptables -A "${chain_id}_FWI" -d "$a" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
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: allow
iptables -A "${chain_id}_FWI" -j ACCEPT
}
#
# 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
# reject packets for blocked address(es)
for a in ${arg_block[*]:-}; do
iptables -A "${chain_id}_FWO" -s "$a" -j REJECT --reject-with icmp-net-unreachable
done
# accept allowed local addresses
for a in ${arg_allow[*]:-} ${arg_allow_in[*]:-}; do
iptables -A "${chain_id}_FWO" -s "$a" -j ACCEPT
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: accept
iptables -A "${chain_id}_FWO" -j ACCEPT
}
#
# HELPER: Update ruleset: `ni`.
#
xynat_ruleset_update_ni(){
## FLUSH CHAIN ##
iptables -t nat -F "${chain_id}_NI"
## ADD RULES ##
# translate destination address (forward to vm)
if [[ "${arg_public_ip:+x}" ]]; then
iptables -t nat -A "${chain_id}_NI" -d "$arg_public_ip" -j DNAT --to-destination "$arg_vm_address"
fi
}
#
# 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/port (forward to internet)
if [[ "${arg_public_ip:+x}" ]]; then
iptables -t nat -A "${chain_id}_NO" -j SNAT --to-source "$arg_public_ip"
else
iptables -t nat -A "${chain_id}_NO" -j MASQUERADE
fi
}
#
# 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 when adding new fw)"
echo " -p, --public-ip=ip - IP address to use for outgoing traffic and DNAT"
echo
echo " -b, --block=ip-or-net - Block address(es) for all connections (multi-use allowed)"
echo
echo " -w, --allow=ip-or-net - Allow local address(es) for all connections (multi-use allowed)"
echo " -x, --allow-in=ip-or-net - Allow local address(es) for incomming connections only (multi-use allowed)"
echo " -y, --allow-host - Allow local host for all 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"
echo
echo "Examples:"
echo " Add a new firewall and NAT for a VM which has IP address '192.168.234.2' and is a member of the bridge interface 'br2'"
echo " All traffic to and from local addresses will be blocked; Traffic to public addresses will be allowed"
echo " $0 --add --iface \"br2\" --vm-address \"192.168.234.2\""
echo
echo " Same as first example, but use '192.168.123.99' as source address for outgoing connections"
echo " $0 --add --iface \"br2\" --vm-address \"192.168.234.2\" --public-ip \"192.168.123.99\""
echo
echo " Same as first example, but also block all traffic to and from '233.252.0.0/24'"
echo " $0 --add --iface \"br2\" --vm-address \"192.168.234.2\" --block \"233.252.0.0/24\""
echo
echo " Same as second example, but allow incomming and outgoing connections from host device"
echo " The VM will also be accessible from devices in '192.168.137.64/30' via its public address"
echo " $0 --add --iface \"br2\" --vm-address \"192.168.234.2\" --public-ip \"192.168.123.99\" --allow-host --allow-in \"192.168.137.64/30\""
echo
echo " Remove all firewall and NAT rules for iface 'br2'"
echo " $0 --remove --iface \"br2\""
}
#
# Display version information.
#
xynat_version(){
echo "XYNat v${VERSION}"
echo "(c) 2024 DrMaxNix"
}
#
# 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: `block`.
#
xynat_validate_block(){
for a in $1; do
## VALIDATE SYNTAX ##
if [[ ! "$a" =~ $regex_ip_or_net ]]; then
log_error "Malformed ip address or subnet in blocklist: '$a'"
fi
done
}
#
# 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 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"
}
## TOOLCHECK ##
toolcheck error "grep sed iptables ip"
## 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
;;
# block
-b|--block|-b=*|--block=*)
if [[ "$1" =~ ^[a-z\-]+=(.+)$ ]]; then
arg_block=(${arg_block[@]:-""} "$(echo $1 | sed -E "s/^[a-z\-]+=(.*)$/\1/g")")
shift; continue; fi
shift; if [[ $# -gt 0 ]]; then
arg_block=(${arg_block[@]:-""} "$1")
else log_error "Value expected for parameter 'block'"; 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
# vm-address
if [[ "$arg_mode" != "remove" ]]; then
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"
fi
# public-ip
if [[ "${arg_public_ip:+x}" ]]; then
xynat_validate_public_ip "$arg_public_ip"
fi
# block
xynat_validate_block "${arg_block[*]:-""}"
# 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