Files
clawd/lib/network.js
2026-04-26 17:29:58 +08:00

581 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const { execSync, spawnSync, spawn } = require('child_process');
const fs = require('fs');
const os = require('os');
const log = require('./logger');
const AP_SSID_PREFIX = 'ClawBox-';
const AP_IP = '10.42.0.1';
const AP_PASSWORD = '12345678';
const AP_IFACE = process.env.CLAWD_WIFI_IFACE || '';
const CON_NAME = 'clawd-hotspot';
/** 产品 RJ45 在 sysfs 中的默认名;等价于检测 `cat /sys/class/net/end0/carrier` */
const DEFAULT_ETH_IFACE = 'end0';
function _ethIfaceEnvOrDefault() {
return process.env.CLAWD_ETH_IFACE || DEFAULT_ETH_IFACE;
}
function _netIfaceExists(name) {
try {
return fs.existsSync(`/sys/class/net/${name}`);
} catch (_) {
return false;
}
}
/** 读取 `/sys/class/net/<iface>/carrier``1` 为链路 up缺失或异常视为 down */
function _sysfsCarrierUp(iface) {
try {
return fs.readFileSync(`/sys/class/net/${iface}/carrier`, 'utf8').trim() === '1';
} catch (_) {
return false;
}
}
/** 非 WiFi、非典型虚拟接口用于开发机扫描有线口enp* 等) */
function _isExcludedVirtualIface(name) {
if (name === 'lo' || name === 'bonding_masters') return true;
if (name.startsWith('wl')) return true;
if (name.startsWith('docker')) return true;
if (name.startsWith('veth')) return true;
if (name.startsWith('virbr')) return true;
if (name.startsWith('br-')) return true;
if (name.startsWith('tun') || name.startsWith('tap')) return true;
if (name.startsWith('wg') || name.startsWith('bond')) return true;
if (name.startsWith('can')) return true;
return false;
}
/**
* 开发机:无 CLAWD_ETH_IFACE 且无 end0 时,扫描 sysfs 找第一个 carrier=1 的有线口。
*/
function _firstScanWiredIfaceWithCarrier() {
try {
const names = fs.readdirSync('/sys/class/net');
for (const name of names.sort()) {
if (_isExcludedVirtualIface(name)) continue;
if (_sysfsCarrierUp(name)) return name;
}
} catch (_) {}
return null;
}
/**
* 返回当前可用于「有线 ping / 路由」的网卡名。
* 优先级CLAWD_ETH_IFACE → 存在 end0 则只用 end0 → 否则扫描 sysfs。
*/
function getWiredIfaceWithCarrier() {
const explicit = process.env.CLAWD_ETH_IFACE;
if (explicit) {
return _netIfaceExists(explicit) && _sysfsCarrierUp(explicit) ? explicit : null;
}
if (_netIfaceExists(DEFAULT_ETH_IFACE)) {
return _sysfsCarrierUp(DEFAULT_ETH_IFACE) ? DEFAULT_ETH_IFACE : null;
}
return _firstScanWiredIfaceWithCarrier();
}
function hasWiredCarrier() {
return getWiredIfaceWithCarrier() !== null;
}
/**
* LAN 面板灯:只反映 RJ45 对应口,与 `cat /sys/class/net/end0/carrier 2>/dev/null` 同源(仅读 carrier
* 若配置的接口在 sysfs 中不存在(常见为开发机无 end0则退回与 hasWiredCarrier() 一致,避免灯永远灭。
*/
function hasLanCableCarrier() {
const iface = _ethIfaceEnvOrDefault();
if (_netIfaceExists(iface)) return _sysfsCarrierUp(iface);
return hasWiredCarrier();
}
function _tryPingInternet() {
try {
run('ping -c 1 -W 3 8.8.8.8');
return true;
} catch (_) {}
// 开热点时默认路由可能走 wlan无 -I 的 ping 会误判;指定有线口再试
const wired = getWiredIfaceWithCarrier();
if (wired) {
try {
run(`ping -c 1 -W 3 -I ${wired} 8.8.8.8`);
return true;
} catch (_) {}
}
return false;
}
/**
* 仅经有线口 ping 公网(不依赖默认路由)。
* AP 开启时 hasInternet() 易误判;维持 WS / 网络监视时用此兜底。
*/
function hasWiredInternetProbe() {
const wired = getWiredIfaceWithCarrier();
if (!wired) return false;
try {
run(`ping -c 1 -W 3 -I ${wired} 8.8.8.8`);
return true;
} catch (_) {}
return false;
}
/**
* 检测是否有互联网连接nmcli 连通性 + ping 兜底)
*/
function hasInternet() {
// 物理层快检:无 WiFi STA 且无任何有线 carrier → 立即 falsenmcli 有缓存,不可信)
if (!isWifiStaConnected() && !hasWiredCarrier()) return false;
try {
const out = run('nmcli networking connectivity check').trim();
if (out === 'full' || out === 'limited') return true;
} catch (_) {}
return _tryPingInternet();
}
/**
* 获取默认 WiFi 接口名wlan0 等)。
* 必须 TYPE 精确为 wifi不能用 grep wifi会误匹配 wifi-p2p导致选到 p2p-dev-wlan0STA/热点均失败)。
*/
function getWifiIface() {
if (AP_IFACE) return AP_IFACE;
try {
const out = run('nmcli -t -f DEVICE,TYPE device');
let fallback = '';
for (const line of out.split('\n')) {
if (!line.trim()) continue;
const parts = line.split(':');
const dev = (parts[0] || '').trim();
const type = (parts[1] || '').trim();
if (type !== 'wifi' || !dev) continue;
if (dev.startsWith('p2p-dev-')) continue;
if (dev.startsWith('wlan')) return dev;
if (!fallback) fallback = dev;
}
if (fallback) return fallback;
} catch (_) {}
try {
const out = run("ls /sys/class/net | grep -E '^wl'");
const iface = out.split('\n')[0].trim();
if (iface) return iface;
} catch (_) {}
return 'wlan0';
}
/**
* 扫描周围 WiFi返回 [{ ssid, signal, security }]
*/
function scanWifi() {
const iface = getWifiIface();
try {
// 先触发一次扫描
try { run(`nmcli device wifi rescan ifname ${iface}`); } catch (_) {}
// 等扫描完成
sleep(2000);
// 指定 ifname避免 AP/多网卡场景下读取到非目标接口或旧缓存;带回频率便于诊断 2.4G/5G。
const out = run(`nmcli -t -f SSID,SIGNAL,SECURITY,FREQ device wifi list ifname ${iface}`);
const seen = new Set();
const results = [];
for (const line of out.split('\n')) {
if (!line.trim()) continue;
const parts = _parseNmcliTerseLine(line);
const ssid = (parts[0] || '').trim();
if (!ssid || seen.has(ssid)) continue;
seen.add(ssid);
const freq = (parts[3] || '').trim();
const freqMhz = parseInt(freq, 10) || null;
results.push({
ssid,
signal: parseInt(parts[1], 10) || 0,
security: (parts[2] || '').trim() || 'Open',
freq,
band: freqMhz ? (freqMhz >= 4900 ? '5G' : '2.4G') : null,
});
}
results.sort((a, b) => b.signal - a.signal);
return results;
} catch (e) {
log.error('network', 'WiFi 扫描失败:', e.message);
return [];
}
}
/** AP 切 STA 后等待网卡进入 connected 的最长时间(不依赖外网探测) */
const CONNECT_WIFI_STA_WAIT_MS = 25_000;
const CONNECT_WIFI_STA_POLL_MS = 1_000;
/** 不走 shell避免 SSID/密码中的引号、空格、$ 等破坏命令 */
function nmcliSync(args, timeoutMs = 60000) {
const r = spawnSync('nmcli', args, {
encoding: 'utf8',
timeout: timeoutMs,
maxBuffer: 2 * 1024 * 1024,
});
if (r.error) throw r.error;
if (r.status !== 0) {
const msg = (r.stderr || '').trim() || (r.stdout || '').trim() || `nmcli exit ${r.status}`;
throw new Error(msg);
}
return (r.stdout || '').trim();
}
function _delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** 异步 nmcli不阻塞事件循环systemd Watchdog 依赖 setInterval 在主线程运行) */
function nmcliAsync(args, timeoutMs = 60000) {
return new Promise((resolve, reject) => {
const child = spawn('nmcli', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error('nmcli 超时'));
}, timeoutMs);
child.stdout.on('data', (d) => { stdout += d; });
child.stderr.on('data', (d) => { stderr += d; });
child.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
child.on('close', (code) => {
clearTimeout(timer);
if (code !== 0) {
const msg = stderr.trim() || stdout.trim() || `nmcli exit ${code}`;
reject(new Error(msg));
} else {
resolve(stdout.trim());
}
});
});
}
/**
* 连接指定 WiFi配网场景成功 = NM 显示 STA 已连上目标网,不要求一定能 ping 通 8.8.8.8
* 必须异步:同步 spawnSync + execSync(sleep) 会卡住主线程,导致 systemd WatchdogSec 内收不到 WATCHDOG=1。
* @returns {Promise<{ success: boolean, error?: string }>}
*/
async function connectWifi(ssid, password) {
const iface = getWifiIface();
log.info('network', `尝试连接 WiFi: ${ssid}ifname=${iface}`);
try {
try {
await nmcliAsync(['connection', 'delete', ssid], 15000);
} catch (_) {}
try {
await nmcliAsync(['device', 'set', iface, 'managed', 'yes'], 8000);
} catch (_) {}
const args = ['device', 'wifi', 'connect', ssid];
if (password) args.push('password', password);
args.push('ifname', iface);
await nmcliAsync(args, 120000);
await _ensureActiveWifiAutoconnect();
const deadline = Date.now() + CONNECT_WIFI_STA_WAIT_MS;
while (Date.now() < deadline) {
if (isWifiStaConnected()) {
if (hasInternet()) {
log.info('network', `WiFi 已连接且有外网: ${ssid}`);
} else {
log.warn(
'network',
`WiFi STA 已连接(${ssid}),暂未检测到外网;配网仍视为成功(内网/防火墙/国内 DNS 常见)`,
);
}
return { success: true };
}
await _delay(CONNECT_WIFI_STA_POLL_MS);
}
return { success: false, error: '超时:网卡未进入已连接状态' };
} catch (e) {
log.error('network', `WiFi 连接失败: ${e.message}`);
return { success: false, error: e.message };
}
}
/**
* 启动 WiFi AP 热点
*/
function startAP(clawId) {
const iface = getWifiIface();
const ssid = `${AP_SSID_PREFIX}${clawId || 'Setup'}`;
log.info('network', `启动 AP 热点: ${ssid} (${iface})`);
// 关闭已有热点
stopAP();
try {
// nmcli 创建热点(开放网络)
const cmd = [
'nmcli device wifi hotspot',
`ifname ${iface}`,
`con-name ${CON_NAME}`,
`ssid "${ssid}"`,
'band bg',
];
// 如果需要密码
if (AP_PASSWORD) {
cmd.push(`password "${AP_PASSWORD}"`);
}
run(cmd.join(' '));
try {
nmcliSync(['connection', 'modify', CON_NAME, 'connection.autoconnect', 'no'], 8000);
} catch (_) {}
// 等待 AP 启动
sleep(2000);
log.info('network', `AP 已启动: ${ssid}, 网关 ${AP_IP}`);
return { ssid, ip: AP_IP, iface };
} catch (e) {
log.error('network', `AP 启动失败: ${e.message}`);
throw e;
}
}
/**
* 关闭热点,恢复普通 WiFi 模式
*/
function stopAP() {
try {
run(`nmcli connection down ${CON_NAME}`);
} catch (_) {}
try {
run(`nmcli connection delete ${CON_NAME}`);
} catch (_) {}
}
// ── 工具 ─────────────────────────────────────────────────────────────────────
function run(cmd, timeout = 10000) {
return execSync(cmd, {
encoding: 'utf8',
timeout,
stdio: ['ignore', 'pipe', 'pipe'],
});
}
function sleep(ms) {
execSync(`sleep ${ms / 1000}`, { timeout: ms + 2000 });
}
function _parseNmcliTerseLine(line) {
const fields = [];
let cur = '';
let escaped = false;
for (const ch of line) {
if (escaped) {
cur += ch;
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (ch === ':') {
fields.push(cur);
cur = '';
continue;
}
cur += ch;
}
fields.push(cur);
return fields;
}
/**
* 列出已保存的 WiFi STA 连接(排除自身热点),按 autoconnect-priority 从高到低排序。
*/
function listSavedWifiConnections() {
const profiles = [];
try {
const out = run('nmcli -t -f NAME,UUID,TYPE,AUTOCONNECT,AUTOCONNECT-PRIORITY connection show');
for (const line of out.split('\n')) {
if (!line.trim()) continue;
const [name, uuid, type, autoconnect, priority] = _parseNmcliTerseLine(line);
if (type !== '802-11-wireless' || name === CON_NAME) continue;
profiles.push({
name,
uuid,
autoconnect: autoconnect === 'yes',
priority: parseInt(priority, 10) || 0,
});
}
} catch (_) {}
profiles.sort((a, b) => {
if (b.priority !== a.priority) return b.priority - a.priority;
if (a.autoconnect !== b.autoconnect) return a.autoconnect ? -1 : 1;
return a.name.localeCompare(b.name);
});
return profiles;
}
/**
* 检测是否有已保存的 WiFi STA 连接(排除自身热点)
*/
function hasSavedWifiConnection() {
return listSavedWifiConnections().length > 0;
}
function getWifiActiveConnectionName() {
const iface = getWifiIface();
try {
const conn = nmcliSync(['-g', 'GENERAL.CONNECTION', 'device', 'show', iface], 8000).trim();
return conn && conn !== '--' ? conn : null;
} catch (_) {
return null;
}
}
async function _ensureActiveWifiAutoconnect() {
const conn = getWifiActiveConnectionName();
if (!conn || conn === CON_NAME) return;
try {
await nmcliAsync(['connection', 'modify', conn, 'connection.autoconnect', 'yes'], 15000);
} catch (e) {
log.warn('network', `设置 WiFi 自动连接失败: ${conn}: ${e.message}`);
}
}
/**
* 主动让 NetworkManager 尝试已保存 WiFi。
* clawd 只做调度真正的认证、DHCP、重连细节仍交给 NM。
*/
async function connectSavedWifiConnections() {
const iface = getWifiIface();
const profiles = listSavedWifiConnections();
if (profiles.length === 0) {
return { success: false, error: '没有已保存的 WiFi 配置' };
}
try {
await nmcliAsync(['device', 'set', iface, 'managed', 'yes'], 8000);
} catch (_) {}
let lastError = '';
for (const profile of profiles) {
const label = profile.name || profile.uuid;
try {
log.info('network', `尝试连接已保存 WiFi: ${label}ifname=${iface}`);
const idArgs = profile.uuid ? ['uuid', profile.uuid] : ['id', profile.name];
await nmcliAsync(['connection', 'up', ...idArgs, 'ifname', iface], 90000);
if (isWifiStaConnected()) {
await _ensureActiveWifiAutoconnect();
log.info('network', `已保存 WiFi 连接成功: ${label}`);
return { success: true, profile };
}
lastError = '连接命令完成但网卡未进入 STA connected 状态';
} catch (e) {
lastError = e.message;
log.warn('network', `已保存 WiFi 连接失败: ${label}: ${e.message}`);
}
}
return { success: false, error: lastError || '所有已保存 WiFi 均连接失败' };
}
/**
* 是否已以 STA 连上某 WiFi排除自身热点
* 不用 device 列表按 `:` 拆字段连接名含冒号会错state 含 connecting 勿误匹配 connected
*/
function isWifiStaConnected() {
const iface = getWifiIface();
let state;
let conn;
try {
state = nmcliSync(['-g', 'GENERAL.STATE', 'device', 'show', iface], 8000);
conn = nmcliSync(['-g', 'GENERAL.CONNECTION', 'device', 'show', iface], 8000);
} catch (_) {
return false;
}
const s = (state || '').trim();
const c = (conn || '').trim();
if (!/\(connected\)/.test(s)) return false;
if (!c || c === CON_NAME) return false;
return true;
}
function _ifaceNetworkType(name) {
const wifi = getWifiIface();
if (name === wifi || name.startsWith('wl')) return 'wifi';
if (name === DEFAULT_ETH_IFACE || name.startsWith('en') || name.startsWith('eth')) return 'lan';
return null;
}
function _localNetworkEntries() {
const ifaces = os.networkInterfaces();
const entries = [];
for (const [name, addrs] of Object.entries(ifaces)) {
if (!addrs) continue;
const type = _ifaceNetworkType(name);
if (!type) continue;
for (const addr of addrs) {
if (addr.family !== 'IPv4' || addr.internal) continue;
// clawd-hotspot 的 AP 管理网段只用于配网,不上报为 BOX 可访问地址。
if (addr.address.startsWith('10.42.')) continue;
entries.push({ ip: addr.address, type, iface: name });
}
}
return entries;
}
/**
* 获取本机所有非回环 IPv4 地址,逗号拼接返回。
* 保持旧协议字段 local_ip 兼容:'192.168.1.100' 或 '192.168.1.100,10.0.0.5'。
*/
function getLocalIps() {
try {
const ips = _localNetworkEntries().map((entry) => entry.ip);
return ips.length > 0 ? ips.join(',') : null;
} catch (e) {
log.warn('network', '获取本机 IP 失败:', e.message);
return null;
}
}
/**
* 获取本机 IPv4 地址及网络类型,用于上报服务器。
* 例:[{ ip: '192.168.1.100', type: 'wifi', iface: 'wlan0' }]
*/
function getLocalNetworks() {
try {
const entries = _localNetworkEntries();
return entries.length > 0 ? entries : null;
} catch (e) {
log.warn('network', '获取本机网络类型失败:', e.message);
return null;
}
}
module.exports = {
hasInternet,
hasWiredCarrier,
hasLanCableCarrier,
hasWiredInternetProbe,
getWiredIfaceWithCarrier,
listSavedWifiConnections,
hasSavedWifiConnection,
connectSavedWifiConnections,
isWifiStaConnected,
getWifiIface,
scanWifi,
connectWifi,
startAP,
stopAP,
AP_IP,
getLocalIps,
getLocalNetworks,
};