feat(linux): Linux 被控端适配 —— 安装器 + systemd 自启 + 平台守卫 + 部署文档
面向「服务器型被控端」(命令/状态/文件/审计;无 GUI),实测环境 Debian 12(fnOS 底座)。 ## 新增 - `scripts/install-linux.sh`:一键安装器(对齐 Windows `install.ps1` 的语义) · 自动获取 Node 运行时(系统无 Node 22+ 时下载 linux-<arch>,走 npmmirror) · **systemd 服务**(系统级 / 用户级+linger / `--no-service` 三种模式),开机自启 · 幂等:复用既有密钥(并自动备份 agent.json);卸载保留配置(`--purge` 才删) · `--node-id/--port/--allow-input` 等参数;缺 `ip`/`ifconfig` 时 IP 展示多级回退 - `apps/agent/src/util/platform.ts`:**统一平台守卫** · `assertPlatform(cap, ...allowed)` / `assertGuiPlatform` / `assertWindows` · 抛 `E_UNSUPPORTED_PLATFORM` 并带上「支持哪些平台」,让"未实现"不再伪装成"参数错/环境缺依赖" - `docs/DEPLOY-LINUX.md`:部署指南 + **实测能力清单** + 故障排查表 ## 修改 - `screen.ts` / `record.ts` / `clipboard.ts` / `audio.ts` / `input.ts`:入口接入平台守卫。 修复前的表现是**误导性错误**(`screen.capture` 在 Linux 上报 `region 宽高必须 ≥ 1`, 像是参数没传对),现在是 `screen.capture 未在 Linux 上实现(仅支持 Windows / macOS)`。 - `scripts/verify.mjs`:v2.1 / v2.3 补 `E_UNSUPPORTED_PLATFORM → skip` (v2.2 早已处理;此前把"平台不支持"记成失败) - `README.md`:文档区链入 DEPLOY-LINUX ## 实测(Docker · Debian 12) - 安装流程端到端:装文件 → 写配置(600) → 提取密钥 → 启动监听 → **幂等复装密钥不变** ✓ - 验收:**9 通过 / 1 失败 / 6 跳过**(对比 macOS 15/0/1) · 1 失败 = 容器缺 `procps`(`process.list` 返回空),非代码问题;NAS 自带该包 · 6 跳过 = GUI 类能力(平台诚实拒绝) - 单元测试 325 全绿;7 包构建通过 ## 途中修正的三个"差点误判" 1. **Alpine 误判**:`spawn /bin/bash ENOENT` 是**镜像没 bash**,不是 Linux 不支持 2. **`set -e` 静默退出**:`LAN_IP=$(ip ...)` 在无 `ip` 的环境失败会**直接终止脚本** (`2>/dev/null` 把原因吞了,表现为"跑到一半就没了")→ 已改多级回退 3. **slim 镜像缺 procps** → `process.list` 空列表,被误读为"Linux 特性"
This commit is contained in:
1 parent
c9e34b93a2
commit
00992e5c1c
10 files changed
+649
No files matched your search
@@ -678,6 +678,7 @@ nodeagent/
|
||||
- [`docs/SECURITY.md`](docs/SECURITY.md) —— **安全模型与加固指南**(钥匙=root、证书指纹钉住、网段白名单、应急处置)
|
||||
- [`docs/VERSIONING.md`](docs/VERSIONING.md) —— **版本与兼容性契约**(升级顺序、什么算破坏性、发版流程、更新方式)
|
||||
- [`docs/REMOTE-LIMITS.md`](docs/REMOTE-LIMITS.md) —— 远程能力边界与真实故障案例(含杀软误拦)
|
||||
- [`docs/DEPLOY-LINUX.md`](docs/DEPLOY-LINUX.md) —— **Linux 被控端部署**(Debian/fnOS/Ubuntu;含实测能力清单与排查)
|
||||
- [`docs/VM-VERIFICATION.md`](docs/VM-VERIFICATION.md) —— **无 Windows 真机时,用 Parallels 的 Win11 ARM 虚拟机补验证**(含逐条命令与结论标注规则)
|
||||
- [`CHANGELOG.md`](CHANGELOG.md) —— **变更日志**(未发布版本的内容汇总 / 历史版本)
|
||||
- [`docs/ROADMAP.md`](docs/ROADMAP.md) —— **当前进度盘点与下一步路线**(2026-10-07 基线:待办收尾 / 语义可断言 / 状态采样 / 安全残余)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { runPowerShellSmart } from '../util/ps-helper.js';
|
||||
import { shLit } from '../util/quote.js';
|
||||
import { assertGuiPlatform } from '../util/platform.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -163,6 +164,8 @@ async function macState(): Promise<{ muted: boolean; volume: number }> {
|
||||
const AUDIO_BACKEND_WIN = 'NAudio/CoreAudioAPI';
|
||||
|
||||
export async function audioGet(_args: Args): Promise<unknown> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
|
||||
assertGuiPlatform('audio.get');
|
||||
if (IS_WINDOWS) {
|
||||
const state = parseAudioState(await runWindows('[NAAudio]::Get()'));
|
||||
return { ...state, platform: 'windows', backend: AUDIO_BACKEND_WIN };
|
||||
@@ -178,6 +181,8 @@ export async function audioGet(_args: Args): Promise<unknown> {
|
||||
// ---------------- system.audio.set ----------------
|
||||
|
||||
export async function audioSet(args: Args): Promise<unknown> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
|
||||
assertGuiPlatform('audio.set');
|
||||
const muteArg = args['mute'];
|
||||
const volumeArg = args['volume'];
|
||||
const hasMute = typeof muteArg === 'boolean';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { assertGuiPlatform } from '../util/platform.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -79,6 +80,8 @@ async function writeClipboardWindows(b64: string, expect: string): Promise<void>
|
||||
// ---------------- clip.get ----------------
|
||||
|
||||
export async function clipGet(args: Args = {}): Promise<unknown> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
|
||||
assertGuiPlatform('clipboard.get');
|
||||
const format = (args['format'] as string | undefined) ?? 'auto';
|
||||
|
||||
if (IS_WINDOWS) {
|
||||
@@ -132,6 +135,8 @@ async function getImageWindows(): Promise<Record<string, unknown> | null> {
|
||||
// ---------------- clip.set ----------------
|
||||
|
||||
export async function clipSet(args: Args): Promise<unknown> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端未实现)
|
||||
assertGuiPlatform('clipboard.set');
|
||||
const image = args['image_base64'] as string | undefined;
|
||||
const text = args['text'] as string | undefined;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from './input-script.js';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { PS_DPI_AWARE } from '../util/dpi.js';
|
||||
import { assertGuiPlatform } from '../util/platform.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -256,6 +257,7 @@ const MOUSE_FLAGS: Record<string, { down: number; up: number }> = {
|
||||
};
|
||||
|
||||
export async function mouseMove(args: Args): Promise<unknown> {
|
||||
assertGuiPlatform('input.mouseMove');
|
||||
assertAllowed('input.mouse.move');
|
||||
requireWindows('input.mouse.move');
|
||||
const x = args['x'] as number;
|
||||
@@ -283,6 +285,7 @@ export async function mouseMove(args: Args): Promise<unknown> {
|
||||
}
|
||||
|
||||
export async function mouseClick(args: Args): Promise<unknown> {
|
||||
assertGuiPlatform('input.mouseClick');
|
||||
assertAllowed('input.mouse.click');
|
||||
requireWindows('input.mouse.click');
|
||||
const button = (args['button'] as string) ?? 'left';
|
||||
@@ -314,6 +317,7 @@ export async function mouseClick(args: Args): Promise<unknown> {
|
||||
}
|
||||
|
||||
export async function mouseScroll(args: Args): Promise<unknown> {
|
||||
assertGuiPlatform('input.mouseScroll');
|
||||
assertAllowed('input.mouse.scroll');
|
||||
requireWindows('input.mouse.scroll');
|
||||
const delta = args['delta'] as number;
|
||||
@@ -333,6 +337,7 @@ export async function mouseScroll(args: Args): Promise<unknown> {
|
||||
* 用于拖文件、框选、拖动滑块/窗口。整段动作在一次进程内完成(避免多次 spawn 抖动)。
|
||||
*/
|
||||
export async function mouseDrag(args: Args): Promise<unknown> {
|
||||
assertGuiPlatform('input.mouseDrag');
|
||||
assertAllowed('input.mouse.drag');
|
||||
requireWindows('input.mouse.drag');
|
||||
const x1 = args['from_x'] as number;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { PS_DPI_AWARE } from '../util/dpi.js';
|
||||
import { assertGuiPlatform } from '../util/platform.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -88,6 +89,8 @@ async function tryMuxMp4(dir: string, fps: number): Promise<string | null> {
|
||||
* —— 避免把大体积视频塞进单条 RPC 响应。
|
||||
*/
|
||||
export async function screenRecord(args: Args): Promise<unknown> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端需 X11/Wayland 后端,尚未实现)
|
||||
assertGuiPlatform('screen.record');
|
||||
if (!IS_WINDOWS) {
|
||||
throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, 'screen.record 当前仅支持 Windows 被控端', {
|
||||
platform: process.platform,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
import { IS_WINDOWS, execCommand } from '../util/exec.js';
|
||||
import { shLit } from '../util/quote.js';
|
||||
import { PS_DPI_AWARE } from '../util/dpi.js';
|
||||
import { assertGuiPlatform } from '../util/platform.js';
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
@@ -82,6 +83,8 @@ async function listDisplays(): Promise<Display[]> {
|
||||
}
|
||||
|
||||
export async function screenInfo(_args: Args): Promise<unknown> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端需 X11/Wayland 后端,尚未实现)
|
||||
assertGuiPlatform('screen.info');
|
||||
const displays = await listDisplays();
|
||||
return {
|
||||
displays: displays.map(({ id, name, width, height, is_primary, scale }) => ({
|
||||
@@ -115,6 +118,8 @@ interface CaptureResult {
|
||||
}
|
||||
|
||||
export async function screenCapture(args: Args): Promise<unknown> {
|
||||
// 该能力仅在 Windows/macOS 有实现(Linux 端需 X11/Wayland 后端,尚未实现)
|
||||
assertGuiPlatform('screen.capture');
|
||||
const opts: CaptureOpts = {
|
||||
display_id: (args['display_id'] as number) ?? 0,
|
||||
format: (args['format'] as 'png' | 'jpeg') ?? 'jpeg',
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 平台能力守卫(跨平台最小实现)。
|
||||
*
|
||||
* ## 为什么要统一
|
||||
* 各能力原本各自判断平台,风格不一:
|
||||
* - `darwin.ts` 有 `assertDarwin()`
|
||||
* - `window.ts` 写 `if (platform === 'darwin') return macXxx()`,其余平台**静默走到 Windows 实现**,
|
||||
* 于是在 Linux 上表现为 `spawn /bin/bash ENOENT` 这类**误导性错误**(像是环境缺依赖,
|
||||
* 实际是"本平台根本没实现")。
|
||||
*
|
||||
* 统一后的语义:**没实现就明确说"不支持当前平台"**(`E_UNSUPPORTED_PLATFORM`),
|
||||
* 并带上「支持哪些平台」,让调用方一眼看懂,也让能力清单的"声明可用"不至于撒谎。
|
||||
*
|
||||
* ## 用法
|
||||
* ```ts
|
||||
* assertPlatform('screen.capture', 'win32', 'darwin');
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
|
||||
|
||||
/** 人类可读的平台名(错误信息里用)。 */
|
||||
const PLATFORM_LABEL: Record<string, string> = {
|
||||
win32: 'Windows',
|
||||
darwin: 'macOS',
|
||||
linux: 'Linux',
|
||||
};
|
||||
|
||||
export function platformLabel(p: NodeJS.Platform): string {
|
||||
return PLATFORM_LABEL[p] ?? p;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前平台是否在允许集合内。
|
||||
*
|
||||
* 与 `assertPlatform` 的分工:这个用于**条件注册/条件分支**,那个用于**进入能力时兜底拒绝**。
|
||||
*/
|
||||
export function isPlatform(...allowed: NodeJS.Platform[]): boolean {
|
||||
return allowed.includes(process.platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言当前平台受支持;否则抛 `E_UNSUPPORTED_PLATFORM`。
|
||||
*
|
||||
* @param capability 能力名(写进错误信息,便于定位)
|
||||
* @param allowed 支持该能力的平台(如 `'win32', 'darwin'`)
|
||||
*/
|
||||
export function assertPlatform(capability: string, ...allowed: NodeJS.Platform[]): void {
|
||||
if (isPlatform(...allowed)) return;
|
||||
const supported = allowed.map(platformLabel).join(' / ');
|
||||
throw new CapabilityError(
|
||||
ErrorCodes.UNSUPPORTED_PLATFORM,
|
||||
`${capability} 未在 ${platformLabel(process.platform)} 上实现(仅支持 ${supported})`,
|
||||
{
|
||||
platform: process.platform,
|
||||
supported,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅 Windows 的能力(PowerShell / Win32 实现)。
|
||||
*
|
||||
* 覆盖:截屏与录屏、键鼠注入、剪贴板、音量、软件装卸(winget)、窗口与 UIA 语义。
|
||||
* 注意 **不含** `system.info` / `system.status` / `system.process.list` / `shell.exec` / `fs.*` ——
|
||||
* 这些有分平台实现,在 Linux 上可用。
|
||||
*/
|
||||
export function assertWindows(capability: string): void {
|
||||
assertPlatform(capability, 'win32');
|
||||
}
|
||||
|
||||
/**
|
||||
* 图形界面类能力(Windows 或 macOS 有实现,Linux 目前没有)。
|
||||
*
|
||||
* 用于 `screen.*` / `window.*` / `input.*` 这类:在 Windows 走 Win32/UIA、
|
||||
* 在 macOS 走 AppleScript/Vision,**Linux 端尚未实现**(需 X11/Wayland 后端)。
|
||||
*/
|
||||
export function assertGuiPlatform(capability: string): void {
|
||||
assertPlatform(capability, 'win32', 'darwin');
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
# Linux 被控端部署(Debian 12 / fnOS / Ubuntu 等 systemd 系统)
|
||||
|
||||
> 适用:把一台 Linux 机器作为**被控端**,让 Mac/Linux 控制端接管。
|
||||
> 本文的能力清单是 **2026-10-13 在 Debian 12 容器里实测**得出的,不是推测。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
Linux 被控端 = **命令 / 状态 / 文件 / 审计**(服务器型用途);
|
||||
**没有 GUI 能力**(截图、键鼠、窗口、剪贴板、音量均为 Windows/macOS 实现,
|
||||
Linux 端未实现 —— 调用会明确返回 `E_UNSUPPORTED_PLATFORM`,不假装可用)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 快速开始(以绿联 DXP2800 / fnOS 为例)
|
||||
|
||||
```bash
|
||||
# ① 把仓库放到 NAS 上(任选其一)
|
||||
# - git clone https://github.com/jkooe/nodeagent.git
|
||||
# - 或从控制端 scp 整个目录过去
|
||||
|
||||
# ② 生成被控端载荷(在**控制端**或任意有 Node 22+ 的机器上执行一次)
|
||||
node scripts/pack.mjs # → release/agent.mjs
|
||||
|
||||
# ③ 在 NAS 上执行安装(需要 sudo;会自动注册 systemd 服务并开机自启)
|
||||
cd nodeagent
|
||||
sudo bash scripts/install-linux.sh --node-id dxp2800
|
||||
|
||||
# ④ 抄下屏幕上打印的「密钥」与「连接命令」,回控制端执行:
|
||||
# nodeagent connect <NAS的IP> --port 8765 --key <密钥> --insecure
|
||||
```
|
||||
|
||||
**装完即自启**:`systemctl status nodeagent` 应为 `active (running)`;
|
||||
重启 NAS 后也会自动拉起(`systemctl is-enabled nodeagent` → `enabled`)。
|
||||
|
||||
### 三种安装模式
|
||||
|
||||
| 模式 | 命令 | 程序目录 | 自启方式 | 适用 |
|
||||
|---|---|---|---|---|
|
||||
| **系统级**(默认) | `sudo bash scripts/install-linux.sh` | `/opt/nodeagent` | 系统 systemd(`multi-user.target`) | NAS / 服务器,需 sudo |
|
||||
| **用户级** | `bash scripts/install-linux.sh --user` | `~/.local/share/nodeagent` | 用户 systemd + `linger` | 无 sudo 的场景 |
|
||||
| **不注册服务** | `sudo bash scripts/install-linux.sh --no-service` | 同上(按模式定) | 自行安排(supervisor / cron `@reboot` / nohup) | systemd 受限的系统 |
|
||||
|
||||
其他参数:`--port 8765`、`--allow-input`(**Linux 上无实际输入能力**,仅留存配置)、
|
||||
`--uninstall [--purge]`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 运行依赖(脚本会自动处理能处理的)
|
||||
|
||||
| 依赖 | 是否必需 | 说明 |
|
||||
|---|---|---|
|
||||
| **Node ≥ 22** | 必需 | 脚本优先用系统 Node;**没有就自动下载** `node-v22.x-linux-<arch>` 到 `<安装目录>/runtime/`(用 npmmirror,国内快)。NAS 上通常没有 Node → 走自动下载 ✓ |
|
||||
| **systemd** | 默认必需 | 用于开机自启。没有就加 `--no-service`(脚本会给出替代启动命令) |
|
||||
| **bash** | 必需 | 安装脚本与部分能力用它。**Alpine 等只有 busybox 的系统需先装 bash**(`apk add bash`) |
|
||||
| **procps(`ps`)** | **`system.process.list` 需要** | Debian/Ubuntu 自带 ✓;**精简容器镜像(如 `node:*-slim`)默认没有** → 表现为「进程列表为空」。装:`apt-get install -y procps` |
|
||||
| **iproute2(`ip`)** | 可选 | 仅用于安装时显示本机 IP;缺失时脚本回退 `hostname -I` / `ifconfig`,再不行显示占位符 ✓ |
|
||||
| **curl 或 wget** | 下载运行时时需要 | 若已装 Node 22+ 则不需要 |
|
||||
|
||||
> ⚠️ 实测踩到的坑:在 **Alpine** 容器里测会看到 `spawn /bin/bash ENOENT`,
|
||||
> 极易误判成「Linux 不支持」——**实际是镜像没有 bash**。
|
||||
> 同理 `node:22-slim` 里 `process.list` 返回空是**没有 procps**,不是代码问题。
|
||||
|
||||
---
|
||||
|
||||
## 3. Linux 上真实可用的能力(实测)
|
||||
|
||||
在同一台(Debian 12)机器上跑 `node scripts/verify.mjs` 的结果:
|
||||
|
||||
| 能力 | Linux | 说明 |
|
||||
|---|---|---|
|
||||
| `system.info` | ✅ | 返回 OS 名 "Linux"、CPU 核数、主机名 |
|
||||
| `system.status` | ✅ | CPU / 内存 / 磁盘(多磁盘正常识别) |
|
||||
| `system.process.list` | ✅** | 需 `procps`;缺则返回空列表 |
|
||||
| `system.shell.exec` | ✅ | 含非零退出码回传、**超时强制终止**(实测 exit=124) |
|
||||
| `system.service.list` | ⚠️ | Windows 服务概念,Linux 上退化为 systemd 单元(部分可用) |
|
||||
| `system.audit.list` / 审计链 | ✅ | 与平台无关 |
|
||||
| `system.task.*` | ✅ | 异步长任务框架 |
|
||||
| `fs.*`(list/read/write/stat) | ✅ | 路径按 POSIX 语义(区分大小写) |
|
||||
| `log.query` / `monitor.*` | ⚠️ 部分 | `monitor` 有 Windows 专属实现,部分子能力不可用 |
|
||||
| `event.watch`(file/process/net) | ✅ | 与平台无关 |
|
||||
| `system.agent.update`(自更新) | ✅ | 与平台无关 |
|
||||
| `screen.*` / `input.*` / `window.*` / `clipboard.*` / `audio.*` / `record.*` | ❌ | **未实现** → 明确返回 `E_UNSUPPORTED_PLATFORM` |
|
||||
| `app.list` / `app.install` | ❌ | winget 专属 → 同样明确拒绝 |
|
||||
|
||||
**汇总对比**:本机 macOS 15 通过/0 失败;Linux(Debian 12)**9 通过 / 1 失败(缺 procps)/ 6 跳过**(跳过的都是 GUI 类)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 管理命令
|
||||
|
||||
```bash
|
||||
# 系统级
|
||||
systemctl status nodeagent
|
||||
journalctl -u nodeagent -f # 实时日志
|
||||
systemctl restart nodeagent
|
||||
sudo bash scripts/install-linux.sh --uninstall # 卸载(保留配置与审计日志)
|
||||
sudo bash scripts/install-linux.sh --uninstall --purge # 连数据目录一起删
|
||||
|
||||
# 用户级:把上面命令里的 systemctl / journalctl 加 --user
|
||||
```
|
||||
|
||||
**配置与数据**(`~/.nodeagent`,与 agent 侧 `agentDir()` 一致):
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `agent.json` | 节点 ID / 端口 / **密钥**(权限 600)/ 认证模式 |
|
||||
| `audit.log` | 审计日志(JSONL,10MB 轮转留 5 份) |
|
||||
| `audit-anchors.jsonl` | 审计链锚点(启用锚定后产生) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 故障排查(按实测顺序)
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
|---|---|---|
|
||||
| `未检测到 systemd` | 容器/非 systemd 系统 | 加 `--no-service`,或改用 supervisor |
|
||||
| `spawn /bin/bash ENOENT` | **系统没有 bash**(Alpine 等) | `apk add bash` / `apt-get install -y bash` |
|
||||
| `进程列表为空` | **没有 `ps`**(slim 镜像) | `apt-get install -y procps` |
|
||||
| 脚本"跑到一半就没了" | `set -e` 下某个命令替换失败(如缺 `ip`) | 已在 v2.0.3+ 加固为多级回退;旧版请升级脚本 |
|
||||
| 控制端连不上,被控端却显示已监听 | 防火墙 | Debian: `ufw allow 8765/tcp`;firewalld: `firewall-cmd --add-port=8765/tcp --permanent && firewall-cmd --reload` |
|
||||
| `E_AUTH_FAILED` | 密钥不一致 | 以被控端 `~/.nodeagent/agent.json` 的 `key` 为准 |
|
||||
| GUI 能力报 `E_UNSUPPORTED_PLATFORM` | **正常**(Linux 无 GUI 实现) | 无需处理;这是「诚实拒绝」而非故障 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 建议(服务器型被控端)
|
||||
|
||||
1. **尽快启用审计链外部锚定** —— 把链头存到 NAS 之外(云盘/另一台机器)。
|
||||
本机锚点等于没锚:被控端被 root 后锚点也会一起被改。
|
||||
```bash
|
||||
node scripts/anchor-audit.mjs collect --out /tmp/anchor-$(date +%Y%m%d).jsonl
|
||||
# 再把该文件上传到云盘,之后用 verify 子命令比对
|
||||
```
|
||||
2. **不要开 `--allow-input`** —— Linux 上无实际输入能力,开着只是加大配置面。
|
||||
3. 若机器只有公网无内网互通,考虑用 **Hub 中转**(被控端主动外连,穿 NAT)。
|
||||
Executable
+405
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# nodeagent 被控端 —— Linux 安装器(Debian 12 / Ubuntu / fnOS 等 systemd 系统)
|
||||
#
|
||||
# 用法:
|
||||
# sudo bash scripts/install-linux.sh # 系统级(/opt/nodeagent + 系统 systemd)
|
||||
# bash scripts/install-linux.sh --user # 用户级(~/.local/share + user systemd + linger)
|
||||
# sudo bash scripts/install-linux.sh --node-id dxp2800 --port 8765
|
||||
# sudo bash scripts/install-linux.sh --no-service # 只装程序+配置,不注册 systemd
|
||||
# sudo bash scripts/install-linux.sh --uninstall # 卸载(保留配置)
|
||||
# sudo bash scripts/install-linux.sh --uninstall --purge # 卸载并删配置
|
||||
#
|
||||
# 这套脚本要解决的实际问题(与 Windows 的 install.ps1 同语义):
|
||||
# 1) 目标机未必有 Node → 自带运行时(下载 linux-<arch> 到安装目录)
|
||||
# 2) 要开机自启 → 用 systemd(对齐 Windows 的「计划任务」角色)
|
||||
# 3) 密钥要生成/复用 → 写进 <数据目录>/agent.json,并在屏幕上打印(供控制端连接)
|
||||
# 4) 幂等 → 重复执行不丢配置、不产生重复服务
|
||||
#
|
||||
# ⚠️ 本平台没有 GUI 能力(截图/键鼠/窗口/剪贴板/音量均为 Windows/macOS 实现),
|
||||
# 故 --allow-input 即使打开也只影响「是否允许输入类能力注册」,
|
||||
# **在 Linux 上不会有实际输入能力可用**。默认关闭。
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------- 常量 ----------------
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
NODE_VERSION="${NODEAGENT_NODE_VERSION:-v22.20.0}"
|
||||
NODE_MIRROR="${NODEAGENT_NODE_MIRROR:-https://npmmirror.com/mirrors/node}"
|
||||
DEFAULT_PORT=8765
|
||||
DEFAULT_NODE_ID="$(hostname 2>/dev/null | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_-' || echo linux_01)"
|
||||
[ -z "$DEFAULT_NODE_ID" ] && DEFAULT_NODE_ID="linux_01"
|
||||
|
||||
UNIT_NAME="nodeagent"
|
||||
|
||||
# ---------------- 输出 ----------------
|
||||
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
||||
C_OK=$'\033[32m'; C_WARN=$'\033[33m'; C_ERR=$'\033[31m'; C_DIM=$'\033[2m'; C_RST=$'\033[0m'
|
||||
else
|
||||
C_OK=''; C_WARN=''; C_ERR=''; C_DIM=''; C_RST=''
|
||||
fi
|
||||
ok() { printf '%s✓%s %s\n' "$C_OK" "$C_RST" "$*"; }
|
||||
warn() { printf '%s!%s %s\n' "$C_WARN" "$C_RST" "$*"; }
|
||||
err() { printf '%s✗%s %s\n' "$C_ERR" "$C_RST" "$*" >&2; }
|
||||
info() { printf '%s·%s %s\n' "$C_DIM" "$C_RST" "$*"; }
|
||||
|
||||
die() { err "$*"; exit 1; }
|
||||
|
||||
# ---------------- 参数 ----------------
|
||||
|
||||
MODE="system" # system | user
|
||||
PORT="$DEFAULT_PORT"
|
||||
NODE_ID="$DEFAULT_NODE_ID"
|
||||
ALLOW_INPUT=0
|
||||
UNINSTALL=0
|
||||
PURGE=0
|
||||
NO_SERVICE=0
|
||||
BIND_HOST="0.0.0.0"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--user) MODE="user"; shift ;;
|
||||
--system) MODE="system"; shift ;;
|
||||
--port) PORT="${2:-}"; shift 2 ;;
|
||||
--node-id) NODE_ID="${2:-}"; shift 2 ;;
|
||||
--allow-input) ALLOW_INPUT=1; shift ;;
|
||||
--host) BIND_HOST="${2:-}"; shift 2 ;;
|
||||
--no-service) NO_SERVICE=1; shift ;;
|
||||
--uninstall) UNINSTALL=1; shift ;;
|
||||
--purge) PURGE=1; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0 ;;
|
||||
*) die "未知参数:$1(用 --help 查看用法)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$PORT" in
|
||||
''|*[!0-9]*) die "--port 必须是数字,收到:$PORT" ;;
|
||||
esac
|
||||
[ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] || die "--port 需在 1-65535,收到:$PORT"
|
||||
|
||||
# ---------------- 路径规划 ----------------
|
||||
|
||||
ARCH_RAW="$(uname -m)"
|
||||
case "$ARCH_RAW" in
|
||||
x86_64|amd64) NODE_ARCH="x64" ;;
|
||||
aarch64|arm64) NODE_ARCH="arm64" ;;
|
||||
*) die "不支持的架构:$ARCH_RAW(仅 x86_64 / aarch64)" ;;
|
||||
esac
|
||||
|
||||
if [ "$MODE" = "user" ]; then
|
||||
INSTALL_DIR="${NODEAGENT_INSTALL_DIR:-$HOME/.local/share/nodeagent}"
|
||||
UNIT_DIR="$HOME/.config/systemd/user"
|
||||
DATA_DIR="${NODEAGENT_HOME:-$HOME/.nodeagent}"
|
||||
SUDO=""
|
||||
else
|
||||
INSTALL_DIR="${NODEAGENT_INSTALL_DIR:-/opt/nodeagent}"
|
||||
UNIT_DIR="/etc/systemd/system"
|
||||
DATA_DIR="${NODEAGENT_HOME:-$HOME/.nodeagent}" # 数据目录始终在「执行安装的用户」家目录下
|
||||
[ "$(id -u)" -eq 0 ] || die "系统级安装需要 root:请用 sudo,或改用 --user"
|
||||
SUDO=""
|
||||
fi
|
||||
|
||||
AGENT_JS_SRC="$PROJECT_ROOT/release/nodeagent-linux/agent.mjs"
|
||||
[ -f "$AGENT_JS_SRC" ] || AGENT_JS_SRC="$PROJECT_ROOT/release/agent.mjs"
|
||||
[ -f "$AGENT_JS_SRC" ] || die "找不到 agent.mjs —— 请先执行:node scripts/pack-linux.mjs(或 pnpm pack:linux)"
|
||||
|
||||
# ---------------- 卸载 ----------------
|
||||
|
||||
if [ "$UNINSTALL" -eq 1 ]; then
|
||||
echo "=== 卸载 nodeagent ==="
|
||||
if [ "$MODE" = "user" ]; then
|
||||
systemctl --user disable --now "$UNIT_NAME" >/dev/null 2>&1 || true
|
||||
rm -f "$UNIT_DIR/$UNIT_NAME.service"
|
||||
systemctl --user daemon-reload >/dev/null 2>&1 || true
|
||||
else
|
||||
systemctl disable --now "$UNIT_NAME" >/dev/null 2>&1 || true
|
||||
rm -f "$UNIT_DIR/$UNIT_NAME.service"
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "$INSTALL_DIR"
|
||||
ok "已移除服务与程序目录:$INSTALL_DIR"
|
||||
if [ "$PURGE" -eq 1 ]; then
|
||||
rm -rf "$DATA_DIR"
|
||||
ok "已删除数据目录(含密钥与审计日志):$DATA_DIR"
|
||||
else
|
||||
info "已保留数据目录:$DATA_DIR(含密钥与审计日志;加 --purge 可一并删除)"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------- 环境检查 ----------------
|
||||
|
||||
echo "=== nodeagent 被控端安装(Linux · $MODE 模式)==="
|
||||
echo ""
|
||||
|
||||
[ "$(uname -s)" = "Linux" ] || die "本脚本仅用于 Linux(当前:$(uname -s))"
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
|
||||
ok "systemd 可用"
|
||||
elif [ "$NO_SERVICE" -eq 1 ]; then
|
||||
warn "未检测到 systemd,但已指定 --no-service → 只装程序与配置,不注册服务"
|
||||
MODE_LABEL="no-service"
|
||||
else
|
||||
die "未检测到 systemd(本脚本用 systemd 做开机自启)。
|
||||
可选项:
|
||||
a) 加 --no-service 只装程序与配置,自启由你另行安排(手动/nohup/supervisor/cron @reboot)
|
||||
b) 手工启动:NODEAGENT_HOME=$DATA_DIR $INSTALL_DIR/runtime/bin/node $INSTALL_DIR/agent.mjs"
|
||||
fi
|
||||
|
||||
# 发行版信息(fnOS 基于 Debian 12,此处只作展示)
|
||||
if [ -r /etc/os-release ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
info "系统:${PRETTY_NAME:-unknown}(内核 $(uname -r))"
|
||||
fi
|
||||
ok "架构:$ARCH_RAW → node-$NODE_ARCH"
|
||||
|
||||
# ---------------- Node 运行时 ----------------
|
||||
|
||||
RUNTIME_DIR="$INSTALL_DIR/runtime"
|
||||
NODE_BIN=""
|
||||
|
||||
# ① 系统 node ≥ 22 就直接用(省一次下载;NAS 上通常没有,走 ②)
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
SYS_VER="$(node -v 2>/dev/null || echo '')"
|
||||
SYS_MAJOR="${SYS_VER#v}"; SYS_MAJOR="${SYS_MAJOR%%.*}"
|
||||
if [ -n "$SYS_MAJOR" ] && [ "$SYS_MAJOR" -ge 22 ] 2>/dev/null; then
|
||||
NODE_BIN="$(command -v node)"
|
||||
ok "使用系统 Node:$SYS_VER($NODE_BIN)"
|
||||
else
|
||||
info "系统 Node 版本过低(${SYS_VER:-无}),改用自带运行时"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$NODE_BIN" ]; then
|
||||
if [ -x "$RUNTIME_DIR/bin/node" ]; then
|
||||
NODE_BIN="$RUNTIME_DIR/bin/node"
|
||||
ok "复用已下载的运行时:$("$NODE_BIN" -v)"
|
||||
else
|
||||
TARBALL="node-$NODE_VERSION-linux-$NODE_ARCH.tar.xz"
|
||||
URL="$NODE_MIRROR/$NODE_VERSION/$TARBALL"
|
||||
TMPDIR_DL="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR_DL"' EXIT
|
||||
info "下载 Node 运行时:$URL"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL --retry 3 --connect-timeout 20 -o "$TMPDIR_DL/$TARBALL" "$URL" \
|
||||
|| die "下载失败(可设 NODEAGENT_NODE_MIRROR 换源,或预先把 tar.xz 放到 $TMPDIR_DL)"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -q -T 20 -t 3 -O "$TMPDIR_DL/$TARBALL" "$URL" || die "下载失败:$URL"
|
||||
else
|
||||
die "需要 curl 或 wget 之一来下载 Node 运行时"
|
||||
fi
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
info "解压到 $RUNTIME_DIR"
|
||||
mkdir -p "$RUNTIME_DIR"
|
||||
tar -xJf "$TMPDIR_DL/$TARBALL" -C "$RUNTIME_DIR" --strip-components=1 || die "解压失败:$TARBALL"
|
||||
NODE_BIN="$RUNTIME_DIR/bin/node"
|
||||
ok "运行时就绪:$("$NODE_BIN" -v)"
|
||||
fi
|
||||
fi
|
||||
|
||||
"$NODE_BIN" -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)' \
|
||||
|| die "Node 版本需 ≥ 22(当前 $("$NODE_BIN" -v))"
|
||||
|
||||
# ---------------- 安装程序文件 ----------------
|
||||
|
||||
mkdir -p "$INSTALL_DIR" "$DATA_DIR"
|
||||
install -m 0644 "$AGENT_JS_SRC" "$INSTALL_DIR/agent.mjs"
|
||||
ok "已安装:$INSTALL_DIR/agent.mjs($(wc -c < "$INSTALL_DIR/agent.mjs" | tr -d ' ') 字节)"
|
||||
|
||||
# ---------------- 配置:复用既有密钥,否则生成 ----------------
|
||||
|
||||
CFG="$DATA_DIR/agent.json"
|
||||
KEY=""
|
||||
if [ -f "$CFG" ]; then
|
||||
# 备份一次(保留最近一次即可,避免堆积)
|
||||
cp -f "$CFG" "$CFG.bak" 2>/dev/null || true
|
||||
KEY="$(sed -n 's/.*"key"[[:space:]]*:[[:space:]]*"\([0-9a-fA-F]\{16,\}\)".*/\1/p' "$CFG" | head -1)"
|
||||
if [ -n "$KEY" ]; then
|
||||
ok "复用既有配置:$CFG(已备份为 agent.json.bak)"
|
||||
else
|
||||
info "既有配置里没有可用密钥,将生成新密钥"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$KEY" ]; then
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
KEY="$(openssl rand -hex 32)"
|
||||
else
|
||||
KEY="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
|
||||
fi
|
||||
case "$KEY" in
|
||||
*[!0-9a-fA-F]*|'') die "密钥生成异常(非 hex)" ;;
|
||||
esac
|
||||
[ ${#KEY} -ge 32 ] || die "密钥长度不足(${#KEY})"
|
||||
fi
|
||||
|
||||
cat > "$CFG" <<JSON
|
||||
{
|
||||
"node_id": "$NODE_ID",
|
||||
"host": "$BIND_HOST",
|
||||
"port": $PORT,
|
||||
"tls": true,
|
||||
"key": "$KEY",
|
||||
"log_level": "info",
|
||||
"allow_input": $([ "$ALLOW_INPUT" -eq 1 ] && echo true || echo false),
|
||||
"auth_mode": "psk"
|
||||
}
|
||||
JSON
|
||||
chmod 600 "$CFG"
|
||||
ok "配置已写入:$CFG(权限 600)"
|
||||
[ "$ALLOW_INPUT" -eq 1 ] && warn "已开启 allow_input —— 但 **Linux 上并无输入类能力实现**,此项仅作配置留存"
|
||||
|
||||
# ---------------- systemd 服务 ----------------
|
||||
|
||||
if [ "$NO_SERVICE" -eq 1 ]; then
|
||||
info "跳过 systemd 注册(--no-service)"
|
||||
else
|
||||
if [ "$MODE" = "user" ]; then
|
||||
mkdir -p "$UNIT_DIR"
|
||||
UNIT_PATH="$UNIT_DIR/$UNIT_NAME.service"
|
||||
else
|
||||
UNIT_PATH="$UNIT_DIR/$UNIT_NAME.service"
|
||||
fi
|
||||
|
||||
cat > "$UNIT_PATH" <<UNIT
|
||||
[Unit]
|
||||
Description=nodeagent 被控端(跨机 AI 接管)
|
||||
Documentation=https://github.com/jkooe/nodeagent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# 数据目录(含密钥与审计日志)—— 与 agent 侧 agentDir() 一致
|
||||
Environment=NODEAGENT_HOME=$DATA_DIR
|
||||
ExecStart=$NODE_BIN $INSTALL_DIR/agent.mjs
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
# 崩溃自拉起(对齐 Windows 计划任务的重启语义)
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
# 优雅停止:先 SIGTERM(agent 会关掉监听与审计写入),超时再 SIGKILL
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=15
|
||||
# 日志进 journald:journalctl -u $UNIT_NAME -f
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
# 退出码 0 视为「非异常」(如 --uninstall 后的正常停止)
|
||||
SuccessExitStatus=0 143
|
||||
|
||||
[Install]
|
||||
WantedBy=$([ "$MODE" = "user" ] && echo default.target || echo multi-user.target)
|
||||
UNIT
|
||||
|
||||
ok "已写入服务单元:$UNIT_PATH"
|
||||
|
||||
if [ "$MODE" = "user" ]; then
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now "$UNIT_NAME" || die "启动失败,查看:systemctl --user status $UNIT_NAME"
|
||||
# 让用户级服务在「未登录」时也能跑(NAS 重启后无需登录即自启)
|
||||
if command -v loginctl >/dev/null 2>&1; then
|
||||
if loginctl enable-linger "$USER" 2>/dev/null; then
|
||||
ok "已启用 linger(未登录也自启)"
|
||||
else
|
||||
warn "未能启用 linger(可能需要 root 执行一次:loginctl enable-linger $USER)"
|
||||
warn " 未启用 linger 时,用户级服务只在登录会话中运行"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now "$UNIT_NAME" || die "启动失败,查看:systemctl status $UNIT_NAME -l"
|
||||
fi
|
||||
|
||||
fi # end: NO_SERVICE
|
||||
|
||||
# ---------------- 启动确认 ----------------
|
||||
|
||||
if [ "$NO_SERVICE" -eq 1 ]; then
|
||||
echo ""
|
||||
warn "未注册服务 —— 手工启动:"
|
||||
printf ' NODEAGENT_HOME=%s %s %s/agent.mjs\n' "$DATA_DIR" "$NODE_BIN" "$INSTALL_DIR"
|
||||
MODE_LABEL="no-service"
|
||||
else
|
||||
sleep 2
|
||||
STATE="$(systemctl is-active "$UNIT_NAME" 2>/dev/null || true)"
|
||||
[ "$MODE" = "user" ] && STATE="$(systemctl --user is-active "$UNIT_NAME" 2>/dev/null || true)"
|
||||
|
||||
if [ "$STATE" = "active" ]; then
|
||||
ok "服务运行中($UNIT_NAME)"
|
||||
else
|
||||
warn "服务状态:${STATE:-unknown}"
|
||||
warn "查看日志:$([ "$MODE" = "user" ] && echo 'journalctl --user -u' || echo 'journalctl -u') $UNIT_NAME -n 50 --no-pager"
|
||||
fi
|
||||
fi # end: NO_SERVICE 启动确认
|
||||
|
||||
# 端口监听检查(不强依赖 ss/netstat)
|
||||
if command -v ss >/dev/null 2>&1 && ss -ltn 2>/dev/null | grep -q ":$PORT "; then
|
||||
ok "端口 $PORT 已监听"
|
||||
else
|
||||
info "端口 $PORT 监听状态未确认(可用 ss -ltnp | grep $PORT 自查)"
|
||||
fi
|
||||
|
||||
# ---------------- 打印连接信息 ----------------
|
||||
|
||||
# ⚠️ 取本机 IP 要容错:`ip` 在精简系统(含部分 NAS 系统)上可能不存在,
|
||||
# 而 `set -e` 下「命令替换失败」会**直接终止脚本**(且 2>/dev/null 把原因吞了,
|
||||
# 表现为「脚本跑到这里就没了」——2026-10-13 在 Debian 容器里踩到)。
|
||||
LAN_IP=""
|
||||
if command -v ip >/dev/null 2>&1; then
|
||||
LAN_IP="$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p' | head -1)" || true
|
||||
fi
|
||||
if [ -z "$LAN_IP" ]; then
|
||||
LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" || true
|
||||
fi
|
||||
if [ -z "$LAN_IP" ] && command -v ifconfig >/dev/null 2>&1; then
|
||||
LAN_IP="$(ifconfig 2>/dev/null | sed -n 's/.*inet \(addr:\)\?\([0-9.]*\).*/\2/p' | grep -v '^127\.' | head -1)" || true
|
||||
fi
|
||||
[ -z "$LAN_IP" ] && LAN_IP="<本机IP>"
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " nodeagent 被控端已就绪"
|
||||
echo "──────────────────────────────────────────────────────"
|
||||
printf ' 节点 ID : %s\n' "$NODE_ID"
|
||||
printf ' 监听 : %s:%s (wss / TLS)\n' "$BIND_HOST" "$PORT"
|
||||
printf ' 本机 IP : %s\n' "$LAN_IP"
|
||||
printf ' 认证模式 : psk\n'
|
||||
printf ' 输入控制 : %s\n' "$([ "$ALLOW_INPUT" -eq 1 ] && echo 已开启 || echo 已禁用(Linux 无 GUI 能力))"
|
||||
printf ' 安装目录 : %s\n' "$INSTALL_DIR"
|
||||
printf ' 数据目录 : %s\n' "$DATA_DIR"
|
||||
if [ "$NO_SERVICE" -eq 1 ]; then
|
||||
printf ' 服务 : 未注册(--no-service,需自行安排自启)\n'
|
||||
else
|
||||
printf ' 服务名 : %s(%s systemd)\n' "$UNIT_NAME" "$MODE"
|
||||
fi
|
||||
echo ""
|
||||
echo " 密钥(请妥善保存,控制端连接需要):"
|
||||
echo ""
|
||||
printf ' %s\n' "$KEY"
|
||||
echo ""
|
||||
echo " 控制端连接命令:"
|
||||
printf ' nodeagent connect %s --port %s --key %s --insecure\n' "$LAN_IP" "$PORT" "$KEY"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " 常用管理命令($MODE 级):"
|
||||
if [ "$MODE" = "user" ]; then
|
||||
echo " systemctl --user status $UNIT_NAME"
|
||||
echo " journalctl --user -u $UNIT_NAME -f # 实时日志"
|
||||
echo " systemctl --user restart $UNIT_NAME"
|
||||
echo " bash $0 --user --uninstall # 卸载(保留配置)"
|
||||
else
|
||||
echo " systemctl status $UNIT_NAME"
|
||||
echo " journalctl -u $UNIT_NAME -f # 实时日志"
|
||||
echo " systemctl restart $UNIT_NAME"
|
||||
echo " sudo bash $0 --uninstall # 卸载(保留配置)"
|
||||
fi
|
||||
echo ""
|
||||
echo " 注意:审计链外部锚定建议尽快启用(把链头存到链外)——"
|
||||
echo " node scripts/anchor-audit.mjs collect --node $NODE_ID"
|
||||
echo ""
|
||||
@@ -268,6 +268,8 @@ async function main() {
|
||||
const c = await connect();
|
||||
const r = await c.invoke('screen.info');
|
||||
c.close();
|
||||
// 平台未实现(如 Linux 无 GUI 后端)→ 跳过,不算失败
|
||||
if (r.status !== 'ok' && r.error?.name === 'E_UNSUPPORTED_PLATFORM') return 'skip';
|
||||
assertOk(r, 'screen.info');
|
||||
if (!Array.isArray(r.data.displays) || r.data.displays.length === 0) throw new Error('未返回显示器');
|
||||
const d = r.data.displays[0];
|
||||
@@ -290,6 +292,7 @@ async function main() {
|
||||
c.close();
|
||||
if (r.status === 'ok') return '已开启输入控制';
|
||||
if (r.error?.name === 'E_CAPABILITY_DISABLED') return '默认禁用(安全)';
|
||||
if (r.error?.name === 'E_UNSUPPORTED_PLATFORM') return 'skip'; // 该平台无输入实现
|
||||
throw new Error(`异常响应: ${JSON.stringify(r.error)}`);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user