一、背景
在一些家庭或办公网络中,局域网内可能同时存在两个网关:
主路由 / 默认网关 A:192.168.10.1
旁路由 / 旁路网关 B:192.168.10.8
Windows 主机 IP:192.168.10.120
默认情况下,Windows 通过 DHCP 获取主路由 A 作为默认网关。但在旁路由 B 可用时,希望所有流量优先经过 B;当 B 不可用时,又希望自动回退到 A,避免网络完全中断。
如果旁路由 B 还承担 DNS、分流、代理或透明网关能力,那么仅切换默认路由还不够。更完整的方案应该做到:
B 可用:
默认路由 -> B
DNS -> B
B 不可用:
删除 B 默认路由
DNS 恢复 DHCP 自动获取
系统回到主路由 A
本文给出一个基于 Node.js + Windows 计划任务 + route + netsh 的完整实现。
二、目标需求
本文方案满足以下目标:
- Windows 默认网关仍由 DHCP 管理,不破坏原有网络配置。
- 旁路由 B 可用时,自动添加低 metric 默认路由,让所有流量优先走 B。
- 旁路由 B 不可用时,自动删除 B 默认路由,回退到 DHCP 默认网关 A。
- DNS 跟随路由状态自动切换。
- B 可用时 DNS 指向 B。
- B 不可用时 DNS 恢复 DHCP 自动获取。
- 支持轮询检测。
- 支持连续成功 / 连续失败防抖,避免频繁切换。
- 支持通过环境变量配置网关 IP、探测地址、检测周期、网卡名称、Wi-Fi 名称等。
- 使用 Windows 计划任务开机或登录后自动运行。
示例配置如下:
默认网关 A:192.168.10.1
旁路网关 B:192.168.10.8
Windows IP:192.168.10.120
探测地址:https://x.com
检测周期:10 秒
连续成功:2 次后切换到 B
连续失败:2 次后回退到 A
DNS 策略:B 可用时 DNS = B,不可用时 DNS = DHCP
https://x.com 只是示例探测目标,实际使用时可以替换为你自己的稳定检测地址。
三、核心设计思路
1. 不修改 DHCP 默认网关
Windows 原本通过 DHCP 拿到默认网关 A,例如:
0.0.0.0/0 -> 192.168.10.1
这个配置不动。
当 B 可用时,脚本额外添加一条默认路由:
0.0.0.0/0 -> 192.168.10.8
并且给它设置更低的 metric,例如:
metric = 5
Windows 会优先使用 metric 更低的默认路由,因此流量会优先走 B。
当 B 不可用时,脚本删除这条 B 默认路由:
删除 0.0.0.0/0 -> 192.168.10.8
系统自然回到 DHCP 默认网关 A。
2. DNS 与默认路由联动
本文采用 DNS 方案 A:
B 可用:
DNS 设置为 192.168.10.8
B 不可用:
DNS 恢复 DHCP 自动获取
这样可以避免手动写死主路由 DNS,也能减少切换到其他网络后残留错误 DNS 的概率。
3. 探测 B 时必须强制走 B
不能简单判断 https://x.com 是否能访问。
原因是:如果当前系统默认流量仍然走 A,那么即使 B 已经不可用,Windows 也可能通过 A 访问到 https://x.com。这样会造成误判。
因此,脚本探测 B 时采用如下方式:
- 解析探测域名的 IPv4 地址。
- 临时添加一条
/32主机路由,让该目标 IP 强制走 B。 - 发起 HTTPS 请求。
- 请求结束后删除临时主机路由。
示意:
x.com 的某个 IPv4 -> 192.168.10.8
这样检测结果才真正代表 B 是否具备访问外网的能力。
四、目录结构
建议创建目录:
C:\foo\gateway-failover\
目录中放两个文件:
gateway-watch.cmd
gateway-watch.js
说明:
gateway-watch.cmd:配置环境变量并启动 Node.js 脚本
gateway-watch.js :执行探测、路由切换、DNS 切换
五、完整启动脚本:gateway-watch.cmd
保存为:
C:\foo\gateway-failover\gateway-watch.cmd
完整内容如下:
@echo off
cd /d "%~dp0"
rem ============================================================
rem 基础网络配置
rem ============================================================
set GATEWAY_A=192.168.10.1
set GATEWAY_B=192.168.10.8
set LOCAL_IP=192.168.10.120
rem ============================================================
rem 探测配置
rem ============================================================
rem 可以配置单个探测地址
set PROBE_URL=https://x.com
rem 也可以配置多个探测地址,多个地址用英文逗号分隔
rem set PROBE_URLS=https://x.com,https://www.cloudflare.com,https://www.microsoft.com
rem 检测周期,单位毫秒
set INTERVAL_MS=10000
rem 单次探测超时时间,单位毫秒
set PROBE_TIMEOUT_MS=5000
rem 连续成功多少次后切换到 B
set OK_THRESHOLD=2
rem 连续失败多少次后回退到 A
set FAIL_THRESHOLD=2
rem ============================================================
rem 路由配置
rem ============================================================
rem B 默认路由 metric,越小优先级越高
set B_METRIC=5
rem 临时探测路由 metric
set PROBE_ROUTE_METRIC=1
rem ============================================================
rem DNS 自动切换配置:方案 A
rem ============================================================
rem 是否启用 DNS 自动切换:1 表示启用
set SWITCH_DNS=1
rem B 可用时 DNS 指向 B
set DNS_B=192.168.10.8
rem B 不可用时 DNS 恢复 DHCP 自动获取
set DNS_FALLBACK_MODE=dhcp
rem 切换 DNS 后刷新 Windows DNS 缓存
set FLUSH_DNS=1
rem ============================================================
rem 网卡 / Wi-Fi 名称匹配配置
rem ============================================================
rem 是否忽略 Wi-Fi / 网卡名称匹配:1 表示忽略
set IGNORE_NAME_MATCH=1
rem 如果只希望连接到指定 Wi-Fi 时启用,使用下面配置
rem set IGNORE_NAME_MATCH=0
rem set MATCH_SSID=your-wifi-name
rem 如果只希望指定网卡启用,使用下面配置
rem set MATCH_ADAPTER_ALIAS=Wi-Fi
rem ============================================================
rem 启动 Node.js 脚本
rem ============================================================
:loop
node "%~dp0gateway-watch.js" >> "%~dp0gateway-watch.log" 2>&1
rem 如果 Node.js 异常退出,等待 5 秒后自动重启
timeout /t 5 /nobreak >nul
goto loop
六、完整 Node.js 脚本:gateway-watch.js
保存为:
C:\foo\gateway-failover\gateway-watch.js
完整内容如下:
'use strict';
const { execFile } = require('child_process');
const dns = require('dns').promises;
const https = require('https');
const env = process.env;
const CFG = {
gatewayA: env.GATEWAY_A || '192.168.10.1',
gatewayB: env.GATEWAY_B || '192.168.10.8',
localIp: env.LOCAL_IP || '192.168.10.120',
probeUrls: (env.PROBE_URLS || env.PROBE_URL || 'https://x.com')
.split(',')
.map(s => s.trim())
.filter(Boolean),
intervalMs: Number(env.INTERVAL_MS || 10000),
timeoutMs: Number(env.PROBE_TIMEOUT_MS || 5000),
okThreshold: Number(env.OK_THRESHOLD || 2),
failThreshold: Number(env.FAIL_THRESHOLD || 2),
bMetric: String(env.B_METRIC || 5),
probeMetric: String(env.PROBE_ROUTE_METRIC || 1),
matchSsid: env.MATCH_SSID || '',
matchAdapterAlias: env.MATCH_ADAPTER_ALIAS || '',
ignoreNameMatch: /^(1|true|yes)$/i.test(env.IGNORE_NAME_MATCH || ''),
maxProbeIpsPerHost: Number(env.MAX_PROBE_IPS_PER_HOST || 4),
switchDns: /^(1|true|yes)$/i.test(env.SWITCH_DNS || ''),
dnsB: env.DNS_B || env.GATEWAY_B || '192.168.10.8',
dnsFallbackMode: env.DNS_FALLBACK_MODE || 'dhcp',
dnsA: env.DNS_A || env.GATEWAY_A || '192.168.10.1',
flushDns: /^(1|true|yes)$/i.test(env.FLUSH_DNS || '1'),
};
function log(message) {
console.log(`[${new Date().toISOString()}] ${message}`);
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function withTimeout(promise, ms, message) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => {
reject(new Error(message || 'timeout'));
}, ms);
});
return Promise.race([promise, timeout]).finally(() => {
clearTimeout(timer);
});
}
function run(file, args, options = {}) {
return new Promise((resolve, reject) => {
execFile(
file,
args,
{
windowsHide: true,
timeout: options.timeout || 15000,
},
(error, stdout, stderr) => {
const result = {
code: error && typeof error.code === 'number' ? error.code : 0,
stdout: stdout || '',
stderr: stderr || '',
error,
};
if (error && !options.ignoreError) {
const e = new Error(
`${file} ${args.join(' ')} failed: ${stderr || stdout || error.message}`
);
e.result = result;
reject(e);
} else {
resolve(result);
}
}
);
});
}
async function ps(command) {
const result = await run('powershell.exe', [
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-Command',
command,
]);
return result.stdout.trim();
}
async function getInterfaceInfo() {
const command = `
$ip = '${CFG.localIp}';
$addr = Get-NetIPAddress -AddressFamily IPv4 -IPAddress $ip -ErrorAction SilentlyContinue | Select-Object -First 1;
if (!$addr) { throw "LOCAL_IP not found: $ip" }
$adapter = Get-NetAdapter -InterfaceIndex $addr.InterfaceIndex -ErrorAction Stop;
[PSCustomObject]@{
InterfaceIndex = $addr.InterfaceIndex;
InterfaceAlias = $addr.InterfaceAlias;
AdapterName = $adapter.Name;
Status = $adapter.Status;
} | ConvertTo-Json -Compress
`;
const output = await ps(command);
return JSON.parse(output);
}
async function getWifiSsid() {
const result = await run('netsh', ['wlan', 'show', 'interfaces'], {
ignoreError: true,
});
const text = result.stdout || '';
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (/^SSID\s*:/i.test(trimmed) && !/^BSSID\s*:/i.test(trimmed)) {
return trimmed.split(':').slice(1).join(':').trim();
}
}
return '';
}
async function nameMatched(interfaceInfo) {
if (CFG.ignoreNameMatch) {
return true;
}
if (CFG.matchAdapterAlias) {
const alias = String(interfaceInfo.InterfaceAlias || interfaceInfo.AdapterName || '');
if (!alias.toLowerCase().includes(CFG.matchAdapterAlias.toLowerCase())) {
log(`网卡名称不匹配:current="${alias}", expected contains="${CFG.matchAdapterAlias}"`);
return false;
}
}
if (CFG.matchSsid) {
const ssid = await getWifiSsid();
if (!ssid || ssid.toLowerCase() !== CFG.matchSsid.toLowerCase()) {
log(`SSID 不匹配:current="${ssid || '(none)'}", expected="${CFG.matchSsid}"`);
return false;
}
}
return true;
}
async function addRoute(destination, mask, gateway, metric, ifIndex) {
const args = [
'add',
destination,
'mask',
mask,
gateway,
'metric',
String(metric),
'if',
String(ifIndex),
];
const result = await run('route', args, {
ignoreError: true,
});
const text = `${result.stdout}\n${result.stderr}`;
if (result.error && !/exist|already|对象已存在|已存在/i.test(text)) {
throw new Error(`route ${args.join(' ')} failed: ${text.trim()}`);
}
}
async function delRoute(destination, mask, gateway) {
await run('route', ['delete', destination, 'mask', mask, gateway], {
ignoreError: true,
});
}
async function flushDnsCache() {
if (!CFG.flushDns) {
return;
}
await run('ipconfig', ['/flushdns'], {
ignoreError: true,
});
log('已刷新 Windows DNS 缓存');
}
async function setDnsToB(interfaceAlias) {
if (!CFG.switchDns) {
return;
}
await run('netsh', [
'interface',
'ipv4',
'set',
'dnsservers',
`name=${interfaceAlias}`,
'static',
CFG.dnsB,
'primary',
]);
await flushDnsCache();
log(`DNS 已切换到 B:${CFG.dnsB}`);
}
async function restoreDns(interfaceAlias) {
if (!CFG.switchDns) {
return;
}
const mode = String(CFG.dnsFallbackMode || 'dhcp').toLowerCase();
if (mode === 'static') {
await run('netsh', [
'interface',
'ipv4',
'set',
'dnsservers',
`name=${interfaceAlias}`,
'static',
CFG.dnsA,
'primary',
]);
log(`DNS 已回退到静态 DNS:${CFG.dnsA}`);
} else {
await run('netsh', [
'interface',
'ipv4',
'set',
'dnsservers',
`name=${interfaceAlias}`,
'source=dhcp',
]);
log('DNS 已恢复为 DHCP 自动获取');
}
await flushDnsCache();
}
async function enableBDefault(ifIndex, interfaceAlias) {
await delRoute('0.0.0.0', '0.0.0.0', CFG.gatewayB);
await addRoute(
'0.0.0.0',
'0.0.0.0',
CFG.gatewayB,
CFG.bMetric,
ifIndex
);
await setDnsToB(interfaceAlias);
log(
`已切换到 B:默认路由 -> ${CFG.gatewayB}, metric=${CFG.bMetric}, DNS=${
CFG.switchDns ? CFG.dnsB : '未切换'
}`
);
}
async function disableBDefault(interfaceAlias) {
await delRoute('0.0.0.0', '0.0.0.0', CFG.gatewayB);
await restoreDns(interfaceAlias);
log(`已回退到 A:删除 B 默认路由,系统将走 DHCP/默认网关 A ${CFG.gatewayA}`);
}
async function resolveIpv4(hostname) {
const rows = await withTimeout(
dns.lookup(hostname, {
all: true,
family: 4,
}),
CFG.timeoutMs,
`DNS resolve timeout: ${hostname}`
);
return [...new Set(rows.map(row => row.address))].slice(0, CFG.maxProbeIpsPerHost);
}
function httpsCheckViaIp(urlString, ip) {
return new Promise(resolve => {
const url = new URL(urlString);
const request = https.request(
url,
{
method: 'GET',
timeout: CFG.timeoutMs,
headers: {
'User-Agent': 'gateway-watch/1.0',
},
// 关键点:
// 强制本次 HTTPS 请求连接到指定 IPv4。
// URL 的 hostname 仍然保留原域名,因此 Host 与 SNI 不变。
lookup: (_hostname, _options, callback) => {
callback(null, ip, 4);
},
},
response => {
response.resume();
// 2xx / 3xx / 4xx 都说明链路可达并且目标站有响应。
// 5xx 更像目标站异常,这里视为失败。
const ok = response.statusCode >= 200 && response.statusCode < 500;
resolve(ok);
}
);
request.on('timeout', () => {
request.destroy(new Error('probe timeout'));
});
request.on('error', () => {
resolve(false);
});
request.end();
});
}
async function probeUrlViaB(urlString, ifIndex) {
const host = new URL(urlString).hostname;
let ips;
try {
ips = await resolveIpv4(host);
} catch (error) {
log(`探测失败:${host} DNS 解析失败:${error.message}`);
return false;
}
if (!ips.length) {
log(`探测失败:${host} 没有解析到 IPv4`);
return false;
}
for (const ip of ips) {
try {
// 临时主机路由:
// 强制这个目标 IP 通过 B 访问,避免通过 A 误判 B 可用。
await addRoute(
ip,
'255.255.255.255',
CFG.gatewayB,
CFG.probeMetric,
ifIndex
);
const ok = await httpsCheckViaIp(urlString, ip);
if (ok) {
await delRoute(ip, '255.255.255.255', CFG.gatewayB);
return true;
}
} catch (error) {
log(`探测 ${urlString} via ${ip} 异常:${error.message}`);
} finally {
await delRoute(ip, '255.255.255.255', CFG.gatewayB);
}
}
return false;
}
async function probeB(ifIndex) {
for (const url of CFG.probeUrls) {
const ok = await probeUrlViaB(url, ifIndex);
if (ok) {
return true;
}
}
return false;
}
async function main() {
log(
[
'启动 gateway-watch',
`A=${CFG.gatewayA}`,
`B=${CFG.gatewayB}`,
`LOCAL_IP=${CFG.localIp}`,
`PROBE=${CFG.probeUrls.join(',')}`,
`SWITCH_DNS=${CFG.switchDns}`,
`DNS_B=${CFG.dnsB}`,
`DNS_FALLBACK_MODE=${CFG.dnsFallbackMode}`,
].join(', ')
);
let okStreak = 0;
let failStreak = 0;
// unknown:脚本刚启动,不确定当前路由/DNS状态
// A:已确认回退到 A
// B:已确认切换到 B
let currentMode = 'unknown';
while (true) {
let iface = null;
try {
iface = await getInterfaceInfo();
if (iface.Status !== 'Up') {
log(`网卡未连接:${iface.InterfaceAlias}, status=${iface.Status}`);
if (currentMode !== 'A') {
await disableBDefault(iface.InterfaceAlias);
currentMode = 'A';
}
okStreak = 0;
failStreak = 0;
await sleep(CFG.intervalMs);
continue;
}
const matched = await nameMatched(iface);
if (!matched) {
log('当前网络环境不匹配,执行回退');
if (currentMode !== 'A') {
await disableBDefault(iface.InterfaceAlias);
currentMode = 'A';
}
okStreak = 0;
failStreak = 0;
await sleep(CFG.intervalMs);
continue;
}
const ifIndex = iface.InterfaceIndex;
const interfaceAlias = iface.InterfaceAlias;
const ok = await probeB(ifIndex);
if (ok) {
okStreak += 1;
failStreak = 0;
log(`B 探测成功 ${okStreak}/${CFG.okThreshold}`);
if (okStreak >= CFG.okThreshold && currentMode !== 'B') {
await enableBDefault(ifIndex, interfaceAlias);
currentMode = 'B';
}
} else {
failStreak += 1;
okStreak = 0;
log(`B 探测失败 ${failStreak}/${CFG.failThreshold}`);
if (failStreak >= CFG.failThreshold && currentMode !== 'A') {
await disableBDefault(interfaceAlias);
currentMode = 'A';
}
}
} catch (error) {
log(`循环异常:${error.message}`);
try {
if (iface && iface.InterfaceAlias) {
await disableBDefault(iface.InterfaceAlias);
currentMode = 'A';
} else {
await delRoute('0.0.0.0', '0.0.0.0', CFG.gatewayB);
currentMode = 'A';
}
} catch (_) {
// 忽略回退过程中的二次异常,避免脚本退出
}
okStreak = 0;
failStreak = 0;
}
await sleep(CFG.intervalMs);
}
}
main().catch(error => {
log(`致命错误:${error.stack || error.message}`);
process.exit(1);
});
七、脚本运行逻辑说明
1. 获取当前网卡
脚本通过 LOCAL_IP 找到当前 Windows 使用的网卡:
Get-NetIPAddress -AddressFamily IPv4 -IPAddress $ip
然后获取:
InterfaceIndex
InterfaceAlias
AdapterName
Status
InterfaceIndex 用于执行 route add ... if <InterfaceIndex>。
InterfaceAlias 用于执行 DNS 切换:
netsh interface ipv4 set dnsservers name=<InterfaceAlias> ...
2. 可选匹配 Wi-Fi 或网卡名称
默认配置:
set IGNORE_NAME_MATCH=1
表示不检查 Wi-Fi 名称或网卡名称。
如果 Windows 主机经常连接不同网络,建议启用匹配,避免在错误网络环境下修改路由和 DNS。
例如只允许在指定 Wi-Fi 下启用:
set IGNORE_NAME_MATCH=0
set MATCH_SSID=your-wifi-name
或者只允许在指定网卡下启用:
set IGNORE_NAME_MATCH=0
set MATCH_ADAPTER_ALIAS=Wi-Fi
这里的 your-wifi-name 是示例,请替换为自己的 Wi-Fi 名称。公开博客或文档中不建议暴露真实 SSID。
3. 探测 B 是否可用
探测过程不是直接访问 https://x.com,而是:
解析 x.com IPv4
临时添加 x.com IPv4 -> B 的 /32 路由
通过 HTTPS 访问 x.com
删除临时 /32 路由
这样可以保证探测流量确实经过 B。
临时路由示例:
route add <x.com-ip> mask 255.255.255.255 192.168.10.8 metric 1 if <InterfaceIndex>
探测结束后删除:
route delete <x.com-ip> mask 255.255.255.255 192.168.10.8
4. B 可用时切换到 B
连续成功达到阈值后,执行:
route delete 0.0.0.0 mask 0.0.0.0 192.168.10.8
route add 0.0.0.0 mask 0.0.0.0 192.168.10.8 metric 5 if <InterfaceIndex>
然后切换 DNS:
netsh interface ipv4 set dnsservers name=<InterfaceAlias> static 192.168.10.8 primary
ipconfig /flushdns
最终结果:
默认路由优先走 B
DNS 指向 B
5. B 不可用时回退到 A
连续失败达到阈值后,执行:
route delete 0.0.0.0 mask 0.0.0.0 192.168.10.8
然后恢复 DNS:
netsh interface ipv4 set dnsservers name=<InterfaceAlias> source=dhcp
ipconfig /flushdns
最终结果:
删除 B 默认路由
Windows 自动回到 DHCP 默认网关 A
DNS 恢复 DHCP 自动获取
八、创建 Windows 计划任务
修改路由表和 DNS 需要管理员权限,因此计划任务必须使用最高权限运行。
使用管理员 CMD 或管理员 PowerShell 执行:
schtasks /Create /TN "GatewayFailoverB" /SC ONLOGON /TR "\"C:\foo\gateway-failover\gateway-watch.cmd\"" /RL HIGHEST /F
手动启动任务:
schtasks /Run /TN "GatewayFailoverB"
停止任务:
schtasks /End /TN "GatewayFailoverB"
删除任务:
schtasks /Delete /TN "GatewayFailoverB" /F
九、验证路由是否切换成功
查看 IPv4 路由表:
route print -4
B 可用时,应该能看到类似:
0.0.0.0 0.0.0.0 192.168.10.8
0.0.0.0 0.0.0.0 192.168.10.1
其中 192.168.10.8 的 metric 应该更低,因此系统优先走 B。
B 不可用时,脚本会删除 B 默认路由,只保留 A:
0.0.0.0 0.0.0.0 192.168.10.1
十、验证 DNS 是否切换成功
查看当前 DNS:
netsh interface ipv4 show dnsservers
B 可用时,应该看到 DNS 指向:
192.168.10.8
B 不可用并回退后,应该显示 DNS 来自 DHCP 自动获取。
也可以查看详细网络配置:
ipconfig /all
十一、查看运行日志
脚本日志默认写入:
C:\foo\gateway-failover\gateway-watch.log
查看日志:
type C:\foo\gateway-failover\gateway-watch.log
日志中会看到类似内容:
[2026-01-01T10:00:00.000Z] 启动 gateway-watch, A=192.168.10.1, B=192.168.10.8
[2026-01-01T10:00:10.000Z] B 探测成功 1/2
[2026-01-01T10:00:20.000Z] B 探测成功 2/2
[2026-01-01T10:00:20.100Z] DNS 已切换到 B:192.168.10.8
[2026-01-01T10:00:20.200Z] 已切换到 B:默认路由 -> 192.168.10.8
十二、手动恢复方法
如果脚本异常,或者想手动恢复网络,可以执行:
route delete 0.0.0.0 mask 0.0.0.0 192.168.10.8
恢复 DNS 为 DHCP:
netsh interface ipv4 set dnsservers name="Wi-Fi" source=dhcp
ipconfig /flushdns
其中 "Wi-Fi" 需要替换为实际网卡名称。
可以通过下面命令查看网卡名称:
netsh interface show interface
十三、常见问题
1. 为什么不直接修改默认网关?
直接修改网卡默认网关容易破坏 DHCP 状态,也不方便恢复。
本文方案保留 DHCP 默认网关 A,只在 B 可用时添加一条优先级更高的临时默认路由。B 不可用时删除该路由即可回退,恢复路径更清晰。
2. 为什么不用 ping 检测 B?
ping 只能说明 ICMP 是否可达,不能证明 B 能正常访问目标网站,也不能证明 DNS、代理、透明网关等链路正常。
本文使用 HTTPS 探测目标地址,更接近真实上网行为。
3. 为什么要添加临时 /32 主机路由?
因为探测 B 的时候,必须确保探测流量真的经过 B。
如果不添加 /32 主机路由,探测请求可能通过 A 访问成功,从而误判 B 可用。
4. 为什么 B 失败后 DNS 要恢复 DHCP?
因为如果 DNS 一直指向 B,而 B 已经不可用,即使默认路由回到了 A,DNS 解析也可能失败。
恢复 DHCP 可以让 Windows 重新使用主路由或 DHCP 服务器下发的 DNS。
5. 切换时是否会断开已有连接?
可能会。
默认路由和 DNS 切换会影响新的连接。已有 TCP 连接、下载、SSH、长连接应用可能需要重连。这是客户端路由切换方案的正常现象。
6. 如果探测域名 DNS 解析失败怎么办?
脚本会把这次探测视为失败,并累计失败次数。
如果 B 当前已经接管 DNS 且 B 故障,解析失败也符合预期。连续失败达到阈值后,脚本会回退 DNS 到 DHCP。
7. 是否建议配置多个探测地址?
建议。
可以使用:
set PROBE_URLS=https://x.com,https://www.cloudflare.com,https://www.microsoft.com
脚本会依次探测,只要其中一个成功,就认为 B 可用。
不过具体站点是否在你的网络中稳定可访问,需要根据实际环境验证。无法保证任意公共站点在所有网络环境中都稳定可达。
十四、推荐配置
对于当前这种双网关旁路由场景,推荐配置如下:
set GATEWAY_A=192.168.10.1
set GATEWAY_B=192.168.10.8
set LOCAL_IP=192.168.10.120
set PROBE_URL=https://x.com
set INTERVAL_MS=10000
set PROBE_TIMEOUT_MS=5000
set OK_THRESHOLD=2
set FAIL_THRESHOLD=2
set B_METRIC=5
set PROBE_ROUTE_METRIC=1
set SWITCH_DNS=1
set DNS_B=192.168.10.8
set DNS_FALLBACK_MODE=dhcp
set FLUSH_DNS=1
对应行为:
B 连续约 20 秒可用:
默认路由切到 B
DNS 切到 B
B 连续约 20 秒不可用:
删除 B 默认路由
DNS 恢复 DHCP
系统回到 A
十五、总结
本文方案的核心是:
保留 DHCP 默认网关 A
B 健康时添加低 metric 默认路由到 B
B 故障时删除 B 默认路由
DNS 跟随路由状态自动切换
相比直接修改网卡默认网关,这种方式更容易恢复,也更适合旁路由场景。
整体链路如下:
正常状态:
Windows DHCP 默认网关 -> A
B 可用:
添加默认路由 -> B
DNS -> B
B 不可用:
删除 B 默认路由
DNS -> DHCP
Windows 自动回到 A
这个方案适合以下场景:
- Windows 客户端旁路由自动切换。
- OpenWrt 旁路由故障自动回退。
- 双网关环境下默认路由优先级管理。
- DNS 与代理网关联动切换。
- 家庭或办公网络中的客户端级容灾。