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:
jkooe committed 2026-10-13 21:15:59 +08:00
1 parent c9e34b93a2
commit 00992e5c1c
10 files changed
+649

No files matched your search

+1
View File
@@ -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 基线:待办收尾 / 语义可断言 / 状态采样 / 安全残余)
+5
View File
@@ -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';
+5
View File
@@ -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;
+5
View File
@@ -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;
+3
View File
@@ -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,
+5
View File
@@ -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',
+80
View File
@@ -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');
}
+137
View File
@@ -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)。
+405
View File
@@ -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 ""
+3
View File
@@ -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)}`);
});