Files
clawd/lib/network.js

793 lines
25 KiB
JavaScript
Raw Permalink 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';
const AP_RETRY_TOKEN_FILE = '/run/clawd-ap-retry.token';
/** 产品 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 _tryPingDefaultInternet() {
try {
run('ping -c 1 -W 3 8.8.8.8');
return true;
} catch (_) {}
return false;
}
function _tryPingWiredInternet() {
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;
}
/**
* 仅经有线口 ping 公网(不依赖默认路由)。
*/
function hasWiredInternetProbe() {
return _tryPingWiredInternet();
}
/**
* 检测是否有真实互联网连接。
* 注意NetworkManager 的 limited/local 可能只是 AP 本地网络或 captive 状态,不能当公网可用。
*/
function hasInternet() {
const wifiSta = isWifiStaConnected();
const wired = getWiredIfaceWithCarrier();
// 物理层快检:无 WiFi STA 且无任何有线 carrier → 立即 falsenmcli 有缓存,不可信)
if (!wifiSta && !wired) return false;
try {
const out = run('nmcli networking connectivity check').trim();
if (out === 'full') return true;
} catch (_) {}
if (wifiSta) return _tryPingDefaultInternet();
if (wired) return _tryPingWiredInternet();
return false;
}
/**
* 获取默认 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) {
cancelHotspotRadioRetry(`准备连接 WiFi: ${ssid}`);
const iface = getWifiIface();
log.info('network', `尝试连接 WiFi: ${ssid}ifname=${iface}`);
try {
try {
await nmcliAsync(['connection', 'delete', ssid], 15000);
} catch (_) {}
await _resetWifiRadioForSTA(iface);
if (password) {
// 显式创建 STA profile并固定为 WPA2-PSK only。
// RK3588/Broadcom DHD 对 NetworkManager 默认生成的 SAE/FT/WPA-PSK-SHA256 混合参数不稳定,
// 可能表现为一直 associating -> disconnected最后误报“需要密钥”。
await nmcliAsync([
'connection', 'add',
'type', 'wifi',
'ifname', iface,
'con-name', ssid,
'ssid', ssid,
], 15000);
await nmcliAsync([
'connection', 'modify', ssid,
// 连接成功前先禁止自动连接,避免失败恢复 AP 时 NM 又自动抢占 wlan0。
'connection.autoconnect', 'no',
'802-11-wireless-security.key-mgmt', 'wpa-psk',
'802-11-wireless-security.proto', 'rsn',
'802-11-wireless-security.pairwise', 'ccmp',
'802-11-wireless-security.group', 'ccmp',
'802-11-wireless-security.pmf', 'disable',
'802-11-wireless-security.psk', password,
], 15000);
await nmcliAsync(['connection', 'up', 'id', ssid, 'ifname', iface], 120000);
} else {
await nmcliAsync(['device', 'wifi', 'connect', ssid, 'ifname', iface], 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) {
try { await nmcliAsync(['connection', 'modify', ssid, 'connection.autoconnect', 'no'], 8000); } catch (_) {}
log.error('network', `WiFi 连接失败: ${e.message}`);
return { success: false, error: e.message };
}
}
function _newHotspotRetryToken() {
const token = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
try {
fs.writeFileSync(AP_RETRY_TOKEN_FILE, token, { mode: 0o600 });
} catch (e) {
log.warn('network', `写入 AP retry token 失败: ${e.message}`);
}
return token;
}
function cancelHotspotRadioRetry(reason = 'cancel') {
try {
fs.unlinkSync(AP_RETRY_TOKEN_FILE);
log.info('network', `已取消后台 AP retry: ${reason}`);
} catch (_) {}
}
async function _resetWifiRadioForSTA(iface, reason = '准备连接 STA 前重置 WiFi radio') {
log.warn('network', `${reason}: ${iface}`);
try { await nmcliAsync(['connection', 'down', CON_NAME], 8000); } catch (_) {}
try { await nmcliAsync(['connection', 'delete', CON_NAME], 8000); } catch (_) {}
try { await nmcliAsync(['device', 'disconnect', iface], 8000); } catch (_) {}
try {
await nmcliAsync(['radio', 'wifi', 'off'], 10000);
} catch (e) {
log.warn('network', `关闭 WiFi radio 失败: ${e.message}`);
}
await _delay(2500);
try {
await nmcliAsync(['radio', 'wifi', 'on'], 10000);
} catch (e) {
log.warn('network', `开启 WiFi radio 失败: ${e.message}`);
}
await _delay(5000);
try { await nmcliAsync(['device', 'set', iface, 'managed', 'yes'], 8000); } catch (_) {}
try { await nmcliAsync(['device', 'wifi', 'rescan', 'ifname', iface], 15000); } catch (_) {}
await _delay(1500);
}
function _resetWifiRadioForAP(iface, reason = '准备 AP 前重置 WiFi radio') {
log.warn('network', `${reason}: ${iface}`);
try { nmcliSync(['connection', 'down', CON_NAME], 8000); } catch (_) {}
try { nmcliSync(['connection', 'delete', CON_NAME], 8000); } catch (_) {}
try { nmcliSync(['device', 'disconnect', iface], 8000); } catch (_) {}
try {
nmcliSync(['radio', 'wifi', 'off'], 10000);
} catch (e) {
log.warn('network', `关闭 WiFi radio 失败: ${e.message}`);
}
sleep(2500);
try {
nmcliSync(['radio', 'wifi', 'on'], 10000);
} catch (e) {
log.warn('network', `开启 WiFi radio 失败: ${e.message}`);
}
sleep(5000);
try { nmcliSync(['device', 'set', iface, 'managed', 'yes'], 8000); } catch (_) {}
}
function _spawnHotspotRadioRetry(ssid, iface) {
const token = _newHotspotRetryToken();
const script = `
set -u
log() { logger -t clawd-ap-retry "$*"; }
check_token() {
if [ ! -f "$TOKEN_FILE" ] || [ "$(cat "$TOKEN_FILE" 2>/dev/null || true)" != "$TOKEN" ]; then
log "AP retry canceled"
exit 0
fi
}
log "AP retry started: ssid=$SSID iface=$IFACE"
check_token
nmcli connection down "$CON_NAME" >/dev/null 2>&1 || true
nmcli connection delete "$CON_NAME" >/dev/null 2>&1 || true
nmcli device disconnect "$IFACE" >/dev/null 2>&1 || true
check_token
nmcli radio wifi off >/dev/null 2>&1 || true
sleep 2.5
# If canceled while radio is off, always turn it back on before exiting.
nmcli radio wifi on >/dev/null 2>&1 || true
sleep 5
check_token
nmcli device set "$IFACE" managed yes >/dev/null 2>&1 || true
check_token
if ! nmcli connection add type wifi ifname "$IFACE" con-name "$CON_NAME" ssid "$SSID" >/dev/null 2>&1; then
log "AP retry failed: connection add failed"
exit 1
fi
args=(
connection modify "$CON_NAME"
connection.autoconnect no
802-11-wireless.mode ap
802-11-wireless.band bg
802-11-wireless.channel 1
802-11-wireless-security.key-mgmt wpa-psk
802-11-wireless-security.proto rsn
802-11-wireless-security.pairwise ccmp
802-11-wireless-security.group ccmp
802-11-wireless-security.pmf disable
ipv4.method shared
ipv4.addresses "$AP_IP/24"
ipv6.method ignore
)
if [ -n "\${AP_PASSWORD:-}" ]; then
args+=(802-11-wireless-security.psk "$AP_PASSWORD")
fi
check_token
if ! nmcli "\${args[@]}" >/dev/null 2>&1; then
log "AP retry failed: connection modify failed"
exit 1
fi
check_token
if nmcli connection up "$CON_NAME" >/dev/null 2>&1; then
if [ ! -f "$TOKEN_FILE" ] || [ "$(cat "$TOKEN_FILE" 2>/dev/null || true)" != "$TOKEN" ]; then
log "AP retry canceled after connection up; tearing hotspot down"
nmcli connection down "$CON_NAME" >/dev/null 2>&1 || true
nmcli connection delete "$CON_NAME" >/dev/null 2>&1 || true
exit 0
fi
log "AP retry success: $SSID"
rm -f "$TOKEN_FILE"
else
log "AP retry failed: connection up failed"
exit 1
fi
`;
const child = spawn('/bin/bash', ['-lc', script], {
detached: true,
stdio: 'ignore',
env: {
...process.env,
SSID: ssid,
IFACE: iface,
CON_NAME,
AP_IP,
AP_PASSWORD: AP_PASSWORD || '',
TOKEN_FILE: AP_RETRY_TOKEN_FILE,
TOKEN: token,
},
});
child.unref();
}
function _createHotspotProfile(ssid, iface) {
nmcliSync([
'connection', 'add',
'type', 'wifi',
'ifname', iface,
'con-name', CON_NAME,
'ssid', ssid,
], 15000);
const modifyArgs = [
'connection', 'modify', CON_NAME,
'connection.autoconnect', 'no',
'802-11-wireless.mode', 'ap',
'802-11-wireless.band', 'bg',
'802-11-wireless.channel', '1',
'802-11-wireless-security.key-mgmt', 'wpa-psk',
'802-11-wireless-security.proto', 'rsn',
'802-11-wireless-security.pairwise', 'ccmp',
'802-11-wireless-security.group', 'ccmp',
'802-11-wireless-security.pmf', 'disable',
'ipv4.method', 'shared',
'ipv4.addresses', `${AP_IP}/24`,
'ipv6.method', 'ignore',
];
if (AP_PASSWORD) {
modifyArgs.push('802-11-wireless-security.psk', AP_PASSWORD);
}
nmcliSync(modifyArgs, 15000);
}
function _activateHotspot(ssid, iface, timeoutMs = 8000) {
_createHotspotProfile(ssid, iface);
nmcliSync(['connection', 'up', CON_NAME], timeoutMs);
}
/**
* 启动 WiFi AP 热点
*/
function startAP(clawId) {
const iface = getWifiIface();
const ssid = `${AP_SSID_PREFIX}${clawId || 'Setup'}`;
log.info('network', `启动 AP 热点: ${ssid} (${iface})`);
// 关闭已有热点,并在重新拉起 AP 前真正 power-cycle WiFi 芯片。
// RK3588/Broadcom DHD 在 LAN 断开后切 AP 时,单纯 ip link down/up 不一定清掉固件残留状态。
stopAP();
_resetWifiRadioForAP(iface, '准备 AP 前重置 WiFi radio');
try {
// 显式创建并激活热点,固定为 WPA2-PSK only。
// 避免 NetworkManager 自动生成 WPA-PSK-SHA256/SAE 混合配置,部分 3588 Broadcom DHD 驱动重启 AP 时会拒绝该 RSN 参数。
try {
_activateHotspot(ssid, iface, 8000);
} catch (firstError) {
log.warn('network', `AP 启动未在短超时内完成,后台再次重置 WiFi radio 后重试;避免阻塞 watchdog: ${firstError.message}`);
_spawnHotspotRadioRetry(ssid, iface);
return { ssid, ip: AP_IP, iface, pending: true };
}
// 等待 AP 启动
sleep(1000);
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() {
cancelHotspotRadioRetry('停止 AP');
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() {
cancelHotspotRadioRetry('准备连接已保存 WiFi');
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,
cancelHotspotRadioRetry,
AP_IP,
getLocalIps,
getLocalNetworks,
};