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

13 阅读12分钟

一、背景

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

主路由 / 默认网关 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 的完整实现。


二、目标需求

本文方案满足以下目标:

  1. Windows 默认网关仍由 DHCP 管理,不破坏原有网络配置。
  2. 旁路由 B 可用时,自动添加低 metric 默认路由,让所有流量优先走 B。
  3. 旁路由 B 不可用时,自动删除 B 默认路由,回退到 DHCP 默认网关 A。
  4. DNS 跟随路由状态自动切换。
  5. B 可用时 DNS 指向 B。
  6. B 不可用时 DNS 恢复 DHCP 自动获取。
  7. 支持轮询检测。
  8. 支持连续成功 / 连续失败防抖,避免频繁切换。
  9. 支持通过环境变量配置网关 IP、探测地址、检测周期、网卡名称、Wi-Fi 名称等。
  10. 使用 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 时采用如下方式:

  1. 解析探测域名的 IPv4 地址。
  2. 临时添加一条 /32 主机路由,让该目标 IP 强制走 B。
  3. 发起 HTTPS 请求。
  4. 请求结束后删除临时主机路由。

示意:

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

这个方案适合以下场景:

  1. Windows 客户端旁路由自动切换。
  2. OpenWrt 旁路由故障自动回退。
  3. 双网关环境下默认路由优先级管理。
  4. DNS 与代理网关联动切换。
  5. 家庭或办公网络中的客户端级容灾。