feat: headscale mesh integration - auto-join on bind, logout on unbind
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,6 +12,7 @@ const log = require('./logger');
|
|||||||
const { getBoxId } = require('./fingerprint');
|
const { getBoxId } = require('./fingerprint');
|
||||||
const { collect } = require('./metrics');
|
const { collect } = require('./metrics');
|
||||||
const { getDashboardInfo, resolveOpenclawConfigFile, startTtyd, FrpcManager } = require('./frpc'); // getDashboardInfo 也用于心跳中定期刷新
|
const { getDashboardInfo, resolveOpenclawConfigFile, startTtyd, FrpcManager } = require('./frpc'); // getDashboardInfo 也用于心跳中定期刷新
|
||||||
|
const headscale = require('./headscale');
|
||||||
const { ProvisionManager } = require('./provisioning');
|
const { ProvisionManager } = require('./provisioning');
|
||||||
const { BtMonitor } = require('./bt-monitor');
|
const { BtMonitor } = require('./bt-monitor');
|
||||||
const { hasInternet, hasWiredInternetProbe, getLocalIps, getLocalNetworks } = require('./network');
|
const { hasInternet, hasWiredInternetProbe, getLocalIps, getLocalNetworks } = require('./network');
|
||||||
@@ -376,6 +377,7 @@ class ClawClient {
|
|||||||
token: this._cfg.token ?? null,
|
token: this._cfg.token ?? null,
|
||||||
version: CLAWD_VERSION,
|
version: CLAWD_VERSION,
|
||||||
ssh_secret_key: this._cfg.ssh_secret_key ?? null,
|
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_ip: getLocalIps(),
|
||||||
local_networks: getLocalNetworks(),
|
local_networks: getLocalNetworks(),
|
||||||
external_ip: this._externalIp ?? null,
|
external_ip: this._externalIp ?? null,
|
||||||
@@ -400,6 +402,9 @@ class ClawClient {
|
|||||||
case 'upgrade':
|
case 'upgrade':
|
||||||
this._handleUpgrade(msg);
|
this._handleUpgrade(msg);
|
||||||
break;
|
break;
|
||||||
|
case 'headscale_logout':
|
||||||
|
headscale.logout().catch(e => log.error('headscale', 'logout 失败:', e.message));
|
||||||
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
log.error('clawd', `服务器错误: ${msg.msg}`);
|
log.error('clawd', `服务器错误: ${msg.msg}`);
|
||||||
if (msg.msg === 'hardware_mismatch') {
|
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();
|
this._startHeartbeat();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const DEFAULTS = {
|
|||||||
/** 云端已激活:用于启动/重连时立即点亮 alarm(pwr),不等首包 connected */
|
/** 云端已激活:用于启动/重连时立即点亮 alarm(pwr),不等首包 connected */
|
||||||
activated: false,
|
activated: false,
|
||||||
ssh_secret_key: null,
|
ssh_secret_key: null,
|
||||||
|
headscale_server: 'https://hs.claw.cutos.ai',
|
||||||
};
|
};
|
||||||
|
|
||||||
function _generateSshSecretKey() {
|
function _generateSshSecretKey() {
|
||||||
|
|||||||
93
lib/headscale.js
Normal file
93
lib/headscale.js
Normal file
@@ -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<boolean>} 是否成功
|
||||||
|
*/
|
||||||
|
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<void>}
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
Reference in New Issue
Block a user