Implement iptables forward manager core
This commit is contained in:
178
iptables-forward.sh
Normal file
178
iptables-forward.sh
Normal file
@@ -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 <<USAGE
|
||||
用法:
|
||||
./iptables-forward.sh
|
||||
./iptables-forward.sh --batch add <proto> <listen_port> <target_ip> <target_port> <ipver> [desc]
|
||||
./iptables-forward.sh --batch delete <uuid>
|
||||
./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 "$@"
|
||||
302
lib/common.sh
Normal file
302
lib/common.sh
Normal file
@@ -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
|
||||
}
|
||||
215
lib/env_check.sh
Normal file
215
lib/env_check.sh
Normal file
@@ -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}" <<SYSCTL
|
||||
net.ipv4.ip_forward=1
|
||||
net.ipv6.conf.all.forwarding=1
|
||||
SYSCTL
|
||||
|
||||
if ! command_is_available "${SYSCTL_BIN}"; then
|
||||
log_err '缺少 sysctl,无法应用转发设置。'
|
||||
if [[ -n ${backup} ]]; then
|
||||
printf '%s' "${backup}" >"${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
|
||||
}
|
||||
236
lib/iptables_ops.sh
Normal file
236
lib/iptables_ops.sh
Normal file
@@ -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}"
|
||||
}
|
||||
32
lib/persist.sh
Normal file
32
lib/persist.sh
Normal file
@@ -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
|
||||
}
|
||||
358
lib/rules_mgr.sh
Normal file
358
lib/rules_mgr.sh
Normal file
@@ -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 '-' </proc/sys/kernel/random/uuid)
|
||||
printf '%s\n' "${raw:0:8}"
|
||||
return 0
|
||||
fi
|
||||
date '+%s%N' | tail -c 9
|
||||
}
|
||||
|
||||
rules_now_iso() {
|
||||
date '+%Y-%m-%dT%H:%M:%S%z'
|
||||
}
|
||||
|
||||
rule_sanitize_desc() {
|
||||
local desc=${1-}
|
||||
desc=${desc//$'\n'/ }
|
||||
desc=${desc//|//}
|
||||
printf '%s\n' "${desc}"
|
||||
}
|
||||
|
||||
rule_target_hint() {
|
||||
case ${1-} in
|
||||
4) printf '请输入目标 IPv4 地址' ;;
|
||||
6) printf '请输入目标 IPv6 地址' ;;
|
||||
both) printf '请输入目标地址,格式为 IPv4,IPv6' ;;
|
||||
*) printf '请输入目标地址' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
rule_validate_target() {
|
||||
local tip=${1-}
|
||||
local ipver=${2-}
|
||||
case ${ipver} in
|
||||
4)
|
||||
validate_ipv4 "${tip}"
|
||||
;;
|
||||
6)
|
||||
validate_ipv6 "${tip}"
|
||||
;;
|
||||
both)
|
||||
[[ ${tip} == *,* ]] || return 1
|
||||
validate_ipv4 "${tip%%,*}" || return 1
|
||||
validate_ipv6 "${tip#*,}" || return 1
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
rule_validate_definition() {
|
||||
local proto=${1-}
|
||||
local lport=${2-}
|
||||
local tip=${3-}
|
||||
local tport=${4-}
|
||||
local ipver=${5-}
|
||||
|
||||
validate_proto "${proto}" || {
|
||||
log_err '协议无效,仅支持 tcp/udp/both。'
|
||||
return 1
|
||||
}
|
||||
validate_port "${lport}" || {
|
||||
log_err '本地端口无效,应为 1-65535。'
|
||||
return 1
|
||||
}
|
||||
validate_port "${tport}" || {
|
||||
log_err '目标端口无效,应为 1-65535。'
|
||||
return 1
|
||||
}
|
||||
validate_ipver "${ipver}" || {
|
||||
log_err 'IP 版本无效,仅支持 4/6/both。'
|
||||
return 1
|
||||
}
|
||||
rule_validate_target "${tip}" "${ipver}" || {
|
||||
log_err '目标地址与 IP 版本不匹配。若选择 both,请使用 IPv4,IPv6。'
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
rule_proto_label() {
|
||||
case ${1-} in
|
||||
tcp) printf 'TCP' ;;
|
||||
udp) printf 'UDP' ;;
|
||||
both) printf 'TCP/UDP' ;;
|
||||
*) printf '%s' "${1-}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
rule_ipver_label() {
|
||||
case ${1-} in
|
||||
4) printf 'IPv4' ;;
|
||||
6) printf 'IPv6' ;;
|
||||
both) printf 'IPv4+IPv6' ;;
|
||||
*) printf '%s' "${1-}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
rule_target_label() {
|
||||
local tip=${1-}
|
||||
if [[ ${tip} == *,* ]]; then
|
||||
printf '%s | %s' "${tip%%,*}" "${tip#*,}"
|
||||
else
|
||||
printf '%s' "${tip}"
|
||||
fi
|
||||
}
|
||||
|
||||
rule_build_line() {
|
||||
local uuid=$1 proto=$2 lport=$3 tip=$4 tport=$5 ipver=$6 desc=${7-}
|
||||
local created
|
||||
created=$(rules_now_iso)
|
||||
desc=$(rule_sanitize_desc "${desc}")
|
||||
printf 'uuid=%s|proto=%s|lport=%s|tip=%s|tport=%s|ipver=%s|desc=%s|created=%s\n' \
|
||||
"${uuid}" "${proto}" "${lport}" "${tip}" "${tport}" "${ipver}" "${desc}" "${created}"
|
||||
}
|
||||
|
||||
rule_field() {
|
||||
storage_parse "$1" "$2"
|
||||
}
|
||||
|
||||
rules_load_lines() {
|
||||
mapfile -t RULES_CACHE < <(storage_list)
|
||||
}
|
||||
|
||||
rule_health_mark() {
|
||||
local uuid=${1-}
|
||||
if ipt_find_by_uuid "${uuid}" >/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
|
||||
}
|
||||
123
lib/storage.sh
Normal file
123
lib/storage.sh
Normal file
@@ -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}"
|
||||
}
|
||||
54
tests/lib/assert.sh
Normal file
54
tests/lib/assert.sh
Normal file
@@ -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})"
|
||||
}
|
||||
1
tests/mocks/ip6tables
Symbolic link
1
tests/mocks/ip6tables
Symbolic link
@@ -0,0 +1 @@
|
||||
iptables-mock.sh
|
||||
1
tests/mocks/ip6tables-save
Symbolic link
1
tests/mocks/ip6tables-save
Symbolic link
@@ -0,0 +1 @@
|
||||
iptables-mock.sh
|
||||
1
tests/mocks/iptables
Symbolic link
1
tests/mocks/iptables
Symbolic link
@@ -0,0 +1 @@
|
||||
iptables-mock.sh
|
||||
109
tests/mocks/iptables-mock.sh
Normal file
109
tests/mocks/iptables-mock.sh
Normal file
@@ -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
|
||||
1
tests/mocks/iptables-save
Symbolic link
1
tests/mocks/iptables-save
Symbolic link
@@ -0,0 +1 @@
|
||||
iptables-mock.sh
|
||||
11
tests/mocks/persist-mock.sh
Normal file
11
tests/mocks/persist-mock.sh
Normal file
@@ -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
|
||||
34
tests/run_all.sh
Normal file
34
tests/run_all.sh
Normal file
@@ -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'
|
||||
50
tests/test_common.sh
Normal file
50
tests/test_common.sh
Normal file
@@ -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'
|
||||
129
tests/test_env_check.sh
Normal file
129
tests/test_env_check.sh
Normal file
@@ -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}" <<MOCK
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' "\$*" >>"${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}" <<MOCK
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' "\$*" >>"${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'
|
||||
88
tests/test_integration.sh
Normal file
88
tests/test_integration.sh
Normal file
@@ -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'
|
||||
76
tests/test_rules_unit.sh
Normal file
76
tests/test_rules_unit.sh
Normal file
@@ -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'
|
||||
48
tests/test_storage.sh
Normal file
48
tests/test_storage.sh
Normal file
@@ -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'
|
||||
Reference in New Issue
Block a user