这是 Debian 服务器搭建指南 的 OpenWrt 移植版。配置思路、证书方案、客户端步骤完全一致,差异只在"OpenWrt 怎么落地"这一层。

设计原则:

  • 改动最小:不动 LAN/WAN/DHCP/DNS,只追加 fw4 规则和 strongSwan 配置
  • 全程可逆:§0.3 做整盘 UCI 备份,§10 一键回滚
  • 每步可验证:每节末尾给一条 cmd → 期望输出,不过就回头查
  • 轻量优先:strongSwan 按需装模块(总 ~2MB),不用 strongswan-full

目标(与 Debian 完全一致)

  • [OK] Apple / Windows / Android 用各自系统自带 VPN 客户端(Android 也可装 strongSwan App)
  • [OK] 分流:只有 10.0.0.0/16 走 VPN,其他流量直连
  • [OK] ZeroSSL 证书,DNS-01,90 天自动续期
  • [OK] 用户名 + 密码(EAP-MSCHAPv2)登录,加用户改一行

与 Debian 教程的根本区别

Debian 教程OpenWrt 本教程
角色内网服务器,前级路由器转发端口路由器本身(主路由模式)/ 内网服务器(旁路由模式)
防火墙iptables-persistentfw4 / nftables + UCI(/etc/config/firewall)
启动systemd (strongswan-starter.service)procd (/etc/init.d/swanctl)
配置格式老 stroke:/etc/ipsec.conf + /etc/ipsec.secrets新 vici/swanctl:/etc/swanctl/swanctl.conf(snapshot 已弃 stroke)
证书路径/etc/ipsec.d/{private,certs,cacerts}//etc/swanctl/{private,x509,x509ca}/
重载配置systemctl restart strongswan-starterswanctl --load-all(不用重启 charon)
查状态ipsec statusall / ipsec listallswanctl --list-sas / --list-conns / --list-certs
包管理aptapk(snapshot 已切 apk;老版 opkg 命令一一对应)
IP 转发默认关,要手工开默认已开,不动
ACME`curl get.acme.sh \sh`acme-acmesh 包(等价 acme.sh)/ luci-app-acme(有 LuCI 的话)
日志journalctl -u ...logread -e charon -f
VPN 子网集成iptables MASQUERADE + FORWARD ACCEPTXFRM interface (xfrm0):VPN 流量经虚拟接口进入 lan zone,架构上等于 LAN 设备,不需要专门的 vpn zone(详见 §5)

§0 准备工作

0.1 环境自检

cat /etc/openwrt_release           # DISTRIB_RELEASE 应是 SNAPSHOT 或 25.x
uname -r                            # 完整内核版本, snapshot kmod 必须严格匹配
df -h /overlay                      # 剩余 > 10MB
free -m                             # 推荐 128MB+ RAM
date                                # 时间必须正确, 见 §8.5
uci show network.lan               # 看 LAN 全部字段
ip -4 addr show br-lan             # 内核里真实下发的 IP/掩码 (最权威)
# 期望: ipaddr=10.0.0.1 且实际是 /16
# 新版可能是 ipaddr='10.0.0.1/16' 一条 CIDR 写法, 没有独立 netmask 字段, 那是正常的
ls /sbin/fw4 && echo "fw4 OK"      # 必须有, 没 fw4 是老固件, 本教程不适用
ls /usr/sbin/nft && echo "nft OK"  # 必须有
apk --version || opkg --version    # 包管理器, snapshot 已是 apk

记下:

  • 内核版本 KVER=...(后面排错用)
  • 包管理器:本教程默认 apk(snapshot 已切)。若你跑的是 24.10 老稳定版还是 opkg,把 apk add 换成 opkg installapk del 换成 opkg remove 即可

验证:

  • uci get network.lan.ipaddr 输出 10.0.0.110.0.0.1/16 都行
  • ip -4 addr show br-lan 看到 inet 10.0.0.1/16 ... 才算真正生效

如果实际不是 /16,先去 LuCI → 网络 → 接口 → LAN 把 IPv4 地址改成 10.0.0.1/16,或者命令行:

uci set network.lan.ipaddr='10.0.0.1/16'
uci delete network.lan.netmask 2>/dev/null
uci commit network
/etc/init.d/network restart

客户端断开 LAN 重连一次拿新 IP,再回来继续。改 LAN 是一次性大动作,改完再开始本教程,中途别再改。

时间没同步 → 证书校验失败。service sysntpd restart 强制同步一次。

0.2 部署模式确认(影响 §1.2 / §5.2)

[A] 主路由模式  (本教程默认路径)
   公网 ───→ OpenWrt(WAN=公网IP)───→ LAN 10.0.0.0/16
                  ↑ strongSwan 直接听 UDP 500/4500

[B] 旁路由模式  (本教程在每节用 ⓑ 标差异)
   公网 ───→ 前级路由器(公网IP)───→ OpenWrt(LAN 内, 10.0.0.X)
                  ↑ 前级要做端口转发 UDP 500/4500 → OpenWrt

后续凡是出现 ⓐ 主路由 / ⓑ 旁路由 标签的,按你所在模式选一条做。

0.3 全量备份(可逆的前提)

TS=$(date +%Y%m%d-%H%M)
mkdir -p /root/ikev2-backup/$TS

cp -a /etc/config /root/ikev2-backup/$TS/config

apk list -I > /root/ikev2-backup/$TS/pkglist.before 2>/dev/null \
  || opkg list-installed > /root/ikev2-backup/$TS/pkglist.before

nft list ruleset > /root/ikev2-backup/$TS/nft.before

echo $TS > /root/ikev2-backup/LAST

验证:

ls -la /root/ikev2-backup/$TS/
# 期望: config/  pkglist.before  nft.before
wc -l /root/ikev2-backup/$TS/pkglist.before    # > 50

应急回滚(任意阶段失败都可用):

TS=$(cat /root/ikev2-backup/LAST)
cp -a /root/ikev2-backup/$TS/config/. /etc/config/
reboot

0.4 拿 DNSPod API Token

照搬 Debian §0.2

记下:

DP_Id    = 123456
DP_Key   = abcdef1234567890abcdef1234567890

§1 域名 + 公网到达性

1.1 加 A 记录

DNSPod 控制台:vpnA → 你的公网 IP。

# 看你的 WAN IP
ifstatus wan | grep -i '"address"' | head -1

# 看 DNS 解析
nslookup vpn.example.com 8.8.8.8

验证:两者必须一致(差几分钟是 DNS 缓存,过会再查)。下文所有 vpn.example.com 替换成你的实际域名。

1.2 端口可达性

ⓐ 主路由:不需要任何端口转发,UDP 500/4500 由 OpenWrt 自己听。§5.2 会在 fw4 上加 input 放行。

ⓑ 旁路由:在前级路由器上加两条转发:

协议外部端口内部 IP(OpenWrt LAN)内部端口
UDP50010.0.0.X500
UDP450010.0.0.X4500
不需要 80 端口(DNS-01)。

§2 装包(最轻量)

2.1 更新源(snapshot 必须立刻接装包)

apk update

snapshot 关键规则:apk update 完之后当场装包。snapshot 源每天重建,隔天就有可能 404。中途中断要重新 apk update

2.2 装 strongSwan(模块化,不用 strongswan-full)

strongswan-full 约 5MB,把不需要的插件全拉上。本教程只装 5 个包,基础模块由 strongswan-default 元包自动拉依赖:

apk add \
  strongswan-default \
  strongswan-mod-eap-mschapv2 \
  strongswan-mod-eap-identity \
  strongswan-mod-eap-dynamic \
  strongswan-mod-openssl
作用为什么不能省
strongswan-default主程序 + 基础密码学 + x509/pem/pkcs1/pubkey + kernel-netlink + socket-default元包,自动拉一串依赖,这些子模块不用单独列
mod-eap-mschapv2用户名密码认证strongswan-default 不带 EAP
mod-eap-identity解析 eap_identity=%identity同上
mod-eap-dynamic客户端/服务端 EAP 方法协商兜底同上
mod-opensslTLS / 哈希 / RSA(ZeroSSL 证书校验)防止 strongswan-default 默认走 mod-gmp 时缺 openssl

不装的几个对比:

  • mod-curl:OCSP/CRL 在线吊销查询,本教程不启用,EAP-MSCHAPv2 也用不到 → 跳过
  • mod-updown:依赖老 iptables(snapshot 已删),会装失败;本教程不设 leftupdown=,路由由 §5 的 xfrm0 interface 接管 → 跳过
  • 如果你看到 iptables / ip6tables 提示 "Not available",这是预期的,本教程不需要


§5 还需要 2 个网络相关的包(放在那里装):

kmod-xfrm-interface    # XFRM interface 内核模块, VPN 流量通过 xfrm0 进入 lan zone
luci-proto-xfrm        # UCI/LuCI 管 xfrm 接口的 proto handler


snapshot 里 strongswan 模块名偶尔会改。某个包报 unknown package / not found 时:

apk search 'strongswan-mod-*' | grep -i <关键词>

找到新名换上。

验证:

swanctl --version
# 期望: strongSwan 5.x.x / 6.x.x

ls /etc/init.d/swanctl && echo "init OK"
ls -d /etc/swanctl/ && echo "config dir OK"
ls /etc/swanctl/private /etc/swanctl/x509 /etc/swanctl/x509ca 2>/dev/null && echo "subdirs OK"
# 子目录若缺, §3-C.1 会创建

ipsec 命令(stroke 接口)在 snapshot 已不打包,整套用 swanctl。教程后续涉及 ipsec.conf / ipsec listall / /etc/init.d/ipsec 的地方已全部改成 swanctl 等价命令。

2.3 装 ACME(两选一)

先看有没有 LuCI:

[ -d /usr/lib/lua/luci ] && echo "有 LuCI → 走 2.3-A" || echo "无 LuCI → 走 2.3-B"

2.3-A 有 LuCI:用 luci-app-acme(GUI)

apk add luci-app-acme acme-acmesh acme-acmesh-dnsapi

包关系:

  • acme-acmesh — acme.sh 脚本本体(/usr/lib/acme/acme.sh/)
  • acme-acmesh-dnsapi — DNS-01 插件,含 dns_dp.sh(DNSPod)
  • luci-app-acme — LuCI 上的"ACME 证书"页面 + UCI 包装

验证:

ls /usr/lib/acme/acme.sh/acme.sh
ls /usr/lib/acme/acme.sh/dnsapi/dns_dp.sh
# 两个文件都在

刷新 LuCI:服务 → ACME 证书 出现。

2.3-B 无 LuCI:命令行 acme.sh

apk add acme-acmesh acme-acmesh-dnsapi

验证:

/usr/lib/acme/acme.sh/acme.sh --version
# 期望: v3.x.x

ls /usr/lib/acme/acme.sh/dnsapi/dns_dp.sh && echo "DNSPod 插件 OK"

2.4 必备杂项

apk add ca-certificates ca-bundle curl
# ca-* 用于 acme.sh 校验 ZeroSSL 服务端证书

验证:

curl -s -o /dev/null -w "%{http_code}\n" https://acme.zerossl.com
# 期望: 200 或 405 (说明 TLS 连接成功, 不是 0/000)

§3 申请 ZeroSSL 证书

照搬 Debian §3 的核心结论:2025-2026 起 Let's Encrypt 的新链 YR1/YR2/YE1/YE2 挂在 Root YR/YE 下,安卓 / 国产 ROM 信任库没有,客户端会报 no trusted public key found + AUTH_FAILED用 ZeroSSL,挂在 USERTrust RSA CA(所有设备都信任)。

3-A 路径(LuCI 用户)

3-A.1 在 LuCI → 服务 → ACME 证书

General Settings:

  • State directory: /etc/acme
  • Account email:your-email@example.com(必须英文,否则注册失败,见 §8.10)

Add → 新增一个 cert:

  • Enabled: [勾选]
  • Use staging server: [不勾选]
  • Domain names:vpn.example.com
  • Key size:2048
  • Validation method:dns
  • DNS API:dns_dp
  • Credentials(每行一对 KEY=VALUE):

    DP_Id=你的ID
    DP_Key=你的Token
  • ACME server:https://acme.zerossl.com/v2/DV90(关键,不写就用默认 LE)

Save & Apply。

3-A.2 触发签发

/etc/init.d/acme start
logread -e acme -f         # 等 "Cert success", 约 30-60 秒, Ctrl+C

证书产出在 /etc/acme/vpn.example.com/

3-B 路径(命令行用户)

3-B.1 切默认 CA 到 ZeroSSL

ACME=/usr/lib/acme/acme.sh/acme.sh
$ACME --set-default-ca --server zerossl
$ACME --register-account -m your-email@example.com    # [!] 英文邮箱

3-B.2 喂 DNSPod 凭据

export DP_Id="123456"
export DP_Key="abcdef1234567890..."
acme.sh 第一次签完会自动把这俩写进 ~/.acme.sh/account.conf,以后续期自动读,不用每次 export。

3-B.3 签证书

$ACME --issue --dns dns_dp -d vpn.example.com --keylength 2048

acme.sh 自动加 TXT → 等 ZeroSSL 验 → 自动删 TXT。不需要 80 端口

3-C 公共部分:摆到 strongSwan 路径 + 续期 hook

3-C.1 创建目标目录(swanctl 路径)

mkdir -p /etc/swanctl/private /etc/swanctl/x509 /etc/swanctl/x509ca
目录放什么swanctl.conf 怎么引用
/etc/swanctl/private/服务器私钥 privkey.pemsecrets.private-*.file = privkey.pem
/etc/swanctl/x509/末端 + 中间证书 fullchain.pemconnections.*.local.certs = fullchain.pem
/etc/swanctl/x509ca/中间证书 chain.pem(独立 CA 副本,供链验证)swanctl 自动扫描加载,无需显式引用

3-C.2 装证书 + 写续期 reloadcmd

LuCI 用户(3-A 路径):LuCI ACME 的产出目录是 /etc/acme/<domain>/,写一个自动检测域名的同步脚本,挂 cron 每天跑一次:

mkdir -p /etc/acme/hooks
cat > /etc/acme/hooks/sync-to-swanctl.sh <<'EOF'
#!/bin/sh
# 优先级: 命令行参数 > swanctl.conf 的 local.id > /etc/acme/ 自动识别
# 这样改 swanctl.conf 的域名时, 续期自动跟着走, 不用改 cron

detect_from_swanctl() {
    [ -f /etc/swanctl/swanctl.conf ] || return
    awk '
        /^[[:space:]]*local[[:space:]]*\{/ { in_local=1; next }
        in_local && /\}/ { in_local=0; next }
        in_local && /^[[:space:]]*id[[:space:]]*=/ {
            sub(/^[[:space:]]*id[[:space:]]*=[[:space:]]*/, "")
            sub(/[[:space:]]*$/, "")
            sub(/^@/, ""); gsub(/"/, "")
            print; exit
        }
    ' /etc/swanctl/swanctl.conf
}

detect_from_acme() {
    cd /etc/acme/ 2>/dev/null || return
    for d in */; do
        d="${d%/}"
        case "$d" in
            ca|hooks|account|http.*|_*) continue ;;
        esac
        [ -f "$d/$d.key" ] && echo "$d"
    done
}

if [ -n "$1" ]; then
    D="$1"
else
    D="$(detect_from_swanctl)"
    if [ -n "$D" ] && [ ! -d "/etc/acme/$D" ]; then
        echo "WARN: swanctl.conf local.id='$D' 但 /etc/acme/$D 不存在, 改用 acme 扫描" >&2
        D=""
    fi
    if [ -z "$D" ]; then
        cand="$(detect_from_acme)"
        n=$(printf '%s\n' "$cand" | grep -c .)
        if [ "$n" -eq 1 ]; then
            D="$cand"
        elif [ "$n" -gt 1 ]; then
            echo "ERR: 多个 acme 域名,但 swanctl.conf 没指明 local.id:" >&2
            printf '  %s\n' $cand >&2
            echo "用法: $0 <domain>" >&2
            exit 1
        else
            echo "ERR: 没找到任何有效域名" >&2
            exit 1
        fi
    fi
fi

[ -d "/etc/acme/$D" ] || { echo "ERR: /etc/acme/$D 不存在" >&2; exit 1; }

SRC=/etc/acme/$D
DST_KEY=/etc/swanctl/private/privkey.pem
DST_FC=/etc/swanctl/x509/fullchain.pem
DST_CA=/etc/swanctl/x509ca/chain.pem

mkdir -p /etc/swanctl/private /etc/swanctl/x509 /etc/swanctl/x509ca

KEY=$(ls $SRC/${D}.key 2>/dev/null | head -1)
FC=$(ls $SRC/fullchain.cer 2>/dev/null || ls $SRC/fullchain.pem 2>/dev/null | head -1)
CA=$(ls $SRC/ca.cer       2>/dev/null || ls $SRC/ca.pem       2>/dev/null | head -1)

for pair in "KEY:$KEY" "FC:$FC" "CA:$CA"; do
    name="${pair%%:*}"; val="${pair#*:}"
    if [ -z "$val" ] || [ ! -f "$val" ]; then
        echo "ERR: $name 在 $SRC/ 里找不到" >&2
        ls -la $SRC/ >&2
        exit 1
    fi
done

# 用 cmp 比内容, 不靠 mtime, 第一次跑也能正确触发
if ! cmp -s "$KEY" "$DST_KEY" 2>/dev/null || ! cmp -s "$FC" "$DST_FC" 2>/dev/null; then
    cp "$KEY" "$DST_KEY"
    cp "$FC"  "$DST_FC"
    cp "$CA"  "$DST_CA"
    chmod 600 "$DST_KEY"
    echo "synced $D -> /etc/swanctl/"
    if pgrep -f charon >/dev/null 2>&1; then
        swanctl --load-all
    else
        echo "charon 未运行, 跳过 swanctl --load-all (首次部署正常)"
    fi
    logger -t acme-sync "synced certs ($D) and reloaded swanctl"
else
    echo "no change: certs already in sync"
fi
EOF
chmod +x /etc/acme/hooks/sync-to-swanctl.sh

# 立即手动跑一次, 把当前证书摆到位
/etc/acme/hooks/sync-to-swanctl.sh
# 期望: synced <你的域名> -> /etc/swanctl/ (然后 charon 未运行提示)

# 验证文件就位
ls -la /etc/swanctl/private/privkey.pem /etc/swanctl/x509/fullchain.pem

# 挂 cron, 不带参数 — 脚本会自己从 swanctl.conf 读 local.id
grep -q sync-to-swanctl /etc/crontabs/root 2>/dev/null \
  || echo '15 4 * * * /etc/acme/hooks/sync-to-swanctl.sh' >> /etc/crontabs/root
/etc/init.d/cron enable
/etc/init.d/cron restart

改域名时:只需改 swanctl.conflocal.id,cron 自动跟随,不用动 crontab。这是"单一可信源(swanctl.conf)" 的好处。

多 acme 域 + swanctl.conf 还没建好的过渡期:脚本会大声报错并列出候选,强制你显式传参,避免拷错证书。

命令行用户(3-B 路径):用 acme.sh 自己的 --install-cert --reloadcmd,只是目标路径和 reload 命令换 swanctl:

$ACME --install-cert -d vpn.example.com \
  --key-file       /etc/swanctl/private/privkey.pem \
  --fullchain-file /etc/swanctl/x509/fullchain.pem \
  --ca-file        /etc/swanctl/x509ca/chain.pem \
  --reloadcmd      "swanctl --load-all"

chmod 600 /etc/swanctl/private/privkey.pem

# 装一次 cron, 让 acme.sh 自动续期
grep -q 'acme.sh --cron' /etc/crontabs/root 2>/dev/null \
  || echo '0 4 * * * /usr/lib/acme/acme.sh/acme.sh --cron --home /root/.acme.sh > /dev/null 2>&1' \
     >> /etc/crontabs/root
/etc/init.d/cron enable
/etc/init.d/cron restart

3-C.3 验证 issuer 是 ZeroSSL

openssl x509 -in /etc/swanctl/x509/fullchain.pem -noout -issuer -dates
  • [OK] 期望:issuer=C=AT, O=ZeroSSL GmbH, CN=ZeroSSL RSA DV SSL CA 2
  • [X] 出现 Let's Encrypt / YR1/YR2/YE1 → 3-A 的 ACME server URL 没填,或 3-B 没 --set-default-ca,回去重做

§4 strongSwan 配置(swanctl 格式,语义与 Debian §4 一一对应)

swanctl 用大括号嵌套的 VICI 语法,一个文件就把连接定义 + IP 池 + 用户密码全装下,不再分 ipsec.conf / ipsec.secrets

4.1 主配置 /etc/swanctl/swanctl.conf

[ -f /etc/swanctl/swanctl.conf ] && cp /etc/swanctl/swanctl.conf /etc/swanctl/swanctl.conf.bak
vi /etc/swanctl/swanctl.conf

粘贴(替换 vpn.example.com 和密码)。注意保留 OpenWrt 默认包里已有的两行 include(在 /etc/swanctl/conf.d//var/swanctl/ 有内容时拉取,默认空,留着无害且向前兼容),把下面这段追加在 include 之后:

# Include config snippets (OpenWrt 默认就有, 保留)
include conf.d/*.conf
include /var/swanctl/swanctl.conf

# ↓↓↓ 下面是 IKEv2 VPN 配置 ↓↓↓

connections {
    ikev2-vpn {
        version = 2
        # 宽松套件, 不要在末尾加 !, 兼容老安卓 / 苹果 / Win
        proposals = aes256gcm16-prfsha384-ecp384,aes256gcm16-prfsha256-modp2048,aes256-sha256-modp2048,aes256-sha384-modp2048,aes256-sha1-modp2048,aes128-sha256-modp2048,aes128-sha1-modp1024
        rekey_time = 0
        pools = ikev2-pool
        fragmentation = yes
        encap = yes              # 等同 forceencaps=yes
        dpd_delay = 300s
        send_certreq = no
        unique = never           # 等同 uniqueids=no

        local {
            auth = pubkey
            certs = fullchain.pem
            id = vpn.example.com
        }

        remote {
            auth = eap-mschapv2
            eap_id = %any
        }

        children {
            net {
                # 分流核心: 只有这个网段走 VPN (等同 leftsubnet)
                local_ts = 10.0.0.0/16
                # ↓↓↓ XFRM interface 绑定 (§5 用) ↓↓↓
                if_id_in = 42                # 数字任意, 必须和 §5.4 创建 xfrm0 时的 ifid 一致
                if_id_out = 42
                # ↑↑↑ 这两行让解密包出现在 xfrm0 接口, 自然进入 lan zone ↑↑↑
                rekey_time = 0
                dpd_action = clear
                esp_proposals = aes256gcm16,aes256-sha384,aes256-sha256,aes256-sha1,aes128-sha256,aes128-sha1
            }
        }
    }
}

pools {
    ikev2-pool {
        # VPN 客户端 IP 池 (等同 rightsourceip)
        addrs = 10.10.10.0/24
        # OpenWrt 自己当 DNS, 让 VPN 客户端能解析 .lan 域名
        dns = 10.0.0.1
    }
}

secrets {
    # 服务器证书私钥, swanctl 自动从 /etc/swanctl/private/ 读
    private-server {
        file = privkey.pem
    }

    # VPN 用户(密码 ≥ 12 位, 大小写+数字+符号), 每人一段:
    eap-zhangsan {
        id = zhangsan
        secret = "ZsKe92!QvLm8rWp3"
    }
    eap-lisi {
        id = lisi
        secret = "X9$mP4kRtY8nB2Lv"
    }
}
chmod 600 /etc/swanctl/swanctl.conf

语法对照表(以后看 Debian 教程不晕):

Debian ipsec.confswanctl.conf 路径
leftid=@vpn.example.comlocal.id = vpn.example.com(swanctl 不要 @)
leftcert=fullchain.pemlocal.certs = fullchain.pem
leftsubnet=10.0.0.0/16children.net.local_ts = 10.0.0.0/16
rightauth=eap-mschapv2remote.auth = eap-mschapv2
rightsourceip=10.10.10.0/24pools.ikev2-pool.addrs = ...
rightdns=10.0.0.1pools.ikev2-pool.dns = 10.0.0.1
eap_identity=%identityremote.eap_id = %any
forceencaps=yesencap = yes
uniqueids=nounique = never
rekey=norekey_time = 0(连接 + child 各一次)
ike=...connections.*.proposals
esp=...children.*.esp_proposals
: RSA "privkey.pem"secrets.private-*.file = privkey.pem
user : EAP "pwd"secrets.eap-*.{ id, secret }

生成强密码:

head -c 12 /dev/urandom | base64

验证:

# 语法检查 (charon 未起也能跑)
swanctl --load-conns 2>&1 | head
# 期望: 看到 "loaded connection 'ikev2-vpn'" 或类似, 不报 syntax error

上面这个 --load-conns 在 charon 没起来时会报 "Connecting to ... failed",这是正常的,只是说明 vici socket 还没起。语法错的话会先打印 parsing 错误,优先看那一行。先不管,等 §6 启动了再统一检验。


§5 防火墙 + 路由(XFRM interface 方案)

[推荐] 本节是本教程踩坑后总结的最优解,采用 Linux XFRM interface(xfrm0 虚拟接口) 让 VPN 流量自然进入 lan zone,等同于 LAN 设备,不需要专门的 vpn zone / 不需要 masquerade / 不需要复杂的 forwarding 规则

早期方案(独立 vpn zone + subnet 绑定)在 immortalwrt snapshot 上踩到 fw4 规则优先级 bug — wan zone 的 iifname 匹配抢先于 vpn zone 的 subnet 匹配,VPN 流量会被错误路由到 input_wan 并拒绝(只 ping 通,TCP 全拒)。XFRM interface 方案彻底绕开这个问题。

5.1 确认 IP 转发已开

sysctl net.ipv4.ip_forward
# 期望: net.ipv4.ip_forward = 1 (OpenWrt 默认)

不用动。

5.2 装 XFRM interface 内核模块

apk update
apk add kmod-xfrm-interface luci-proto-xfrm
作用
kmod-xfrm-interface内核模块,提供 ip link add ... type xfrm 能力
luci-proto-xfrmLuCI / netifd 的 xfrm proto handler,让 UCI 能管 xfrm 接口

验证:

modprobe xfrm_interface
lsmod | grep xfrm_interface
# 期望: 看到 xfrm_interface

ls /lib/netifd/proto/xfrm.sh
# 期望: 文件存在

[提示] kmod-xfrm-interface 安装时自动建了 /etc/modules.d/xfrm-interface(内容就是模块名),开机自动加载已经搞定,不用再手工创建别的文件。验证:

cat /etc/modules.d/xfrm-interface
# 期望: xfrm_interface

5.3 放行 UDP 500/4500/ESP 入口

无论主路由还是旁路由,只要 OpenWrt 自己跑 strongSwan,就要让对应端口能进 charon。

ⓐ 主路由模式:在 wan 区域加 input 规则。

uci batch <<EOF
add firewall rule
set firewall.@rule[-1].name='Allow-IKEv2-IKE'
set firewall.@rule[-1].src='wan'
set firewall.@rule[-1].proto='udp'
set firewall.@rule[-1].dest_port='500'
set firewall.@rule[-1].target='ACCEPT'

add firewall rule
set firewall.@rule[-1].name='Allow-IKEv2-NATT'
set firewall.@rule[-1].src='wan'
set firewall.@rule[-1].proto='udp'
set firewall.@rule[-1].dest_port='4500'
set firewall.@rule[-1].target='ACCEPT'

add firewall rule
set firewall.@rule[-1].name='Allow-IKEv2-ESP'
set firewall.@rule[-1].src='wan'
set firewall.@rule[-1].proto='esp'
set firewall.@rule[-1].target='ACCEPT'

commit firewall
EOF

ⓑ 旁路由模式:LAN zone 默认 input=ACCEPT,UDP 500/4500 经前级路由器 NAT 转发后已经从 LAN 进来,通常不用加规则

5.4 创建 xfrm0 接口(UCI)

uci set network.xfrm0=interface
uci set network.xfrm0.proto='xfrm'
uci set network.xfrm0.tunlink='lan'             # 底层接口
uci set network.xfrm0.ifid='42'                 # 必须和 swanctl.conf 的 if_id_in/out 一致
uci set network.xfrm0.defaultroute='0'          # 不当默认网关
uci set network.xfrm0.zone='lan'                # 加入 lan zone (核心!)
uci commit network
ifup xfrm0
sleep 2

LuCI 法(等价):

  • 网络 → 接口 → 添加新接口
  • 名称:xfrm0,协议:XFRM
  • 常规设置:Tunnel Link = lan,IF ID = 42
  • 高级设置:取消勾选"使用该接口的网关作为默认网关"
  • 防火墙设置:分配到现有 lan zone(不要新建!)

验证:

ip link show xfrm0
# 期望: xfrm0@br-lan: <MULTICAST,NOARP,UP,LOWER_UP> mtu 1280 ...

nft list table inet fw4 | grep xfrm0 | head -4
# 期望: iifname { "xfrm0", "br-lan" } jump input_lan ...
#       iifname { "xfrm0", "br-lan" } jump forward_lan ...
# xfrm0 和 br-lan 平起平坐, fw4 视为 lan 接口

5.5 加静态路由 10.10.10.0/24 → xfrm0

这是 XFRM interface 模式必须手动加的一步 —— 内核需要知道"VPN 客户端 IP 段要从 xfrm0 出去"才能把响应包送回隧道。没这条路由,响应包会走默认路由出 wan,客户端永远收不到。

uci add network route
uci set network.@route[-1].interface='xfrm0'
uci set network.@route[-1].target='10.10.10.0/24'
uci commit network
service network reload

LuCI 法:

  • 网络 → 接口 → 顶部 静态路由 标签
  • 添加 IPv4 路由:接口 xfrm0,目标 10.10.10.0/24,网关留空,跃点留空

验证:

ip route get 10.10.10.1
# 期望: 10.10.10.1 dev xfrm0 src 10.0.0.1 ...
# 不能是 "via xxx dev pppoe-wan" — 那样响应包就丢了

5.6 MSS clamp(通常不需要,踩 MTU 坑时再加)

默认不用做这一步:xfrm0 接口 MTU 已经是 1280(LuCI 设置或 proto handler 默认),Linux 内核会:

  • 路由表里 10.10.10.0/24 dev xfrm0 自动继承 1280 MTU
  • TCP 建立时按 path MTU 自动算 MSS(= 1280 - 40 = 1240),双向都对
  • 转发的超大 TCP 包靠 PMTU discovery 兜底(ICMP "Frag needed")

实测在现代 Linux + 正常运营商网络下 xfrm0 MTU=1280 已经够用,不需要显式 MSS clamp。

什么时候才需要(踩坑后再加)

如果你看到这些征兆 → 再加显式 MSS clamp:

  • 浏览器开内网页面卡在加载某些大文件(JS / 图片)
  • 内网大文件下载到 N% 不动
  • SSH 连内网设备能连上,但敲长命令 / 粘贴大段卡死
  • ping -c 3 -s 1240 10.0.0.1 不通(应该通才对)

修法(加显式 MSS clamp 文件):

mkdir -p /etc/nftables.d
cat > /etc/nftables.d/99-vpn-mss.nft <<'EOF'
chain mangle_vpn_mss {
    type filter hook forward priority mangle; policy accept;
    iifname "xfrm0" tcp flags syn tcp option maxseg size set 1280
    oifname "xfrm0" tcp flags syn tcp option maxseg size set 1280
}
EOF
service firewall restart

# 验证
nft list chain inet fw4 mangle_vpn_mss
# 期望: 看到 2 条 maxseg 规则

/etc/nftables.d/*.nft 是 fw4 的 include 目录,fw4 重启自动加载。

备注:本教程编写期间最初默认加了这条 MSS clamp,后来用户反馈"删了也能稳跑",所以改成"按需"。这是 Linux 6.x 内核 + 现代 ISP 的进步,老内核或 ICMP 被严格过滤的网络可能还是需要。

5.7 模块开机自动加载(包安装时自动处理)

apk add kmod-xfrm-interface包脚本自动建了 /etc/modules.d/xfrm-interface,开机时 kmodloader 自动加载,不需要手工做任何事

验证:

cat /etc/modules.d/xfrm-interface
# 期望: xfrm_interface

# 看 kmodloader 确实在管理它
ls /etc/modules-boot.d/ 2>/dev/null | grep xfrm

如果你装包失败 / 文件意外丢失,可以手动建一份:

echo 'xfrm_interface' > /etc/modules.d/xfrm-interface

通常用不到。

5.8 完整验证(全过才进 §6)

echo '==== A. xfrm0 接口 UP + 在 lan zone ===='
ip link show xfrm0 | head -1
nft list table inet fw4 | grep xfrm0 | head -2

echo '==== B. 路由 10.10.10.0/24 走 xfrm0 ===='
ip route get 10.10.10.1
ip route | grep 10.10.10

echo '==== C. MSS clamp 规则在 ===='
nft list chain inet fw4 mangle_vpn_mss 2>&1 | grep maxseg

echo '==== D. wan 入口放行 ===='
nft list ruleset | grep -E 'udp dport (500|4500)' | head -2

echo '==== E. 模块加载持久化 ===='
cat /etc/modules.d/xfrm-interface 2>/dev/null
# 期望: xfrm_interface (包装时自动建)

5 节都有期望输出后才进 §6。

失败立刻回滚:

TS=$(cat /root/ikev2-backup/LAST)
cp /root/ikev2-backup/$TS/config/firewall /etc/config/firewall
cp /root/ikev2-backup/$TS/config/network /etc/config/network
service firewall restart
service network restart

§6 启动 + 验证

6.1 启动 swanctl

/etc/init.d/swanctl enable
/etc/init.d/swanctl start

# 等 3 秒看 charon 起来
sleep 3
pgrep -fa charon
# 期望: 看到 /usr/lib/ipsec/charon 或 charon-systemd 进程

6.2 加载配置

swanctl --load-all
# 期望输出 (按行):
#   loaded certificate from '/etc/swanctl/x509/fullchain.pem'
#   loaded RSA key from '/etc/swanctl/private/privkey.pem'
#   loaded eap secret 'eap-zhangsan'
#   loaded eap secret 'eap-lisi'
#   loaded pool 'ikev2-pool'
#   loaded connection 'ikev2-vpn'
#   successfully loaded 1 connections, 0 unloaded

OpenWrt 的 /etc/init.d/swanctl start 通常会自动跑一次 --load-all,但改完 swanctl.conf 后手动跑一次最稳。日后改密码 / 加用户也只需 swanctl --load-all,不用重启 charon。

6.3 关键自检

# 1. 证书加载
swanctl --list-certs --pretty | head -20
# 期望: subject "CN=vpn.example.com"
#       issuer  "C=AT, O=ZeroSSL GmbH, ..."   ← 必须 ZeroSSL
#       has private key
# 2. 连接定义加载
swanctl --list-conns
# 期望: ikev2-vpn: IKEv2 ... local: ... remote: ... children: net
# 3. IP 池加载
swanctl --list-pools
# 期望: ikev2-pool: 10.10.10.0/24, 254 online 0 / usage 0

任一缺失 → §8 排查。

6.4 实时日志

logread -e charon -f
# 或 logread -f | grep charon

打开手机连一次,日志里期望看到:

... received cert request for "..."
... authentication of 'vpn.example.com' with RSA_EMSA_PKCS1_SHA2_256 successful
... IKE_SA ikev2-vpn[N] established
... peer requested virtual IP %any
... assigning virtual IP 10.10.10.1 to peer

看在线客户端:

swanctl --list-sas
# 期望: ikev2-vpn: #1, ESTABLISHED, IKEv2 ...
#         net: #1, ..., reqid 1 ...

6.5 验证 XFRM interface 流量真的走 xfrm0

手机连上 VPN 后,在路由器抓 xfrm0 接口的明文流量:

tcpdump -ni xfrm0 -c 20

让 tcpdump 跑着,手机端 termux 跑 ping 10.0.0.1 或浏览器开内网页面。

期望看到:

IP 10.10.10.1.xxxxx > 10.0.0.1.xxx: ICMP echo request ...
IP 10.0.0.1.xxx > 10.10.10.1.xxxxx: ICMP echo reply ...

双向都有 → XFRM 路径 100% 通了,VPN 部署完工。

只有单向(只有 client → router 没有 router → client):§5.5 静态路由没装好或 swanctl.conf 的 if_id_in/out 跟 xfrm0 的 ifid 不一致。回查 §8.5b。

6.6 验证证书自动续期

LuCI 用户:

grep -q sync-to-swanctl /etc/crontabs/root && echo "cron OK"
/etc/acme/hooks/sync-to-swanctl.sh   # 手动跑一次, 不报错说明脚本正常

命令行用户:

crontab -l | grep acme.sh
# 期望: 看到 acme.sh --cron 行

# 强制续期一次预演(不会真换证):
/usr/lib/acme/acme.sh/acme.sh --renew -d vpn.example.com --force
# 期望: Cert success + Run reload cmd: swanctl --load-all

§7 客户端配置

完全照搬 Debian §7。服务器域名、用户名密码、分流网段都和 Debian 教程一致,iOS / Android / Windows 步骤一字不差。这里不重复。

要点速记:

  • iOS:IKEv2 类型,服务器 = 远程 ID = vpn.example.com(不带 @),自动分流
  • Android:装 strongSwan App,VPN Type IKEv2 EAP,Split tunneling 仅 10.0.0.0/16
  • Windows:IKEv2,装完后管理员 PowerShell 跑 Set-VpnConnection -SplitTunneling $True + Add-VpnConnectionRoute -DestinationPrefix 10.0.0.0/16

§8 OpenWrt / snapshot 特有踩坑

Debian 教程 §8 的所有坑(LE 链不被信任 / ipsec.secrets 第一行格式 / 安卓"较不安全"标签 / 移动网络分片 / hairpin 等)在 OpenWrt 上同样适用,直接对照 Debian §8。下面只列 OpenWrt / snapshot 独有的。

8.1 snapshot kmod 装不上

ERROR: <pkg>: unable to select packages:
  kernel-<X.Y.Z>: providers not available

(老 opkg 报错:incompatible with the architectures configured)

原因:snapshot 每天重建,kmod-*必须严格匹配 running kernel 的小版本

修法:

apk update && apk add <pkg>    # 一气呵成, 别隔夜

如果 24h 之内没装上,先 sysupgrade -F 拉最新固件,再装包。

8.2 装到一半 Out of memory / No space left

ERROR: ... No space left on device

修法(优先级排序):

  1. df -h /overlay 确认确实满了
  2. apk list -I | grep luci-app- 删用不到的 LuCI 主题/插件
  3. 把模块清单削更狠:本教程已是最小集,再减就跑不起来
  4. 最终方案:扩展 USB 存储做 overlay,或者换 32MB+ 闪存的硬件

8.3 service firewall restart 后自定义 nft 规则消失

OpenWrt 重启 fw4 会重新生成 nftables 整张表,你用 nft add rule 直接写的规则会丢。

只能走 UCI(本教程 §5 全程 UCI),或者写 include 文件到 /etc/nftables.d/*.nft

8.4 sysupgrade 后 /etc/swanctl/ 证书丢失

OpenWrt 升级默认保留 /etc/config/*,但 /etc/swanctl/ 是否在保留名单要看 strongswan 包本身。保险做法:

cat >> /etc/sysupgrade.conf <<EOF
/etc/swanctl/
/etc/acme/
/root/.acme.sh/
EOF

sysupgrade -l 2>/dev/null | grep -E 'swanctl|acme' 看下里面有没有,有就稳了。

8.5 时间不准 → 证书校验失败

certificate ... is not valid yet

OpenWrt 部分硬件没 RTC,断电后时间归零,会出现"未来才生效"的奇怪错误。

service sysntpd status     # 必须 running
service sysntpd restart
date                        # 重新看时间

snapshot 里没 sysntpd?装 chrony:

apk add chrony && /etc/init.d/chronyd enable && /etc/init.d/chronyd start

8.5 crypto-safexcel 硬件加速驱动崩溃(IPsec 触发)

症状:客户端一连 VPN 几秒内整个路由器重启,WAN 重新拨号,所有 LAN 设备掉线。uptime 归零。

pstore (/sys/fs/pstore/dmesg-ramoops-0)证据:

Unable to handle kernel read from unreadable memory at virtual address 0x20
CPU: x Comm: irq/76-15600000      ← crypto-safexcel 硬件加速 IRQ 线程
Tainted: ... 6.18.31
pc : 0xffffffc079XXXXXX            ← 模块代码段
Call trace: irq_thread_fn ...
Kernel panic - not syncing: Oops: Fatal exception

真凶:crypto_safexcel 驱动在 ESP 解密完成的 IRQ 处理路径上踩 NULL 指针。OpenWrt 25 snapshot 内核(6.18.x)的回归 bug,不能通过配置修

误判警告:崩溃 dump 里 Modules linked in: ... nft_fullcone(O) ... 看似指向 fullcone,但关掉 fullcone 后仍然崩(我们实测过)。fullcone 只是恰好在已加载模块列表里。

修法:卸掉硬件加速驱动,strongSwan 自动回退软件 crypto:

# 1. 找包名
apk list -I | grep -iE 'safexcel|crypto-hw'
lsmod | grep safexcel

# 2. 停 strongSwan, 卸包, 重启
/etc/init.d/swanctl stop
apk del kmod-crypto-hw-safexcel       # 实际包名按第 1 步结果
reboot

# 3. 重启回来验证
lsmod | grep safexcel                  # 期望: 空
dmesg | grep safexcel                  # 期望: 空

性能影响:软件 AES-GCM 在带 ARMv8 AES 指令的 SoC 上 200-400 Mbps,家用 VPN 远程访问完全够用

如果卸了还崩:那不是 safexcel 单独的问题,是 snapshot 内核 IPsec 子系统更深的 bug,走架构层方案:

  • 把 strongSwan 搬到内网另一台 Linux 设备(NAS / Pi / 迷你 PC),OpenWrt 只做端口转发 — 见 Debian 服务器搭建指南,最干净
  • 或者改用 WireGuard(不走 xfrm / safexcel 路径)
  • 或者降级 OpenWrt 24.10 稳定版(代价:你硬件可能没适配,查 ToH)

8.5a 连上 VPN 路由器就重启 / WAN 重拨号

症状:客户端一连 VPN,几秒内整个路由器重启,WAN 重新拨号拿新 IP,所有 LAN 设备掉线。uptime 回到几分钟。

真凶:kmod-nft-fullcone 模块(OpenWrt 社区 fullcone NAT 补丁)在 IPsec 流量 + NAT 路径上踩内核空指针 panic

证据来源:/sys/fs/pstore/dmesg-ramoops-0 里能看到:

Unable to handle kernel read from unreadable memory at virtual address 0x20
Modules linked in: ... nft_fullcone(O) ...
Tainted: [O]=OOT_MODULE
Comm: irq/76-15600000   ← crypto-safexcel(IPsec 硬件加速)中断
Kernel panic - not syncing: Oops: Fatal exception

根因:kmod-nft-fullcone 是社区补丁,2023 年的代码塞进 2026 内核(6.18),代码没跟上 API,在 IPsec + xfrm 流量这种非常规路径下崩。OpenWrt 主线没采用这个模块就是因为代码质量。

修法(按损失递增):

# 方案 A (推荐先试): 保留 wan 的 fullcone, 只关 vpn zone 的
# 90% 概率有效, 内网设备仍享受 NAT1
VPN_IDX=$(uci show firewall | grep -E "name='vpn'" | sed -E "s/.*\[([0-9]+)\].*/\1/")
uci set firewall.@defaults[0].fullcone='1'        # 保证全局开
uci set firewall.@zone[$VPN_IDX].fullcone='0'     # 单独关 vpn zone
uci commit firewall
service firewall restart
nft list ruleset | grep -i fullcone
# 期望: 只剩 "Handle wan IPv4 fullcone..." 两条, 没有 vpn

# 方案 B: 全局关 fullcone
uci set firewall.@defaults[0].fullcone='0'
uci commit firewall
service firewall restart
nft list ruleset | grep -c fullcone   # 应 = 0

# 方案 C: 直接卸了模块 (最彻底)
apk del kmod-nft-fullcone
service firewall restart

为什么方案 A 多半够用:崩溃路径是 crypto IRQ → IPsec 解密 → NAT 链 → vpn zone fullcone hook → 空指针。VPN 客户端流量主要被 vpn zone 的 NAT 规则匹配,不走 wan 的。fullcone 模块的 bug 似乎触发在 "按 subnet 绑定的非接口 zone"(我们的 vpn zone 就是这种),wan zone 因为绑物理接口,代码路径不一样,通常不崩。

方案 A 失败时(新崩溃日志里仍有 nft_fullcone(O))再退到 B 或 C。

影响对比:

方案NAT1(LAN 设备)NAT1(VPN 客户端)不崩
A[OK][X]通常 [OK]
B/C[X][X][OK]

家用 + VPN 服务器场景,方案 A 是最优解。即使退到 B/C,损失也只是 P2P 游戏(怪猎/魂)联机偶尔慢一点,刷视频 / 网页 / 上班完全无感。

这个坑只在 kmod-nft-fullcone 安装且开启时出现。snapshot 默认不带,但很多第三方编译的 OpenWrt 固件(Tenda、小米、Redmi 等品牌的"科学上网增强版"固件)预装 + 默认开。firewall restart 时如果看到 IPv4 fullcone enabled for zone '...' 就是埋了雷。

8.5b XFRM interface 方案的 3 个易错点(本教程 §5 必读)

xfrm0 方案省事且架构干净,但 3 个细节漏一个就死:

a. swanctl.conf 必须有 if_id_in/out

children {
    net {
        local_ts = 10.0.0.0/16
        if_id_in = 42       # 必填
        if_id_out = 42      # 必填
        ...

数字必须和 ip link add xfrm0 type xfrm ... if_id 42 的 ifid 完全一致。漏了或不一致 → SA 建立成功但解密包不进 xfrm0,VPN 客户端发啥都没反应。

b. 必须显式加静态路由 10.10.10.0/24 dev xfrm0

XFRM interface 模式不像传统 policy-based XFRM 那样自动安装 kernel 路由,响应包没路由就走默认网关出 wan,客户端永远收不到。征兆:

  • ping 客户端 → router 通(因为客户端发包能进 xfrm0)
  • router → ping 客户端不通,curl 连客户端任何 TCP 拒绝
  • ip route get 10.10.10.1 显示 via xxx dev pppoe-wan ← 错的
  • ip route get 10.10.10.1 应该显示 dev xfrm0 src 10.0.0.1 ← 对的

修法见 §5.5。

c. xfrm0 必须在 lan zone(不是新建 vpn zone)

xfrm0 加到 lan zone 等于"VPN 客户端 = LAN 设备",fw4 input_lan / forward_lan 自动放行。如果新建一个 vpn zone 单独管 xfrm0,你又会回到本教程一开始踩到的"vpn zone 入站不被识别"的坑(因为 IPsec 解密后 iifname 不一定准确,subnet 匹配优先级被 wan iifname 抢)。

LuCI 创建 xfrm0 接口时,"防火墙设置"那一步选 现有的 lan,不是创建新区域。


8.5c TCP 通但浏览器卡(MTU/MSS 问题)

征兆:

  • ping 10.0.0.1 -s 1000 通,ping -s 1400 不通
  • curl http://10.0.0.1 连接建立后卡住没数据(SYN 通,后续大包丢)
  • 浏览器开了页面但加载不出图片/CSS

原因:IPsec 加上 ~50-70 字节 overhead,中间某段路径 MTU 不够;Linux PMTU discovery 没生效(ICMP 被某网络设备屏蔽 / 客户端不支持 PMTUD)。

先检查:xfrm0 接口 MTU 是不是 1280

ip link show xfrm0 | grep mtu
# 期望: mtu 1280

如果 MTU 对的,现代 Linux 99% 情况自动处理就够了。出现上面征兆才说明 PMTU 兜底失败,才需要加 §5.6 的显式 MSS clamp(/etc/nftables.d/99-vpn-mss.nft 写死 1280)。

为什么 xfrm0 MTU 通常够用:

  • 路由器自己起 TCP:内核知道 path MTU 自动算 MSS
  • 转发 TCP:大包到 xfrm0 → ICMP "Frag needed" 给发送方 → 降 MSS 重发

为什么有时不够:

  • 中间网络设备过滤 ICMP type 3 code 4(Frag needed)
  • 老旧客户端不支持 PMTU discovery
  • 容器 / WSL 等隔离环境的网卡不响应 ICMP

碰到上面任何一条 → §5.6 的 MSS clamp 文件加上,问题消失。

想要榨干性能?用 ping -M do 测真实无分片 MTU

-M do 强制设置 DF (Don't Fragment) 位,禁止 IP 分片,内核会拒绝超过 path MTU 的包并报告真实 MTU。

# 手机 termux 连着 VPN, 从小到大试
ping -c 3 -s 1240 -M do 10.0.0.1
ping -c 3 -s 1280 -M do 10.0.0.1
ping -c 3 -s 1320 -M do 10.0.0.1
ping -c 3 -s 1360 -M do 10.0.0.1

期望某个尺寸开始报:

ping: local error: Message too long, mtu=XXXX

mtu=XXXX 就是这条路径的真实 inner MTU(手机 strongSwan App 给 tun 接口设的 MTU)。把 xfrm0 设比这个小 5-10 字节做余量。

这个值跟运营商 / 5G/4G / 客户端实现都相关。不同手机 / 不同时间可能不一样。除非你能接受"换网络就重测",否则xfrm0 MTU 保持 1280 是最稳的折中。性能提升 < 5%,日常感知不到。


8.6a 安卓原生 VPN no matching peer config found

服务端 swanctl --log 看到:

looking for peer configs matching <WAN_IP>[<WAN_IP>]...<client_IP>[<your_domain>]
no matching peer config found
sending AUTH_FAILED

原因:某些安卓 ROM(尤其 MIUI/HyperOS)的原生 VPN 把 IKE 的 IDi/IDr 装反了 — 把"服务器域名"塞进自己的身份(IDi),把解析出的 IP 当作期望的服务器身份(IDr)发过来。

我们 local.id = jp.vpn.wuzuxi.com,客户端期望 <WAN_IP>,严格匹配失败。

修法:swanctl.conflocal.id 改成 %any:

local {
    auth = pubkey
    certs = fullchain.pem
    id = %any                  # 接受客户端要求的任何 IDr
}

swanctl --load-all

安全性几乎不变 — 真正的服务端身份验证还在 cert 上(ZeroSSL 真证书),用户认证还在 EAP 密码上。local.id 这一道只是配置整洁度,不是安全边界。strongSwan App / iOS / Windows 一直正常发 IDr=域名,%any 都包含。

8.6b 安卓原生 VPN 握手到 IKE_AUTH 后无响应

服务端 swanctl --log 看到 peer config 匹配成功、发出 IKE_AUTH response,然后没有任何后续:

generating IKE_AUTH response 1 [ IDr AUTH EAP/REQ/ID ]   ← 注意缺 CERT
sending packet: ...
(然后没了)

原因:安卓原生 VPN 不发 CERTREQ(请求服务端证书),strongSwan 默认 send_cert = ifasked → 不主动发 CERT → 安卓收到 IDr AUTH 没法验证服务端签名 → 默默丢弃。

对比 strongSwan App / iOS 的请求里都有 CERTREQ,服务端响应就带 IDr CERT CERT AUTH EAP/REQ/ID,客户端能验。

修法:swanctl.confconnections.ikev2-vpn 顶层加一行强制送证:

connections {
    ikev2-vpn {
        version = 2
        ...
        send_cert = always           # 强制每次都送 CERT, 不管对方有没要
        ...
    }
}

swanctl --load-all 重载。所有客户端都受益,没副作用(就是每次响应大几 KB)。

8.6 acme.sh 在 busybox ash 下偶发语法错

OpenWrt /bin/sh 是 busybox ash,acme.sh 主要为 bash 写的,某些边角语法可能炸(常见症状:syntax error: bad substitution)。

修法:装 bash 并改 shebang:

apk add bash
sed -i '1s|.*|#!/bin/bash|' /usr/lib/acme/acme.sh/acme.sh

8.7 snapshot 包名漂移找不到

snapshot 长期演进,部分包会改名 / 拆分 / 合并(典型:strongswan-mod-* 模块拆分变动)。

通用查找:

apk search <关键词>
apk search 'strongswan-mod-*'

strongswan-mod-xxx 找不到时,看元包依赖找当前真实子包名:

apk info strongswan-full | sed -n '/depends/,/provides/p'

8.8 默认 LAN 是 192.168.1.0/24

OpenWrt 出厂默认 192.168.1.0/24。本教程要求 10.0.0.0/16(§0.1 已检查)。如果你保留默认网段,全文做替换:

  • 10.0.0.0/16192.168.1.0/24
  • 10.0.0.1192.168.1.1
  • 10.10.10.0/24 → 改成不冲突的段(例如 192.168.99.0/24),否则 VPN 客户端 IP 池和 LAN 冲撞

强烈建议改 LAN 到 10.0.0.0/16 一次性对齐,后面所有配置直接抄。

8.9 LuCI ACME 页面提交后没动静

LuCI ACME 提交后只是写 /etc/config/acme,真正触发签发要手动:

/etc/init.d/acme start
logread -e acme -f

不要傻等 UI 自动刷新。

8.10 acme 账号注册 invalidContact

"type": "urn:ietf:params:acme:error:invalidContact"
"detail": "contact email contains non-ASCII characters"

原因:邮箱含中文 / 全角。

修法:用纯英文邮箱,重新跑:

/usr/lib/acme/acme.sh/acme.sh --register-account -m real-english@example.com --server zerossl

LuCI 用户去页面把 Account email 改成英文,Save & Apply,再 /etc/init.d/acme start


§9 教程没办法替你解决的"环境坑"

完全同 Debian §9:

  • [X] CGNAT / 双层 NAT(WAN IP 是 100.64.0.0/10)
  • [X] ISP 封 UDP 500/4500
  • [X] GFW 干扰
  • [X] 公司/学校防火墙拦截 IPSec

跟你 OpenWrt 配置无关,改不了。


§10 日常运维 + 完全卸载

10.1 日常运维

操作命令
**修改 VPN 池(改 pool 后 swanctl --load-all 报 "online leases")先踢:swanctl --terminate --ike ikev2-vpn,再 --load-all
加用户/etc/swanctl/swanctl.confsecrets {} 段加一个 eap-<name> { id=...; secret="..."; }swanctl --load-all
删用户删对应 eap-* 段 → swanctl --load-all
看在线swanctl --list-sas
实时日志logread -e charon -f
看证书openssl x509 -in /etc/swanctl/x509/fullchain.pem -noout -dates -issuer
手动续期(3-A) /etc/init.d/acme start / (3-B) /usr/lib/acme/acme.sh/acme.sh --renew -d vpn.example.com --force
改完配置重载swanctl --load-all(不重启 charon,在线会话不断)
全停/etc/init.d/swanctl stop
重启/etc/init.d/swanctl restart(会断所有在线 VPN)

10.2 完全卸载 / 回滚到装之前

按反向顺序,全部基于 §0.3 的备份:

# 1. 停服务
/etc/init.d/swanctl stop ; /etc/init.d/swanctl disable
/etc/init.d/acme stop    ; /etc/init.d/acme disable 2>/dev/null

# 2. 删 swanctl / acme 配置与证书
rm -rf /etc/swanctl/swanctl.conf /etc/swanctl/swanctl.conf.bak \
       /etc/swanctl/private/privkey.pem \
       /etc/swanctl/x509/fullchain.pem \
       /etc/swanctl/x509ca/chain.pem \
       /etc/acme /etc/acme.sh /root/.acme.sh
# 留 /etc/swanctl/ 目录骨架(后续若再装 strongswan 不用重建)

# 3. 删 cron 任务
sed -i '/sync-to-swanctl/d;/acme.sh.*--cron/d' /etc/crontabs/root
/etc/init.d/cron restart

# 4. 删 sysupgrade.conf 里我们加的行
sed -i '/\/etc\/swanctl/d;/\/etc\/acme/d;/\.acme\.sh/d' /etc/sysupgrade.conf

# 5. 删 xfrm0 接口 + 静态路由 (UCI)
uci -q delete network.xfrm0
# 删 10.10.10.0/24 静态路由 (找到对应 index)
for r in $(uci show network 2>/dev/null | grep -E '@route\[' | sed -E "s/.*\[([0-9]+)\].*/\1/" | sort -ru); do
    tgt=$(uci -q get network.@route[$r].target)
    [ "$tgt" = "10.10.10.0/24" ] && uci delete network.@route[$r]
done
uci commit network
service network reload

# 6. 删模块自动加载 (apk del kmod-xfrm-interface 会自动清掉,无需手动)
# /etc/modules.d/xfrm-interface 跟着包一起卸,无需 rm

# 7. 删 MSS clamp 文件
rm -f /etc/nftables.d/99-vpn-mss.nft

# 8. 还原 firewall (整文件回退到备份, 顺带清掉 wan 入口 IKEv2 三条规则)
TS=$(cat /root/ikev2-backup/LAST)
cp /root/ikev2-backup/$TS/config/firewall /etc/config/firewall
service firewall restart

# 9. 卸载包 (只列我们显式装的, strongswan-default 拉进来的依赖会随它一起被清掉)
apk del \
  luci-app-acme acme-acmesh acme-acmesh-dnsapi \
  strongswan-default \
  strongswan-mod-eap-mschapv2 strongswan-mod-eap-identity strongswan-mod-eap-dynamic \
  strongswan-mod-openssl \
  kmod-xfrm-interface luci-proto-xfrm \
  2>/dev/null

# 残留依赖看一下, 不想要的话再单独 apk del
apk list -I | grep -E 'strongswan|xfrm'

# 10. 验证清理干净
apk list -I | grep -iE 'strongswan|acme|xfrm-interface'   # 期望: 空
nft list ruleset | grep -E '500|4500|10\.10\.10|xfrm0'    # 期望: 空
ip link show xfrm0 2>/dev/null                            # 期望: 不存在
ls /etc/swanctl/swanctl.conf 2>/dev/null                  # 期望: 不存在
ls /etc/swanctl/private/privkey.pem 2>/dev/null           # 期望: 不存在

完全卸载验证:重启路由器,确认上网、LuCI、原有所有服务正常。

10.3 如果只是临时停用 VPN,不卸载

/etc/init.d/swanctl stop
/etc/init.d/swanctl disable

fw4 规则留着不影响其他功能,真要清理再走 §10.2。


§11 安全说明

完全同 Debian §11:

  • 用 EAP-MSCHAPv2 而不是 PSK(PSK 没有服务器身份验证)
  • 密码 ≥ 12 位,head -c 12 /dev/urandom | base64 生成
  • MSCHAPv2 包在 AES-256-GCM 隧道里,理论弱点摸不到

§12 关键文件位置速查

文件作用谁维护在 §10 卸载里怎么处理
/etc/swanctl/swanctl.conf连接 + IP 池 + 用户密码 + 私钥引用 + if_id_in/outrm
/etc/swanctl/private/privkey.pem服务器私钥acme(同步脚本)rm
/etc/swanctl/x509/fullchain.pem末端 + 中间证书acme(同步脚本)rm
/etc/swanctl/x509ca/chain.pem中间证书(供链验证)acme(同步脚本)rm
/etc/acme/<domain>/ (LuCI)acme 工作目录acmerm -rf
/root/.acme.sh/ (CLI)acme.sh 工作目录acme.shrm -rf
/etc/config/networkxfrm0 interface + 10.10.10.0/24 静态路由uci(§5.4 / §5.5 加)uci delete + restore
/etc/config/firewallfw4 规则(wan 入口 + xfrm0 加入 lan zone)uci(§5.3)还原备份
/etc/nftables.d/99-vpn-mss.nftMSS clamp 1280 防大包丢(§5.6)rm
/etc/modules.d/xfrm-interface开机自动加载 xfrm_interface 模块kmod-xfrm-interface 包(装包自动建)卸包自动清,无需手动
/etc/config/acme (LuCI)LuCI ACME 配置uci删 acme 包时会清
/etc/crontabs/root续期 cronsed -i 删两行
/etc/sysupgrade.conf升级保留名单sed -i 删几行
/root/ikev2-backup/§0.3 备份(config/network + config/firewall 都在)全卸载验证后再删

§13 完工检查清单

按顺序,任一不过回到对应章节:

  • [ ] cat /etc/openwrt_release 显示 SNAPSHOT / 25.x (§0.1)
  • [ ] ls /sbin/fw4 存在 (§0.1)
  • [ ] uci show network.lan.ipaddr10.0.0.1 (§0.1)
  • [ ] /root/ikev2-backup/$(cat /root/ikev2-backup/LAST)/config 备份完整 (§0.3)
  • [ ] nslookup vpn.example.com 8.8.8.8 返回你的 WAN IP (§1.1)
  • [ ] (旁路由) 前级路由器已转发 UDP 500/4500 (§1.2)
  • [ ] swanctl --version 输出 strongSwan 5.x/6.x (§2.2)
  • [ ] ls /usr/lib/acme/acme.sh/dnsapi/dns_dp.sh 存在 (§2.3)
  • [ ] openssl x509 -in /etc/swanctl/x509/fullchain.pem -noout -issuer 显示 ZeroSSL (§3-C.3)
  • [ ] /etc/swanctl/swanctl.conf 里有 secrets.private-server.file = privkey.pem 段 (§4.1)
  • [ ] /etc/swanctl/swanctl.confchildren.netif_id_in = 42if_id_out = 42 (§4.1)
  • [ ] sysctl net.ipv4.ip_forward 显示 1 (§5.1)
  • [ ] lsmod | grep xfrm_interface 看到模块加载 (§5.2)
  • [ ] nft list ruleset | grep -E 'udp dport (500|4500)' 有规则 (§5.3)
  • [ ] ip link show xfrm0 显示 UP + LOWER_UP + MTU 1280 (§5.4)
  • [ ] nft list table inet fw4 | grep xfrm0 看到 iifname { "xfrm0", "br-lan" } jump input_lan / forward_lan (§5.4)
  • [ ] ip route get 10.10.10.1 输出 dev xfrm0(不是 via pppoe-wan)(§5.5)
  • [ ] nft list chain inet fw4 mangle_vpn_mss 看到 MSS 1280 规则 (§5.6)
  • [ ] cat /etc/modules.d/xfrm-interface 显示 xfrm_interface(包装时自动建)(§5.2)
  • [ ] /etc/init.d/swanctl status 显示 running (§6.1)
  • [ ] swanctl --load-all 输出 loaded RSA key + loaded connection 'ikev2-vpn' (§6.2)
  • [ ] swanctl --list-certs --pretty 看到 RSA 私钥 + ZeroSSL 证书 (§6.3)
  • [ ] 手机连上后 swanctl --list-sas 显示 ikev2-vpn: ... ESTABLISHED (§6.4)
  • [ ] 手机连上后 tcpdump -ni xfrm0 能看到明文流量(证明走对了 xfrm0)
  • [ ] 手机连上后能 curl http://10.0.0.1 返回 HTTP 200/301/302(TCP 不再 Connection refused)
  • [ ] 手机连上后 ping youtube.com 解析得到 IP 且能通(DNS 验证)
  • [ ] 手机连上后 ping -s 1240 10.0.0.1 大包能通(MSS clamp 验证)
  • [ ] 手机连上后访问 ip.sb 显示手机原本运营商 IP(分流验证,不应显示路由器 IP)
  • [ ] (可选)crontab -l | grep -E 'acme|sync-to-swanctl' 看到续期 cron (§6.4)

全部 [OK] 即完成。


附录:与 Debian 教程的章节映射

OpenWrt §XDebian §X是否一致
§0.1 环境自检§0.1命令换 OpenWrt 版
§0.2 部署模式(无,Debian 隐含旁路由)新增
§0.3 全量备份(无)新增(可逆要求)
§0.4 DNSPod token§0.2完全一致
§1 域名 + 端口§1ⓐ/ⓑ 分支
§2 装包§2包名/命令换 OpenWrt
§3 ZeroSSL 证书§3A/B 两路径
§4 strongSwan 配置§4完全一致
§5 防火墙 + 路由§5(iptables 直配)完全重新设计:XFRM interface(xfrm0)+ 加入 lan zone + 静态路由,本质上"VPN 客户端 = LAN 设备"
§6 启动验证§6服务名 / 日志命令换 OpenWrt
§7 客户端§7完全一致,直接引用
§8 踩坑§8OpenWrt 特有的 10 条
§9 环境坑§9完全一致
§10 运维 + 卸载§10加"卸载/回滚"全流程
§11 安全§11完全一致
§12 文件速查§12OpenWrt 路径
§13 检查清单§13OpenWrt 版命令
Last modification:June 6, 2026
If you think my article is useful to you, please feel free to appreciate