From 00992e5c1c39f74d0f18dbf9bc0601741a4033f1 Mon Sep 17 00:00:00 2001 From: jkooe <62494645+jkooe@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:15:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(linux):=20Linux=20=E8=A2=AB=E6=8E=A7?= =?UTF-8?q?=E7=AB=AF=E9=80=82=E9=85=8D=20=E2=80=94=E2=80=94=20=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E5=99=A8=20+=20systemd=20=E8=87=AA=E5=90=AF=20+=20?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E5=AE=88=E5=8D=AB=20+=20=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 面向「服务器型被控端」(命令/状态/文件/审计;无 GUI),实测环境 Debian 12(fnOS 底座)。 ## 新增 - `scripts/install-linux.sh`:一键安装器(对齐 Windows `install.ps1` 的语义) · 自动获取 Node 运行时(系统无 Node 22+ 时下载 linux-,走 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 特性" --- README.md | 1 + apps/agent/src/capabilities/audio.ts | 5 + apps/agent/src/capabilities/clipboard.ts | 5 + apps/agent/src/capabilities/input.ts | 5 + apps/agent/src/capabilities/record.ts | 3 + apps/agent/src/capabilities/screen.ts | 5 + apps/agent/src/util/platform.ts | 80 +++++ docs/DEPLOY-LINUX.md | 137 ++++++++ scripts/install-linux.sh | 405 +++++++++++++++++++++++ scripts/verify.mjs | 3 + 10 files changed, 649 insertions(+) create mode 100644 apps/agent/src/util/platform.ts create mode 100644 docs/DEPLOY-LINUX.md create mode 100755 scripts/install-linux.sh diff --git a/README.md b/README.md index 872e2dc..ca6db04 100644 --- a/README.md +++ b/README.md @@ -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 基线:待办收尾 / 语义可断言 / 状态采样 / 安全残余) diff --git a/apps/agent/src/capabilities/audio.ts b/apps/agent/src/capabilities/audio.ts index fa34c01..ee48f4c 100644 --- a/apps/agent/src/capabilities/audio.ts +++ b/apps/agent/src/capabilities/audio.ts @@ -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; @@ -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 { + // 该能力仅在 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 { // ---------------- system.audio.set ---------------- export async function audioSet(args: Args): Promise { + // 该能力仅在 Windows/macOS 有实现(Linux 端未实现) + assertGuiPlatform('audio.set'); const muteArg = args['mute']; const volumeArg = args['volume']; const hasMute = typeof muteArg === 'boolean'; diff --git a/apps/agent/src/capabilities/clipboard.ts b/apps/agent/src/capabilities/clipboard.ts index 1178f1c..ee739d8 100644 --- a/apps/agent/src/capabilities/clipboard.ts +++ b/apps/agent/src/capabilities/clipboard.ts @@ -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; @@ -79,6 +80,8 @@ async function writeClipboardWindows(b64: string, expect: string): Promise // ---------------- clip.get ---------------- export async function clipGet(args: Args = {}): Promise { + // 该能力仅在 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 | null> { // ---------------- clip.set ---------------- export async function clipSet(args: Args): Promise { + // 该能力仅在 Windows/macOS 有实现(Linux 端未实现) + assertGuiPlatform('clipboard.set'); const image = args['image_base64'] as string | undefined; const text = args['text'] as string | undefined; diff --git a/apps/agent/src/capabilities/input.ts b/apps/agent/src/capabilities/input.ts index 94d260a..5b6a423 100644 --- a/apps/agent/src/capabilities/input.ts +++ b/apps/agent/src/capabilities/input.ts @@ -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; @@ -256,6 +257,7 @@ const MOUSE_FLAGS: Record = { }; export async function mouseMove(args: Args): Promise { + 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 { } export async function mouseClick(args: Args): Promise { + 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 { } export async function mouseScroll(args: Args): Promise { + 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 { * 用于拖文件、框选、拖动滑块/窗口。整段动作在一次进程内完成(避免多次 spawn 抖动)。 */ export async function mouseDrag(args: Args): Promise { + assertGuiPlatform('input.mouseDrag'); assertAllowed('input.mouse.drag'); requireWindows('input.mouse.drag'); const x1 = args['from_x'] as number; diff --git a/apps/agent/src/capabilities/record.ts b/apps/agent/src/capabilities/record.ts index acf39ec..706c271 100644 --- a/apps/agent/src/capabilities/record.ts +++ b/apps/agent/src/capabilities/record.ts @@ -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; @@ -88,6 +89,8 @@ async function tryMuxMp4(dir: string, fps: number): Promise { * —— 避免把大体积视频塞进单条 RPC 响应。 */ export async function screenRecord(args: Args): Promise { + // 该能力仅在 Windows/macOS 有实现(Linux 端需 X11/Wayland 后端,尚未实现) + assertGuiPlatform('screen.record'); if (!IS_WINDOWS) { throw new CapabilityError(ErrorCodes.UNSUPPORTED_PLATFORM, 'screen.record 当前仅支持 Windows 被控端', { platform: process.platform, diff --git a/apps/agent/src/capabilities/screen.ts b/apps/agent/src/capabilities/screen.ts index 34a8cec..01919ba 100644 --- a/apps/agent/src/capabilities/screen.ts +++ b/apps/agent/src/capabilities/screen.ts @@ -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; @@ -82,6 +83,8 @@ async function listDisplays(): Promise { } export async function screenInfo(_args: Args): Promise { + // 该能力仅在 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 { + // 该能力仅在 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', diff --git a/apps/agent/src/util/platform.ts b/apps/agent/src/util/platform.ts new file mode 100644 index 0000000..b35a931 --- /dev/null +++ b/apps/agent/src/util/platform.ts @@ -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 = { + 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'); +} diff --git a/docs/DEPLOY-LINUX.md b/docs/DEPLOY-LINUX.md new file mode 100644 index 0000000..5d29921 --- /dev/null +++ b/docs/DEPLOY-LINUX.md @@ -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 --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-` 到 `<安装目录>/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)。 diff --git a/scripts/install-linux.sh b/scripts/install-linux.sh new file mode 100755 index 0000000..bc4eb1a --- /dev/null +++ b/scripts/install-linux.sh @@ -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- 到安装目录) +# 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" < "$UNIT_PATH" </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 "" diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 2deba44..cfed2eb 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -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)}`); });