793 lines
25 KiB
JavaScript
793 lines
25 KiB
JavaScript
'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 → 立即 false(nmcli 有缓存,不可信)
|
||
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-wlan0,STA/热点均失败)。
|
||
*/
|
||
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,
|
||
};
|