Windows 双网关自动切换:Node.js + 计划任务实现旁路由优先

在一些家庭或办公局域网中,可能会同时存在两个网关:

  • 默认网关 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 计划任务

核心需求:

  1. B 可用时,所有流量优先走 B。
  2. B 不可用时,自动回退到 DHCP 默认网关 A。
  3. B 恢复后,自动重新切回 B。
  4. 允许短暂网络中断。
  5. 支持通过环境变量配置 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。

实现方式是:

  1. 解析 x.com 的 IPv4 地址。
  2. 临时添加一条 /32 主机路由,让该目标 IP 强制走 B。
  3. 使用 HTTPS 请求访问 https://x.com
  4. 请求结束后删除临时主机路由。

示意如下:

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 逻辑

脚本主要包含以下几部分:

  1. 获取当前 Windows 网卡的 InterfaceIndex。
  2. 可选匹配 Wi-Fi SSID 或网卡名称。
  3. 解析检测目标域名。
  4. 临时添加目标 IP 到 B 的主机路由。
  5. 发起 HTTPS 探测。
  6. 根据连续成功或失败次数切换默认路由。

核心路由命令如下。

添加 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 addroute delete 需要管理员权限,因此计划任务必须使用最高权限运行。

5. Wi-Fi 名称匹配是可选能力

如果 Windows 主机经常连接不同网络,建议启用 SSID 或网卡名称匹配,避免脚本在错误网络环境中修改路由。


总结

这个方案的核心是:保留 DHCP 默认网关 A,不直接修改网卡配置;旁路网关 B 健康时添加低 metric 默认路由,B 故障时删除该路由。

通过 Node.js 做健康探测和防抖判断,再结合 Windows 计划任务长期运行,可以实现一个简单、可控、易恢复的双网关自动切换机制。

适用场景包括:

  • Windows 客户端旁路由自动切换
  • OpenWrt 旁路网关故障回退
  • 家庭网络双网关容灾
  • 代理网关或透明网关可用性检测
  • DHCP 默认路由与临时优先路由共存管理
相关推荐
嵌入式小企鹅3 小时前
UiPath推出AI编程“总指挥台”,SiFive发布RISC-V第三代猛兽
人工智能·学习·google·程序员·ai编程·risc-v·开源工具
JiaWen技术圈7 小时前
HTTP3 与 DTLS 的关系
网络协议
Java技术小馆9 小时前
零代码搭建家庭私有化健康中台
程序员
我要改名叫嘟嘟11 小时前
《微信读书》也出Skill啦~
程序员
lularible14 小时前
PTP协议精讲(4.5):编译运行与测试
网络·网络协议·开源·嵌入式·ptp
Simon5231414 小时前
HTTP、Cookie、Session知识小计
网络·网络协议·http
XS03010614 小时前
HTTP协议
网络·网络协议·http
暴力求解14 小时前
Linux--网络-->UDP_socket
linux·网络·网络协议·udp·操作系统
顶点多余15 小时前
传输层协议TCP详解----下
网络·网络协议·tcp/ip