From 4b82340a9f662154ceea09905e5d196d2b32e82f Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 8 Jul 2026 20:11:15 +0800 Subject: [PATCH] fix: dynamic firewall config + UFW support + anti-spoof bug fix Root cause: UFW FORWARD policy DROP overrides lmvpn_nat accept rules. DNS (small UDP) worked but TCP packets were silently dropped. - Add firewall_linux.go: dynamic NAT/forward/UFW config from ApplySettings - Add firewall_darwin.go/firewall_other.go: stubs - Fix anti-spoof bug in switch.go: return dropPacket sentinel instead of nil, so spoofed packets are no longer written to TUN - Simplify install_linux.sh: remove hardcoded subnets and NAT config - Add UFW detection to diag panel --- install_linux.sh | 108 ++--------------------- internal/vpn/diag.go | 2 + internal/vpn/diag_linux.go | 37 ++++++++ internal/vpn/firewall_darwin.go | 13 +++ internal/vpn/firewall_linux.go | 152 ++++++++++++++++++++++++++++++++ internal/vpn/firewall_other.go | 13 +++ internal/vpn/service.go | 8 ++ internal/vpn/switch.go | 21 ++++- internal/vpn/tunnel.go | 3 + 9 files changed, 251 insertions(+), 106 deletions(-) create mode 100644 internal/vpn/firewall_darwin.go create mode 100644 internal/vpn/firewall_linux.go create mode 100644 internal/vpn/firewall_other.go diff --git a/install_linux.sh b/install_linux.sh index 9cdc30a..131e5f4 100755 --- a/install_linux.sh +++ b/install_linux.sh @@ -1,14 +1,6 @@ #!/usr/bin/env bash set -e -# ───────────────────────────────────────────────────────────── -# VPN 子网(与后台 VPN 设置中的子网保持一致) -# 若后台修改了子网,需同步修改此处并重新执行脚本,或手动更新 iptables 规则 -# ───────────────────────────────────────────────────────────── -VPN_SUBNET="192.168.77.0/24" -# IPv6 子网(留空则不配置 IPv6 NAT;与后台设置保持一致) -VPN_SUBNET6="fd00:dead:beef::/112" - if [ "$(id -u)" -ne 0 ]; then echo "请使用 root 用户执行此脚本: sudo bash install_linux.sh" exit 1 @@ -49,106 +41,14 @@ chown -R lmvpn:lmvpn /opt/lmvpn echo ">>> 配置内核 IP 转发..." # 临时生效 sysctl -w net.ipv4.ip_forward=1 >/dev/null -if [ -n "$VPN_SUBNET6" ]; then - sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null -fi +sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null # 持久化 cat > /etc/sysctl.d/99-lmvpn.conf << EOF net.ipv4.ip_forward = 1 +net.ipv6.conf.all.forwarding = 1 EOF -if [ -n "$VPN_SUBNET6" ]; then - echo "net.ipv6.conf.all.forwarding = 1" >> /etc/sysctl.d/99-lmvpn.conf -fi sysctl -p /etc/sysctl.d/99-lmvpn.conf >/dev/null -echo ">>> 配置 NAT 与转发规则..." -# 自动检测默认路由出口网卡 -WAN_IFACE=$(ip route show default 2>/dev/null | awk '{print $5; exit}') -if [ -z "$WAN_IFACE" ]; then - echo "警告: 未能自动检测出口网卡,跳过 NAT 配置,请手动设置" -else - echo "出口网卡: $WAN_IFACE" - - # 选择可用的防火墙前端:优先 nft(原生,Debian 12+ 的 iptables 是 nft 包装器,操作原生 nft nat 表会不兼容) - NAT_TOOL="" - if command -v nft >/dev/null 2>&1; then - NAT_TOOL="nft" - elif command -v iptables >/dev/null 2>&1; then - NAT_TOOL="iptables" - fi - - if [ -z "$NAT_TOOL" ]; then - echo "警告: 未找到 nft 或 iptables,跳过 NAT 配置" - echo " 客户端将无法上外网。请安装: apt install nftables" - echo " 安装后重新执行本脚本即可自动配置" - elif [ "$NAT_TOOL" = "nft" ]; then - echo "使用 nft 配置 NAT..." - # 幂等:先删除旧表(忽略错误),再创建 - nft delete table inet lmvpn_nat 2>/dev/null || true - nft add table inet lmvpn_nat - nft 'add chain inet lmvpn_nat postrouting { type nat hook postrouting priority 100 ; }' - nft add rule inet lmvpn_nat postrouting oifname "$WAN_IFACE" ip saddr "$VPN_SUBNET" masquerade - if [ -n "$VPN_SUBNET6" ]; then - nft add rule inet lmvpn_nat postrouting oifname "$WAN_IFACE" ip6 saddr "$VPN_SUBNET6" masquerade - fi - nft 'add chain inet lmvpn_nat forward { type filter hook forward priority 0 ; policy accept ; }' - nft add rule inet lmvpn_nat forward ip saddr "$VPN_SUBNET" accept - nft add rule inet lmvpn_nat forward ip daddr "$VPN_SUBNET" accept - if [ -n "$VPN_SUBNET6" ]; then - nft add rule inet lmvpn_nat forward ip6 saddr "$VPN_SUBNET6" accept - nft add rule inet lmvpn_nat forward ip6 daddr "$VPN_SUBNET6" accept - fi - - # 持久化 nft 规则 - mkdir -p /etc/nftables.d - nft list ruleset > /etc/nftables.d/lmvpn.nft - # 确保主配置文件 include 该目录 - if [ -f /etc/nftables.conf ] && ! grep -q 'include "/etc/nftables.d' /etc/nftables.conf; then - echo 'include "/etc/nftables.d/*.nft"' >> /etc/nftables.conf - fi - systemctl enable nftables 2>/dev/null || true - echo "nft 规则已写入 /etc/nftables.d/lmvpn.nft" - elif [ "$NAT_TOOL" = "iptables" ]; then - echo "使用 iptables 配置 NAT..." - # 幂等:先删除旧规则(忽略错误),再添加 - iptables -t nat -D POSTROUTING -s "$VPN_SUBNET" -o "$WAN_IFACE" -j MASQUERADE 2>/dev/null || true - iptables -t nat -A POSTROUTING -s "$VPN_SUBNET" -o "$WAN_IFACE" -j MASQUERADE - - iptables -D FORWARD -s "$VPN_SUBNET" -j ACCEPT 2>/dev/null || true - iptables -D FORWARD -d "$VPN_SUBNET" -j ACCEPT 2>/dev/null || true - iptables -A FORWARD -s "$VPN_SUBNET" -j ACCEPT - iptables -A FORWARD -d "$VPN_SUBNET" -j ACCEPT - - # IPv6 NAT(需 ip6tables) - if [ -n "$VPN_SUBNET6" ] && command -v ip6tables >/dev/null 2>&1; then - echo "配置 IPv6 NAT (ip6tables)..." - ip6tables -t nat -D POSTROUTING -s "$VPN_SUBNET6" -o "$WAN_IFACE" -j MASQUERADE 2>/dev/null || true - ip6tables -t nat -A POSTROUTING -s "$VPN_SUBNET6" -o "$WAN_IFACE" -j MASQUERADE - ip6tables -D FORWARD -s "$VPN_SUBNET6" -j ACCEPT 2>/dev/null || true - ip6tables -D FORWARD -d "$VPN_SUBNET6" -j ACCEPT 2>/dev/null || true - ip6tables -A FORWARD -s "$VPN_SUBNET6" -j ACCEPT - ip6tables -A FORWARD -d "$VPN_SUBNET6" -j ACCEPT - fi - - # 持久化 iptables 规则 - if command -v netfilter-persistent >/dev/null 2>&1; then - netfilter-persistent save - echo "iptables 规则已通过 netfilter-persistent 持久化" - elif command -v iptables-save >/dev/null 2>&1; then - mkdir -p /etc/iptables - iptables-save > /etc/iptables/rules.v4 - if [ -n "$VPN_SUBNET6" ] && command -v ip6tables-save >/dev/null 2>&1; then - ip6tables-save > /etc/iptables/rules.v6 - echo "ip6tables 规则已写入 /etc/iptables/rules.v6" - fi - echo "iptables 规则已写入 /etc/iptables/rules.v4" - echo "注意: 重启后规则持久化需配合 iptables-persistent 包,建议安装: apt install iptables-persistent" - else - echo "警告: 未找到 iptables-save,规则仅在本次运行有效,请手动持久化" - fi - fi -fi - echo ">>> 安装 systemd 服务..." cat > /etc/systemd/system/lmvpn.service << 'EOF' [Unit] @@ -176,4 +76,8 @@ systemctl enable lmvpn systemctl restart lmvpn echo ">>> 安装完成" +echo "" +echo "NAT/转发/UFW 规则由服务端程序在启动时根据后台子网配置自动管理,无需手动设置。" +echo "若修改了后台 VPN 子网,只需在后台保存即可,程序会自动更新防火墙规则。" +echo "" systemctl status lmvpn --no-pager diff --git a/internal/vpn/diag.go b/internal/vpn/diag.go index 288366d..b763635 100644 --- a/internal/vpn/diag.go +++ b/internal/vpn/diag.go @@ -18,6 +18,8 @@ type DiagResult struct { IP6ForwardNote string `json:"ip6_forward_note,omitempty"` Masquerade6 *bool `json:"masquerade6"` Masquerade6Note string `json:"masquerade6_note,omitempty"` + UFWActive *bool `json:"ufw_active"` + UFWForwardNote string `json:"ufw_forward_note,omitempty"` TUNCreate string `json:"tun_create"` TUNRunning bool `json:"tun_running"` TUNName string `json:"tun_name,omitempty"` diff --git a/internal/vpn/diag_linux.go b/internal/vpn/diag_linux.go index 066c7b0..b619a64 100644 --- a/internal/vpn/diag_linux.go +++ b/internal/vpn/diag_linux.go @@ -34,6 +34,15 @@ func fillPlatformDiag(r *DiagResult) { m6, note6 := checkMasquerade6() r.Masquerade6 = m6 r.Masquerade6Note = note6 + + ufwActive := checkUFWActive() + r.UFWActive = &ufwActive + if ufwActive { + ufwOk, ufwNote := checkUFWForward() + if !ufwOk { + r.UFWForwardNote = ufwNote + } + } } func ptrBool(b bool) *bool { return &b } @@ -163,3 +172,31 @@ func checkMasquerade6() (*bool, string) { return nil, "ip6tables 与 nft 均未安装,无法检测 IPv6 NAT 规则" } + +// checkUFWForward checks if VPN forward rules exist in UFW's user-forward chains. +func checkUFWForward() (bool, string) { + nftPath := findExecutable("nft") + if nftPath == "" { + return true, "" + } + + // Check IPv4 + out, err := exec.Command(nftPath, "list", "chain", "ip", "filter", "ufw-user-forward").Output() + if err == nil { + s := string(out) + if !strings.Contains(s, "jump lmvpn-fwd") && !strings.Contains(s, "192.168.") { + return false, "UFW 已启用但 IPv4 转发规则未配置,客户端可能无法上网" + } + } + + // Check IPv6 + out6, err := exec.Command(nftPath, "list", "chain", "ip6", "filter", "ufw6-user-forward").Output() + if err == nil { + s := string(out6) + if !strings.Contains(s, "jump lmvpn6-fwd") && !strings.Contains(s, "fd00:") { + return false, "UFW 已启用但 IPv6 转发规则未配置,IPv6 客户端可能无法上网" + } + } + + return true, "" +} diff --git a/internal/vpn/firewall_darwin.go b/internal/vpn/firewall_darwin.go new file mode 100644 index 0000000..a3b0204 --- /dev/null +++ b/internal/vpn/firewall_darwin.go @@ -0,0 +1,13 @@ +//go:build darwin + +package vpn + +import "log" + +func configureFirewall(ipNet, ipNet6 string, tunName string) { + log.Printf("防火墙配置在当前平台不支持,请手动配置 NAT 和转发规则") +} + +func checkUFWActive() bool { + return false +} diff --git a/internal/vpn/firewall_linux.go b/internal/vpn/firewall_linux.go new file mode 100644 index 0000000..31c31a3 --- /dev/null +++ b/internal/vpn/firewall_linux.go @@ -0,0 +1,152 @@ +//go:build linux + +package vpn + +import ( + "fmt" + "log" + "os/exec" + "strings" +) + +// configureFirewall dynamically configures NAT masquerade, forward accept rules, +// and UFW forward rules based on the current VPN subnets. +// This is called from ApplySettings() so subnet changes in the backend +// are automatically reflected in firewall rules. +func configureFirewall(ipNet, ipNet6 string, tunName string) { + wanIface := detectWANInterface() + if wanIface == "" { + log.Printf("警告: 未能检测出口网卡,跳过防火墙配置") + return + } + log.Printf("出口网卡: %s", wanIface) + + configureNAT(wanIface, ipNet, ipNet6) + configureForward(ipNet, ipNet6) + configureUFWForward(ipNet, ipNet6) +} + +func detectWANInterface() string { + out, err := exec.Command("ip", "route", "show", "default").Output() + if err != nil { + return "" + } + fields := strings.Fields(string(out)) + for i, f := range fields { + if f == "dev" && i+1 < len(fields) { + return fields[i+1] + } + } + return "" +} + +func nftExists(args ...string) bool { + return exec.Command("nft", args...).Run() == nil +} + +func nftExec(args ...string) { + cmd := exec.Command("nft", args...) + cmd.Stderr = nil + _ = cmd.Run() +} + +func configureNAT(wanIface, ipNet, ipNet6 string) { + if !nftExists("list", "table", "inet", "lmvpn_nat") { + nftExec("add", "table", "inet", "lmvpn_nat") + } + + // postrouting chain + if !nftExists("list", "chain", "inet", "lmvpn_nat", "postrouting") { + nftExec("add", "chain", "inet", "lmvpn_nat", "postrouting", + "{ type nat hook postrouting priority 100 ; }") + } + nftExec("flush", "chain", "inet", "lmvpn_nat", "postrouting") + if ipNet != "" { + nftExec("add", "rule", "inet", "lmvpn_nat", "postrouting", + "oifname", wanIface, "ip", "saddr", ipNet, "masquerade") + } + if ipNet6 != "" { + nftExec("add", "rule", "inet", "lmvpn_nat", "postrouting", + "oifname", wanIface, "ip6", "saddr", ipNet6, "masquerade") + } + log.Printf("NAT masquerade 已配置: wan=%s v4=%s", wanIface, ipNet) + if ipNet6 != "" { + log.Printf("NAT masquerade IPv6: %s", ipNet6) + } +} + +func configureForward(ipNet, ipNet6 string) { + if !nftExists("list", "chain", "inet", "lmvpn_nat", "forward") { + nftExec("add", "chain", "inet", "lmvpn_nat", "forward", + "{ type filter hook forward priority 0 ; policy accept ; }") + } + nftExec("flush", "chain", "inet", "lmvpn_nat", "forward") + if ipNet != "" { + nftExec("add", "rule", "inet", "lmvpn_nat", "forward", + "ip", "saddr", ipNet, "accept") + nftExec("add", "rule", "inet", "lmvpn_nat", "forward", + "ip", "daddr", ipNet, "accept") + } + if ipNet6 != "" { + nftExec("add", "rule", "inet", "lmvpn_nat", "forward", + "ip6", "saddr", ipNet6, "accept") + nftExec("add", "rule", "inet", "lmvpn_nat", "forward", + "ip6", "daddr", ipNet6, "accept") + } +} + +// configureUFWForward adds VPN subnet accept rules to UFW's user-forward chains. +// UFW's FORWARD chain has policy DROP by default, which overrides the lmvpn_nat +// forward chain's accept rules. We use dedicated chains (lmvpn-fwd/lmvpn6-fwd) +// that are jumped to from ufw-user-forward/ufw6-user-forward. +func configureUFWForward(ipNet, ipNet6 string) { + // IPv4 + if nftExists("list", "chain", "ip", "filter", "ufw-user-forward") { + if !nftExists("list", "chain", "ip", "filter", "lmvpn-fwd") { + nftExec("add", "chain", "ip", "filter", "lmvpn-fwd") + } + nftExec("flush", "chain", "ip", "filter", "lmvpn-fwd") + if ipNet != "" { + nftExec("add", "rule", "ip", "filter", "lmvpn-fwd", + "ip", "saddr", ipNet, "accept") + nftExec("add", "rule", "ip", "filter", "lmvpn-fwd", + "ip", "daddr", ipNet, "accept") + } + // Add jump if not already present + if !nftJumpExists("ip", "filter", "ufw-user-forward", "lmvpn-fwd") { + nftExec("add", "rule", "ip", "filter", "ufw-user-forward", "jump", "lmvpn-fwd") + } + log.Printf("UFW IPv4 转发规则已配置") + } + + // IPv6 + if ipNet6 != "" && nftExists("list", "chain", "ip6", "filter", "ufw6-user-forward") { + if !nftExists("list", "chain", "ip6", "filter", "lmvpn6-fwd") { + nftExec("add", "chain", "ip6", "filter", "lmvpn6-fwd") + } + nftExec("flush", "chain", "ip6", "filter", "lmvpn6-fwd") + nftExec("add", "rule", "ip6", "filter", "lmvpn6-fwd", + "ip6", "saddr", ipNet6, "accept") + nftExec("add", "rule", "ip6", "filter", "lmvpn6-fwd", + "ip6", "daddr", ipNet6, "accept") + if !nftJumpExists("ip6", "filter", "ufw6-user-forward", "lmvpn6-fwd") { + nftExec("add", "rule", "ip6", "filter", "ufw6-user-forward", "jump", "lmvpn6-fwd") + } + log.Printf("UFW IPv6 转发规则已配置") + } +} + +// nftJumpExists checks if a jump rule to the target chain already exists +// in the source chain. +func nftJumpExists(family, table, chain, target string) bool { + out, err := exec.Command("nft", "list", "chain", family, table, chain).Output() + if err != nil { + return false + } + return strings.Contains(string(out), fmt.Sprintf("jump %s", target)) +} + +// checkUFWActive checks if UFW is active by looking for its forward chain. +func checkUFWActive() bool { + return nftExists("list", "chain", "ip", "filter", "ufw-user-forward") +} diff --git a/internal/vpn/firewall_other.go b/internal/vpn/firewall_other.go new file mode 100644 index 0000000..fc2c6f6 --- /dev/null +++ b/internal/vpn/firewall_other.go @@ -0,0 +1,13 @@ +//go:build !linux && !darwin + +package vpn + +import "log" + +func configureFirewall(ipNet, ipNet6 string, tunName string) { + log.Printf("防火墙配置在当前平台不支持,请手动配置 NAT 和转发规则") +} + +func checkUFWActive() bool { + return false +} diff --git a/internal/vpn/service.go b/internal/vpn/service.go index 3f80e84..af09059 100644 --- a/internal/vpn/service.go +++ b/internal/vpn/service.go @@ -155,6 +155,14 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations4, res s.mu.Unlock() go s.serveTUN() + + subnet4 := ipNet.String() + var subnet6Str string + if ipNet6 != nil { + subnet6Str = ipNet6.String() + } + configureFirewall(subnet4, subnet6Str, tun.Name()) + log.Printf("VPN 服务已启动: tun=%s subnet=%s server=%s", tun.Name(), ipNet.String(), serverIP.String()) if ipNet6 != nil { log.Printf(" IPv6: subnet=%s server=%s", ipNet6.String(), serverIP6.String()) diff --git a/internal/vpn/switch.go b/internal/vpn/switch.go index 6159190..6165e5e 100644 --- a/internal/vpn/switch.go +++ b/internal/vpn/switch.go @@ -106,6 +106,16 @@ func parseIPAddrs(packet []byte) (src, dest net.IP, ok bool) { return nil, nil, false } +// dropPacket is a sentinel non-nil, non-empty slice that signals the caller +// to silently drop the packet (do not write to TUN, do not relay). +var dropPacket = []SwitchConn{nil} + +// isDrop returns true if the result from RouteFromClient indicates the packet +// should be silently dropped rather than forwarded to TUN or relayed. +func isDrop(targets []SwitchConn) bool { + return len(targets) == 1 && targets[0] == nil +} + func (s *PacketSwitch) allowC2C() bool { s.mu.RLock() v := s.allowClientToClient @@ -113,21 +123,24 @@ func (s *PacketSwitch) allowC2C() bool { return v } +// RouteFromClient returns the list of target connections to relay the packet to. +// Returns nil to indicate the packet should be written to TUN (internet-bound). +// Returns dropPacket to indicate the packet should be silently dropped (e.g. anti-spoof). func (s *PacketSwitch) RouteFromClient(src SwitchConn, packet []byte) []SwitchConn { srcIP, dest, ok := parseIPAddrs(packet) if !ok { - return nil + return dropPacket } // anti-spoof: enforce assigned source IP by version if srcIP != nil { if srcIP.To4() != nil { if !srcIP.Equal(src.AssignedIP()) { - return nil + return dropPacket } } else { assigned6 := src.AssignedIP6() if assigned6 == nil || !srcIP.Equal(assigned6) { - return nil + return dropPacket } } } @@ -140,7 +153,7 @@ func (s *PacketSwitch) RouteFromClient(src SwitchConn, packet []byte) []SwitchCo if s.allowC2C() { return s.allExcept(src) } - return nil + return dropPacket } func (s *PacketSwitch) RouteFromTUN(packet []byte) []SwitchConn { diff --git a/internal/vpn/tunnel.go b/internal/vpn/tunnel.go index 621665b..6b3ce4f 100644 --- a/internal/vpn/tunnel.go +++ b/internal/vpn/tunnel.go @@ -217,6 +217,9 @@ func runTunnel(conn *websocket.Conn, user *model.User) { tc.rxBytes.Add(int64(len(data))) targets := VPN.RouteFromClient(tc, data) + if isDrop(targets) { + continue + } if len(targets) == 0 { if err := VPN.WriteToTUN(data); err != nil { log.Printf("用户 %s 写入 TUN 失败: %v", user.Username, err)