2 Commits
Author SHA1 Message Date
kevin 82709f8c46 Merge pull request 'fix: 网卡更换后 NAT/诊断规则绑定失效网卡导致客户端无法上网' (#1) from dsh/lmvpn_server:fix/nat-wan-iface-redetect into main
Reviewed-on: kevin/lmvpn_server#1
2026-08-17 00:24:24 -04:00
dsh e82fb7421d fix: 网卡更换后 NAT/诊断规则绑定失效网卡导致客户端无法上网
问题: 更换网卡后(如 enp1s0f3 -> enp1s0), nft masquerade 规则仍绑定旧网卡名,
规则永不命中, 客户端连上 VPN 但无法出网; 且诊断面板只检查规则是否存在,
会误报正常。

修改:
1. systemd 单元增加 Wants/After=network-online.target, 开机等待默认路由
   就绪后再启动, 避免启动竞态下检测不到出口网卡而跳过防火墙配置;
2. detectWANInterface 增加 10x1s 重试, 开机初期无默认路由时短暂等待;
3. 诊断面板校验 masquerade 规则绑定的网卡是否真实存在, 不存在时给出
   明确提示(网卡已更换?), 不再误报正常。
2026-08-17 00:22:19 -04:00
3 changed files with 54 additions and 15 deletions
+2 -1
View File
@@ -53,7 +53,8 @@ echo ">>> 安装 systemd 服务..."
cat > /etc/systemd/system/lmvpn.service << 'EOF' cat > /etc/systemd/system/lmvpn.service << 'EOF'
[Unit] [Unit]
Description=LMVPN Server Description=LMVPN Server
After=network.target Wants=network-online.target
After=network-online.target
[Service] [Service]
Type=simple Type=simple
+38 -6
View File
@@ -3,6 +3,7 @@
package vpn package vpn
import ( import (
"fmt"
"os" "os"
"os/exec" "os/exec"
"strconv" "strconv"
@@ -103,10 +104,18 @@ func checkMasquerade() (*bool, string) {
out, err := exec.Command(nftPath, "list", "ruleset").Output() out, err := exec.Command(nftPath, "list", "ruleset").Output()
if err == nil { if err == nil {
has := strings.Contains(string(out), "masquerade") has := strings.Contains(string(out), "masquerade")
if has { if !has {
return &has, "" return &has, "未检测到 masquerade 规则,客户端无法出网"
} }
return &has, "未检测到 masquerade 规则,客户端无法出网" // masquerade 规则存在,但若绑定到已不存在的网卡名(如更换网卡后),
// 规则永远不会命中,客户端仍然无法上网——需要单独校验。
if iface := masqueradeIface(string(out)); iface != "" {
if err := exec.Command("ip", "link", "show", iface).Run(); err != nil {
return ptrBool(false), fmt.Sprintf(
"masquerade 规则绑定到不存在的网卡 %s(网卡已更换?),请在管理后台保存 VPN 设置或重启服务以重新配置防火墙", iface)
}
}
return &has, ""
} }
// nft 存在但执行失败(权限不足等),仍回退 iptables 尝试 // nft 存在但执行失败(权限不足等),仍回退 iptables 尝试
} }
@@ -141,16 +150,39 @@ func readIP6Forward() *bool {
return &v return &v
} }
// masqueradeIface 从 nft ruleset 文本中提取 masquerade 规则绑定的出口网卡名;
// 若规则未绑定网卡(对所有出口生效)则返回空字符串。
func masqueradeIface(ruleset string) string {
for _, line := range strings.Split(ruleset, "\n") {
if !strings.Contains(line, "masquerade") {
continue
}
if i := strings.Index(line, `oifname "`); i >= 0 {
rest := line[i+len(`oifname "`):]
if j := strings.Index(rest, `"`); j > 0 {
return rest[:j]
}
}
}
return ""
}
func checkMasquerade6() (*bool, string) { func checkMasquerade6() (*bool, string) {
nftPath := findExecutable("nft") nftPath := findExecutable("nft")
if nftPath != "" { if nftPath != "" {
out, err := exec.Command(nftPath, "list", "ruleset").Output() out, err := exec.Command(nftPath, "list", "ruleset").Output()
if err == nil { if err == nil {
s := string(out) s := string(out)
if strings.Contains(s, "ip6 saddr") && strings.Contains(s, "masquerade") { if !(strings.Contains(s, "ip6 saddr") && strings.Contains(s, "masquerade")) {
return ptrBool(true), "" return ptrBool(false), "未检测到 IPv6 masquerade 规则,IPv6 客户端无法出网"
} }
return ptrBool(false), "未检测到 IPv6 masquerade 规则,IPv6 客户端无法出网" if iface := masqueradeIface(s); iface != "" {
if err := exec.Command("ip", "link", "show", iface).Run(); err != nil {
return ptrBool(false), fmt.Sprintf(
"masquerade 规则绑定到不存在的网卡 %s(网卡已更换?),请在管理后台保存 VPN 设置或重启服务以重新配置防火墙", iface)
}
}
return ptrBool(true), ""
} }
} }
+14 -8
View File
@@ -7,6 +7,7 @@ import (
"log" "log"
"os/exec" "os/exec"
"strings" "strings"
"time"
) )
// configureFirewall dynamically configures NAT masquerade, forward accept rules, // configureFirewall dynamically configures NAT masquerade, forward accept rules,
@@ -26,16 +27,21 @@ func configureFirewall(ipNet, ipNet6 string, tunName string) {
configureUFWForward(ipNet, ipNet6) configureUFWForward(ipNet, ipNet6)
} }
// detectWANInterface 通过默认路由确定出口网卡。
// 开机初期(如 systemd 尚未完成网络在线、DHCP 未取得租约)可能没有默认路由,
// 此时短暂重试,避免在网卡变更/开机竞态下留下绑定旧网卡名的陈旧防火墙规则。
func detectWANInterface() string { func detectWANInterface() string {
out, err := exec.Command("ip", "route", "show", "default").Output() for attempt := 0; attempt < 10; attempt++ {
if err != nil { out, err := exec.Command("ip", "route", "show", "default").Output()
return "" if err == nil {
} fields := strings.Fields(string(out))
fields := strings.Fields(string(out)) for i, f := range fields {
for i, f := range fields { if f == "dev" && i+1 < len(fields) {
if f == "dev" && i+1 < len(fields) { return fields[i+1]
return fields[i+1] }
}
} }
time.Sleep(time.Second)
} }
return "" return ""
} }