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
This commit is contained in:
2026-07-08 20:11:15 +08:00
parent 3306fc7ee4
commit 4b82340a9f
9 changed files with 251 additions and 106 deletions
+2
View File
@@ -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"`
+37
View File
@@ -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, ""
}
+13
View File
@@ -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
}
+152
View File
@@ -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")
}
+13
View File
@@ -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
}
+8
View File
@@ -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())
+17 -4
View File
@@ -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 {
+3
View File
@@ -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)