diff --git a/iptables-forward.sh b/iptables-forward.sh new file mode 100644 index 0000000..3c8a46e --- /dev/null +++ b/iptables-forward.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +LIB_DIR=${SCRIPT_DIR}/lib + +# shellcheck source=lib/common.sh +source "${LIB_DIR}/common.sh" + +on_error() { + local exit_code=$1 + local line_no=$2 + log_err "脚本在第 ${line_no} 行发生错误,退出码 ${exit_code}。" + exit "${exit_code}" +} +trap 'on_error "$?" "$LINENO"' ERR + +require_root || exit $? + +# shellcheck source=lib/storage.sh +source "${LIB_DIR}/storage.sh" +# shellcheck source=lib/persist.sh +source "${LIB_DIR}/persist.sh" +# shellcheck source=lib/iptables_ops.sh +source "${LIB_DIR}/iptables_ops.sh" +# shellcheck source=lib/env_check.sh +source "${LIB_DIR}/env_check.sh" +# shellcheck source=lib/rules_mgr.sh +source "${LIB_DIR}/rules_mgr.sh" + +: "${IPF_SKIP_ENV_CHECK:=0}" + +usage() { + cat < [desc] + ./iptables-forward.sh --batch delete + ./iptables-forward.sh --batch list + ./iptables-forward.sh --batch save + ./iptables-forward.sh --help + +说明: + - proto: tcp / udp / both + - ipver: 4 / 6 / both + - 当 ipver=both 时,target_ip 需使用 IPv4,IPv6 形式,例如 127.0.0.1,::1 +USAGE +} + +bootstrap() { + if [[ ${IPF_SKIP_ENV_CHECK} != 1 ]]; then + env_check_all + fi + storage_init +} + +render_main_menu() { + local status persist count width + status=$(env_status_summary) + count=$(storage_count) + if persist_available; then + persist='[✓]' + else + persist='[!]' + fi + + if use_box_ui; then + width=$(term_width) + if (( width > 78 )); then + width=78 + fi + box_top "${width}" + box_line "${width}" "${IPF_APP_NAME}" + box_separator "${width}" + box_line "${width}" "状态: ${status} 规则数: ${count} 持久化: ${persist}" + box_separator "${width}" + box_line "${width}" '[1] 查看所有转发规则' + box_line "${width}" '[2] 添加新的转发规则' + box_line "${width}" '[3] 删除现有转发规则' + box_line "${width}" '[4] 查看系统环境状态' + box_line "${width}" '[5] 立即保存到磁盘' + box_line "${width}" '[0] 退出' + box_bottom "${width}" + else + printf '%s\n' "${IPF_APP_NAME}" + printf '状态: %s | 规则数: %s | 持久化: %s\n' "${status}" "${count}" "${persist}" + printf '[1] 查看所有转发规则\n' + printf '[2] 添加新的转发规则\n' + printf '[3] 删除现有转发规则\n' + printf '[4] 查看系统环境状态\n' + printf '[5] 立即保存到磁盘\n' + printf '[0] 退出\n' + fi +} + +main_menu_loop() { + local choice + while true; do + render_main_menu + choice=$(prompt_input '请选择 [0-5]' '') + case ${choice} in + 1) cmd_list ;; + 2) cmd_add ;; + 3) cmd_delete ;; + 4) cmd_show_env_status ;; + 5) + cmd_save_rules + pause_return + ;; + 0) + log_info '已退出。' + return 0 + ;; + *) + log_warn '无效选择,请输入 0-5。' + ;; + esac + done +} + +run_batch() { + local action=${1-} + shift || true + case ${action} in + add) + if (($# < 5)); then + usage + return 1 + fi + cmd_add_batch "$@" + ;; + delete) + if (($# != 1)); then + usage + return 1 + fi + cmd_delete_uuid "$1" + ;; + list) + storage_list + ;; + save) + cmd_save_rules + ;; + env) + env_check_all + ;; + *) + usage + return 1 + ;; + esac +} + +main() { + bootstrap + + if (($# == 0)); then + main_menu_loop + return 0 + fi + + case ${1-} in + --help|-h) + usage + ;; + --batch) + shift + run_batch "$@" + ;; + *) + usage + return 1 + ;; + esac +} + +main "$@" diff --git a/lib/common.sh b/lib/common.sh new file mode 100644 index 0000000..ea80a9e --- /dev/null +++ b/lib/common.sh @@ -0,0 +1,302 @@ +#!/usr/bin/env bash + +if [[ -n ${IPF_COMMON_SH_LOADED:-} ]]; then + return 0 +fi +IPF_COMMON_SH_LOADED=1 + +: "${IPF_COLOR_ENABLED:=1}" +: "${IPF_FORCE_PLAIN_UI:=0}" +: "${IPF_APP_NAME:=IPTables 端口转发管理工具}" + +if [[ ! -t 1 ]]; then + IPF_COLOR_ENABLED=0 +fi + +if [[ ${IPF_COLOR_ENABLED} == 1 ]]; then + CLR_RED=$'\033[31m' + CLR_GREEN=$'\033[32m' + CLR_YELLOW=$'\033[33m' + CLR_BLUE=$'\033[34m' + CLR_CYAN=$'\033[36m' + CLR_BOLD=$'\033[1m' + CLR_DIM=$'\033[2m' + CLR_RESET=$'\033[0m' +else + CLR_RED='' + CLR_GREEN='' + CLR_YELLOW='' + CLR_BLUE='' + CLR_CYAN='' + CLR_BOLD='' + CLR_DIM='' + CLR_RESET='' +fi + +command_is_available() { + local cmd=${1:-} + if [[ -z ${cmd} ]]; then + return 1 + fi + if [[ ${cmd} == */* ]]; then + [[ -x ${cmd} ]] + return + fi + command -v "${cmd}" >/dev/null 2>&1 +} + +ts_now() { + date '+%H:%M:%S' +} + +_log_with_color() { + local color=$1 + local level=$2 + shift 2 + printf '%s[%s] [%s]%s %s\n' "${color}" "$(ts_now)" "${level}" "${CLR_RESET}" "$*" >&2 +} + +log_info() { _log_with_color "${CLR_BLUE}" INFO "$@"; } +log_warn() { _log_with_color "${CLR_YELLOW}" WARN "$@"; } +log_err() { _log_with_color "${CLR_RED}" ERROR "$@"; } +log_ok() { _log_with_color "${CLR_GREEN}" OK "$@"; } + +display_width() { + local input=${1-} + awk -v str="${input}" 'BEGIN { + n = split(str, chars, "") + width = 0 + for (i = 1; i <= n; i++) { + if (chars[i] ~ /[ -~]/) { + width += 1 + } else { + width += 2 + } + } + print width + }' +} + +repeat_char() { + local char=$1 + local count=$2 + local out='' + while (( count > 0 )); do + out+="${char}" + ((count--)) + done + printf '%s' "${out}" +} + +term_width() { + if [[ -n ${COLUMNS:-} ]]; then + printf '%s\n' "${COLUMNS}" + return + fi + if command_is_available tput; then + tput cols 2>/dev/null && return + fi + printf '80\n' +} + +use_box_ui() { + [[ ${IPF_FORCE_PLAIN_UI} != 1 ]] || return 1 + [[ $(term_width) -ge 60 ]] +} + +pad_right() { + local text=${1-} + local width=$2 + local actual padding + actual=$(display_width "${text}") + if (( actual >= width )); then + printf '%s' "${text}" + return + fi + padding=$((width - actual)) + printf '%s%*s' "${text}" "${padding}" '' +} + +box_top() { + local width=${1:-60} + printf '╔%s╗\n' "$(repeat_char '═' "$((width - 2))")" +} + +box_bottom() { + local width=${1:-60} + printf '╚%s╝\n' "$(repeat_char '═' "$((width - 2))")" +} + +box_separator() { + local width=${1:-60} + printf '╠%s╣\n' "$(repeat_char '═' "$((width - 2))")" +} + +box_line() { + local width=${1:-60} + local text=${2-} + local inner=$((width - 4)) + printf '║ %s ║\n' "$(pad_right "${text}" "${inner}")" +} + +print_section() { + local title=${1-} + if use_box_ui; then + local width + width=$(term_width) + if (( width > 78 )); then + width=78 + fi + box_top "${width}" + box_line "${width}" "${title}" + box_bottom "${width}" + else + printf '== %s ==\n' "${title}" + fi +} + +read_test_input() { + local reply='' + if [[ -n ${IPF_TEST_INPUTS-} ]]; then + if [[ ${IPF_TEST_INPUTS} == *$'\n'* ]]; then + reply=${IPF_TEST_INPUTS%%$'\n'*} + IPF_TEST_INPUTS=${IPF_TEST_INPUTS#*$'\n'} + else + reply=${IPF_TEST_INPUTS} + IPF_TEST_INPUTS='' + fi + fi + printf '%s' "${reply}" +} + +prompt_input() { + local prompt=$1 + local default_value=${2-} + local reply='' + + if [[ -n ${IPF_TEST_INPUTS-} ]]; then + reply=$(read_test_input) + if [[ -z ${reply} && -n ${default_value} ]]; then + reply=${default_value} + fi + printf '%s\n' "${reply}" >&2 + printf '%s\n' "${reply}" + return 0 + fi + + if [[ -n ${default_value} ]]; then + read -r -p "${prompt} [${default_value}]: " reply + reply=${reply:-${default_value}} + else + read -r -p "${prompt}: " reply + fi + printf '%s\n' "${reply}" +} + +prompt_confirm() { + local prompt=$1 + local default_choice=${2:-n} + local answer normalized suffix + + if [[ ${default_choice} == y ]]; then + suffix='[Y/n]' + else + suffix='[y/N]' + fi + + answer=$(prompt_input "${prompt} ${suffix}" "") + if [[ -z ${answer} ]]; then + answer=${default_choice} + fi + normalized=${answer,,} + [[ ${normalized} == y || ${normalized} == yes ]] +} + +prompt_select() { + local prompt=$1 + shift + local options=("$@") + local idx answer count + count=${#options[@]} + + for ((idx = 0; idx < count; idx++)); do + printf ' [%d] %s\n' "$((idx + 1))" "${options[idx]}" >&2 + done + + while true; do + answer=$(prompt_input "${prompt}" "") + if [[ ${answer} =~ ^[0-9]+$ ]] && (( answer >= 1 && answer <= count )); then + printf '%s\n' "$((answer - 1))" + return 0 + fi + log_warn "请输入 1-${count} 之间的编号。" + done +} + +pause_return() { + if [[ -n ${IPF_TEST_INPUTS-} ]]; then + return 0 + fi + read -r -p '按回车继续...' _ +} + +validate_ipv4() { + local ip=${1-} + local IFS='.' + local -a parts=() + local part + [[ ${ip} =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1 + read -r -a parts <<<"${ip}" + for part in "${parts[@]}"; do + [[ ${part} =~ ^[0-9]+$ ]] || return 1 + (( part >= 0 && part <= 255 )) || return 1 + done +} + +validate_ipv6() { + local ip=${1-} + local base scope + [[ -n ${ip} ]] || return 1 + [[ ${ip} != *.* ]] || return 1 + [[ ${ip} == *:* ]] || return 1 + [[ ${ip} != *:::* ]] || return 1 + + scope='' + base=${ip} + if [[ ${ip} == *%* ]]; then + scope=${ip#*%} + base=${ip%%\%*} + [[ -n ${scope} ]] || return 1 + [[ ${scope} =~ ^[A-Za-z0-9_.:-]+$ ]] || return 1 + fi + + [[ ${base} =~ ^[0-9A-Fa-f:]+$ ]] || return 1 + [[ ${base} =~ :: ]] || [[ ${base} =~ ^([0-9A-Fa-f]{1,4}:){1,7}[0-9A-Fa-f]{1,4}$ ]] || return 1 +} + +validate_port() { + local port=${1-} + [[ ${port} =~ ^[0-9]+$ ]] || return 1 + (( port >= 1 && port <= 65535 )) +} + +validate_proto() { + case ${1-} in + tcp|udp|both) return 0 ;; + *) return 1 ;; + esac +} + +validate_ipver() { + case ${1-} in + 4|6|both) return 0 ;; + *) return 1 ;; + esac +} + +require_root() { + if (( EUID != 0 )); then + log_err '请使用 root 或 sudo 运行此脚本。' + return 2 + fi +} diff --git a/lib/env_check.sh b/lib/env_check.sh new file mode 100644 index 0000000..683269a --- /dev/null +++ b/lib/env_check.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash + +if [[ -n ${IPF_ENV_CHECK_SH_LOADED:-} ]]; then + return 0 +fi +IPF_ENV_CHECK_SH_LOADED=1 + +: "${IPF_CHECK_IPTABLES_CMD:=iptables}" +: "${IPF_CHECK_IP6TABLES_CMD:=ip6tables}" +: "${IPF_CHECK_PERSIST_CMD:=netfilter-persistent}" +: "${DPKG_BIN:=dpkg}" +: "${APT_GET_BIN:=apt-get}" +: "${DEBCONF_SET_SELECTIONS_BIN:=debconf-set-selections}" +: "${SYSCTL_BIN:=sysctl}" +: "${SYSTEMCTL_BIN:=systemctl}" +: "${IPF_SYSCTL_FILE:=/etc/sysctl.d/99-iptables-forward.conf}" +: "${IPF_IPV4_FORWARD_FILE:=/proc/sys/net/ipv4/ip_forward}" +: "${IPF_IPV6_FORWARD_FILE:=/proc/sys/net/ipv6/conf/all/forwarding}" +: "${IPF_ASSUME_YES:=0}" + +ENV_CHECK_ISSUES=() +ENV_CHECK_WARNINGS=() +ENV_CHECK_NEED_PACKAGES=0 +ENV_CHECK_NEED_SYSCTL=0 +ENV_CHECK_NEED_STORAGE=0 + +_env_add_issue() { + ENV_CHECK_ISSUES+=("$1") +} + +_env_add_warning() { + ENV_CHECK_WARNINGS+=("$1") +} + +_env_file_value() { + local file=$1 + [[ -f ${file} ]] || return 1 + tr -d '[:space:]' <"${file}" +} + +_env_package_installed() { + if ! command_is_available "${DPKG_BIN}"; then + return 1 + fi + "${DPKG_BIN}" -s iptables-persistent >/dev/null 2>&1 +} + +env_check_collect_issues() { + ENV_CHECK_ISSUES=() + ENV_CHECK_WARNINGS=() + ENV_CHECK_NEED_PACKAGES=0 + ENV_CHECK_NEED_SYSCTL=0 + ENV_CHECK_NEED_STORAGE=0 + + if ! command_is_available "${IPF_CHECK_IPTABLES_CMD}"; then + _env_add_issue '缺少 iptables 命令。' + ENV_CHECK_NEED_PACKAGES=1 + fi + + if ! command_is_available "${IPF_CHECK_IP6TABLES_CMD}"; then + _env_add_issue '缺少 ip6tables 命令。' + ENV_CHECK_NEED_PACKAGES=1 + fi + + if ! _env_package_installed || ! command_is_available "${IPF_CHECK_PERSIST_CMD}"; then + _env_add_issue '缺少 iptables-persistent / netfilter-persistent。' + ENV_CHECK_NEED_PACKAGES=1 + fi + + if [[ $(_env_file_value "${IPF_IPV4_FORWARD_FILE}" 2>/dev/null || printf '0') != 1 ]]; then + _env_add_issue 'IPv4 转发未开启。' + ENV_CHECK_NEED_SYSCTL=1 + fi + + if [[ $(_env_file_value "${IPF_IPV6_FORWARD_FILE}" 2>/dev/null || printf '0') != 1 ]]; then + _env_add_issue 'IPv6 转发未开启。' + ENV_CHECK_NEED_SYSCTL=1 + fi + + if [[ ! -d ${IPF_STORAGE_DIR} || ! -w ${IPF_STORAGE_DIR} ]]; then + _env_add_issue "状态目录不可用: ${IPF_STORAGE_DIR}" + ENV_CHECK_NEED_STORAGE=1 + fi + + if command_is_available "${SYSTEMCTL_BIN}"; then + if "${SYSTEMCTL_BIN}" is-active --quiet ufw 2>/dev/null; then + _env_add_warning '检测到 ufw 正在运行,可能会影响转发规则。' + fi + if "${SYSTEMCTL_BIN}" is-active --quiet firewalld 2>/dev/null; then + _env_add_warning '检测到 firewalld 正在运行,可能会影响转发规则。' + fi + fi + + return 0 +} + +env_check_print_report() { + local issue + if ((${#ENV_CHECK_ISSUES[@]} == 0)); then + log_ok '环境检查通过。' + else + log_warn '发现以下待修复项:' + for issue in "${ENV_CHECK_ISSUES[@]}"; do + printf ' - %s\n' "${issue}" >&2 + done + fi + + if ((${#ENV_CHECK_WARNINGS[@]} > 0)); then + log_warn '附加警告:' + for issue in "${ENV_CHECK_WARNINGS[@]}"; do + printf ' - %s\n' "${issue}" >&2 + done + fi +} + +_env_install_packages() { + command_is_available "${APT_GET_BIN}" || { + log_err '缺少 apt-get,无法自动安装依赖。' + return 1 + } + command_is_available "${DEBCONF_SET_SELECTIONS_BIN}" || { + log_err '缺少 debconf-set-selections,无法预置安装选项。' + return 1 + } + + printf 'iptables-persistent iptables-persistent/autosave_v4 boolean true\n' | "${DEBCONF_SET_SELECTIONS_BIN}" + printf 'iptables-persistent iptables-persistent/autosave_v6 boolean true\n' | "${DEBCONF_SET_SELECTIONS_BIN}" + DEBIAN_FRONTEND=noninteractive "${APT_GET_BIN}" update -qq + DEBIAN_FRONTEND=noninteractive "${APT_GET_BIN}" install -y -qq iptables iptables-persistent +} + +_env_write_sysctl() { + local backup='' + if [[ -f ${IPF_SYSCTL_FILE} ]]; then + backup=$(cat "${IPF_SYSCTL_FILE}") + fi + + cat >"${IPF_SYSCTL_FILE}" <"${IPF_SYSCTL_FILE}" + else + rm -f "${IPF_SYSCTL_FILE}" + fi + return 1 + fi + + if ! "${SYSCTL_BIN}" --system >/dev/null; then + if [[ -n ${backup} ]]; then + printf '%s' "${backup}" >"${IPF_SYSCTL_FILE}" + else + rm -f "${IPF_SYSCTL_FILE}" + fi + return 1 + fi +} + +env_check_apply_fixes() { + if (( ENV_CHECK_NEED_PACKAGES == 1 )); then + log_info '正在安装缺失软件包...' + _env_install_packages || return 1 + fi + + if (( ENV_CHECK_NEED_STORAGE == 1 )); then + log_info '正在创建状态目录...' + mkdir -p "${IPF_STORAGE_DIR}" + chmod 750 "${IPF_STORAGE_DIR}" + fi + + if (( ENV_CHECK_NEED_SYSCTL == 1 )); then + log_info '正在写入 sysctl 配置并启用转发...' + _env_write_sysctl || return 1 + fi +} + +env_check_all() { + env_check_collect_issues + env_check_print_report + + if ((${#ENV_CHECK_ISSUES[@]} == 0)); then + return 0 + fi + + if [[ ${IPF_ASSUME_YES} == 1 ]]; then + log_info '已启用自动确认,开始修复。' + elif ! prompt_confirm '是否自动修复以上问题?' n; then + log_err '用户取消自动修复,请根据提示手动安装依赖并重试。' + return 3 + fi + + env_check_apply_fixes || return 1 + env_check_collect_issues + env_check_print_report + + if ((${#ENV_CHECK_ISSUES[@]} > 0)); then + log_err '自动修复后仍存在未解决的问题。' + return 1 + fi + + return 0 +} + +env_status_summary() { + env_check_collect_issues + if ((${#ENV_CHECK_ISSUES[@]} == 0)); then + printf '就绪\n' + else + printf '待修复(%d)\n' "${#ENV_CHECK_ISSUES[@]}" + fi +} diff --git a/lib/iptables_ops.sh b/lib/iptables_ops.sh new file mode 100644 index 0000000..732d43a --- /dev/null +++ b/lib/iptables_ops.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash + +if [[ -n ${IPF_IPTABLES_OPS_SH_LOADED:-} ]]; then + return 0 +fi +IPF_IPTABLES_OPS_SH_LOADED=1 + +: "${IPTABLES_BIN:=iptables}" +: "${IP6TABLES_BIN:=ip6tables}" +: "${IPTABLES_SAVE_BIN:=iptables-save}" +: "${IP6TABLES_SAVE_BIN:=ip6tables-save}" + +ipt_comment_tag() { + printf 'MGMT:%s\n' "$1" +} + +ipt_protocols_for() { + case ${1-} in + tcp|udp) printf '%s\n' "$1" ;; + both) printf 'tcp\nudp\n' ;; + *) return 1 ;; + esac +} + +ipt_families_for() { + case ${1-} in + 4|6) printf '%s\n' "$1" ;; + both) printf '4\n6\n' ;; + *) return 1 ;; + esac +} + +ipt_bin_for_family() { + case ${1-} in + 4) printf '%s\n' "${IPTABLES_BIN}" ;; + 6) printf '%s\n' "${IP6TABLES_BIN}" ;; + *) return 1 ;; + esac +} + +ipt_save_bin_for_family() { + case ${1-} in + 4) printf '%s\n' "${IPTABLES_SAVE_BIN}" ;; + 6) printf '%s\n' "${IP6TABLES_SAVE_BIN}" ;; + *) return 1 ;; + esac +} + +ipt_target_for_family() { + local raw=${1-} + local family=${2-} + if [[ ${raw} == *,* ]]; then + case ${family} in + 4) printf '%s\n' "${raw%%,*}" ;; + 6) printf '%s\n' "${raw#*,}" ;; + *) return 1 ;; + esac + return 0 + fi + printf '%s\n' "${raw}" +} + +ipt_to_destination() { + local tip_raw=${1-} + local tport=${2-} + local family=${3-} + local tip + tip=$(ipt_target_for_family "${tip_raw}" "${family}") + if [[ ${family} == 6 ]]; then + printf '[%s]:%s\n' "${tip}" "${tport}" + else + printf '%s:%s\n' "${tip}" "${tport}" + fi +} + +_ipt_exec_rule() { + local bin=$1 + local table=$2 + local action=$3 + local chain=$4 + shift 4 + if [[ -n ${table} ]]; then + "${bin}" -t "${table}" "-${action}" "${chain}" "$@" + else + "${bin}" "-${action}" "${chain}" "$@" + fi +} + +_ipt_check_rule() { + local bin=$1 + local table=$2 + local chain=$3 + shift 3 + if [[ -n ${table} ]]; then + "${bin}" -t "${table}" -C "${chain}" "$@" >/dev/null 2>&1 + else + "${bin}" -C "${chain}" "$@" >/dev/null 2>&1 + fi +} + +_ipt_apply_expected_rules() { + local action=$1 + local uuid=$2 + local proto=$3 + local lport=$4 + local tip_raw=$5 + local tport=$6 + local ipver=$7 + local family protocol bin tip comment destination + comment=$(ipt_comment_tag "${uuid}") + + while IFS= read -r family; do + [[ -n ${family} ]] || continue + bin=$(ipt_bin_for_family "${family}") + tip=$(ipt_target_for_family "${tip_raw}" "${family}") + destination=$(ipt_to_destination "${tip_raw}" "${tport}" "${family}") + + while IFS= read -r protocol; do + [[ -n ${protocol} ]] || continue + if ! _ipt_exec_rule "${bin}" nat "${action}" PREROUTING \ + -p "${protocol}" --dport "${lport}" \ + -j DNAT --to-destination "${destination}" \ + -m comment --comment "${comment}"; then + return 1 + fi + + if ! _ipt_exec_rule "${bin}" nat "${action}" POSTROUTING \ + -p "${protocol}" -d "${tip}" --dport "${tport}" \ + -j MASQUERADE \ + -m comment --comment "${comment}"; then + return 1 + fi + + if ! _ipt_exec_rule "${bin}" '' "${action}" FORWARD \ + -p "${protocol}" -d "${tip}" --dport "${tport}" \ + -m conntrack --ctstate NEW,ESTABLISHED,RELATED \ + -j ACCEPT \ + -m comment --comment "${comment}"; then + return 1 + fi + done < <(ipt_protocols_for "${proto}") + done < <(ipt_families_for "${ipver}") +} + +ipt_apply_rule() { + local uuid=$1 proto=$2 lport=$3 tip_raw=$4 tport=$5 ipver=$6 + if ! _ipt_apply_expected_rules A "${uuid}" "${proto}" "${lport}" "${tip_raw}" "${tport}" "${ipver}"; then + ipt_remove_rule "${uuid}" "${proto}" "${lport}" "${tip_raw}" "${tport}" "${ipver}" >/dev/null 2>&1 || true + return 1 + fi +} + +ipt_remove_rule() { + local uuid=$1 proto=$2 lport=$3 tip_raw=$4 tport=$5 ipver=$6 + local -a families protocols + local family protocol bin tip comment destination + local missing=0 + comment=$(ipt_comment_tag "${uuid}") + mapfile -t families < <(ipt_families_for "${ipver}") + mapfile -t protocols < <(ipt_protocols_for "${proto}") + + for ((family_index=${#families[@]} - 1; family_index >= 0; family_index--)); do + family=${families[family_index]} + [[ -n ${family} ]] || continue + bin=$(ipt_bin_for_family "${family}") + tip=$(ipt_target_for_family "${tip_raw}" "${family}") + destination=$(ipt_to_destination "${tip_raw}" "${tport}" "${family}") + + for ((proto_index=${#protocols[@]} - 1; proto_index >= 0; proto_index--)); do + protocol=${protocols[proto_index]} + [[ -n ${protocol} ]] || continue + + if _ipt_check_rule "${bin}" '' FORWARD \ + -p "${protocol}" -d "${tip}" --dport "${tport}" \ + -m conntrack --ctstate NEW,ESTABLISHED,RELATED \ + -j ACCEPT \ + -m comment --comment "${comment}"; then + _ipt_exec_rule "${bin}" '' D FORWARD \ + -p "${protocol}" -d "${tip}" --dport "${tport}" \ + -m conntrack --ctstate NEW,ESTABLISHED,RELATED \ + -j ACCEPT \ + -m comment --comment "${comment}" || return 1 + else + missing=1 + fi + + if _ipt_check_rule "${bin}" nat POSTROUTING \ + -p "${protocol}" -d "${tip}" --dport "${tport}" \ + -j MASQUERADE \ + -m comment --comment "${comment}"; then + _ipt_exec_rule "${bin}" nat D POSTROUTING \ + -p "${protocol}" -d "${tip}" --dport "${tport}" \ + -j MASQUERADE \ + -m comment --comment "${comment}" || return 1 + else + missing=1 + fi + + if _ipt_check_rule "${bin}" nat PREROUTING \ + -p "${protocol}" --dport "${lport}" \ + -j DNAT --to-destination "${destination}" \ + -m comment --comment "${comment}"; then + _ipt_exec_rule "${bin}" nat D PREROUTING \ + -p "${protocol}" --dport "${lport}" \ + -j DNAT --to-destination "${destination}" \ + -m comment --comment "${comment}" || return 1 + else + missing=1 + fi + done + done + + if (( missing == 1 )); then + return 0 + fi +} + +ipt_find_by_uuid() { + local uuid=${1-} + local family save_bin found=1 line + [[ -n ${uuid} ]] || return 1 + + for family in 4 6; do + save_bin=$(ipt_save_bin_for_family "${family}") + if ! command_is_available "${save_bin}"; then + continue + fi + while IFS= read -r line || [[ -n ${line} ]]; do + [[ ${line} == *"MGMT:${uuid}"* ]] || continue + printf '%s|%s\n' "${family}" "${line}" + found=0 + done < <("${save_bin}") + done + + return "${found}" +} diff --git a/lib/persist.sh b/lib/persist.sh new file mode 100644 index 0000000..a18ceaf --- /dev/null +++ b/lib/persist.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +if [[ -n ${IPF_PERSIST_SH_LOADED:-} ]]; then + return 0 +fi +IPF_PERSIST_SH_LOADED=1 + +: "${NETFILTER_PERSISTENT_BIN:=netfilter-persistent}" +: "${IPF_SKIP_PERSIST:=0}" + +persist_available() { + [[ ${IPF_SKIP_PERSIST} == 1 ]] && return 0 + command_is_available "${NETFILTER_PERSISTENT_BIN}" +} + +persist_save() { + [[ ${IPF_SKIP_PERSIST} == 1 ]] && return 0 + persist_available || { + log_err '未找到 netfilter-persistent,无法保存规则。' + return 1 + } + "${NETFILTER_PERSISTENT_BIN}" save >/dev/null +} + +persist_reload() { + [[ ${IPF_SKIP_PERSIST} == 1 ]] && return 0 + persist_available || { + log_err '未找到 netfilter-persistent,无法重载规则。' + return 1 + } + "${NETFILTER_PERSISTENT_BIN}" reload >/dev/null +} diff --git a/lib/rules_mgr.sh b/lib/rules_mgr.sh new file mode 100644 index 0000000..bd8e09a --- /dev/null +++ b/lib/rules_mgr.sh @@ -0,0 +1,358 @@ +#!/usr/bin/env bash + +if [[ -n ${IPF_RULES_MGR_SH_LOADED:-} ]]; then + return 0 +fi +IPF_RULES_MGR_SH_LOADED=1 + +rules_new_uuid() { + local raw + if [[ -r /proc/sys/kernel/random/uuid ]]; then + raw=$(tr -d '-' /dev/null 2>&1; then + printf '✓\n' + else + printf '!\n' + fi +} + +render_rules_plain() { + local -a lines=("$@") + local line idx uuid proto lport tip tport ipver desc health + if ((${#lines[@]} == 0)); then + printf '当前没有规则。\n' + return 0 + fi + + printf '%-3s %-10s %-10s %-32s %-10s %-12s %-4s %s\n' '#' '协议' '本地端口' '目标地址' '目标端口' 'IP版本' '状态' '描述' + for ((idx = 0; idx < ${#lines[@]}; idx++)); do + line=${lines[idx]} + uuid=$(rule_field "${line}" uuid) + proto=$(rule_proto_label "$(rule_field "${line}" proto)") + lport=$(rule_field "${line}" lport) + tip=$(rule_target_label "$(rule_field "${line}" tip)") + tport=$(rule_field "${line}" tport) + ipver=$(rule_ipver_label "$(rule_field "${line}" ipver)") + desc=$(rule_field "${line}" desc || true) + health=$(rule_health_mark "${uuid}") + printf '%-3s %-10s %-10s %-32s %-10s %-12s %-4s %s\n' \ + "$((idx + 1))" "${proto}" "${lport}" "${tip}" "${tport}" "${ipver}" "${health}" "${desc}" + done +} + +cmd_list() { + local pause=${1:-1} + rules_load_lines + render_rules_plain "${RULES_CACHE[@]}" + printf '[✓] 已在 iptables 中找到规则;[!] 仅存在于数据库中。\n' + if [[ ${pause} == 1 ]]; then + pause_return + fi +} + +cmd_add_batch() { + local proto=${1-} + local lport=${2-} + local tip=${3-} + local tport=${4-} + local ipver=${5-} + local desc=${6-} + local uuid line + + rule_validate_definition "${proto}" "${lport}" "${tip}" "${tport}" "${ipver}" || return 1 + + uuid=$(rules_new_uuid) + line=$(rule_build_line "${uuid}" "${proto}" "${lport}" "${tip}" "${tport}" "${ipver}" "${desc}") + + if ! ipt_apply_rule "${uuid}" "${proto}" "${lport}" "${tip}" "${tport}" "${ipver}"; then + log_err 'iptables 规则写入失败。' + return 1 + fi + + if ! storage_add "${line}"; then + ipt_remove_rule "${uuid}" "${proto}" "${lport}" "${tip}" "${tport}" "${ipver}" >/dev/null 2>&1 || true + log_err '写入规则数据库失败。' + return 1 + fi + + if ! persist_save; then + storage_delete "${uuid}" >/dev/null 2>&1 || true + ipt_remove_rule "${uuid}" "${proto}" "${lport}" "${tip}" "${tport}" "${ipver}" >/dev/null 2>&1 || true + log_err '保存持久化规则失败,已回滚。' + return 1 + fi + + log_ok "规则已添加,UUID=${uuid}。" + printf '%s\n' "${uuid}" +} + +cmd_add() { + local proto_choice ipver_choice proto ipver lport tip tport desc + + print_section '添加新的转发规则' + proto_choice=$(prompt_select '请选择协议' 'TCP' 'UDP' 'TCP + UDP') + case ${proto_choice} in + 0) proto=tcp ;; + 1) proto=udp ;; + 2) proto=both ;; + *) return 1 ;; + esac + + while true; do + lport=$(prompt_input '请输入本地监听端口' '') + validate_port "${lport}" && break + log_warn '端口无效,请重新输入。' + done + + ipver_choice=$(prompt_select '请选择 IP 版本' '仅 IPv4' '仅 IPv6' '同时 IPv4 + IPv6') + case ${ipver_choice} in + 0) ipver=4 ;; + 1) ipver=6 ;; + 2) ipver=both ;; + *) return 1 ;; + esac + + while true; do + tip=$(prompt_input "$(rule_target_hint "${ipver}")" '') + rule_validate_target "${tip}" "${ipver}" && break + log_warn '目标地址无效,请重新输入。' + done + + while true; do + tport=$(prompt_input '请输入目标端口' '') + validate_port "${tport}" && break + log_warn '目标端口无效,请重新输入。' + done + + desc=$(prompt_input '请输入描述(可留空)' '') + desc=$(rule_sanitize_desc "${desc}") + + printf '协议: %s\n' "$(rule_proto_label "${proto}")" + printf '监听端口: %s\n' "${lport}" + printf '目标地址: %s\n' "$(rule_target_label "${tip}")" + printf '目标端口: %s\n' "${tport}" + printf 'IP 版本: %s\n' "$(rule_ipver_label "${ipver}")" + printf '描述: %s\n' "${desc:-(空)}" + + if ! prompt_confirm '确认添加该规则?' y; then + log_warn '已取消添加。' + return 1 + fi + + cmd_add_batch "${proto}" "${lport}" "${tip}" "${tport}" "${ipver}" "${desc}" >/dev/null +} + +cmd_delete_uuid() { + local uuid=${1-} + local line proto lport tip tport ipver + line=$(storage_get "${uuid}") || { + log_err '未找到指定 UUID 的规则。' + return 1 + } + + proto=$(rule_field "${line}" proto) + lport=$(rule_field "${line}" lport) + tip=$(rule_field "${line}" tip) + tport=$(rule_field "${line}" tport) + ipver=$(rule_field "${line}" ipver) + + if ! ipt_remove_rule "${uuid}" "${proto}" "${lport}" "${tip}" "${tport}" "${ipver}"; then + log_err '删除 iptables 规则失败。' + return 1 + fi + + if ! storage_delete "${uuid}"; then + log_err '删除规则数据库记录失败。' + return 1 + fi + + if ! persist_save; then + storage_add "${line}" >/dev/null 2>&1 || true + ipt_apply_rule "${uuid}" "${proto}" "${lport}" "${tip}" "${tport}" "${ipver}" >/dev/null 2>&1 || true + log_err '持久化保存失败,已尝试回滚。' + return 1 + fi + + log_ok "规则 ${uuid} 已删除。" +} + +cmd_delete() { + local index answer line uuid + rules_load_lines + if ((${#RULES_CACHE[@]} == 0)); then + log_info '当前没有可删除的规则。' + pause_return + return 0 + fi + + cmd_list 0 + + while true; do + answer=$(prompt_input '请输入要删除的规则编号' '') + if [[ ${answer} =~ ^[0-9]+$ ]] && (( answer >= 1 && answer <= ${#RULES_CACHE[@]} )); then + index=$((answer - 1)) + break + fi + log_warn '编号无效,请重新输入。' + done + + line=${RULES_CACHE[index]} + uuid=$(rule_field "${line}" uuid) + printf '即将删除: %s -> %s:%s (%s / %s)\n' \ + "$(rule_field "${line}" lport)" \ + "$(rule_target_label "$(rule_field "${line}" tip)")" \ + "$(rule_field "${line}" tport)" \ + "$(rule_proto_label "$(rule_field "${line}" proto)")" \ + "$(rule_ipver_label "$(rule_field "${line}" ipver)")" + + if ! prompt_confirm '确认删除该规则?' n; then + log_warn '已取消删除。' + return 1 + fi + + cmd_delete_uuid "${uuid}" +} + +cmd_show_env_status() { + print_section '环境状态' + env_check_collect_issues + env_check_print_report + printf '规则总数: %s\n' "$(storage_count)" + if persist_available; then + printf '持久化: 可用\n' + else + printf '持久化: 不可用\n' + fi + pause_return +} + +cmd_save_rules() { + if persist_save; then + log_ok '规则已保存到磁盘。' + else + log_err '规则保存失败。' + return 1 + fi +} diff --git a/lib/storage.sh b/lib/storage.sh new file mode 100644 index 0000000..7007491 --- /dev/null +++ b/lib/storage.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash + +if [[ -n ${IPF_STORAGE_SH_LOADED:-} ]]; then + return 0 +fi +IPF_STORAGE_SH_LOADED=1 + +: "${IPF_STORAGE_DIR:=/var/lib/iptables-forward}" +: "${IPF_STORAGE_DB:=${IPF_STORAGE_DIR}/rules.db}" +: "${IPF_LOCK_FILE:=${IPF_STORAGE_DIR}/.lock}" + +storage_dir() { + printf '%s\n' "${IPF_STORAGE_DIR}" +} + +storage_db_path() { + printf '%s\n' "${IPF_STORAGE_DB}" +} + +storage_lock_path() { + printf '%s\n' "${IPF_LOCK_FILE}" +} + +storage_init() { + local dir db lock + dir=$(storage_dir) + db=$(storage_db_path) + lock=$(storage_lock_path) + + mkdir -p "${dir}" + touch "${db}" "${lock}" + chmod 750 "${dir}" + chmod 640 "${db}" + chmod 600 "${lock}" +} + +storage_add() { + local line=${1-} + local db lock + [[ -n ${line} ]] || return 1 + storage_init + db=$(storage_db_path) + lock=$(storage_lock_path) + + ( + flock -x 9 + printf '%s\n' "${line}" >>"${db}" + ) 9>>"${lock}" +} + +storage_list() { + local db + db=$(storage_db_path) + [[ -f ${db} ]] || return 0 + cat "${db}" +} + +storage_parse() { + local line=${1-} + local key=${2-} + local part + IFS='|' read -r -a parts <<<"${line}" + for part in "${parts[@]}"; do + if [[ ${part} == "${key}="* ]]; then + printf '%s\n' "${part#*=}" + return 0 + fi + done + return 1 +} + +storage_get() { + local uuid=${1-} + local line current + [[ -n ${uuid} ]] || return 1 + while IFS= read -r line || [[ -n ${line} ]]; do + current=$(storage_parse "${line}" uuid || true) + if [[ ${current} == "${uuid}" ]]; then + printf '%s\n' "${line}" + return 0 + fi + done < <(storage_list) + return 1 +} + +storage_delete() { + local uuid=${1-} + local db lock tmp found=0 line current + [[ -n ${uuid} ]] || return 1 + storage_init + db=$(storage_db_path) + lock=$(storage_lock_path) + tmp="${db}.tmp.$$" + + ( + flock -x 9 + : >"${tmp}" + while IFS= read -r line || [[ -n ${line} ]]; do + current=$(storage_parse "${line}" uuid || true) + if [[ ${current} == "${uuid}" ]]; then + found=1 + continue + fi + printf '%s\n' "${line}" >>"${tmp}" + done <"${db}" + + if (( found == 0 )); then + rm -f "${tmp}" + exit 1 + fi + + mv "${tmp}" "${db}" + ) 9>>"${lock}" +} + +storage_count() { + local count=0 line + while IFS= read -r line || [[ -n ${line} ]]; do + [[ -n ${line} ]] || continue + ((count++)) + done < <(storage_list) + printf '%s\n' "${count}" +} diff --git a/tests/lib/assert.sh b/tests/lib/assert.sh new file mode 100644 index 0000000..fb90c80 --- /dev/null +++ b/tests/lib/assert.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +fail() { + printf 'ASSERT FAIL: %s\n' "$*" >&2 + exit 1 +} + +pass() { + printf 'ASSERT PASS: %s\n' "$*" +} + +assert_eq() { + local expected=$1 + local actual=$2 + local message=${3:-values differ} + [[ ${expected} == "${actual}" ]] || fail "${message} (expected='${expected}' actual='${actual}')" +} + +assert_ne() { + local left=$1 + local right=$2 + local message=${3:-values should differ} + [[ ${left} != "${right}" ]] || fail "${message} (value='${left}')" +} + +assert_contains() { + local haystack=$1 + local needle=$2 + local message=${3:-substring not found} + [[ ${haystack} == *"${needle}"* ]] || fail "${message} (needle='${needle}')" +} + +assert_not_contains() { + local haystack=$1 + local needle=$2 + local message=${3:-unexpected substring found} + [[ ${haystack} != *"${needle}"* ]] || fail "${message} (needle='${needle}')" +} + +assert_file_contains() { + local file=$1 + local needle=$2 + local message=${3:-file does not contain substring} + [[ -f ${file} ]] || fail "file not found: ${file}" + grep -Fq -- "${needle}" "${file}" || fail "${message} (file='${file}' needle='${needle}')" +} + +assert_status() { + local expected=$1 + local actual=$2 + local message=${3:-unexpected exit status} + [[ ${expected} == "${actual}" ]] || fail "${message} (expected=${expected} actual=${actual})" +} diff --git a/tests/mocks/ip6tables b/tests/mocks/ip6tables new file mode 120000 index 0000000..5e143f6 --- /dev/null +++ b/tests/mocks/ip6tables @@ -0,0 +1 @@ +iptables-mock.sh \ No newline at end of file diff --git a/tests/mocks/ip6tables-save b/tests/mocks/ip6tables-save new file mode 120000 index 0000000..5e143f6 --- /dev/null +++ b/tests/mocks/ip6tables-save @@ -0,0 +1 @@ +iptables-mock.sh \ No newline at end of file diff --git a/tests/mocks/iptables b/tests/mocks/iptables new file mode 120000 index 0000000..5e143f6 --- /dev/null +++ b/tests/mocks/iptables @@ -0,0 +1 @@ +iptables-mock.sh \ No newline at end of file diff --git a/tests/mocks/iptables-mock.sh b/tests/mocks/iptables-mock.sh new file mode 100644 index 0000000..16ebdf1 --- /dev/null +++ b/tests/mocks/iptables-mock.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${IPTABLES_MOCK_DIR:=/tmp/iptables-mock}" +: "${IPTABLES_MOCK_LOG:=${IPTABLES_MOCK_DIR}/calls.log}" + +mkdir -p "${IPTABLES_MOCK_DIR}" +touch "${IPTABLES_MOCK_LOG}" + +base=$(basename -- "$0") +case ${base} in + iptables|iptables-save) family=4 ;; + ip6tables|ip6tables-save) family=6 ;; + *) family=${IPTABLES_MOCK_FAMILY:-4} ;; +esac + +state_file="${IPTABLES_MOCK_DIR}/state.v${family}" +counter_file="${IPTABLES_MOCK_DIR}/add-counter" +touch "${state_file}" "${counter_file}" + +serialize_args() { + local out='' + local arg + for arg in "$@"; do + out+="${arg}"$'\t' + done + printf '%s' "${out}" +} + +line_key() { + local table=$1 + local chain=$2 + shift 2 + printf '%s|%s|%s' "${table}" "${chain}" "$(serialize_args "$@")" +} + +log_call() { + printf '%s %s\n' "${base}" "$*" >>"${IPTABLES_MOCK_LOG}" +} + +load_rule_exists() { + local key=$1 + grep -Fqx -- "${key}" "${state_file}" +} + +save_emit() { + local table chain serialized + while IFS='|' read -r table chain serialized || [[ -n ${table:-} ]]; do + [[ -n ${table:-} ]] || continue + IFS=$'\t' read -r -a args <<<"${serialized}" + printf -- '-A %s' "${chain}" + local arg + for arg in "${args[@]}"; do + [[ -n ${arg} ]] || continue + printf ' %s' "${arg}" + done + printf '\n' + done <"${state_file}" +} + +increment_add_counter() { + local count=0 + count=$(cat "${counter_file}") + count=$((count + 1)) + printf '%s\n' "${count}" >"${counter_file}" + printf '%s\n' "${count}" +} + +if [[ ${base} == *save ]]; then + save_emit + exit 0 +fi + +log_call "$@" + +table='filter' +if (($# >= 2)) && [[ $1 == -t ]]; then + table=$2 + shift 2 +fi + +operation=${1-} +chain=${2-} +shift 2 || true +key=$(line_key "${table}" "${chain}" "$@") + +case ${operation} in + -A) + current=$(increment_add_counter) + if [[ -n ${IPTABLES_MOCK_FAIL_ON_N:-} && ${current} == "${IPTABLES_MOCK_FAIL_ON_N}" ]]; then + exit 1 + fi + printf '%s\n' "${key}" >>"${state_file}" + ;; + -D) + if ! load_rule_exists "${key}"; then + exit 1 + fi + grep -Fvx -- "${key}" "${state_file}" >"${state_file}.tmp" || true + mv "${state_file}.tmp" "${state_file}" + ;; + -C) + load_rule_exists "${key}" + ;; + *) + printf 'unsupported operation: %s\n' "${operation}" >&2 + exit 2 + ;; +esac diff --git a/tests/mocks/iptables-save b/tests/mocks/iptables-save new file mode 120000 index 0000000..5e143f6 --- /dev/null +++ b/tests/mocks/iptables-save @@ -0,0 +1 @@ +iptables-mock.sh \ No newline at end of file diff --git a/tests/mocks/persist-mock.sh b/tests/mocks/persist-mock.sh new file mode 100644 index 0000000..f8b792b --- /dev/null +++ b/tests/mocks/persist-mock.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${PERSIST_MOCK_LOG:=/tmp/persist-mock.log}" +: "${PERSIST_MOCK_FAIL:=0}" + +printf '%s %s\n' "$(basename -- "$0")" "$*" >>"${PERSIST_MOCK_LOG}" + +if [[ ${PERSIST_MOCK_FAIL} == 1 ]]; then + exit 1 +fi diff --git a/tests/run_all.sh b/tests/run_all.sh new file mode 100644 index 0000000..f4402f2 --- /dev/null +++ b/tests/run_all.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +SKIP_INTEGRATION=0 + +for arg in "$@"; do + case ${arg} in + --skip-integration) + SKIP_INTEGRATION=1 + ;; + *) + printf '未知参数: %s\n' "${arg}" >&2 + exit 1 + ;; + esac +done + +run_test() { + local file=$1 + printf '\n==> 运行 %s\n' "$(basename -- "${file}")" + bash "${file}" +} + +run_test "${ROOT_DIR}/tests/test_common.sh" +run_test "${ROOT_DIR}/tests/test_storage.sh" +run_test "${ROOT_DIR}/tests/test_env_check.sh" +run_test "${ROOT_DIR}/tests/test_rules_unit.sh" + +if [[ ${SKIP_INTEGRATION} == 0 ]]; then + run_test "${ROOT_DIR}/tests/test_integration.sh" +fi + +printf '\n全部测试完成。\n' diff --git a/tests/test_common.sh b/tests/test_common.sh new file mode 100644 index 0000000..452cf3a --- /dev/null +++ b/tests/test_common.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +# shellcheck source=tests/lib/assert.sh +source "${ROOT_DIR}/tests/lib/assert.sh" +# shellcheck source=lib/common.sh +source "${ROOT_DIR}/lib/common.sh" + +status_of() { + set +e + "$@" >/dev/null 2>&1 + local rc=$? + set -e + printf '%s\n' "${rc}" +} + +assert_status 0 "$(status_of validate_ipv4 '0.0.0.0')" 'validate_ipv4 should accept 0.0.0.0' +assert_status 0 "$(status_of validate_ipv4 '192.168.1.1')" 'validate_ipv4 should accept private address' +assert_status 0 "$(status_of validate_ipv4 '255.255.255.255')" 'validate_ipv4 should accept broadcast' +assert_status 1 "$(status_of validate_ipv4 '256.1.1.1')" 'validate_ipv4 should reject overflow octet' +assert_status 1 "$(status_of validate_ipv4 '1.2.3')" 'validate_ipv4 should reject short address' +assert_status 1 "$(status_of validate_ipv4 'abc')" 'validate_ipv4 should reject text' +assert_status 1 "$(status_of validate_ipv4 '')" 'validate_ipv4 should reject empty string' + +assert_status 0 "$(status_of validate_ipv6 '::1')" 'validate_ipv6 should accept loopback' +assert_status 0 "$(status_of validate_ipv6 '2001:db8::1')" 'validate_ipv6 should accept compressed address' +assert_status 0 "$(status_of validate_ipv6 'fe80::1%eth0')" 'validate_ipv6 should accept scoped address' +assert_status 1 "$(status_of validate_ipv6 ':::1')" 'validate_ipv6 should reject malformed address' +assert_status 1 "$(status_of validate_ipv6 '1.2.3.4')" 'validate_ipv6 should reject ipv4 literal' +assert_status 1 "$(status_of validate_ipv6 '')" 'validate_ipv6 should reject empty string' + +assert_status 0 "$(status_of validate_port '1')" 'validate_port should accept lower bound' +assert_status 0 "$(status_of validate_port '65535')" 'validate_port should accept upper bound' +assert_status 1 "$(status_of validate_port '0')" 'validate_port should reject zero' +assert_status 1 "$(status_of validate_port '65536')" 'validate_port should reject overflow' +assert_status 1 "$(status_of validate_port '-1')" 'validate_port should reject negative' +assert_status 1 "$(status_of validate_port 'abc')" 'validate_port should reject text' + +assert_status 0 "$(status_of validate_proto 'tcp')" 'validate_proto should accept tcp' +assert_status 0 "$(status_of validate_proto 'udp')" 'validate_proto should accept udp' +assert_status 0 "$(status_of validate_proto 'both')" 'validate_proto should accept both' +assert_status 1 "$(status_of validate_proto 'icmp')" 'validate_proto should reject unsupported protocol' + +assert_status 0 "$(status_of validate_ipver '4')" 'validate_ipver should accept 4' +assert_status 0 "$(status_of validate_ipver '6')" 'validate_ipver should accept 6' +assert_status 0 "$(status_of validate_ipver 'both')" 'validate_ipver should accept both' +assert_status 1 "$(status_of validate_ipver '5')" 'validate_ipver should reject unsupported family' + +pass 'test_common.sh' diff --git a/tests/test_env_check.sh b/tests/test_env_check.sh new file mode 100644 index 0000000..4e5c2fd --- /dev/null +++ b/tests/test_env_check.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +# shellcheck source=tests/lib/assert.sh +source "${ROOT_DIR}/tests/lib/assert.sh" + +status_of() { + set +e + "$@" >/dev/null 2>&1 + local rc=$? + set -e + printf '%s\n' "${rc}" +} + +TMP_DIR=$(mktemp -d) +trap 'rm -rf "${TMP_DIR}"' EXIT +BIN_DIR="${TMP_DIR}/bin" +mkdir -p "${BIN_DIR}" + +IPTABLES_PATH="${BIN_DIR}/iptables" +IP6TABLES_PATH="${BIN_DIR}/ip6tables" +PERSIST_PATH="${BIN_DIR}/netfilter-persistent" +DPKG_PATH="${BIN_DIR}/dpkg" +SYSCTL_PATH="${BIN_DIR}/sysctl" +SYSTEMCTL_PATH="${BIN_DIR}/systemctl" +DEBCONF_PATH="${BIN_DIR}/debconf-set-selections" +APT_PATH="${BIN_DIR}/apt-get" + +cat >"${DPKG_PATH}" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +exit 1 +MOCK + +cat >"${SYSCTL_PATH}" <>"${TMP_DIR}/sysctl.log" +printf '1\n' >"${TMP_DIR}/ipv4_forward" +printf '1\n' >"${TMP_DIR}/ipv6_forward" +MOCK + +cat >"${SYSTEMCTL_PATH}" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +exit 1 +MOCK + +cat >"${DEBCONF_PATH}" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +cat >/dev/null +MOCK + +cat >"${APT_PATH}" <>"${TMP_DIR}/apt.log" +MOCK + +chmod +x "${DPKG_PATH}" "${SYSCTL_PATH}" "${SYSTEMCTL_PATH}" "${DEBCONF_PATH}" "${APT_PATH}" + +export IPF_STORAGE_DIR="${TMP_DIR}/storage" +export IPF_SYSCTL_FILE="${TMP_DIR}/99-iptables-forward.conf" +export IPF_IPV4_FORWARD_FILE="${TMP_DIR}/ipv4_forward" +export IPF_IPV6_FORWARD_FILE="${TMP_DIR}/ipv6_forward" +export IPF_CHECK_IPTABLES_CMD="${BIN_DIR}/missing-iptables" +export IPF_CHECK_IP6TABLES_CMD="${BIN_DIR}/missing-ip6tables" +export IPF_CHECK_PERSIST_CMD="${BIN_DIR}/missing-persist" +export DPKG_BIN="${DPKG_PATH}" +export SYSCTL_BIN="${SYSCTL_PATH}" +export SYSTEMCTL_BIN="${SYSTEMCTL_PATH}" +export DEBCONF_SET_SELECTIONS_BIN="${DEBCONF_PATH}" +export APT_GET_BIN="${APT_PATH}" + +echo 0 >"${IPF_IPV4_FORWARD_FILE}" +echo 0 >"${IPF_IPV6_FORWARD_FILE}" + +# shellcheck source=lib/common.sh +source "${ROOT_DIR}/lib/common.sh" +# shellcheck source=lib/env_check.sh +source "${ROOT_DIR}/lib/env_check.sh" + +env_check_collect_issues +assert_eq '6' "${#ENV_CHECK_ISSUES[@]}" 'env_check_collect_issues should capture missing binaries, persistence, forwarding and storage' + +cat >"${IPTABLES_PATH}" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +exit 0 +MOCK +cat >"${IP6TABLES_PATH}" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +exit 0 +MOCK +cat >"${PERSIST_PATH}" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +exit 0 +MOCK +cat >"${DPKG_PATH}" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +exit 0 +MOCK +chmod +x "${IPTABLES_PATH}" "${IP6TABLES_PATH}" "${PERSIST_PATH}" "${DPKG_PATH}" + +mkdir -p "${IPF_STORAGE_DIR}" +export IPF_CHECK_IPTABLES_CMD="${IPTABLES_PATH}" +export IPF_CHECK_IP6TABLES_CMD="${IP6TABLES_PATH}" +export IPF_CHECK_PERSIST_CMD="${PERSIST_PATH}" +export IPF_ASSUME_YES=1 + +env_check_all +assert_file_contains "${IPF_SYSCTL_FILE}" 'net.ipv4.ip_forward=1' 'env_check_all should write IPv4 forwarding setting' +assert_file_contains "${IPF_SYSCTL_FILE}" 'net.ipv6.conf.all.forwarding=1' 'env_check_all should write IPv6 forwarding setting' +assert_file_contains "${TMP_DIR}/sysctl.log" '--system' 'env_check_all should apply sysctl settings' + +export IPF_CHECK_IPTABLES_CMD="${BIN_DIR}/missing-iptables-again" +export IPF_CHECK_IP6TABLES_CMD="${BIN_DIR}/missing-ip6tables-again" +export IPF_CHECK_PERSIST_CMD="${BIN_DIR}/missing-persist-again" +export IPF_ASSUME_YES=0 +export IPF_TEST_INPUTS=$'n\n' + +assert_eq '3' "$(status_of env_check_all)" 'env_check_all should return 3 when user rejects autofix' + +pass 'test_env_check.sh' diff --git a/tests/test_integration.sh b/tests/test_integration.sh new file mode 100644 index 0000000..7d50656 --- /dev/null +++ b/tests/test_integration.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +# shellcheck source=tests/lib/assert.sh +source "${ROOT_DIR}/tests/lib/assert.sh" + +status_of() { + set +e + "$@" >/dev/null 2>&1 + local rc=$? + set -e + printf '%s\n' "${rc}" +} + +maybe_enter_namespace() { + if (( EUID == 0 )); then + return 0 + fi + + if [[ ${IPF_IN_NAMESPACE:-0} == 1 ]]; then + return 0 + fi + + if command -v unshare >/dev/null 2>&1 && unshare -Urn true >/dev/null 2>&1; then + exec unshare -Urn env IPF_IN_NAMESPACE=1 bash "$0" + fi + + printf 'SKIP: root 或 unshare 不可用,跳过集成测试。\n' + exit 0 +} + +maybe_enter_namespace + +if command -v ip >/dev/null 2>&1; then + ip link set lo up >/dev/null 2>&1 || true +fi + +if ! command -v iptables >/dev/null 2>&1; then + printf 'SKIP: iptables 不可用,跳过集成测试。\n' + exit 0 +fi + +TMP_DIR=$(mktemp -d) +BACKUP_V4="${TMP_DIR}/iptables.v4.bak" +BACKUP_V6="${TMP_DIR}/iptables.v6.bak" + +cleanup() { + if [[ -f ${BACKUP_V4} ]]; then + iptables-restore <"${BACKUP_V4}" >/dev/null 2>&1 || true + fi + if [[ -f ${BACKUP_V6} ]]; then + ip6tables-restore <"${BACKUP_V6}" >/dev/null 2>&1 || true + fi + rm -rf "${TMP_DIR}" +} +trap cleanup EXIT + +iptables-save >"${BACKUP_V4}" +if command -v ip6tables-save >/dev/null 2>&1; then + ip6tables-save >"${BACKUP_V6}" +fi + +export IPF_STORAGE_DIR="${TMP_DIR}/storage" +export IPF_STORAGE_DB="${IPF_STORAGE_DIR}/rules.db" +export IPF_LOCK_FILE="${IPF_STORAGE_DIR}/.lock" +export IPF_SKIP_ENV_CHECK=1 +export IPF_SKIP_PERSIST=1 +export IPF_FORCE_PLAIN_UI=1 + +uuid_v4=$("${ROOT_DIR}/iptables-forward.sh" --batch add tcp 65432 127.0.0.1 22 4 'integration-v4') +assert_contains "$(iptables-save)" "MGMT:${uuid_v4}" 'IPv4 rule should appear in iptables-save output' +assert_contains "$("${ROOT_DIR}/iptables-forward.sh" --batch list)" "uuid=${uuid_v4}" 'batch list should include managed rule' + +iptables -A FORWARD -p tcp --dport 65000 -j ACCEPT + +"${ROOT_DIR}/iptables-forward.sh" --batch delete "${uuid_v4}" +assert_status 1 "$(status_of grep -F "MGMT:${uuid_v4}" <(iptables-save))" 'deleted IPv4 rule should disappear from iptables-save' +assert_status 0 "$(status_of grep -F -- '--dport 65000 -j ACCEPT' <(iptables-save))" 'unmanaged rule should remain after deleting managed rule' + +if command -v ip6tables >/dev/null 2>&1; then + uuid_v6=$("${ROOT_DIR}/iptables-forward.sh" --batch add tcp 65433 ::1 22 6 'integration-v6') + assert_contains "$(ip6tables-save)" "MGMT:${uuid_v6}" 'IPv6 rule should appear in ip6tables-save output' + "${ROOT_DIR}/iptables-forward.sh" --batch delete "${uuid_v6}" + assert_status 1 "$(status_of grep -F "MGMT:${uuid_v6}" <(ip6tables-save))" 'deleted IPv6 rule should disappear from ip6tables-save' +fi + +pass 'test_integration.sh' diff --git a/tests/test_rules_unit.sh b/tests/test_rules_unit.sh new file mode 100644 index 0000000..077c371 --- /dev/null +++ b/tests/test_rules_unit.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +# shellcheck source=tests/lib/assert.sh +source "${ROOT_DIR}/tests/lib/assert.sh" + +status_of() { + set +e + "$@" >/dev/null 2>&1 + local rc=$? + set -e + printf '%s\n' "${rc}" +} + +TMP_DIR=$(mktemp -d) +trap 'rm -rf "${TMP_DIR}"' EXIT + +export IPF_STORAGE_DIR="${TMP_DIR}/storage" +export IPF_STORAGE_DB="${IPF_STORAGE_DIR}/rules.db" +export IPF_LOCK_FILE="${IPF_STORAGE_DIR}/.lock" +export IPTABLES_MOCK_DIR="${TMP_DIR}/iptables-mock" +export IPTABLES_MOCK_LOG="${TMP_DIR}/iptables.log" +export IPTABLES_BIN="${ROOT_DIR}/tests/mocks/iptables" +export IP6TABLES_BIN="${ROOT_DIR}/tests/mocks/ip6tables" +export IPTABLES_SAVE_BIN="${ROOT_DIR}/tests/mocks/iptables-save" +export IP6TABLES_SAVE_BIN="${ROOT_DIR}/tests/mocks/ip6tables-save" +export NETFILTER_PERSISTENT_BIN="${ROOT_DIR}/tests/mocks/persist-mock.sh" +export PERSIST_MOCK_LOG="${TMP_DIR}/persist.log" + +# shellcheck source=lib/common.sh +source "${ROOT_DIR}/lib/common.sh" +# shellcheck source=lib/storage.sh +source "${ROOT_DIR}/lib/storage.sh" +# shellcheck source=lib/persist.sh +source "${ROOT_DIR}/lib/persist.sh" +# shellcheck source=lib/iptables_ops.sh +source "${ROOT_DIR}/lib/iptables_ops.sh" +# shellcheck source=lib/rules_mgr.sh +source "${ROOT_DIR}/lib/rules_mgr.sh" + +reset_mock_state() { + rm -rf "${IPTABLES_MOCK_DIR}" "${IPF_STORAGE_DIR}" + mkdir -p "${IPTABLES_MOCK_DIR}" "${IPF_STORAGE_DIR}" + : >"${IPTABLES_MOCK_LOG}" + : >"${PERSIST_MOCK_LOG}" + unset IPTABLES_MOCK_FAIL_ON_N || true + storage_init +} + +reset_mock_state +uuid_v4=$(cmd_add_batch tcp 8080 127.0.0.1 80 4 'web service') +assert_eq '1' "$(storage_count)" 'cmd_add_batch should persist one rule' +assert_eq '3' "$(grep -Ec '^iptables ' "${IPTABLES_MOCK_LOG}")" 'tcp/ipv4 add should emit three iptables commands' +assert_contains "$(storage_get "${uuid_v4}")" "uuid=${uuid_v4}" 'stored line should contain generated uuid' +assert_eq '1' "$(grep -Ec 'persist-mock\.sh save' "${PERSIST_MOCK_LOG}")" 'successful add should trigger persist_save' +assert_contains "$(ipt_find_by_uuid "${uuid_v4}")" "MGMT:${uuid_v4}" 'ipt_find_by_uuid should locate saved mock rules' + +reset_mock_state +uuid_both=$(cmd_add_batch both 5353 '127.0.0.1,::1' 53 both 'dual stack dns') +add_count=$(grep -Ec '^(iptables|ip6tables) ' "${IPTABLES_MOCK_LOG}") +assert_eq '12' "${add_count}" 'both/both add should emit twelve commands' + +cmd_delete_uuid "${uuid_both}" +del_count=$(grep -Ec ' -D ' "${IPTABLES_MOCK_LOG}") +assert_eq '12' "${del_count}" 'deleting both/both rule should emit twelve delete commands' +assert_eq '0' "$(storage_count)" 'cmd_delete_uuid should remove rule from storage' + +reset_mock_state +export IPTABLES_MOCK_FAIL_ON_N=2 +assert_eq '1' "$(status_of cmd_add_batch tcp 9000 127.0.0.1 90 4 'rollback')" 'cmd_add_batch should fail when iptables mock injects an error' +assert_eq '0' "$(storage_count)" 'failed add should not persist storage' +assert_contains "$(cat "${IPTABLES_MOCK_LOG}")" ' -D ' 'failed add should trigger rollback deletes' +assert_eq '0' "$(wc -l < "${PERSIST_MOCK_LOG}")" 'failed add before persistence should not call persist_save' + +pass 'test_rules_unit.sh' diff --git a/tests/test_storage.sh b/tests/test_storage.sh new file mode 100644 index 0000000..99b5afc --- /dev/null +++ b/tests/test_storage.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +# shellcheck source=tests/lib/assert.sh +source "${ROOT_DIR}/tests/lib/assert.sh" + +TMP_DIR=$(mktemp -d) +trap 'rm -rf "${TMP_DIR}"' EXIT + +export IPF_STORAGE_DIR="${TMP_DIR}/storage" +export IPF_STORAGE_DB="${IPF_STORAGE_DIR}/rules.db" +export IPF_LOCK_FILE="${IPF_STORAGE_DIR}/.lock" + +# shellcheck source=lib/storage.sh +source "${ROOT_DIR}/lib/storage.sh" + +storage_init +[[ -f ${IPF_STORAGE_DB} ]] || fail 'storage_init should create rules db' + +line1='uuid=a1|proto=tcp|lport=80|tip=127.0.0.1|tport=8080|ipver=4|desc=one|created=2026-04-17T00:00:00+0800' +line2='uuid=b2|proto=udp|lport=53|tip=127.0.0.1|tport=5353|ipver=4|desc=two|created=2026-04-17T00:00:01+0800' +line3='uuid=c3|proto=both|lport=22|tip=127.0.0.1,::1|tport=22|ipver=both|desc=three|created=2026-04-17T00:00:02+0800' + +storage_add "${line1}" +storage_add "${line2}" +storage_add "${line3}" +assert_eq '3' "$(storage_count)" 'storage_count should reflect inserted lines' + +mapfile -t listed < <(storage_list) +assert_eq "${line1}" "${listed[0]}" 'storage_list should preserve insertion order' +assert_eq "${line2}" "${listed[1]}" 'storage_list should preserve second line' +assert_eq "${line3}" "${listed[2]}" 'storage_list should preserve third line' + +assert_eq "${line2}" "$(storage_get 'b2')" 'storage_get should find existing uuid' +if storage_get 'missing' >/dev/null 2>&1; then + fail 'storage_get should fail for missing uuid' +fi + +storage_delete 'b2' +assert_eq '2' "$(storage_count)" 'storage_delete should remove one line' +if storage_delete 'b2' >/dev/null 2>&1; then + fail 'storage_delete should fail for missing uuid' +fi +assert_eq '2' "$(storage_count)" 'storage_delete failure should keep file unchanged' +assert_eq '22' "$(storage_parse "${line3}" tport)" 'storage_parse should extract requested field' + +pass 'test_storage.sh'