diff --git a/lib/client.js b/lib/client.js index 2472a04..c52349f 100644 --- a/lib/client.js +++ b/lib/client.js @@ -12,6 +12,7 @@ const log = require('./logger'); const { getBoxId } = require('./fingerprint'); const { collect } = require('./metrics'); const { getDashboardInfo, resolveOpenclawConfigFile, startTtyd, FrpcManager } = require('./frpc'); // getDashboardInfo 也用于心跳中定期刷新 +const headscale = require('./headscale'); const { ProvisionManager } = require('./provisioning'); const { BtMonitor } = require('./bt-monitor'); const { hasInternet, hasWiredInternetProbe, getLocalIps, getLocalNetworks } = require('./network'); @@ -370,16 +371,17 @@ class ClawClient { _sendConnect() { const msg = { - type: 'connect', - box_id: this._boxId, - claw_id: this._cfg.claw_id ?? null, - token: this._cfg.token ?? null, - version: CLAWD_VERSION, - ssh_secret_key: this._cfg.ssh_secret_key ?? null, - local_ip: getLocalIps(), - local_networks: getLocalNetworks(), - external_ip: this._externalIp ?? null, - location: this._location ?? null, + type: 'connect', + box_id: this._boxId, + claw_id: this._cfg.claw_id ?? null, + token: this._cfg.token ?? null, + version: CLAWD_VERSION, + ssh_secret_key: this._cfg.ssh_secret_key ?? null, + headscale_joined: headscale.isInstalled() && headscale.isJoined(this._cfg.headscale_server || 'https://hs.claw.cutos.ai'), + local_ip: getLocalIps(), + local_networks: getLocalNetworks(), + external_ip: this._externalIp ?? null, + location: this._location ?? null, ...this._dashInfo, }; this._send(msg); @@ -400,6 +402,9 @@ class ClawClient { case 'upgrade': this._handleUpgrade(msg); break; + case 'headscale_logout': + headscale.logout().catch(e => log.error('headscale', 'logout 失败:', e.message)); + break; case 'error': log.error('clawd', `服务器错误: ${msg.msg}`); if (msg.msg === 'hardware_mismatch') { @@ -443,6 +448,18 @@ class ClawClient { }); } + // headscale:收到 authkey 则加入 mesh + if (msg.headscale_authkey && msg.headscale_server) { + const hostname = msg.headscale_hostname || `claw-${msg.claw_id}`; + this._cfg.headscale_server = msg.headscale_server; + config.save(this._cfg); + headscale.join(msg.headscale_server, msg.headscale_authkey, hostname) + .then(ok => { + if (ok) log.info('headscale', `已加入 mesh: ${hostname}`); + }) + .catch(e => log.error('headscale', '加入 mesh 失败:', e.message)); + } + this._startHeartbeat(); } diff --git a/lib/config.js b/lib/config.js index 02971f8..37031bc 100644 --- a/lib/config.js +++ b/lib/config.js @@ -20,6 +20,7 @@ const DEFAULTS = { /** 云端已激活:用于启动/重连时立即点亮 alarm(pwr),不等首包 connected */ activated: false, ssh_secret_key: null, + headscale_server: 'https://hs.claw.cutos.ai', }; function _generateSshSecretKey() { diff --git a/lib/headscale.js b/lib/headscale.js new file mode 100644 index 0000000..47aad62 --- /dev/null +++ b/lib/headscale.js @@ -0,0 +1,93 @@ +'use strict'; + +const { execSync, exec } = require('child_process'); +const fs = require('fs'); +const log = require('./logger'); + +const TAILSCALE_BIN_CANDIDATES = [ + '/usr/bin/tailscale', + '/usr/local/bin/tailscale', + '/sbin/tailscale', +]; + +/** 查找 tailscale 可执行文件路径,找不到返回 null */ +function findTailscaleBin() { + for (const p of TAILSCALE_BIN_CANDIDATES) { + if (fs.existsSync(p)) return p; + } + return null; +} + +/** tailscale 是否已安装 */ +function isInstalled() { + return findTailscaleBin() !== null; +} + +/** + * 检查是否已加入指定 headscale 服务端。 + * @param {string} loginServer headscale 服务地址,如 https://hs.claw.cutos.ai + * @returns {boolean} + */ +function isJoined(loginServer) { + const bin = findTailscaleBin(); + if (!bin) return false; + try { + const out = execSync(`${bin} status --json`, { timeout: 5000 }).toString(); + const status = JSON.parse(out); + if (status.BackendState !== 'Running') return false; + const currentServer = (status.CurrentTailnet?.MagicDNSSuffix || '') + || status.ControlURL || ''; + // 检查是否连接到同一 headscale 实例(比对 hostname) + const serverHost = new URL(loginServer).hostname; + return currentServer.includes(serverHost) || (status.Self?.Online === true); + } catch (_) { + return false; + } +} + +/** + * 加入 headscale mesh 网络。 + * @param {string} loginServer headscale 服务地址 + * @param {string} authkey 一次性 preauth key + * @param {string} hostname 节点 hostname(如 claw-1014) + * @returns {Promise} 是否成功 + */ +function join(loginServer, authkey, hostname) { + return new Promise((resolve) => { + const bin = findTailscaleBin(); + if (!bin) { + log.warn('headscale', 'tailscale 未安装,跳过 mesh 注册'); + resolve(false); + return; + } + const cmd = `${bin} up --login-server "${loginServer}" --authkey "${authkey}" --hostname "${hostname}" --accept-routes`; + log.info('headscale', `加入 mesh: ${loginServer}, hostname=${hostname}`); + exec(cmd, { timeout: 30_000 }, (err, stdout, stderr) => { + if (err) { + log.error('headscale', `tailscale up 失败: ${stderr || err.message}`); + resolve(false); + } else { + log.info('headscale', `成功加入 mesh: ${stdout.trim() || 'ok'}`); + resolve(true); + } + }); + }); +} + +/** + * 退出 headscale mesh 网络(解绑时调用)。 + * @returns {Promise} + */ +function logout() { + return new Promise((resolve) => { + const bin = findTailscaleBin(); + if (!bin) { resolve(); return; } + exec(`${bin} logout`, { timeout: 10_000 }, (err) => { + if (err) log.warn('headscale', `tailscale logout 失败: ${err.message}`); + else log.info('headscale', 'tailscale logout 成功'); + resolve(); + }); + }); +} + +module.exports = { isInstalled, isJoined, join, logout };