欢迎光临
我们一直在努力

路由器当AP,为什么一台能上网、一台不行?看看是谁发了两份DHCP配置

路由器当AP,为什么一台能上网、一台不行?看看是谁发了两份DHCP配置

家里增加一个路由器,想让它只负责扩大Wi-Fi覆盖。手机连上了,信号也满格,可电脑能打开网页,手机不行。重连几次,表现又可能不一样。

先别急着调无线功率。连上Wi-Fi之后,这台设备还会获得地址、网关、DNS等配置。要是同一个网络里,有两台设备都在回答“这些参数由我来发”,客户端会拿到哪一份?

这是一条值得检查的线索,并不是所有AP故障都由它引起。我们先把它单独复现出来,再看在家里怎么确认是不是同一回事。

这台AP究竟要不要替我们转发IP?

先约定本篇想搭的网络:主路由负责跨网段转发,新增设备把有线口与无线客户端接到同一个二层网络。新增设备不再隔出另一个IP子网。

可以把AP想成给无线设备增加了一个进入原网络的入口:

客户端甲 ─┐
├─ AP的桥 ─ 主路由 ─ 目标网络
客户端乙 ─┘

同一侧:192.0.2.0/24
主路由:192.0.2.1
AP管理:192.0.2.2

客户端访问另一个子网时,默认网关应当指向主路由.1。AP自己有一个管理地址.2,让我们能打开配置页面;有管理地址,不等于客户端需要把它设为默认网关。

同理,谁发送DHCP配置,也不等于谁负责转发后续数据包。DHCP可以在别处运行,里面的“路由器选项”告诉客户端该找哪个网关。

我们之前把DHCP的地址、网关、DNS分开验证,那里只有一个DHCP服务。本篇增加的难点是:两个服务都能回答,而且给的配置不一样。

两份Offer,不只是两个不同的IP

在隔离网络里,主路由运行一个dnsmasq 2.90,AP的桥上再运行一个,模拟忘记关闭的旧DHCP服务。

它们提供的配置是:

配置来自谁地址池下发的默认网关下发的DNS
主路由.1 .20到.30 192.0.2.1 192.0.2.1
AP.2 .90到.99 192.0.2.254 192.0.2.1

两个地址池都在192.0.2.0/24,并且没有重叠。错误网关.254在这个实验里不存在。DNS参数故意保持相同,本篇不启动DNS服务、也不用域名测试,先让唯一差别落在选路上。

如果我们只检查“手机是不是拿到了192.0.2开头的地址”,它们看上去都正常。甚至不会有地址冲突。但一份配置把跨网段流量送给.1,另一份让它去找不存在的.254。

客户端初次获取租约时,会广播Discover。这里它确实收到了两份Offer,里面有各自的服务器标识、建议地址和选项。

客户端:有没有人给我配置?
主路由:我给你地址,网关用.1
AP: 我也给你地址,网关用.254

客户端选择其中一份,发Request
被选中的服务确认,返回ACK
客户端才把这份配置应用到接口

DHCP RFC 2131允许客户端收到多个Offer,并选择其中一个。它并没有规定所有客户端永远只能选最先到达的那份。实现、已有状态等都可能影响选择,所以不要把“谁快谁赢”当成所有设备的固定规则。

让两个客户端各选一份,看看差别在哪里

为了保证结果可以重复,本次写了一个最小DHCP实验客户端:先收集Offer,再明确选择指定服务器。这是人为控制的对照,不是在统计真实手机随机选错的概率。

服务端是两个真实dnsmasq实例,客户端实际发送Discover和Request,收到ACK后才配置地址与路由。没有先手工设地址,再假装这是DHCP结果。

甲选主路由,乙选AP。实际检查如下:

检查甲乙
收到的Offer数量 2 2
到主路由.1 成功 成功
到目标198.51.100.2 成功 失败
目标的下一跳 .1 .254

这里的198.51.100.2是另一命名空间里的真实实验节点,主路由有路由与转发能力,目标也有回程路由;不是只打印一条路由就宣布远端已访问成功。它不是互联网,不能拿这次ping证明家中宽带状态。

乙为什么能到.1,却到不了另一个子网?

它自己的地址和.1在同一子网,访问.1直接在本地寻找对方。而目标在另一个子网,才轮到默认网关出场。

乙 → 192.0.2.1
本地子网,直接找.1 → 能通

乙 → 198.51.100.2
跨子网,交给默认网关.254
可实验里没有.254 → 失败

因此,“能打开主路由页面”也不能证明默认网关配置正确。

把多余DHCP关掉,旧配置会立刻消失吗?

先只停止AP里的DHCP服务。桥没有拆,主路由没有重启,转发也没有改变。

然后让乙丢弃实验接口上的旧配置,重新获取租约。这回只有主路由发Offer,乙采用.1作为网关,实际访问目标成功。

这一步有个容易漏掉的动作:重新获取配置。关闭一个DHCP服务,不会把所有客户端已经拿到的租约立刻收回。客户端可能继续使用旧配置,直到重新获取或按自身规则续租。

所以现场操作不能只看路由器上的开关。改完以后,还得在失败的那台终端上核对实际地址、网关、DNS和租约来源。

在自己的网络里,先做哪些检查?

挑一台正常、一台异常的设备作对照。Windows可以用ipconfig /all看相应网卡的IPv4地址、默认网关、DHCP服务器和DNS;Linux先看ip -4 addr、ip route,再按所用网络管理器查看租约记录。不同设备显示字段不完全一样。

别只抄IP地址。尤其注意:异常设备的默认网关是不是你打算让它走的主路由,DHCP服务器是否来自预期位置。DHCP服务器地址与网关本来就可以不同,不能仅凭不同就判错,需要对照网络设计。

若有抓包条件,可以观察一次重新获取租约,Wireshark按UDP 67/68端口过滤,检查Offer和ACK中的服务器标识、路由器和DNS选项。续租不一定重走完整广播发现过程,没有看到两份Offer也不能单独证明网络里只有一个服务。

确认是新增设备误发配置后,再按这台设备的说明设置桥接/AP模式。不同固件的AP模式对WAN口、LAN口桥接和DHCP开关的处理不同,不能只背“必须LAN接LAN”。手工搭桥时,要同时核对接线、桥成员、DHCP职责和管理地址,管理地址也要避免冲突。

两台DHCP也不是天然错误:有协调的地址分配、冗余或明确的策略可以是合法设计。本篇的问题是两份配置互相矛盾,且其中一份网关不可用。不要看到数字“2”就把一台服务器删掉。

附录:不碰家庭网络,怎样做这次实验?

程序新建五个Linux网络命名空间:AP桥、主路由、远端目标、甲、乙。veth只连接这些新空间,没有桥接物理网卡,没有真实上联。只在主路由空间内开启IPv4转发,退出时关闭自建进程并删除自建空间。

需要Linux、root权限、Python 3、iproute2、ping和dnsmasq 2.90。root用于新建隔离网络和接收链路层报文;不要把脚本中的DHCP启动命令拆出来放到家里的LAN接口上运行。

本次收到Offer但最初的UDP接收器没取到结果,服务端日志却显示已发广播回复。因此客户端改成用链路层socket接收尚未配置地址时的报文,再核对事务ID、消息类型和服务器标识。这个客户端只是教学实现,不含完整的续租、冲突探测、错误报文防护和生产超时策略。

同目录保存ap_dhcp.py,标准路径安装dnsmasq时运行:

sudo env BLOG_NETWORK_TOOLS=/ python3 ap_dhcp.py

解包安装则把BLOG_NETWORK_TOOLS换成包含usr/sbin/dnsmasq的根目录。结果保存为ap-results.json,租约、Offer选择及服务端日志保留在ap-新目录。本实验没有测试无线射频、漫游、客户端隔离和IPv6配置。

"""Root-only isolated bridge + two dnsmasq services; no host interface attached."""
import ipaddress
import json
import os
from pathlib import Path
import socket
import struct
import subprocess as sp
import sys
import tempfile
import time
import uuid

base = Path(__file__).resolve().parent
def cmd(*args):
p = sp.run(args, text=True, capture_output=True, timeout=15)
assert p.returncode == 0, (args, p.stdout, p.stderr)
return p.stdout.strip()
def options(data):
assert data[236:240] == b'\\x63\\x82\\x53\\x63'
out = {}; i = 240
while i < len(data):
tag = data[i]; i += 1
if tag == 255: break
if tag == 0: continue
length = data[i]; i += 1
out[tag] = data[i:i+length]; i += length
return out
def ip(b): return socket.inet_ntoa(b[:4])

if len(sys.argv) > 1 and sys.argv[1] == '–client':
# Minimal teaching DHCP client. Selection is deliberate, not a timing race.
selected, outfile = sys.argv[2:4]
mac = bytes.fromhex(Path('/sys/class/net/eth0/address').read_text().strip().replace(':',''))
xid = os.urandom(4)
header = struct.pack('!4B', 1, 1, 6, 0)+xid+struct.pack('!HH', 0, 0x8000)+bytes(16)+mac+bytes(10)+bytes(192)+b'\\x63\\x82\\x53\\x63'
def message(kind, extra=b''):
return header+b'\\x35\\x01'+bytes([kind])+b'\\x37\\x03\\x01\\x03\\x06'+extra+b'\\xff'
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
s.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, b'eth0\\0')
s.bind(('0.0.0.0', 68)); s.settimeout(.15)
# Before an address is configured, capture link-layer broadcast replies.
rx = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0800))
rx.bind(('eth0', 0)); rx.settimeout(.15)
def receive():
while True:
frame = rx.recv(4096)
if len(frame)<42 or frame[23]!=17: continue
start = 14+(frame[14]&15)*4
if struct.unpack('!H',frame[start+2:start+4])[0] != 68: continue
return frame[start+8:]
s.sendto(message(1), ('255.255.255.255', 67))
offers = {}; deadline = time.monotonic()+1.5
while time.monotonic() < deadline:
try: data = receive()
except socket.timeout: continue
if data[4:8] != xid: continue
opts = options(data)
if opts.get(53) == b'\\x02':
offers[ip(opts[54])] = {'address':ip(data[16:20]), 'gateway':ip(opts[3]), 'dns':ip(opts[6])}
assert selected in offers, offers
chosen = offers[selected]
extra = b'\\x32\\x04'+socket.inet_aton(chosen['address'])+b'\\x36\\x04'+socket.inet_aton(selected)
s.sendto(message(3, extra), ('255.255.255.255', 67))
deadline = time.monotonic()+3
while True:
assert time.monotonic() < deadline
try: data = receive()
except socket.timeout: continue
if data[4:8] != xid: continue
opts = options(data)
if opts.get(53) == b'\\x05' and ip(opts[54]) == selected: break
address, gateway = ip(data[16:20]), ip(opts[3])
assert address == chosen['address'] and gateway == chosen['gateway']
prefix = ipaddress.IPv4Network('0.0.0.0/'+ip(opts[1])).prefixlen
cmd('ip', 'addr', 'replace', address+'/'+str(prefix), 'dev', 'eth0')
cmd('ip', 'route', 'replace', 'default', 'via', gateway)
route = cmd('ip', 'route', 'get', '198.51.100.2')
local = sp.run(['ping','-c','1','-W','1','192.0.2.1'], capture_output=True).returncode == 0
remote = sp.run(['ping','-c','1','-W','1','198.51.100.2'], capture_output=True).returncode == 0
Path(outfile).write_text(json.dumps({'offers':offers,'server':selected,'address':address,
'gateway':gateway,'dns':ip(opts[6]),'route':route,'localReachable':local,'remoteReachable':remote},indent=2))
rx.close()
sys.exit(0)

assert os.geteuid() == 0, 'Run with sudo/root; creates only its own network namespaces.'
tools = Path(os.environ.get('BLOG_NETWORK_TOOLS','/mnt/d/blog-ops/tmp/dhcp-tools-20261004/extracted'))
env = dict(os.environ, LD_LIBRARY_PATH=str(tools/'usr/lib/x86_64-linux-gnu')+':'+str(tools/'lib/x86_64-linux-gnu'))
run = Path(tempfile.mkdtemp(prefix='ap-', dir=base))
token = 'ap'+uuid.uuid4().hex[:7]
names = {k:token+k for k in ['b','r','t','c','d']}
created = []; processes = []; logs = []
record = {'passed':False,'run':str(run),'cases':[]}
def ni(n, *args): return cmd('ip','-n',names[n],*args)
def ne(n, *args): return cmd('ip','netns','exec',names[n],*args)
def link(a, aname, b, bname):
ni(a,'link','add',aname,'type','veth','peer','name',bname,'netns',names[b])
ni(a,'link','set',aname,'up'); ni(b,'link','set',bname,'up')
try:
for name in names.values():
cmd('ip','netns','add',name); created.append(name)
cmd('ip','-n',name,'link','set','lo','up')
ni('b','link','add','br0','type','bridge'); ni('b','link','set','br0','up')
ni('b','addr','add','192.0.2.2/24','dev','br0')
for node in ['r','c','d']:
link('b','p'+node,node,'eth0'); ni('b','link','set','p'+node,'master','br0')
ni('r','addr','add','192.0.2.1/24','dev','eth0')
link('r','eth1','t','eth0')
ni('r','addr','add','198.51.100.1/24','dev','eth1')
ni('t','addr','add','198.51.100.2/24','dev','eth0')
ni('t','route','add','192.0.2.0/24','via','198.51.100.1')
ne('r','sysctl','-qw','net.ipv4.ip_forward=1')
for node in ['c','d']:
ni(node,'route','add','255.255.255.255/32','dev','eth0')
for node, address, gateway in [('r','192.0.2.20','192.0.2.1'),('b','192.0.2.90','192.0.2.254')]:
log = open(run/(node+'.log'),'wb'); logs.append(log)
args = ['ip','netns','exec',names[node],str(tools/'usr/sbin/dnsmasq'),
'–keep-in-foreground','–conf-file=/dev/null','–port=0','–user=root','–log-facility=-','–log-dhcp',
'–interface='+('eth0' if node=='r' else 'br0'),'–bind-interfaces','–no-ping',
'–dhcp-range='+address+','+('192.0.2.30' if node=='r' else '192.0.2.99')+',255.255.255.0,1h',
'–dhcp-option=3,'+gateway,'–dhcp-option=6,192.0.2.1',
'–dhcp-leasefile='+str(run/(node+'.leases')),'–pid-file='+str(run/(node+'.pid'))]
processes.append(sp.Popen(args,env=env,stdout=log,stderr=sp.STDOUT))
time.sleep(.4); assert all(p.poll() is None for p in processes)
for node, server in [('c','192.0.2.1'),('d','192.0.2.2')]:
out = run/(node+'-dual.json')
ne(node,'python3',str(Path(__file__).resolve()),'–client',server,str(out))
r = json.loads(out.read_text()); assert len(r['offers'])==2
assert r['localReachable'] and r['remoteReachable']==(node=='c'), r
record['cases'].append(r)
# Only the unwanted service stops; bridge and router forwarding stay untouched.
processes[1].terminate(); processes[1].wait(timeout=5)
ni('d','addr','flush','dev','eth0'); ni('d','route','flush','table','main')
ni('d','route','add','255.255.255.255/32','dev','eth0')
out=run/'d-fixed.json'; ne('d','python3',str(Path(__file__).resolve()),'–client','192.0.2.1',str(out))
r=json.loads(out.read_text()); assert len(r['offers'])==1 and r['remoteReachable']
record['repaired']=r; record['passed']=True
finally:
for p in processes:
if p.poll() is None: p.terminate()
for p in processes:
try: p.wait(timeout=5)
except sp.TimeoutExpired: p.kill(); p.wait()
for f in logs: f.close()
for name in reversed(created): cmd('ip','netns','del',name)
left=cmd('ip','netns','list')
record['namespacesRemoved']=all(name not in left for name in created)
record['serversStopped']=all(p.poll() is not None for p in processes)
(base/'ap-results.json').write_text(json.dumps(record,indent=2))
print(json.dumps(record))

赞(0)
未经允许不得转载:171主机测评 » 路由器当AP,为什么一台能上网、一台不行?看看是谁发了两份DHCP配置
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址