服务器操作指北(11)EdgeRouter IPv6 排障:从默认路由到 NDP 代理
创建于 2026-09-19
更新于 2026-09-19
科技
服务器
ipv6
edgeos
ndp
21003 字 · 约 71 分钟

前言

服务器通过 EdgeRouter 接入上游网络后,IPv4 正常,IPv6 只有链路本地地址和 ULA,无法访问 IPv6 外网。同一上游网络中的直连服务器可以正常使用 IPv6。

排查确认了以下问题:

  1. 路由器存在一条只指定出口接口、未指定下一跳的错误 IPv6 默认路由。
  2. 上游 DHCPv6-PD 请求返回 NoPrefixAvail,未获得用于下游网络的委派前缀。
  3. LAN 使用上游相同的 /64 时,需要 NDP 代理处理跨接口邻居发现,并配置 WAN 到 LAN 的回程路由。

修复采用 RA/SLAAC、ndppd 和策略路由,并通过 systemd 定时同步前缀。路由器及服务器的 IPv6 连通性、入站防火墙和整机重启后的自动恢复均已验证。

以下记录配置与验证过程。地址和接口名已替换为示例;2001:db8:100::/64 为文档专用前缀,使用时需替换为实际前缀。

一、检查 IPv6 地址与路由

1.1 检查地址类型及出口连通性

在服务器上检查 IPv6 地址、路由,并发起 IPv6 HTTPS 请求:

bash
1
2
3
4
ip -6 addr show ip -6 route show curl --noproxy '*' -6 --connect-timeout 10 --max-time 20 \ -I https://www.cloudflare.com/

fe80::/10 是链路本地地址,通常用于同一链路上的通信;fc00::/7 范围内的 ULA 也不能直接当作公网地址使用。即使拿到了全球单播地址,缺少正确路由、邻居解析失败或者被防火墙拦截,外网仍然不通。

--noproxy '*' 禁用本次请求的代理,测试服务器本身的 IPv6 出口。-6 则限制目标连接使用 IPv6;DNS 查询本身不因此保证走 IPv6。

1.2 RA、SLAAC、DHCPv6 与 NDP 的作用

相关协议的作用如下:

机制 在本次问题中的作用
RA,路由器通告 告知主机默认路由器、链路前缀及相关标志
SLAAC,无状态地址自动配置 主机根据 RA 中允许自动配置的前缀生成地址
DHCPv6 地址分配 由 DHCPv6 服务向客户端分配地址等参数
DHCPv6-PD,前缀委派 向下游路由器委派用于其下游网络的前缀
NDP,邻居发现 在链路上发现邻居,并解析目标 IPv6 地址对应的链路层地址

默认路由器由 RA 通告,开启 DHCPv6 不等于已配置默认路由。RFC 4861RFC 4862 分别描述了邻居发现与 SLAAC 的相关机制。

WAN 获得 /64 内的地址,只能证明该接口具有地址配置。下游独立子网还需要上游提供前缀委派或相应的静态路由。

二、修复默认路由并检查前缀委派

2.1 删除错误的接口默认路由

排查时,路由器存在一条只指定出口接口的 IPv6 默认路由,形式如下:

text
1
default dev eth0

在这次以太网接入环境中,这条路由缺少上游下一跳。对于没有更具体路由的目的地址,它会让系统尝试直接在出口链路解析目的地址,而上游实际上要求把这些流量交给网关。

删除这条错误静态配置后,由上游 RA 学习到的默认路由可以正常承担转发,其形式变为:

text
1
default via fe80::1 dev eth0 proto ra

这里的接口和网关均为示例。fe80::1 这样的链路本地地址作为下一跳并不异常,只要同时绑定正确接口即可。

在 EdgeOS 配置模式中,删除对应配置的命令结构如下。执行前应先确认当前配置确实存在这一项,不要把它当作通用初始化命令:

bash
1
2
3
4
5
6
configure # eth0 为示例名称,替换为实际 WAN 接口 delete protocols static interface-route6 ::/0 next-hop-interface eth0 commit save exit

删除后,在路由器上执行 IPv6 HTTPS 测试,确认 WAN 出口恢复,再检查 LAN 转发。

2.2 DHCPv6-PD 返回 NoPrefixAvail

随后尝试 DHCPv6 前缀委派。在本次测试中,三次请求都收到了 NoPrefixAvail,状态码为 6。该状态表示服务器没有可提供给请求方的前缀,含义可查阅 DHCPv6 协议中的状态码说明

本次接入未获得 PD,因此无法按“获取委派前缀,再分配给 LAN”的方式配置。该结果仅对应本次请求及当时的上游策略。

三、确定 NDP 代理方案

DHCPv6 relay 转发的是 DHCPv6 消息。它本身不会同时完成 RA 传递、默认路由配置和跨接口的邻居发现。即使 DHCPv6 成功分配了地址,上游依然需要知道返回流量应该发到哪里。

上游提供链路前缀 /64,WAN 与 LAN 位于不同的二层链路。方案示意如下:

text
1
2
3
4
5
6
7
上游 IPv6 链路 | WAN:获得上游 /64 内的地址,学习 RA 默认路由 | EdgeRouter:路由转发、NDP 代理、防火墙 | LAN:广播同一 /64,终端通过 SLAAC 配置地址

LAN 广播相同的 /64 后,终端可以生成地址。但上游仍将这些地址视为链路上的邻居,在 WAN 一侧发送邻居请求;LAN 主机无法直接接收并回应。NDP 代理用于处理这部分查询。

本次未获得 PD,且需要保留现有 IPv4 路由配置,因此采用共享前缀的 NDP 代理方案。桥接会改变二层结构,未采用。

四、配置 ndppd、策略路由与防火墙

4.1 部署位置与环境检查

ndppd 和同步程序安装在 EdgeRouter 上,下游服务器不安装 ndppd。 下游接口需要启用 IPv6 自动配置并接受 RA。本次服务器已有这些设置,无需修改服务器的持久网络配置。

本文使用以下示例参数,操作前替换为自己的配置:

参数 本文示例 用途
路由器 SSH 目标 router-admin@192.0.2.1 上传及登录,替换用户名和管理地址
WAN eth0 连接上游的以太网接口
LAN switch0 EdgeOS switch 类型接口
程序目录 /config/ipv6-proxy 本文指定的安装目录
策略表与优先级 10640 必须未被其他策略占用

192.0.2.1 是文档地址。接口名也是示例,不表示所有 EdgeRouter 都使用相同端口。下方脚本默认 LAN 为 switch 类型;若使用 ethernet 或 bridge 类型接口,还需修改脚本中的 RA 配置路径,不能只替换接口名称。

EdgeRouter 的普通 shell 中执行:

bash
1
2
3
4
5
6
7
8
9
10
11
uname -a dpkg --print-architecture file /bin/bash python --version systemctl --version ip -o link show ip -6 addr show dev eth0 ip -6 route show default ip -6 rule show sudo sysctl net.ipv6.conf.all.forwarding sudo sysctl net.ipv6.conf.eth0.accept_ra

本文对应 EdgeOS 3.0.1、MIPS32 大端用户态、Python 2.7 和 systemd。IPv6 转发应为 1;转发开启时,WAN 若仍通过 RA 学习路由,Linux 的 accept_ra 应为 2。本次在原生自动配置生效后满足这些条件。若尚未满足,先修复 WAN 的自动配置与转发,再安装代理。

WAN 原生自动配置的命令结构如下,接口名按实际情况修改:

bash
1
2
3
4
5
configure set interfaces ethernet eth0 ipv6 address autoconf commit save exit

执行后重新检查公网地址、accept_radefault via ... dev eth0 proto ra。路由器自身的 IPv6 HTTPS 测试必须先通过。已有 WAN IPv6 本机入站防火墙时,还需要允许上游 RA 和必要的邻居发现消息。

修改前在路由器上备份原生配置和相关运行状态:

bash
1
2
3
4
5
6
7
8
# 在 EdgeRouter 上执行,记录命令输出的备份目录 backup_dir="/config/ipv6-before-proxy-$(date +%Y%m%d-%H%M%S)" sudo mkdir -p "$backup_dir" sudo cp /config/config.boot "$backup_dir/config.boot" ip -6 route show table all | sudo tee "$backup_dir/routes.txt" >/dev/null ip -6 rule show | sudo tee "$backup_dir/rules.txt" >/dev/null sudo ip6tables-save | sudo tee "$backup_dir/ip6tables.txt" >/dev/null printf '%s\n' "$backup_dir"

以下步骤面向首次部署。如果已存在同名 ndppd 服务、安装目录或 post-config 钩子,先备份并比较文件,不要直接覆盖。

4.2 在开发电脑上编译 ndppd

在有 Docker 和 Git 的 Linux 开发电脑上执行以下命令。这里固定构建 MIPS32 大端程序,其他架构需要更换工具链。

bash
1
2
3
4
5
6
7
8
9
10
11
12
13
mkdir -p edgerouter-ipv6-build cd edgerouter-ipv6-build git clone https://github.com/DanielAdolfsson/ndppd.git cd ndppd git checkout --detach 4f0301f94345c92b723392ed2d73f04f910e6c30 docker run --rm \ -v "$PWD:/src" -w /src debian:trixie sh -ec ' apt-get update apt-get install -y --no-install-recommends make g++-mips-linux-gnu file make CXX=mips-linux-gnu-g++ LDFLAGS=-static file ndppd '

检查 file ndppd 输出应包含 ELF 32-bitMSBMIPSstatically linked。只执行 make 会默认使用构建机器的编译器,可能得到无法在路由器上运行的 x86 程序。静态链接用于避免依赖构建容器中的动态库版本。

4.3 创建同步脚本和服务文件

以下命令在 Linux 开发电脑上执行,从上一步的 ndppd 源码目录开始:

bash
1
2
3
mkdir -p ../ipv6-proxy-deploy cp ndppd LICENSE ../ipv6-proxy-deploy/ cd ../ipv6-proxy-deploy

先创建同步脚本。修改开头的 WANLANTABLE,其中 RA 的第二项是 EdgeOS 接口类型:switch 接口用 switch,以太网接口用 ethernet,桥接口用 bridge。目录统一使用本文示例的 /config/ipv6-proxy

bash
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
cat > prefix_sync.py <<'EOF' #!/usr/bin/python """Reconcile EdgeOS NDP/SLAAC with WAN /64s. Python 2.7 and 3, stdlib only.""" from __future__ import print_function import fcntl, grp, json, os, re, shlex, socket, subprocess, sys, tempfile, time BASE='/config/ipv6-proxy' RUN='/run/ndppd' WAN='eth0' LAN='switch0' TABLE='10640' RA=['interfaces','switch',LAN,'ipv6','router-advert'] CFG='/opt/vyatta/sbin/vyatta-cfg-cmd-wrapper' API='/bin/cli-shell-api' GRACE=300 def validate_prefix(prefix): try: addr, length=prefix.split('/') packed=socket.inet_pton(socket.AF_INET6,addr) first=bytearray(packed)[0] if length!='64' or first & 0xe0 != 0x20 or packed[8:]!=b'\0'*8: raise ValueError('not a public /64 network') return socket.inet_ntop(socket.AF_INET6,packed)+'/64' except (ValueError, socket.error): raise ValueError('invalid public /64: '+prefix) def wan_prefixes(text): result=set() for line in text.splitlines(): if re.search(r'\b(tentative|dadfailed|deprecated)\b',line): continue m=re.search(r'inet6 ([0-9a-fA-F:]+)/64\b',line) life=re.search(r'preferred_lft (forever|[0-9]+sec)',line) if not m or not life or life.group(1)=='0sec': continue p=socket.inet_pton(socket.AF_INET6,m.group(1)) try: result.add(validate_prefix(socket.inet_ntop(socket.AF_INET6,p[:8]+b'\0'*8)+'/64')) except ValueError: pass return sorted(result) def plan(state, active, now): active=sorted(set(validate_prefix(p) for p in active)) retiring={validate_prefix(p):expiry for p,expiry in state.get('retiring',{}).items() if expiry>now and p not in active} for p in state.get('active',[]): if p not in active and p not in retiring: retiring[validate_prefix(p)]=now+GRACE return {'active':active,'retiring':retiring} def proxy_config(prefixes): if not prefixes: return '' blocks=['# Generated by prefix_sync.py; edit the controller, not this file.'] for iface,other,autowire in [(WAN,LAN,True),(LAN,WAN,False)]: blocks.append('proxy %s {\n router yes\n timeout 1000\n ttl 30000'%iface) if autowire: blocks.append(' autowire yes') for p in sorted(prefixes): blocks.append(' rule %s {\n iface %s\n }'%(validate_prefix(p),other)) blocks.append('}') return '\n'.join(blocks)+'\n' def run(args, check=True): p=subprocess.Popen(args,stdout=subprocess.PIPE,stderr=subprocess.PIPE) out,err=p.communicate() out=out.decode('utf-8','replace'); err=err.decode('utf-8','replace') if check and p.returncode: raise RuntimeError('%s: %s'%(args[0],(out+err).strip()[:1500])) return out,p.returncode def read(path, fallback=''): try: with open(path) as f:return f.read() except IOError:return fallback def atomic(path, content): if read(path,None)==content:return False fd,tmp=tempfile.mkstemp(prefix='.sync-',dir=os.path.dirname(path)) try: with os.fdopen(fd,'w') as f:f.write(content) os.chmod(tmp,0o644) os.rename(tmp,path) finally: if os.path.exists(tmp):os.unlink(tmp) return True def store(path, obj):return atomic(path,json.dumps(obj,sort_keys=True)+'\n') def route_sync(prefixes, managed): rules=run(['ip','-6','rule','show'])[0] routes=run(['ip','-6','route','show','table',TABLE])[0] for p in prefixes: marker='to %s iif %s lookup %s'%(p,WAN,TABLE) if not re.search(r'^'+re.escape(p)+r' dev '+LAN+r'\b',routes,re.M): run(['ip','-6','route','replace',p,'dev',LAN,'table',TABLE]) if marker not in rules: run(['ip','-6','rule','add','pref',TABLE,'iif',WAN,'to',p,'lookup',TABLE]) for p in sorted(set(managed)-set(prefixes)): marker='to %s iif %s lookup %s'%(p,WAN,TABLE) for _ in range(rules.count(marker)): run(['ip','-6','rule','del','pref',TABLE,'iif',WAN,'to',p,'lookup',TABLE]) if re.search(r'^'+re.escape(p)+r'\b',routes,re.M): run(['ip','-6','route','del',p,'dev',LAN,'table',TABLE]) def apply_ra(desired, managed): # These wrappers require the vyattacfg group and one stable session id. os.setgid(grp.getgrnam('vyattacfg').gr_gid) os.environ['CMD_WRAPPER_SESSION_ID']=str(os.getpid()) current=shlex.split(run([API,'listActiveNodes']+RA+['prefix'])[0]) wanted=sorted(desired['active']+list(desired['retiring'])) run([CFG,'begin']) try: for p in sorted(set(current)&set(managed)-set(wanted)): run([CFG,'delete']+RA+['prefix',p]) for p in wanted: values={'autonomous-flag':'true','on-link-flag':'true', 'preferred-lifetime':'900' if p in desired['active'] else '0', 'valid-lifetime':'1800' if p in desired['active'] else str(GRACE)} for key,value in sorted(values.items()): run([CFG,'set']+RA+['prefix',p,key,value]) run([CFG,'set']+RA+['default-lifetime','1800' if desired['active'] else '0']) run([CFG,'set']+RA+['managed-flag','false']) run([CFG,'commit']) except Exception: run([CFG,'discard'],False) raise finally: run([CFG,'end'],False) def sync(dry_run=False): registry=json.loads(read(BASE+'/managed-prefixes.json','[]')) for p in registry:validate_prefix(p) old=json.loads(read(RUN+'/state.json',json.dumps({'active':registry,'retiring':{}}))) links=run(['ip','-o','link','show','dev',WAN])[0] defaults=run(['ip','-6','route','show','default'])[0] addresses=run(['ip','-6','-o','addr','show','dev',WAN,'scope','global'])[0] online='LOWER_UP' in links and re.search(r'^default via .* dev '+WAN+r'\b',defaults,re.M) active=wan_prefixes(addresses) if online else [] desired=plan(old,active,int(time.time())) prefixes=sorted(desired['active']+list(desired['retiring'])) managed=sorted(set(registry+prefixes)) if dry_run: print(json.dumps({'online':bool(online),'desired':desired,'managed':managed},sort_keys=True)) return # Remember ownership before touching config, so a partial commit is retried safely. store(BASE+'/managed-prefixes.json',managed) route_sync(prefixes,[]) proxy_changed=atomic(RUN+'/ndppd.conf',proxy_config(prefixes)) if prefixes: running=run(['systemctl','is-active','--quiet','ndppd'],False)[1]==0 if proxy_changed or not running:run(['systemctl','restart','ndppd']) changed=desired!=old or not os.path.exists(RUN+'/state.json') if changed:apply_ra(desired,managed) route_sync(prefixes,managed) if not prefixes:run(['systemctl','stop','ndppd']) store(RUN+'/state.json',desired) store(BASE+'/managed-prefixes.json',prefixes) if changed: run(['logger','-t','ndppd-sync','active=%s retiring=%s'%(desired['active'],sorted(desired['retiring']))]) print(json.dumps(desired,sort_keys=True)) def main(): os.environ['PATH']='/usr/sbin:/usr/bin:/sbin:/bin:/opt/vyatta/bin:/opt/vyatta/sbin' if not os.path.isdir(RUN):os.makedirs(RUN,0o755) with open(RUN+'/sync.lock','w') as lock: try:fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB) except IOError:return try:sync('--dry-run' in sys.argv) except Exception as e: run(['logger','-t','ndppd-sync','ERROR: '+str(e)],False) print('ERROR: '+str(e),file=sys.stderr) sys.exit(1) if __name__=='__main__':main() EOF

创建代理服务。配置文件由同步脚本生成,服务以前台方式运行:

bash
1
2
3
4
5
6
7
8
9
10
11
12
13
14
cat > ndppd.service <<'EOF' [Unit] Description=NDP proxy for automatically learned WAN IPv6 prefixes After=network.target ConditionPathExists=/run/ndppd/ndppd.conf [Service] Type=simple Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin ExecStartPre=/usr/bin/test -s /run/ndppd/ndppd.conf ExecStart=/config/ipv6-proxy/ndppd -c /run/ndppd/ndppd.conf Restart=on-failure RestartSec=5 EOF

创建一次性同步服务,在 EdgeOS 原生配置加载完成后运行:

bash
1
2
3
4
5
6
7
8
9
10
11
cat > ndppd-sync.service <<'EOF' [Unit] Description=Synchronize EdgeOS IPv6 prefixes, NDP proxy and return routing After=network.target vyatta-router.service ConditionPathExists=/run/ndppd/edgeos-ready [Service] Type=oneshot ExecStart=/usr/bin/python /config/ipv6-proxy/prefix_sync.py TimeoutStartSec=90 EOF

创建定时器,每次同步结束后约 30 秒再次执行:

bash
1
2
3
4
5
6
7
8
9
10
11
12
13
cat > ndppd-sync.timer <<'EOF' [Unit] Description=Check WAN IPv6 prefix changes every 30 seconds [Timer] OnBootSec=30 OnUnitInactiveSec=30 AccuracySec=1 Unit=ndppd-sync.service [Install] WantedBy=timers.target EOF

创建开机钩子。它恢复单元链接、标记配置已就绪并启动 timer:

bash
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cat > 50-ndppd <<'EOF' #!/bin/sh set -eu mkdir -p /run/ndppd for unit in ndppd.service ndppd-sync.service ndppd-sync.timer; do ln -sf "/config/ipv6-proxy/$unit" "/etc/systemd/system/$unit" done systemctl daemon-reload if [ -L /etc/systemd/system/multi-user.target.wants/ndppd.service ]; then rm /etc/systemd/system/multi-user.target.wants/ndppd.service fi systemctl enable ndppd-sync.timer touch /run/ndppd/edgeos-ready systemctl start ndppd-sync.timer EOF

文件创建完成后,检查 Python 与 shell 语法,再上传。以下操作仍在开发电脑执行:

bash
1
2
3
4
5
6
7
8
9
10
python3 -m py_compile prefix_sync.py sh -n 50-ndppd # 替换为路由器的实际管理账号和地址 router_target=router-admin@192.0.2.1 ssh "$router_target" 'mkdir -p /tmp/ipv6-proxy-upload' scp ndppd LICENSE prefix_sync.py \ ndppd.service ndppd-sync.service ndppd-sync.timer 50-ndppd \ "$router_target:/tmp/ipv6-proxy-upload/" ssh "$router_target"

使用自定义 SSH 端口时,ssh 使用 -pscp 使用 -P;也可以在本机 SSH 配置中设置别名。

登录 EdgeRouter 后安装文件,此时还不启动服务:

bash
1
2
3
4
5
6
7
8
9
10
11
sudo install -d -m 0755 /config/ipv6-proxy sudo install -m 0755 /tmp/ipv6-proxy-upload/ndppd /config/ipv6-proxy/ndppd sudo install -m 0644 /tmp/ipv6-proxy-upload/LICENSE /config/ipv6-proxy/LICENSE sudo install -m 0644 /tmp/ipv6-proxy-upload/prefix_sync.py /config/ipv6-proxy/ sudo install -m 0644 /tmp/ipv6-proxy-upload/ndppd.service /config/ipv6-proxy/ sudo install -m 0644 /tmp/ipv6-proxy-upload/ndppd-sync.service /config/ipv6-proxy/ sudo install -m 0644 /tmp/ipv6-proxy-upload/ndppd-sync.timer /config/ipv6-proxy/ sudo install -m 0755 /tmp/ipv6-proxy-upload/50-ndppd /config/ipv6-proxy/ file /config/ipv6-proxy/ndppd sudo python -m py_compile /config/ipv6-proxy/prefix_sync.py

后续启动服务时,如果执行程序时报 Exec format error,返回检查编译架构;如果程序存在却提示找不到文件,检查 ELF 解释器与动态链接依赖。不要继续执行后续启动步骤。

4.4 NDP 代理配置

同步程序会生成 /run/ndppd/ndppd.conf,内容结构如下。这段配置用于解释代理行为,无需手工保存,也不要把文档示例前缀写入路由器。 实際前缀由脚本读取 WAN 地址后生成:

conf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 示例配置,不可直接用于生产网络 proxy eth0 { router yes timeout 1000 ttl 30000 autowire yes rule 2001:db8:100::/64 { iface switch0 } } proxy switch0 { router yes timeout 1000 ttl 30000 rule 2001:db8:100::/64 { iface eth0 } }

WAN 一侧的规则将该前缀的邻居查询送往 LAN,LAN 一侧则能够查询 WAN 上的同前缀邻居。这里使用 iface 探测实际邻居,没有让代理无条件宣称整个 /64 的所有地址都存在。

LAN 仍由 EdgeOS 原生 RA 配置广播前缀,启用自主地址配置,managed-flagfalse。原来的 ULA 保留,服务器通过 SLAAC 获得全球单播地址,不需要给每台服务器手工写固定公网地址。

4.5 配置 WAN 入站回程路由

测试发现,仅依靠 autowire 学习 /128 主机路由,仍存在回程转发失败。

上游可能已经缓存了邻居信息,在代理尚未学习到对应 /128 路由时,返回数据包就已经到达 WAN。此时主路由表中的共享 /64 仍指向 WAN,返回包可能再次被选到 WAN,而没有转入 LAN。

因此又增加了一张单独的路由表,并限制只有从 WAN 进入、目的地址属于共享前缀的流量查询它:

bash
1
2
3
4
# 以下均为示例值;先检查规则与表号是否已被占用 sudo ip -6 route replace 2001:db8:100::/64 dev switch0 table 10640 sudo ip -6 rule add pref 10640 iif eth0 \ to 2001:db8:100::/64 lookup 10640

这两条命令说明同步程序所执行的操作,按本文自动部署时不必手工执行。它们是运行时配置,且 ip rule add 重复执行会产生重复规则;同步程序负责检查现状、避免重复添加,并在重启后重建。

iif eth0 将规则限制为 WAN 入站流量。若直接将主表的 /64 指向 LAN,访问上游同前缀主机的流量也会被送入 LAN。配置后的路由选择如下:

数据包 路由选择
从 WAN 进入,目标为共享前缀中的下游地址 查专用表,转入 LAN
从 LAN 进入,访问上游同前缀主机 继续查主表,经 WAN 到达上游
目标是路由器自己的地址 优先由优先级为 0 的 local 表处理

4.6 配置 WAN 入站防火墙

服务器获得全球单播地址后,还检查了 WAN 入站转发规则。最终保留的策略是允许已建立连接及其关联流量,允许 ICMPv6,其余入站转发默认丢弃。

ICMPv6 不只有 ping,还参与邻居发现和错误报告,不能为了关闭 ping 就整体屏蔽。这里描述的是 WAN 到 LAN 的转发策略,路由器自身管理面的访问控制应另行检查。

验证时,从上游直连主机访问内网服务器的 TCP/22 超时,同时观察到对应防火墙 DROP 计数增加。确认请求命中了丢弃规则。

下面给出独立的新规则集示例。在 EdgeOS 配置模式执行,WAN 名称按实际情况修改:

bash
1
2
3
4
5
6
7
8
9
10
11
configure set firewall ipv6-name IPV6_PROXY_IN default-action drop set firewall ipv6-name IPV6_PROXY_IN rule 10 action accept set firewall ipv6-name IPV6_PROXY_IN rule 10 state established enable set firewall ipv6-name IPV6_PROXY_IN rule 10 state related enable set firewall ipv6-name IPV6_PROXY_IN rule 20 action accept set firewall ipv6-name IPV6_PROXY_IN rule 20 protocol ipv6-icmp set interfaces ethernet eth0 firewall in ipv6-name IPV6_PROXY_IN commit save exit

已有 WAN 入站 IPv6 规则集时,应将所需规则合并到现有规则集,不要直接替换绑定。 上面的命令适用于该方向没有既有规则集的情况。配置提交报错时先查看错误并处理,不要继续启动自动同步,更不要以手工创建 ip6tables 链代替原生配置提交。

五、自动同步前缀与启动配置

5.1 前缀检测与同步范围

为处理上游前缀变化和路由器重启,增加同步程序,统一维护 RA、代理规则和策略路由。

这次使用 Python 标准库实现同步程序,通过 systemd timer 周期运行。程序兼容设备上的 Python 2.7,也在本地 Python 3 下执行了测试。每次执行读取 WAN 地址、链路状态和默认路由,筛选可用的全球单播 /64,排除暂定、重复地址检测失败、已经弃用或首选寿命为零的地址。

只有链路已连接,并存在经 WAN 的 IPv6 下一跳默认路由时,才把这些前缀作为当前可用前缀。这里检查的是接口和路由就绪情况,没有持续探测互联网;如果上游链路仍在但远端发生黑洞,不能保证仅靠这组条件检测出来。

同步过程同时维护三处状态:

  • LAN 的原生 RA 配置;
  • ndppd 的运行时配置;
  • WAN 入站流量使用的前缀路由与策略规则。

程序记录自己管理的前缀,删除旧配置时只处理这些前缀,保留 ULA 和其他手工配置。运行状态放在 /run,持久目录只记录必要的管理信息;没有每隔几十秒保存一次完整 EdgeOS 配置。

5.2 前缀变化与掉线处理

当前前缀的 RA 首选寿命为 900 秒,有效寿命为 1800 秒。检测到前缀退出后,将其首选寿命设为 0,并保留 300 秒的代理和路由过渡时间;过渡到期后移除对应配置。WAN 不再满足就绪条件时,LAN 广播的默认路由寿命降为 0,恢复后重新发布。

300 秒仅表示旧代理与路由配置的保留时间,不代表客户端会在 300 秒后删除旧地址。主机还受已有地址寿命及 SLAAC 的寿命更新规则影响,具体行为见 RFC 4862 第 5.5.3 节。如果旧前缀已被上游撤回,保留本地规则也无法保证旧连接继续工作。

定时器的关键配置如下:

ini
1
2
3
4
5
[Timer] OnBootSec=30 OnUnitInactiveSec=30 AccuracySec=1 Unit=ndppd-sync.service

OnUnitInactiveSec=30 表示上次执行结束约 30 秒后再次运行。同步失败时保留可重试状态,由后续执行重试;并发执行通过文件锁避免重叠。状态未变化时,不重写配置,也不重启代理进程。

5.3 启动顺序与配置保存

同步程序需要在 EdgeOS 原生配置加载完成后执行。

这次通过 EdgeOS 的 post-config 钩子恢复 systemd 单元链接、设置就绪标志并启动 timer,同步服务同时声明在路由器配置服务之后执行。钩子只负责启动定时器,不在配置加载过程中阻塞等待同步完成。

保存的启动配置保留 ULA,LAN 的默认路由广播寿命初始为 0,也不固定保存当前公网前缀。等 WAN 就绪后,同步程序再提交运行时 RA 配置、启动代理并建立路由。

实现中使用 EdgeOS 自带的配置命令包装器,并为同一事务保持会话标识及 vyattacfg 组身份,完成 begin、set/delete、commit 和 end;失败时执行 discard。同步结果以配置事务提交成功为准。

公网前缀由同步程序管理。直接编辑生成文件会被后续同步覆盖;手动保存整个配置前,应确认是否把当前动态前缀一并保存了进去。

5.4 保存 LAN RA 启动基线

EdgeOS 配置模式设置 LAN 的启动基线。这里假定 LAN 是 switch0,ULA 沿用现有配置,不新增示例 ULA:

bash
1
2
3
4
5
6
7
configure set interfaces switch switch0 ipv6 router-advert send-advert true set interfaces switch switch0 ipv6 router-advert managed-flag false set interfaces switch switch0 ipv6 router-advert default-lifetime 0 commit save exit

如果之前手工配置过公网 RA 前缀,应先删除那一条公网前缀,再 commitsave;不要删除原有 ULA。命令结构为 delete interfaces switch switch0 ipv6 router-advert prefix 实际旧前缀/64

首次启动前,公网 RA 前缀应为空,默认路由寿命为 0。同步程序在 WAN 就绪后发布真实前缀并将默认路由寿命设为 1800。不要在自动同步完成后,为了“保存 NDP”再次保存整个原生配置;程序、服务及启动钩子已位于持久目录。

5.5 安装开机钩子并首次启动

上文创建的 ndppd.service 使用以下启动命令,代理以 root 身份访问原始套接字:

ini
1
2
3
4
5
6
7
[Service] Type=simple Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin ExecStartPre=/usr/bin/test -s /run/ndppd/ndppd.conf ExecStart=/config/ipv6-proxy/ndppd -c /run/ndppd/ndppd.conf Restart=on-failure RestartSec=5

ndppd-sync.service 调用 /usr/bin/python /config/ipv6-proxy/prefix_sync.py,并在 EdgeOS 配置服务之后执行。第 4.3 节已给出完整文件,以上片段仅说明服务的启动方式。

先在 EdgeRouter 普通 shell 中执行只读规划,核对检测到的前缀:

bash
1
sudo python /config/ipv6-proxy/prefix_sync.py --dry-run

输出中的 online 应为 truedesired.active 应包含实际 WAN 公网 /64。这里的 dry-run 可能创建运行目录和锁文件,但不修改网络配置。若 active 为空,先检查接口参数、地址首选寿命和默认路由。

确认后安装并执行开机钩子:

bash
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 首次部署前确认该文件名没有被已有配置使用 sudo install -d -m 0755 /config/scripts/post-config.d sudo install -m 0755 /config/ipv6-proxy/50-ndppd \ /config/scripts/post-config.d/50-ndppd sudo /config/scripts/post-config.d/50-ndppd # 首次安装立即执行一次;后续由 timer 周期执行 sudo systemctl start ndppd-sync.service sudo systemctl show ndppd-sync.service -p Result -p ExecMainStatus sudo systemctl status ndppd.service ndppd-sync.timer --no-pager sudo cat /run/ndppd/state.json sudo cat /run/ndppd/ndppd.conf ip -6 rule show ip -6 route show table 10640 sudo cat /etc/radvd.conf

预期状态如下:

  • 同步服务 Result=successExecMainStatus=0
  • ndppd 和 timer 均为 active
  • state.json 中有当前前缀,代理配置同时包含 WAN 与 LAN。
  • 优先级 10640 的规则带有 iif eth0,表 10640 的对应前缀指向 switch0
  • radvd.conf 中出现实际公网前缀,允许 SLAAC,默认路由寿命为 1800。

故障日志可按以下方式查看:

bash
1
2
3
sudo journalctl -u ndppd.service -u ndppd-sync.service -n 80 --no-pager # 本次设备没有可用的 journal 文件,使用系统日志 sudo grep -E 'ndppd-sync|ndppd' /var/log/messages | tail -80

本方案不需要额外启用 proxy_ndp 内核静态代理;ndppd 自身负责 NDP 消息处理。不要再为每个服务器地址重复添加静态 proxy neighbor 条目。

六、连通性与重启验证

6.1 实机验证结果

实机验证结果如下:

检查项 实际结果
路由器 IPv6 HTTPS 返回 HTTP 200
三台服务器的两个被测网口 六个网口均通过 IPv6 HTTPS 测试
下游与上游直连主机互相 ping 双向通过
上游主动连接下游 TCP/22 超时,WAN 入站 DROP 计数增加
连续执行前缀同步 配置时间戳和代理 PID 保持不变
整台路由器重启 启动标识改变,代理、timer、RA 和策略路由自动恢复
重启后重复连通性测试 路由器及服务器均通过

按接口测试可以避免多网卡机器恰好走另一个正常出口:

bash
1
2
3
4
5
# eno1 为示例被测接口,另一接口应单独再测一次 curl --noproxy '*' -6 --interface eno1 \ --connect-timeout 10 --max-time 20 \ -sS -o /dev/null -w 'IPv6 HTTPS %{http_code}\n' \ https://www.cloudflare.com/

整机重启前,先检查登录会话和现有入站连接,核对来源归属;确认没有其他用户正在使用后,才执行重启。恢复后没有手动启动代理,也没有手动执行同步程序,因此这次结果能够验证启动流程本身,而不只是运行期间的配置。

服务器侧应能看到新地址和默认路由:

bash
1
2
3
4
# 在每台下游服务器执行,接口名按实际情况替换 ip -6 addr show dev eno1 ip -6 route show default sysctl net.ipv6.conf.eno1.accept_ra

如果服务器本身启用了 IPv6 转发,需要检查其网络管理器是否允许转发模式下接收 RA;仅看到路由器广播成功,并不能证明客户端会采用配置。本次客户端原有配置即可接收 RA,未额外修改。

连通性通过后,核对在线用户和入站连接,确认可以中断网络,再在路由器上执行 sudo reboot。重新登录后先重复 5.5 节的状态检查,不要手动启动服务,随后再跑各服务器的 IPv6 HTTPS 测试,以验证自动启动。

6.2 同步程序测试

同步程序的 10 项测试在本地和路由器上均通过,覆盖了前缀筛选、换号、退出与恢复、过渡期不被反复延长、提交失败后重试,以及不变状态下不重写文件、不重启进程等行为。

前缀变更和上游退出、恢复采用模拟输入测试,未执行真实上游换号。实机测试覆盖当前前缀下的双向通信、防火墙和整机重启恢复。

此外,timer 触发的一次性服务执行结束后显示 inactive 是正常现象,应结合 Result=success、退出码以及运行状态判断,不能把它当作守护进程异常退出。

七、停止与回滚

停止自动配置前,先记录当前管理的前缀,后续用于精确删除:

bash
1
2
3
4
5
6
7
# 在 EdgeRouter 上执行 sudo cat /config/ipv6-proxy/managed-prefixes.json sudo systemctl disable --now ndppd-sync.timer sudo systemctl stop ndppd-sync.service # 保留钩子备份,但移出 post-config.d,避免重启后再次执行 sudo mv /config/scripts/post-config.d/50-ndppd \ /config/ipv6-proxy/50-ndppd.disabled

随后进入 EdgeOS 配置模式,将 LAN default-lifetime 设为 0,并删除记录中的公网 RA 前缀。保留 ULA,执行 commitsave 后退出,再停止代理:

bash
1
2
3
4
5
6
sudo systemctl stop ndppd.service # 将示例前缀和接口替换为记录中的实际值;多个前缀逐个处理 sudo ip -6 rule del pref 10640 iif eth0 \ to 2001:db8:100::/64 lookup 10640 sudo ip -6 route del 2001:db8:100::/64 dev switch0 table 10640

最后根据备份恢复此次改动的 WAN 自动配置、LAN RA 和防火墙绑定。不要清空整张 IPv6 路由表或所有防火墙规则。完整加载 config.boot 备份会覆盖备份之后的其他配置,回滚前应先比较差异。

八、适用条件

该配置适用于上游提供 SLAAC 链路前缀、允许代理邻居通信,但本次接入未获得 PD 的环境。上游地址绑定或接入策略变化后,需要重新验证。若能够获得 PD,可改为下游独立前缀的路由配置。

参考

手机扫码阅读
本文作者: 有次元袋的 tiger
本文链接: https://www.superheaoz.top/2026/09/2072836977/
版权声明: 本站点所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 我的个人天地