在一些家庭或办公局域网中,可能会同时存在两个网关:
- 默认网关 A:普通主路由,例如
192.168.10.1 - 旁路网关 B:OpenWrt、软路由,例如
192.168.10.8
客户端 Windows 主机通过 DHCP 获取默认网关 A,但在旁路网关 B 可用时,希望所有流量优先经过 B;当 B 不可用时,又希望自动回退到 A,避免断网。
本文介绍一种基于 Node.js + Windows 计划任务 + 动态路由表 的实现方式。
需求目标
目标环境如下:
text
Windows 主机 IP:192.168.10.120
默认网关 A:192.168.10.1
旁路网关 B:192.168.10.8
旁路网关类型:OpenWrt
检测目标:https://x.com
检测周期:10 秒
切换条件:连续 2 次成功切到 B,连续 2 次失败回退 A
运行方式:Windows 计划任务
核心需求:
- B 可用时,所有流量优先走 B。
- B 不可用时,自动回退到 DHCP 默认网关 A。
- B 恢复后,自动重新切回 B。
- 允许短暂网络中断。
- 支持通过环境变量配置 A、B、检测地址、网卡或 Wi-Fi 名称匹配规则。
方案思路
不要直接修改 Windows 网卡中的 DHCP 默认网关,而是保留 A 作为系统默认网关。
当 B 可用时,脚本添加一条更低 metric 的默认路由:
text
0.0.0.0/0 -> 192.168.10.8
由于这条路由的 metric 更低,Windows 会优先走 B。
当 B 不可用时,删除这条到 B 的默认路由:
text
删除 0.0.0.0/0 -> 192.168.10.8
此时系统自动回到 DHCP 下发的默认网关 A。
这种方式的优点是:
- 不破坏 DHCP 配置。
- 回退逻辑简单可靠。
- 即使脚本异常退出,也可以手动删除 B 路由恢复。
- 适合计划任务长期运行。
关键问题:如何判断 B 真正可用
不能简单判断 https://x.com 是否可访问。
因为如果当前系统默认流量已经走 A,那么即使 B 不可用,Windows 也可能通过 A 访问到 https://x.com,从而误判 B 可用。
因此,探测 B 时应当强制检测流量走 B。
实现方式是:
- 解析
x.com的 IPv4 地址。 - 临时添加一条
/32主机路由,让该目标 IP 强制走 B。 - 使用 HTTPS 请求访问
https://x.com。 - 请求结束后删除临时主机路由。
示意如下:
text
x.com 某个 IPv4 -> 192.168.10.8
这样探测结果才能代表 B 是否真的具备外网访问能力。
文件结构
建议创建目录:
text
C:\gateway-failover\
包含两个文件:
text
gateway-watch.js
gateway-watch.cmd
其中:
gateway-watch.js:负责探测、添加路由、删除路由。gateway-watch.cmd:负责配置环境变量并启动 Node.js 脚本。
启动脚本示例
gateway-watch.cmd 示例:
bat
@echo off
cd /d "%~dp0"
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
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
:loop
node "%~dp0gateway-watch.js" >> "%~dp0gateway-watch.log" 2>&1
timeout /t 5 /nobreak >nul
goto loop
其中敏感或环境相关信息可以通过环境变量替换,不建议在公开博客中暴露真实网络名称、账号、密码或内网资产信息。
核心 Node.js 逻辑
脚本主要包含以下几部分:
- 获取当前 Windows 网卡的 InterfaceIndex。
- 可选匹配 Wi-Fi SSID 或网卡名称。
- 解析检测目标域名。
- 临时添加目标 IP 到 B 的主机路由。
- 发起 HTTPS 探测。
- 根据连续成功或失败次数切换默认路由。
核心路由命令如下。
添加 B 默认路由:
bat
route add 0.0.0.0 mask 0.0.0.0 192.168.10.8 metric 5 if <InterfaceIndex>
删除 B 默认路由:
bat
route delete 0.0.0.0 mask 0.0.0.0 192.168.10.8
添加临时探测路由:
bat
route add <x.com-ip> mask 255.255.255.255 192.168.10.8 metric 1 if <InterfaceIndex>
删除临时探测路由:
bat
route delete <x.com-ip> mask 255.255.255.255 192.168.10.8
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),
};
function log(msg) {
console.log(`[${new Date().toISOString()}] ${msg}`);
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
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 r = await run('powershell.exe', [
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-Command',
command,
]);
return r.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 out = await ps(command);
return JSON.parse(out);
}
async function getWifiSsid() {
const r = await run('netsh', ['wlan', 'show', 'interfaces'], {
ignoreError: true,
});
const text = r.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 r = await run('route', args, { ignoreError: true });
const text = `${r.stdout}\n${r.stderr}`;
if (r.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 enableBDefault(ifIndex) {
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
);
log(`已切换:默认流量优先走 B ${CFG.gatewayB},metric=${CFG.bMetric}, if=${ifIndex}`);
}
async function disableBDefault() {
await delRoute('0.0.0.0', '0.0.0.0', CFG.gatewayB);
log(`已回退:删除 B 默认路由,系统将走 DHCP/默认网关 A ${CFG.gatewayA}`);
}
async function resolveIpv4(hostname) {
const rows = await dns.lookup(hostname, {
all: true,
family: 4,
});
return [...new Set(rows.map(r => r.address))].slice(0, CFG.maxProbeIpsPerHost);
}
function httpsCheckViaIp(urlString, ip) {
return new Promise(resolve => {
const url = new URL(urlString);
const req = https.request(
url,
{
method: 'GET',
timeout: CFG.timeoutMs,
headers: {
'User-Agent': 'gateway-watch/1.0',
},
// 强制这个 HTTPS 请求连接到指定 IPv4。
// Host/SNI 仍然是原域名,所以 HTTPS 证书校验正常。
lookup: (_hostname, _options, callback) => callback(null, ip, 4),
},
res => {
res.resume();
// 2xx / 3xx / 4xx 都说明链路可达并收到了 HTTPS 响应。
// 5xx 更像目标站异常,这里视为失败。
resolve(res.statusCode >= 200 && res.statusCode < 500);
}
);
req.on('timeout', () => {
req.destroy(new Error('probe timeout'));
});
req.on('error', () => resolve(false));
req.end();
});
}
async function probeUrlViaB(urlString, ifIndex) {
const host = new URL(urlString).hostname;
const ips = await resolveIpv4(host);
if (!ips.length) {
log(`探测失败:${host} 没有解析到 IPv4`);
return false;
}
for (const ip of ips) {
try {
// 临时主机路由:强制 x.com 的这个 IP 走 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 (e) {
log(`探测 ${urlString} via ${ip} 异常:${e.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(`启动。A=${CFG.gatewayA}, B=${CFG.gatewayB}, LOCAL_IP=${CFG.localIp}, PROBE=${CFG.probeUrls.join(',')}`);
let okStreak = 0;
let failStreak = 0;
let bEnabled = false;
while (true) {
try {
const iface = await getInterfaceInfo();
if (iface.Status !== 'Up') {
log(`网卡未连接:${iface.InterfaceAlias}, status=${iface.Status}`);
if (bEnabled) {
await disableBDefault();
bEnabled = false;
}
await sleep(CFG.intervalMs);
continue;
}
const matched = await nameMatched(iface);
if (!matched) {
okStreak = 0;
failStreak = 0;
if (bEnabled) {
await disableBDefault();
bEnabled = false;
} else {
await delRoute('0.0.0.0', '0.0.0.0', CFG.gatewayB);
}
await sleep(CFG.intervalMs);
continue;
}
const ifIndex = iface.InterfaceIndex;
const ok = await probeB(ifIndex);
if (ok) {
okStreak += 1;
failStreak = 0;
log(`B 探测成功 ${okStreak}/${CFG.okThreshold}`);
if (!bEnabled && okStreak >= CFG.okThreshold) {
await enableBDefault(ifIndex);
bEnabled = true;
}
} else {
failStreak += 1;
okStreak = 0;
log(`B 探测失败 ${failStreak}/${CFG.failThreshold}`);
if (bEnabled && failStreak >= CFG.failThreshold) {
await disableBDefault();
bEnabled = false;
} else if (!bEnabled) {
await delRoute('0.0.0.0', '0.0.0.0', CFG.gatewayB);
}
}
} catch (e) {
log(`循环异常:${e.message}`);
try {
await disableBDefault();
} catch (_) {}
bEnabled = false;
okStreak = 0;
failStreak = 0;
}
await sleep(CFG.intervalMs);
}
}
main().catch(e => {
log(`致命错误:${e.stack || e.message}`);
process.exit(1);
});
切换策略
推荐使用防抖策略,避免旁路由短暂抖动导致频繁切换:
text
检测周期:10 秒
连续成功:2 次后切换到 B
连续失败:2 次后回退到 A
也就是说:
- B 连续约 20 秒可用,才切换到 B。
- B 连续约 20 秒不可用,才回退到 A。
这种策略比单次检测更稳定。
使用计划任务运行
需要使用管理员权限创建计划任务,因为修改路由表需要管理员权限。
示例命令:
bat
schtasks /Create /TN "GatewayFailoverB" /SC ONLOGON /TR "\"C:\gateway-failover\gateway-watch.cmd\"" /RL HIGHEST /F
手动启动任务:
bat
schtasks /Run /TN "GatewayFailoverB"
查看日志:
bat
type C:\gateway-failover\gateway-watch.log
停止任务:
bat
schtasks /End /TN "GatewayFailoverB"
删除任务:
bat
schtasks /Delete /TN "GatewayFailoverB" /F
验证效果
B 可用时,执行:
bat
route print -4
应该能看到两条默认路由:
text
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:
text
0.0.0.0 0.0.0.0 192.168.10.1
如果需要手动恢复,也可以执行:
bat
route delete 0.0.0.0 mask 0.0.0.0 192.168.10.8
注意事项
1. 检测目标不宜只依赖一个站点
示例中使用 https://x.com,但生产环境建议支持多个检测地址,例如:
text
https://x.com,https://www.cloudflare.com,https://www.microsoft.com
只要其中一个成功,就可以认为 B 可用。
2. HTTPS 探测比 ping 更可靠
很多网络环境会禁用 ICMP,ping 不通不代表网络不可用。因此更推荐使用 HTTPS 请求作为探测方式。
3. 已有连接可能会中断
默认路由切换后,已有 TCP 连接可能会受影响。例如下载、SSH、长连接应用可能需要重连。
4. 需要管理员权限
route add 和 route delete 需要管理员权限,因此计划任务必须使用最高权限运行。
5. Wi-Fi 名称匹配是可选能力
如果 Windows 主机经常连接不同网络,建议启用 SSID 或网卡名称匹配,避免脚本在错误网络环境中修改路由。
总结
这个方案的核心是:保留 DHCP 默认网关 A,不直接修改网卡配置;旁路网关 B 健康时添加低 metric 默认路由,B 故障时删除该路由。
通过 Node.js 做健康探测和防抖判断,再结合 Windows 计划任务长期运行,可以实现一个简单、可控、易恢复的双网关自动切换机制。
适用场景包括:
- Windows 客户端旁路由自动切换
- OpenWrt 旁路网关故障回退
- 家庭网络双网关容灾
- 代理网关或透明网关可用性检测
- DHCP 默认路由与临时优先路由共存管理