Implement iptables forward manager core

This commit is contained in:
2026-04-17 09:36:40 +08:00
parent d1a5392476
commit 28960eee03
20 changed files with 2047 additions and 0 deletions

302
lib/common.sh Normal file
View 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
View 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
View 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
View 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
View 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
View 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}"
}