From 3677b5d3691f852e1ebf916b3b01321a1cfd20c0 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Tue, 27 Oct 2026 09:28:40 +0800 Subject: [PATCH 1/6] feat(fiber): persist devnet config and make initialization optional --- .changeset/fiber-persistent-environment.md | 7 + README.md | 38 ++- docs/fiber-devnet.md | 94 ++++++ docs/fiber-validation.md | 38 +++ package.json | 9 +- pnpm-lock.yaml | 8 + scripts/build.js | 3 + src/cli.ts | 69 +++-- src/cmd/clean.ts | 6 + src/cmd/devnet-config.ts | 8 +- src/cmd/fiber.ts | 36 ++- src/cmd/node.ts | 27 +- src/fiber/accounts.ts | 98 ++++++- src/fiber/auth.ts | 181 ++++++++++++ src/fiber/clean.ts | 2 +- src/fiber/config-gen.ts | 139 ++++++--- src/fiber/files.ts | 34 +++ src/fiber/init.ts | 305 ++++++++++++++++++++ src/fiber/manager.ts | 108 +++---- src/fiber/nodes-yml.ts | 223 ++++++--------- src/fiber/paths.ts | 12 +- src/fiber/rpc.ts | 48 +++- src/fiber/runtime.ts | 15 + src/fiber/status.ts | 188 ++++++------ src/util/json-rpc.ts | 24 +- tests/fiber-accounts.test.ts | 40 +++ tests/fiber-auth.test.ts | 114 ++++++++ tests/fiber-config-gen.test.ts | 68 +++-- tests/fiber-lifecycle.test.ts | 71 +++-- tests/fiber-nodes-yml.test.ts | 314 +++++++++++++-------- tests/fiber-reset.test.ts | 50 ++++ tests/fiber-status.test.ts | 91 +++++- 32 files changed, 1912 insertions(+), 556 deletions(-) create mode 100644 .changeset/fiber-persistent-environment.md create mode 100644 docs/fiber-devnet.md create mode 100644 docs/fiber-validation.md create mode 100644 src/fiber/auth.ts create mode 100644 src/fiber/files.ts create mode 100644 src/fiber/init.ts create mode 100644 tests/fiber-auth.test.ts create mode 100644 tests/fiber-reset.test.ts diff --git a/.changeset/fiber-persistent-environment.md b/.changeset/fiber-persistent-environment.md new file mode 100644 index 0000000..6dca23c --- /dev/null +++ b/.changeset/fiber-persistent-environment.md @@ -0,0 +1,7 @@ +--- +'@offckb/cli': minor +--- + +Prepare Fiber devnets with optional `fiber init`, persistent full node configs, explicit funding-account bindings and per-node RPC management tokens. Direct starts still initialize new environments automatically; subsequent starts preserve configs and data and reuse the saved program. Add nodes with `fiber init --add`, including unfunded random accounts. Status reports custom configs and distinguishes authentication failures from stopped nodes. + +Clarify that stop/start preserves data while clean commands reset it; devnet config editing no longer recommends deleting chain and Fiber stores for normal runtime changes. The unreleased legacy Fiber configuration format is not migrated. diff --git a/README.md b/README.md index 189886a..7105422 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ Options: Commands: node [CKB-Version] Use the CKB to start devnet node stop Stop the running CKB devnet daemon + fiber init [FNN-Version] Prepare Fiber configs and keys without starting FNN (optional) fiber start [FNN-Version] Start Fiber (FNN) nodes on the running devnet CKB fiber stop Stop the daemon-managed fiber nodes fiber status Show the status of the local CKB and all fiber nodes @@ -390,13 +391,15 @@ offckb devnet config --set ckb.rpc.enable_deprecated_rpc=true --set miner.client If your terminal is non-interactive (no TTY, e.g. CI/remote pipeline), use `--set` mode directly instead of the full-screen editor. -1. Save changes and restart devnet: +1. Save changes and restart devnet. Stop a foreground node with Ctrl+C, or stop a daemon with: ```sh -offckb clean -d +offckb node stop offckb node ``` +Ordinary runtime config changes do not require deleting data. Changes to the genesis/spec may require an explicit reset with `offckb clean -d`: this deletes chain history and Fiber channel/payment stores, while preserving configs and keys. `offckb clean` removes the whole environment, including Fiber keys. + 1. (Advanced) Locate your Devnet config folder for manual edits: ```sh @@ -418,8 +421,8 @@ Example result: Pay attention to the `devnet.configPath` and `devnet.dataPath`. 1. `cd` into the `devnet.configPath` . Modify the config files as needed. See [Custom Devnet Setup](https://docs.nervos.org/docs/node/run-devnet-node#custom-devnet-setup) and [Configure CKB](https://github.com/nervosnetwork/ckb/blob/develop/docs/configure.md) for details. -2. After modifications, run `offckb clean -d` to remove the chain data if needed while keeping the updated config files. -3. Restart local blockchain by running `offckb node` +2. Stop and restart the local blockchain with `offckb node` to apply runtime changes. +3. Only when rebuilding the genesis/spec, explicitly reset with `offckb clean -d`; it deletes chain history and Fiber channel/payment stores while keeping the updated configs and keys. ### 7. Fork Mainnet/Testnet Into Your Devnet {#fork-devnet} @@ -459,7 +462,7 @@ OffCKB can start and manage a local [Fiber](https://github.com/nervosnetwork/fib # Start CKB, miner, RPC proxy and 2 FNN nodes in one command offckb node --fiber -# Or start only the FNN nodes on an already-running devnet +# Or start only the FNN nodes on an already-running devnet (auto-initializes on first use) offckb node offckb fiber start @@ -478,11 +481,28 @@ offckb fiber clean # delete the whole fiber environment - Only the plain local devnet is supported: no mainnet/testnet, and no forked devnet (a `fork.json` present in the devnet directory rejects Fiber startup). - A devnet created by an offckb version without Fiber support does not have the Fiber contracts in its genesis. `fiber start` / `node --fiber` on such a devnet refuse with migration guidance: rebuild with `offckb clean` (which deletes the local chain data) and start again; a plain `offckb node` keeps working on the old devnet unchanged. -- Node `N` uses built-in CKB account `N+2` (accounts 3-18 are reserved for Fiber), RPC port `21713+N` and P2P port `8343+N`. Up to 16 nodes: `offckb fiber start --nodes 4`. -- `offckb fiber start [FNN-Version]` downloads a tested FNN release (currently `0.9.0`). Downloaded tarballs are verified against SHA-256 digests pinned in offckb before installation. Use `--binary-path ` (or `--fnn-binary-path ` with `node --fiber`) to run a locally built FNN. -- Every FNN writes its stdout/stderr to `devnet/fiber/nodes//fnn.log`, never to your terminal. Per-node FNN config overrides live in `devnet/fiber/nodes.yml` (regenerated `config.yml` files do not keep hand edits). Fields owned by offckb — chain, scripts, listening/bootnode addresses, store path, CKB RPC/UDT wiring, services — are managed and cannot be overridden there. -- Startup verifies that the devnet spec, the running CKB and every FNN agree on the same chain (genesis hash), and checks each node's identity key, CKB account and available balance before reporting ready. +- New node `N` defaults to built-in CKB account `N+2`, RPC port `21713+N` and P2P port `8343+N`. The account binding is saved, not recalculated on restart. Up to 16 nodes: first start with `offckb fiber start --nodes 4`, or prepare them without starting with `offckb fiber init --nodes 4`. Append a node with `offckb fiber init --add`; optionally choose `--account-index 12` or `--random-account` (unfunded). +- First initialization downloads a tested FNN release (currently `0.9.0`), unless a version or `--binary-path ` is supplied (`--fnn-binary-path` with `node --fiber`). Downloads are verified against pinned SHA-256 digests. The absolute program and template paths are saved in `fiber/nodes.yml`; subsequent starts reuse that program without downloading or upgrading. Startup selection flags must agree with an existing environment. Use `fiber init --add` or `fiber init --nodes N` to prepare additional nodes. +- Every FNN writes stdout/stderr to `devnet/fiber/nodes//fnn.log`. Each node has a persistent full `config.yml`, generated once by initialization and never overwritten by startup. `nodes.yml` records its `config_file`, funding `account`, and `rpc_token_file`; relative paths resolve from `nodes.yml`. Keep the local chain/scripts, fixed ports, node store location and required `fiber`/`rpc`/`ckb` services and `info`/`peer` RPC modules. Other services and modules may be added. The old `nodes[].config` format is unsupported; there is no legacy environment migration. +- Startup checks every registered config and key before creating FNN processes, then verifies chain, network identity and funding lock. Zero-balance accounts can start and wait for external funding; initialization does not pre-fund random accounts. Shared funding locks are permitted with a warning about Cell contention. Missing files or changed account bindings fail explicitly instead of generating replacement keys. - One fiber environment per machine: the RPC/P2P ports are fixed per node id, so a second concurrent fiber environment fails its port check. Note the CKB side of the check is the chain's genesis hash, and every plain offckb devnet shares the same genesis — if you run several offckb environments on one machine (e.g. separate `XDG_DATA_HOME`), make sure `fiber start` attaches to the CKB you actually started for it; when in doubt, check `offckb fiber status` against the environment you mean to use. +- Initialization also prepares independent Biscuit keys (`nodes//biscuit/private.key`, `public.key`) and a management `rpc-token` for each node. Authentication is off by default; to enable it, put the generated public key in `rpc.biscuit_public_key`. OffCKB automatically sends the node's token on internal RPC calls and reports authentication errors separately from stopped nodes. LSP tools must reuse the same Biscuit keys, or provide a token matching their replacement public key. These credentials do not grant channel/payment access. +- `fiber status` includes `CUSTOM_CONFIG` (`yes`, `no`, `unknown`) compared with the node's initialization baseline `config.default.yml`. Running nodes use their startup snapshot until restarted; stopped/new nodes report pending config. Missing baselines do not overwrite user files. Runtime records contain public account information and credential paths, never private keys or token contents. +- Stop/restart preserves chain and Fiber data. `fiber clean --data` resets only FNN stores and keeps configs, accounts, passwords, Biscuit keys and tokens. `fiber clean` deletes the complete Fiber environment. `clean --data` resets the chain and dependent FNN stores; `clean` deletes the entire devnet. None of the clean commands is a normal restart. + +To customize a node before its first launch, after CKB is running: + +```sh +offckb fiber init --nodes 3 --binary-path /absolute/path/to/fnn +# Edit the printed node config path, or prepare LSP with its matching lsp-cli. +offckb fiber start --daemon +# After editing config: stop and start Fiber to apply changes. +offckb fiber stop +offckb fiber start --daemon +``` + +The complete lifecycle and configuration contract is in [Fiber devnet design](docs/fiber-devnet.md). + - UDT channels: the FNN config whitelists the devnet sUDT and xUDT issued by built-in account 19, so issue test UDTs from that account (`offckb udt issue ... --privkey-file` with account 19's key) to the node accounts before opening UDT channels. A few things that differ from plain CKB channels: - `offckb udt issue ` takes the amount in the token's base unit (no 10^8 conversion): `3000` issues 0.00003 sUDT at 8 decimals, so issue generously before funding a channel. - Both sides of a UDT channel must hold the UDT. The accepting node needs its own UDT cells — otherwise `accept_channel` fails with "can not find enough UDT owner cells" — so issue the UDT to both node accounts first. diff --git a/docs/fiber-devnet.md b/docs/fiber-devnet.md new file mode 100644 index 0000000..6db8873 --- /dev/null +++ b/docs/fiber-devnet.md @@ -0,0 +1,94 @@ +# Fiber devnet:初始化、配置与数据生命周期 + +目标是让默认节点和改过配置的节点都能由 OffCKB 统一管理:首次直接启动就能用,需要提前配置时才单独初始化,日常重启保留配置和数据。 + +本次功能面向尚未正式发布的 Fiber devnet,不兼容旧 `nodes[].config` 格式,不自动导入旧目录或迁移旧环境。只管理一套普通 OffCKB 本地链;不支持 fork、主网、测试网或并行的多套环境。LSP 与 Fiber 在同一个 FNN 进程内运行。 + +## 使用方式 + +与 CKB 的 `offckb node` 一样,简单启动不要求先执行初始化命令: + +```sh +offckb node --fiber --daemon # 首次自动初始化两个 Fiber 节点,再启动 +# 或先运行 CKB,再单独启动 Fiber +offckb node --daemon +offckb fiber start --daemon +``` + +`offckb fiber init [FNN-Version]` 是可选的“只准备、不启动”步骤。CKB 本地链准备好后,可以先准备配置和密钥,修改配置或使用配套的 `lsp-cli` 配置 LSP,最后执行 `fiber start`。 + +```sh +offckb fiber init --nodes 3 --binary-path /absolute/path/to/fnn +# 编辑输出的节点 3 config.yml,或用 lsp-cli 准备 LSP +offckb fiber start --daemon +offckb fiber init --add --account-index 12 +# 或:offckb fiber init --add --random-account +``` + +- `init` 的 `--nodes N` 与 `--add` 互斥。首次默认创建节点 1、2;已有环境不传参数时只检查、复用。`--nodes N` 补齐 1 至 N,不删除其他节点,列表中有范围外节点时拒绝。 +- `--add` 只用于已有环境;编号为已登记节点和保留目录的最大编号加一,不复用旧目录。编号限制为 1 至 16。 +- `--account-index` 与 `--random-account` 互斥,只用于 `--add`。批量初始化可预先在新格式的 `nodes.yml` 中指定各节点账户;尚未初始化的记录需显式标记 `initialized: false`。 +- `fiber start` 保留 `--daemon`,首次也接受版本、`--binary-path`、`--nodes`,复用同一个初始化流程。已有环境中的这些参数必须与已保存记录一致,否则报错,不修改环境;增添节点使用 `init`。 +- `node --fiber` 同样保留首次初始化用的 `--fiber-nodes`、`--fnn-version`、`--fnn-binary-path`。这不是旧配置兼容,而是与 CKB 保持一致的启动入口。 +- 初始化后固定 FNN 程序及模板的绝对路径;启动不自动下载、升级或替换程序。路径失效时明确报错。自编译程序允许没有版本号;模板优先使用程序旁的 `config/testnet/config.yml`,没有时明确提示使用 OffCKB 配套模板。 +- 追加节点可以在原节点运行时进行;新节点不会自动启动。追加后停止并重新启动 Fiber,才会运行全部节点。 + +## 配置和账户 + +`devnet/fiber/nodes.yml` 记录程序、节点和账户,文件权限为当前用户读写(支持 POSIX 权限的平台为 `0600`): + +```yaml +fnn: + binary_path: /absolute/path/to/fnn + template_file: /absolute/path/to/config/testnet/config.yml + version: 0.9.0 +nodes: + - id: 1 + initialized: true + account: + type: builtin + index: 3 + config_file: nodes/1/config.yml + rpc_token_file: nodes/1/rpc-token +``` + +配置和 token 的相对路径均相对 `nodes.yml` 所在目录解析。每个节点只使用 `config_file` 指向的完整 FNN 配置;初始化只生成一次,重复初始化和启动不得改写。初始化的默认配置另外保存在节点的 `config.default.yml` 中,用于 `CUSTOM_CONFIG` 判定。 + +允许添加 LSP 服务、RPC 模块及 Biscuit 认证等 FNN 配置。OffCKB 只检查其管理所需条件:保留 `fiber`、`rpc`、`ckb` 服务及 `info`、`peer` RPC 模块;使用本环境的链、合约、CKB RPC、固定节点端口和节点内的数据目录。其他 FNN 字段由 FNN 校验,不复制整套 FNN 配置规则。所有节点通过预检查后才创建进程。 + +默认账户只在首次准备时按“节点编号 + 2”计算并写入。内置账户显式保存 `type: builtin`、`index`;自选或生成私钥使用 `type: random`、`key: "0x…"`。新的随机账户可省略 `key`,由初始化生成并保存;重复初始化、列表重排及重启不重新生成。随机账户不加入创世预分配、不自动注资。零余额允许启动,并输出等待注资的提示。 + +资金账户、Fiber 网络身份、Biscuit 密钥分别管理。启动前检查账户与 `ckb/key`(含 FNN 加密后的格式)一致;启动后和状态查询核对 `node_info.default_funding_lock_script`。多个节点共用同一资金锁时仅警告,说明可能发生 Cell 竞争或被 FNN 拒绝共同出资。 + +初始化采用持久的准备记录保存新账户,再创建文件,最后提交节点登记。中断后重试复用已保存的密钥;已完成节点缺少必需文件时拒绝重新生成。无登记也无准备记录的旧目录不会被导入或覆盖。 + +## RPC 认证与状态 + +每个新节点独立生成 Biscuit 密钥对和覆盖 `node_info`、`list_peers`、`connect_peer` 所需 node/peer 权限的管理 token(不授予通道、支付操作权限)。密钥保存为节点目录下 `biscuit/private.key`、`biscuit/public.key`,token 保存为 `rpc-token`。默认不启用 RPC 认证;用户启用时把对应公钥配置到 `rpc.biscuit_public_key`。外部 LSP 工具必须复用这对密钥;若主动换了认证公钥,则需要提供匹配的管理 token。不会自动覆盖外部工具的密钥或配置。 + +内部 RPC 在认证开启时自动读取对应 token,并发送 `Authorization: Bearer …`。认证失败、权限不足、文件丢失与节点停止分别报告,不输出 token 或私钥。 + +`fiber status` 增加 `CUSTOM_CONFIG`:比较解析后的 FNN 配置值,忽略注释、空白及字段顺序;与初始化基准相同时为 `no`,否则为 `yes`。基准缺失或损坏为 `unknown`,附原因,不妨碍使用有效的活动配置。 + +运行记录保存启动时使用的节点列表、公开账户信息、配置及 token 路径、RPC 地址和配置判定,不保存私钥或 token 正文。运行中修改登记或配置,需重启才生效;状态继续使用启动时记录。原 token 路径中的文件内容可以替换,管理调用会重新读取,方便凭证恢复。未运行节点按当前文件判断,注明配置尚未生效。 + +## 重启与重置 + +| 操作 | 结果 | +| -------------------------- | --------------------------------------------------------------------- | +| 停止后重新启动 CKB / Fiber | 复用配置、密钥和数据;普通运行设置重启后生效 | +| `fiber init` / 追加节点 | 准备并检查,不清理链、不删除已有节点数据 | +| `fiber clean --data` | 删除 Fiber 通道、支付等运行数据;保留账户、身份、密码、认证材料和配置 | +| `fiber clean` | 删除整个 Fiber 环境;保留 CKB 本地链 | +| `clean --data` | 重置 CKB 链及依赖它的 Fiber 运行数据;保留配置和密钥 | +| `clean` | 删除整套 devnet,含 Fiber 配置、密钥和数据 | + +后台节点用 `node stop` / `fiber stop` 停止;前台用 Ctrl+C。`node --fiber --daemon` 由同一个管理进程启动,停止该管理进程会停止整套环境。清理前沿用进程身份和数据库锁检查。 + +CKB 配置编辑器不再把 `clean -d` 当作普通重启步骤。普通运行配置修改后停止并重新启动;修改创世链规格才需要明确的重置,并告知会丢失链和 Fiber 数据。Fiber 数据重置清理 OffCKB 管理的 `fiber/store`;配套 LSP 如使用独立存储,其清理边界需按该工具版本单独验证,不把密钥目录当作数据库删除。 + +## 验证与交付 + +自动化覆盖默认自动初始化、显式初始化、追加与编号边界、配置保留、准备中断重试、密钥一致性、零余额、RPC 认证错误、运行状态记录以及重启与重置边界。检查文档和 CLI 帮助一致,并运行类型检查、lint、构建和测试。 + +实际 CKB/FNN 验证:默认启动、三个节点、认证、停止重启及外部注资。LSP 验证使用调用方提供的配套 FNN / lsp-cli 版本,检查初始化前配置、Biscuit 复用和重启后的数据保留。交付和 PR 如实记录已验证与未验证项。 diff --git a/docs/fiber-validation.md b/docs/fiber-validation.md new file mode 100644 index 0000000..f52fc84 --- /dev/null +++ b/docs/fiber-validation.md @@ -0,0 +1,38 @@ +# Fiber devnet 验证记录 + +对应设计:[Fiber devnet 初始化、配置与数据生命周期](fiber-devnet.md)。本记录描述开发者检查结果,不代表人工验收。 + +## 环境 + +- 2026-10-27,macOS arm64,Node.js 24.8.0。 +- 实际 CKB 0.208.0、FNN 0.9.0(e6cb7ac)。FNN 发布包 SHA-256 与仓库已有固定值一致。 +- 测试数据和下载文件放在本任务工作树的忽略目录中。独立配置指向测试链目录,没有重置用户原有本地链。 + +## 自动化检查 + +- `pnpm install --frozen-lockfile`:通过;仅新增官方 Biscuit WASM 依赖,保留原有其他依赖锁定记录。 +- `pnpm typecheck`、`pnpm build`:通过。 +- `pnpm lint`:通过,保留 `src/node/install.ts` 的一个既有 `any` 警告。 +- `pnpm test:ci --runInBand --silent`:51 个测试套件通过,464 项通过,7 项按平台/原有条件跳过;覆盖率阈值通过。 +- `git diff --check`:通过。 +- 发行构建额外验证:运行 `build/index.js` 的隔离入口执行已存在环境的 `fiber init`,加载随包发布的 Biscuit WASM/ESM 文件成功;无需启用 Node 的实验性 WASM 模块参数。 + +自动化重点包括:首次默认准备、显式准备、追加与编号上限、账户导入和随机账户、配置不覆盖、准备中断后复用密钥、FNN 加密密钥比对、启动前检查所有节点、Biscuit 签名和权限、凭证文件替换、HTTP 认证失败、运行信息快照、默认配置损坏及数据重置边界。 + +## 实际 CKB/FNN 流程 + +- CKB 已运行时显式 `fiber init --nodes 3 --binary-path …`:准备三个节点,未创建 FNN 进程或数据库。 +- 修改节点 3 的名称并启用 Biscuit 认证:三个节点统一启动成功,节点 3 的状态查询携带 token,`CUSTOM_CONFIG` 为 `yes`。 +- 原节点运行时追加随机账户节点 4:只准备新节点;停止并启动 Fiber 后四个节点全部就绪,节点 4 以零余额启动并显示等待注资。 +- 用本地链预分配账户向节点 4 转入 1000 CKB,确认及索引更新后可查到余额;没有重新初始化或重启 FNN,网络身份保持不变。 +- 运行期间修改节点 3 的登记账户、配置文件内 RPC 地址及 token 路径:状态继续使用启动时的信息;恢复原文件后无需修复环境。 +- 将运行中节点 3 的 token 文件临时替换为无效值:状态为 `unknown` 并明确指出认证失败,没有显示为 `stopped`;恢复文件后恢复正常。 +- 在自动互连使用的第一个节点上启用认证:启动完成并互连成功,实际覆盖带 token 的 `node_info`、`connect_peer` 和 `list_peers`。 +- 停止并再次启动四节点环境:逐文件 SHA-256 核对配置、FNN 加密后的 CKB 密钥、密码、Fiber 身份、Biscuit 密钥及 token,均未改变。 +- 全新环境直接 `node --fiber --daemon --fnn-binary-path …`:无需先执行 `fiber init`,自动准备并启动默认两个节点。`node stop` 停止整套环境。 + +## 未验证项与既有问题 + +- 未提供与 LSP 配套的 FNN 分支/版本和 `lsp-cli` 程序,尚未实际验证 LSP 初始化、Biscuit 文件约定及 LSP 独立存储。普通 FNN 自定义配置与 RPC 认证已验证,不能据此视为 LSP 联调通过。 +- 没有在 Windows/Linux 或 Node 20 上运行真实程序;自动化测试为本机结果,其他平台交由 PR CI。 +- 发现既有 macOS 后台进程身份检查限制:`ps` 返回未加引号的含空格脚本路径时,原有检查拒绝停止该进程。本任务路径含空格,真实联调用隔离包装脚本保留进程标题中的路径引号后完成后台停止与重启;未放宽生产代码的进程身份检查。最初的测试进程已按确切 PID/进程组清理。该问题需要单独处理。 diff --git a/package.json b/package.json index f4218d6..7b8ee95 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,10 @@ "lint": "eslint \"src/**/*.ts\" --ignore-pattern 'node_modules/'", "lint:fix": "eslint \"src/**/*.ts\" --ignore-pattern 'node_modules/' --fix", "fmt": "prettier --write '{src,templates,account}/**/*.{js,jsx,ts,tsx,md,json}'", - "test": "jest", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", - "test:ci": "jest --coverage --ci", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", + "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", + "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage", + "test:ci": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage --ci", "test:legacy-data": "bash scripts/legacy-data-test.sh", "typecheck": "tsc --noEmit", "changeset": "changeset", @@ -75,6 +75,7 @@ "typescript": "^5.3.3" }, "dependencies": { + "@biscuit-auth/biscuit-wasm": "0.5.0", "@ckb-ccc/core": "1.14.0", "@iarna/toml": "^2.2.5", "@inquirer/prompts": "^7.8.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0d28cc..afc1b00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: .: dependencies: + '@biscuit-auth/biscuit-wasm': + specifier: 0.5.0 + version: 0.5.0 '@ckb-ccc/core': specifier: 1.14.0 version: 1.14.0(typescript@5.8.2)(zod@3.25.76) @@ -140,6 +143,9 @@ importers: packages: + '@biscuit-auth/biscuit-wasm@0.5.0': + resolution: {integrity: sha512-tMkjrfAHxlXVfqQ8se+pZdU6c6Suvt8HWgjRfxytoS3yzuCqPuUq0GvNP4ORfjJIslKb60WkzLoxlNZULrbqXA==} + '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} @@ -3200,6 +3206,8 @@ packages: snapshots: + '@biscuit-auth/biscuit-wasm@0.5.0': {} + '@adraffy/ens-normalize@1.11.1': {} '@babel/code-frame@7.28.6': diff --git a/scripts/build.js b/scripts/build.js index 931186d..f5fb2cd 100755 --- a/scripts/build.js +++ b/scripts/build.js @@ -14,6 +14,9 @@ try { // Step 2: Bundle with NCC console.log('🔗 Bundling with NCC...'); execSync('ncc build dist/cli.js -o build --external cpu-features', { stdio: 'inherit' }); + fs.cpSync('node_modules/@biscuit-auth/biscuit-wasm/module', 'build/biscuit', { recursive: true }); + fs.writeFileSync('build/biscuit/package.json', JSON.stringify({ type: 'module' })); + fs.copyFileSync('node_modules/@biscuit-auth/biscuit-wasm/LICENSE', 'build/biscuit/LICENSE'); console.log('✅ NCC bundling complete\n'); // Step 3: Verify build output diff --git a/src/cli.ts b/src/cli.ts index 53e7f0a..dc765c9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,7 @@ import { printSystemScripts } from './cmd/system-scripts'; import { transferAll } from './cmd/transfer-all'; import { fiberCleanCommand, + fiberInit, fiberLogs, fiberStart, fiberStatusCommand, @@ -95,13 +96,17 @@ const nodeCommand = program .option('--fiber', 'Also start Fiber (FNN) nodes on the devnet (plain local chain only)') .option('--fnn-version ', 'Specify the FNN version to use with --fiber') .option('--fnn-binary-path ', 'Specify a locally built FNN binary to use with --fiber') - .option('--fiber-nodes ', 'Number of FNN nodes to start with --fiber (1-16, default 2)', (value: string) => { - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 1 || parsed > 16) { - throw new InvalidArgumentError('--fiber-nodes must be an integer between 1 and 16'); - } - return parsed; - }) + .option( + '--fiber-nodes ', + 'Initial FNN node count (1-16, default 2); add nodes with fiber init', + (value: string) => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 16) { + throw new InvalidArgumentError('--fiber-nodes must be an integer between 1 and 16'); + } + return parsed; + }, + ) .action( async ( version: string, @@ -138,16 +143,48 @@ nodeCommand const fiberCommand = program.command('fiber').description('Manage Fiber (FNN) nodes on the local devnet'); +fiberCommand + .command('init [FNN-Version]') + .description('Prepare Fiber configs and keys without starting FNN (optional before start)') + .addOption( + new Option('--nodes ', 'Prepare node ids 1 through N (1-16, first-use default 2)') + .argParser((value: string) => { + const count = Number(value); + if (!Number.isInteger(count) || count < 1 || count > 16) + throw new InvalidArgumentError('--nodes must be between 1 and 16'); + return count; + }) + .conflicts('add'), + ) + .option('--add', 'Append one new node to an existing environment') + .option('-b, --binary-path ', 'FNN binary to select when first preparing the environment') + .addOption( + new Option('--account-index ', 'Built-in funding account for --add') + .argParser((value: string) => { + const index = Number(value); + if (!Number.isInteger(index) || index < 0) + throw new InvalidArgumentError('--account-index must be a non-negative integer'); + return index; + }) + .conflicts('randomAccount'), + ) + .option('--random-account', 'Generate an unfunded account for --add') + .action(fiberInit); + fiberCommand .command('start [FNN-Version]') .description('Start Fiber (FNN) nodes on the running devnet CKB') - .option('--nodes ', 'Total number of FNN nodes (1-16, default 2)', (value: string) => { - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 1 || parsed > 16) { - throw new InvalidArgumentError('--nodes must be an integer between 1 and 16'); - } - return parsed; - }) + .option( + '--nodes ', + 'Initial node count (1-16, default 2); existing environments retain their node list', + (value: string) => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 16) { + throw new InvalidArgumentError('--nodes must be an integer between 1 and 16'); + } + return parsed; + }, + ) .option('-b, --binary-path ', 'Specify a locally built FNN binary path to use') .option('--daemon', 'Run the fiber nodes in the background as a daemon') .action(async (version: string | undefined, options: FiberStartOptions) => { @@ -277,8 +314,8 @@ program program .command('clean') - .description('Clean the devnet data, need to stop running the chain first') - .option('-d, --data', 'Only remove chain data, keep devnet config files') + .description('Reset the devnet (deletes data); stop the chain first') + .option('-d, --data', 'Delete chain and Fiber stores, keep configs, accounts and keys') .action((options: { data?: boolean }) => clean(options)); program .command('accounts') diff --git a/src/cmd/clean.ts b/src/cmd/clean.ts index 9a2c818..453b2ba 100644 --- a/src/cmd/clean.ts +++ b/src/cmd/clean.ts @@ -44,6 +44,9 @@ export function clean(options?: CleanOptions) { assertFiberFullyStopped(settings); if (dataOnly) { + logger.warn( + 'Resetting devnet: chain history and Fiber channel/payment stores will be deleted. Configs, accounts, identity keys, Biscuit keys and tokens are kept. This is not a restart.', + ); // Only clean the chain data subdirectory const chainDataPath = settings.devnet.dataPath; if (isFolderExists(chainDataPath)) { @@ -66,6 +69,9 @@ export function clean(options?: CleanOptions) { logger.info(`Fiber store cleaned: ${storeDir}`); } } else { + logger.warn( + 'Resetting the whole devnet: chain history and the entire Fiber environment (including configs and keys) will be deleted. This is not a restart.', + ); // Clean everything - the original behavior // this is the root folder of devnet, it contains config, data, debugFullTransactions, transactions, failed-transactions, contracts // and the whole fiber environment (configs, keys, stores, logs) diff --git a/src/cmd/devnet-config.ts b/src/cmd/devnet-config.ts index bfc33f7..1f307c3 100644 --- a/src/cmd/devnet-config.ts +++ b/src/cmd/devnet-config.ts @@ -54,7 +54,9 @@ export async function devnetConfig(options: DevnetConfigOptions = {}) { logger.info( `Applied ${parsedItems.length} setting(s): ${parsedItems.map((item) => `${item.key}=${item.value}`).join(', ')}`, ); - logger.info('Restart devnet to apply changes: offckb clean -d && offckb node'); + logger.info( + 'Stop devnet (Ctrl+C or offckb node stop), then run offckb node to apply runtime config changes. Only reset with offckb clean -d for genesis/spec changes; reset deletes chain and Fiber channel/payment data.', + ); return; } @@ -69,7 +71,9 @@ export async function devnetConfig(options: DevnetConfigOptions = {}) { if (isSaved) { logger.success(`Devnet config updated at: ${configPath}`); - logger.info('Restart devnet to apply changes: offckb clean -d && offckb node'); + logger.info( + 'Stop devnet (Ctrl+C or offckb node stop), then run offckb node to apply runtime config changes. Only reset with offckb clean -d for genesis/spec changes; reset deletes chain and Fiber channel/payment data.', + ); return; } diff --git a/src/cmd/fiber.ts b/src/cmd/fiber.ts index dddad78..9e60820 100644 --- a/src/cmd/fiber.ts +++ b/src/cmd/fiber.ts @@ -8,12 +8,14 @@ import { startFiberEnvironment, stopFiberNodes, FiberEnvironment } from '../fibe import { startFiberDaemon, stopFiber } from '../fiber/daemon'; import { fiberStatus } from '../fiber/status'; import { fiberClean, FiberCleanOptions } from '../fiber/clean'; -import { fiberAccountIndex, fiberNodePaths, FIBER_DAEMON_PID_FILE, fiberDaemonPaths } from '../fiber/paths'; +import { fiberNodePaths, FIBER_DAEMON_PID_FILE, fiberDaemonPaths } from '../fiber/paths'; import { readNodesYml } from '../fiber/nodes-yml'; import { readLogTail, followLogFile } from '../devnet/log-file'; import { cleanupPidFile } from '../util/daemon'; import { enterGracefulShutdown } from '../util/shutdown'; import * as fs from 'fs'; +import { initializeFiberEnvironment, FiberInitOptions } from '../fiber/init'; +import { resolveFundingAccount } from '../fiber/accounts'; export interface FiberStartOptions { nodes?: number; @@ -37,7 +39,7 @@ export function printFiberSummary(env: FiberEnvironment) { const info = env.nodeInfos.get(node.id); const version = info ? `${info.version} (${(info.commit_hash || '').slice(0, 7) || 'unknown commit'})` : 'unknown'; logger.info( - ` node ${node.id}: FNN ${version}, RPC ${node.rpcUrl}, account #${fiberAccountIndex(node.id)}, log: ${node.logFile}`, + ` node ${node.id}: FNN ${version}, RPC ${node.rpcUrl}, funding ${node.entry ? resolveFundingAccount(node.entry.account).address : 'unknown'}, log: ${node.logFile}`, ); } } @@ -110,7 +112,6 @@ export async function fiberStart(version: string | undefined, options: FiberStar try { await assertCkbEnvReadyForFiber(settings); - const fnn = await resolveFnnBinary({ version, binaryPath: options.binaryPath }, settings); let chainScripts; try { chainScripts = resolveFiberChainScripts(); @@ -124,8 +125,8 @@ export async function fiberStart(version: string | undefined, options: FiberStar } const env = await startFiberEnvironment({ - fnnPath: fnn.fnnPath, - testnetConfigPath: fnn.testnetConfigPath, + version, + binaryPath: options.binaryPath, chainScripts, nodeCount: options.nodes, settings, @@ -149,6 +150,31 @@ export async function fiberStart(version: string | undefined, options: FiberStar } } +export async function fiberInit(version: string | undefined, options: FiberInitOptions) { + const settings = readSettings(); + assertPlainDevnet(settings); + const lock = acquireEnvLock('offckb fiber init'); + try { + await assertCkbEnvReadyForFiber(settings); + const manifest = await initializeFiberEnvironment({ ...options, version }, resolveFiberChainScripts(), settings); + logger.success(`Fiber environment prepared (${manifest.nodes.length} nodes); no new FNN processes started.`); + logger.result({ + command: 'fiber.init', + binaryPath: manifest.fnn.binary_path, + nodes: manifest.nodes.map((node) => ({ + id: node.id, + configFile: node.config_file, + rpcTokenFile: node.rpc_token_file, + accountType: node.account.type, + accountIndex: node.account.type === 'builtin' ? node.account.index : undefined, + fundingAddress: resolveFundingAccount(node.account).address, + })), + }); + } finally { + lock.release(); + } +} + export async function fiberStopCommand() { const settings = readSettings(); const lock = acquireEnvLock('offckb fiber stop'); diff --git a/src/cmd/node.ts b/src/cmd/node.ts index 3515c0d..30a332f 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -33,7 +33,6 @@ import { } from '../util/daemon'; import { assertPlainDevnet } from '../fiber/ckb-env'; import { acquireEnvLock, EnvLockHandle } from '../fiber/env-lock'; -import { resolveFnnBinary, ResolvedFnn } from '../fiber/install'; import { resolveFiberChainScripts } from '../fiber/scripts'; import { FiberEnvironment, startFiberEnvironment, stopFiberNodes } from '../fiber/manager'; import { printFiberSummary } from './fiber'; @@ -269,16 +268,6 @@ async function superviseDevnet( ckbExited = true; }); - // With --fiber, FNN selection/download starts as soon as CKB begins to - // start, so it overlaps with the devnet readiness wait below. - let fnnPrep: Promise | null = null; - if (fiber) { - fnnPrep = resolveFnnBinary({ version: fnnVersion, binaryPath: fnnBinaryPath }, settings); - fnnPrep.catch(() => { - // surfaced when awaited after the CKB environment is ready - }); - } - const timeoutMs = forkState ? FORK_NODE_READY_TIMEOUT_MS : NODE_READY_TIMEOUT_MS; const readiness = await waitForNodeReady(settings.devnet.rpcUrl, timeoutMs, () => !ckbExited); if (!readiness.ready) { @@ -352,11 +341,10 @@ async function superviseDevnet( logger.info('Follow the full node log with: offckb logs -f'); } - // The CKB environment is up. With --fiber, wait for the FNN binary - // preparation (started above, concurrent with CKB startup) and run the - // shared Fiber startup flow. Any failure stops everything started here. + // The CKB environment is up. With --fiber, reuse the saved environment + // or initialize a fresh one through the shared Fiber startup flow. Any failure stops everything started here. let fiberEnv: FiberEnvironment | null = null; - if (fiber && fnnPrep) { + if (fiber) { const stopStartedProcesses = () => { logSubscription?.close(); if (!ckbProcess.killed) ckbProcess.kill('SIGTERM'); @@ -365,14 +353,13 @@ async function superviseDevnet( envLock?.release(); }; try { - const fnn = await fnnPrep; - // startFiberEnvironment owns the signals while it starts the FNNs: it - // stops them and exits, and our exit hook then stops CKB and the miner. + // startFiberEnvironment owns signals throughout preparation and FNN + // startup; its exit then triggers cleanup of CKB and the miner. const endDelegation = signals.delegate(); try { fiberEnv = await startFiberEnvironment({ - fnnPath: fnn.fnnPath, - testnetConfigPath: fnn.testnetConfigPath, + version: fnnVersion, + binaryPath: fnnBinaryPath, chainScripts: resolveFiberChainScripts(), nodeCount: fiberNodes, settings, diff --git a/src/fiber/accounts.ts b/src/fiber/accounts.ts index a185364..ccc9381 100644 --- a/src/fiber/accounts.ts +++ b/src/fiber/accounts.ts @@ -4,6 +4,9 @@ import { ccc } from '@ckb-ccc/core'; import accountConfig from '../../account/account.json'; import { fiberAccountIndex, fiberNodePaths, UDT_ISSUER_ACCOUNT_INDEX } from './paths'; import { readSettings, Settings } from '../cfg/setting'; +import type { FundingAccount, FiberNodeEntry } from './nodes-yml'; +import { readRegularFile, writePrivateFile } from './files'; +import { logger } from '../util/logger'; export interface BuiltinAccount { privkey: string; @@ -21,7 +24,7 @@ const accounts = accountConfig as unknown as BuiltinAccount[]; export function getBuiltinAccount(index: number): BuiltinAccount { const account = accounts[index]; - if (!account) { + if (!Number.isInteger(index) || index < 0 || !account) { throw new Error(`Built-in account #${index} does not exist (account.json has ${accounts.length} accounts).`); } return account; @@ -59,12 +62,78 @@ export function readFiberNodeSecretKey(nodeId: number, settings: Settings = read } } -function writePrivateFile(file: string, content: string) { - fs.writeFileSync(file, content, { mode: 0o600 }); +export function validatePrivateKey(key: unknown, field = 'account.key'): asserts key is string { + if (typeof key !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(key)) + throw new Error(`Invalid ${field}: expected a 0x-prefixed 32-byte private key.`); try { - fs.chmodSync(file, 0o600); + fiberPublicKeyFromSecret(Buffer.from(key.slice(2), 'hex')); } catch { - // Windows has no POSIX modes; the file inherits directory ACLs. + throw new Error(`Invalid ${field}: not a valid secp256k1 private key.`); + } +} + +export function resolveFundingAccount(account: FundingAccount): BuiltinAccount { + if (account.type === 'builtin') return getBuiltinAccount(account.index); + validatePrivateKey(account.key); + const signer = new ccc.SignerCkbPrivateKey({} as never, account.key as `0x${string}`); + const args = ccc.hashCkb(signer.publicKey).slice(0, 42); + const lockScript = { ...getBuiltinAccount(0).lockScript, args }; + const client = new ccc.ClientPublicTestnet(); + return { + privkey: account.key, + pubkey: signer.publicKey, + args, + lockScript, + address: ccc.Address.fromScript(ccc.Script.from(lockScript), client).toString(), + }; +} + +export function warnSharedAccounts(nodes: FiberNodeEntry[]) { + const groups = new Map(); + for (const node of nodes) { + const account = resolveFundingAccount(node.account); + const lock = ccc.Script.from(account.lockScript).hash(); + const group = groups.get(lock) ?? { ids: [], address: account.address }; + group.ids.push(node.id); + groups.set(lock, group); + } + for (const group of groups.values()) + if (group.ids.length > 1) { + logger.warn( + `Fiber nodes ${group.ids.join(', ')} share funding account ${group.address}. Concurrent transactions may compete for Cells; FNN may reject shared funding inputs.`, + ); + } +} + +// FNN v0.9.0 utils/encrypt_decrypt_file.rs: version | salt(16) | nonce(12) | +// AES-256-GCM ciphertext and tag(16), scrypt 0.11 recommended parameters. +export function assertNodeFundingKey(node: FiberNodeEntry, settings: Settings = readSettings()) { + const file = fiberNodePaths(node.id, settings).ckbKeyFile; + const data = readRegularFile(file); + let actual: string; + try { + if (/^(0x)?[0-9a-f]{64}$/i.test(data.toString('utf8').trim())) + actual = data.toString('utf8').trim().replace(/^0x/, ''); + else { + if (data[0] !== 0 || data.length < 46) throw new Error('unsupported encrypted key'); + const key = crypto.scryptSync(readNodePassword(node.id, settings), data.subarray(1, 17), 32, { + N: 131072, + r: 8, + p: 1, + maxmem: 256 * 1024 * 1024, + }); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, data.subarray(17, 29)); + decipher.setAuthTag(data.subarray(-16)); + const plain = Buffer.concat([decipher.update(data.subarray(29, -16)), decipher.final()]); + actual = plain.length === 32 ? plain.toString('hex') : plain.toString('utf8').trim().replace(/^0x/, ''); + } + } catch { + throw new Error(`Fiber node ${node.id}: cannot decrypt or read ${file}; check its key format and password.`); + } + if (actual.toLowerCase() !== resolveFundingAccount(node.account).privkey.slice(2).toLowerCase()) { + throw new Error( + `Fiber node ${node.id}: account record does not match ${file}. Restore the original binding; use a new node to test another account.`, + ); } } @@ -79,14 +148,19 @@ function writePrivateFile(file: string, content: string) { * key material is an error, never silently re-created (the on-chain account * and any channels are tied to the original keys). */ -export function ensureNodeKeyMaterial(nodeId: number, settings: Settings = readSettings()): { created: boolean } { +export function ensureNodeKeyMaterial( + nodeId: number, + settings: Settings = readSettings(), + accountBinding?: FundingAccount, + preparing = false, +): { created: boolean } { const paths = fiberNodePaths(nodeId, settings); const keyExists = fs.existsSync(paths.ckbKeyFile); const passwordExists = fs.existsSync(paths.passwordFile); if (keyExists && passwordExists) { return { created: false }; } - if (keyExists !== passwordExists) { + if (keyExists !== passwordExists && !preparing) { const missing = keyExists ? paths.passwordFile : paths.ckbKeyFile; throw new Error( `Fiber node ${nodeId} has incomplete key material: ${missing} is missing. ` + @@ -97,16 +171,18 @@ export function ensureNodeKeyMaterial(nodeId: number, settings: Settings = readS fs.mkdirSync(paths.ckbDir, { recursive: true }); fs.mkdirSync(paths.fiberDir, { recursive: true }); - const account = fiberNodeAccount(nodeId); - writePrivateFile(paths.ckbKeyFile, account.privkey.replace(/^0x/, '')); - writePrivateFile(paths.passwordFile, crypto.randomBytes(24).toString('base64')); + const account = accountBinding ? resolveFundingAccount(accountBinding) : fiberNodeAccount(nodeId); + if (!keyExists) writePrivateFile(paths.ckbKeyFile, account.privkey.replace(/^0x/, '')); + if (!passwordExists) writePrivateFile(paths.passwordFile, crypto.randomBytes(24).toString('base64')); return { created: true }; } export function readNodePassword(nodeId: number, settings: Settings = readSettings()): string { const passwordFile = fiberNodePaths(nodeId, settings).passwordFile; try { - return fs.readFileSync(passwordFile, 'utf8').trim(); + const password = readRegularFile(passwordFile).toString('utf8').trim(); + if (!password) throw new Error('password is empty'); + return password; } catch (error) { throw new Error(`Failed to read the password of fiber node ${nodeId}: ${(error as Error).message}`); } diff --git a/src/fiber/auth.ts b/src/fiber/auth.ts new file mode 100644 index 0000000..76b88d4 --- /dev/null +++ b/src/fiber/auth.ts @@ -0,0 +1,181 @@ +import fs from 'fs'; +import path from 'path'; +import { pathToFileURL } from 'url'; +import { randomBytes } from 'crypto'; +import type * as BiscuitApi from '@biscuit-auth/biscuit-wasm' with { 'resolution-mode': 'import' }; +import { fiberNodePaths } from './paths'; +import { resolveNodeFile, FiberNodeEntry } from './nodes-yml'; +import { Settings, readSettings } from '../cfg/setting'; +import { readRegularFile, writePrivateFile } from './files'; + +let biscuitPromise: Promise | undefined; +export function loadBiscuit(): Promise { + if (!biscuitPromise) + biscuitPromise = (async () => { + // Load the official bindings with Node's native WASM API. This avoids + // experimental WASM module flags and works with the CLI's CommonJS build. + const candidates = [ + path.join(__dirname, 'biscuit'), + ...(require.resolve.paths('@biscuit-auth/biscuit-wasm') ?? []).map((dir: string) => + path.join(dir, '@biscuit-auth/biscuit-wasm/module'), + ), + ]; + const dir = candidates.find((candidate) => fs.existsSync(path.join(candidate, 'biscuit_bg.wasm'))); + if (!dir) throw new Error('Biscuit runtime assets are missing; reinstall OffCKB.'); + const module = new WebAssembly.Module(readRegularFile(path.join(dir, 'biscuit_bg.wasm'))); + const nativeImport = (url: string): Promise> => import(/* webpackIgnore: true */ url); + const imports: WebAssembly.Imports = {}; + let api: Record = {}; + const instanceId = randomBytes(8).toString('hex'); + for (const name of new Set(WebAssembly.Module.imports(module).map((entry) => entry.module))) { + const url = pathToFileURL(path.join(dir, name)); + url.searchParams.set('instance', instanceId); + const bindings = await nativeImport(url.href); + if (name === './biscuit_bg.js') api = bindings; + // WASM's startup banner bypasses our JSON logger. Suppress its console + // imports; callers report sanitized errors through OffCKB's logger. + imports[name] = Object.fromEntries( + Object.entries(bindings).map(([key, value]) => [ + key, + /^__wbg_(log|debug|info|warn|error)_/.test(key) ? () => {} : value, + ]), + ) as WebAssembly.ModuleImports; + } + const instance = new WebAssembly.Instance(module, imports); + (api.__wbg_set_wasm as (exports: WebAssembly.Exports) => void)(instance.exports); + (instance.exports.__wbindgen_start as () => void)(); + return api as unknown as typeof BiscuitApi; + })(); + return biscuitPromise; +} + +export function biscuitPaths(id: number, settings: Settings = readSettings()) { + const dir = path.join(fiberNodePaths(id, settings).dir, 'biscuit'); + return { privateKey: path.join(dir, 'private.key'), publicKey: path.join(dir, 'public.key') }; +} + +export async function prepareBiscuit(node: FiberNodeEntry, settings: Settings) { + const api = await loadBiscuit(); + const files = biscuitPaths(node.id, settings); + if (!fs.existsSync(files.privateKey)) { + const pair = new api.KeyPair(); + const key = pair.getPrivateKey(); + try { + writePrivateFile(files.privateKey, `ed25519-private/${key.toString()}\n`); + } finally { + key.free(); + pair.free(); + } + } + const key = api.PrivateKey.fromString( + readRegularFile(files.privateKey) + .toString('utf8') + .trim() + .replace(/^ed25519-private\//, ''), + ); + const pair = api.KeyPair.fromPrivateKey(key); + const pubkey = pair.getPublicKey(); + try { + const expected = `ed25519/${pubkey.toString()}`; + if (!fs.existsSync(files.publicKey)) writePrivateFile(files.publicKey, `${expected}\n`); + else if (readRegularFile(files.publicKey).toString('utf8').trim() !== expected) + throw new Error(`Fiber node ${node.id}: Biscuit key pair mismatch in ${files.publicKey}.`); + const tokenFile = resolveNodeFile(node.rpc_token_file, settings); + if (!fs.existsSync(tokenFile)) { + const builder = api.Biscuit.builder(); + let consumed = false; + try { + builder.addCode('read("node"); read("peers"); write("peers");'); + consumed = true; + const token = builder.build(key); + try { + writePrivateFile(tokenFile, `${token.toBase64()}\n`); + } finally { + token.free(); + } + } finally { + if (!consumed) builder.free(); + } + } + } finally { + pubkey.free(); + pair.free(); + key.free(); + } +} + +export async function validateBiscuitKeyPair(id: number, settings: Settings): Promise { + const files = biscuitPaths(id, settings); + const api = await loadBiscuit(); + try { + const text = readRegularFile(files.privateKey).toString('utf8').trim(); + if (!/^ed25519-private\/[0-9a-f]{64}$/i.test(text)) throw new Error('invalid format'); + const key = api.PrivateKey.fromString(text.split('/')[1]); + try { + const pair = api.KeyPair.fromPrivateKey(key); + try { + const publicKey = pair.getPublicKey(); + try { + const expected = `ed25519/${publicKey.toString()}`; + if (readRegularFile(files.publicKey).toString('utf8').trim() !== expected) throw new Error('mismatch'); + return expected; + } finally { + publicKey.free(); + } + } finally { + pair.free(); + } + } finally { + key.free(); + } + } catch { + throw new Error(`Fiber node ${id}: invalid or mismatched Biscuit key files in ${path.dirname(files.privateKey)}.`); + } +} + +export class FiberAuthError extends Error {} +export interface FiberRpcAuth { + tokenFile: string; +} +export function readRpcToken(auth?: FiberRpcAuth): string | undefined { + if (!auth) return undefined; + let token: string; + try { + token = readRegularFile(auth.tokenFile).toString('utf8').trim(); + } catch { + throw new FiberAuthError(`Cannot read RPC token file ${auth.tokenFile}.`); + } + if (!token || !/^[A-Za-z0-9_+/=-]+$/.test(token)) + throw new FiberAuthError(`Invalid RPC token file ${auth.tokenFile}.`); + return token; +} + +export async function validateRpcToken(tokenFile: string, publicKey: string) { + const api = await loadBiscuit(); + try { + const key = api.PublicKey.fromString(publicKey.replace(/^ed25519\//, '')); + try { + const token = api.Biscuit.fromBase64(readRpcToken({ tokenFile })!, key); + try { + for (const permission of ['read("node")', 'read("peers")', 'write("peers")']) { + const authorizer = new api.Authorizer(); + try { + authorizer.addToken(token); + authorizer.addCode(`time(${new Date().toISOString()}); allow if ${permission};`); + authorizer.authorize(); + } finally { + authorizer.free(); + } + } + } finally { + token.free(); + } + } finally { + key.free(); + } + } catch { + throw new FiberAuthError( + `RPC token ${tokenFile} is invalid, expired, or lacks node/peer management permissions for the configured public key.`, + ); + } +} diff --git a/src/fiber/clean.ts b/src/fiber/clean.ts index f29f5a9..f97beac 100644 --- a/src/fiber/clean.ts +++ b/src/fiber/clean.ts @@ -75,7 +75,7 @@ export async function fiberClean(options: FiberCleanOptions, settings: Settings .filter((storeDir) => isFolderExists(storeDir)); logger.warn( 'This permanently deletes every FNN store (channels, payments and other node data). ' + - 'Deleted data cannot be recovered. Node accounts, identity keys, passwords and logs are kept.', + 'Deleted data cannot be recovered. Node accounts, identity keys, passwords, Biscuit keys, RPC tokens, configs and logs are kept. This is a data reset, not a restart.', ); for (const store of stores) { logger.info(` will delete: ${store}`); diff --git a/src/fiber/config-gen.ts b/src/fiber/config-gen.ts index 859aecb..9369bf5 100644 --- a/src/fiber/config-gen.ts +++ b/src/fiber/config-gen.ts @@ -2,7 +2,10 @@ import * as fs from 'fs'; import yaml from 'js-yaml'; import { fiberNodePaths, fiberP2pAddr, fiberRpcPort } from './paths'; import { FiberChainScripts } from './scripts'; -import { FiberNodeEntry } from './nodes-yml'; +import { FiberNodeEntry, resolveNodeFile } from './nodes-yml'; +import { isDeepStrictEqual } from 'util'; +import path from 'path'; +import { isMapping, readYaml, writePrivateFile } from './files'; import { readSettings, Settings } from '../cfg/setting'; // The devnet spec file, resolved by FNN relative to the node directory @@ -14,43 +17,20 @@ const DEV_TOML_RELATIVE_TO_NODE = '../../../specs/dev.toml'; // dev-only modules (only available in debug builds) are not relied upon. const ENABLED_RPC_MODULES = ['channel', 'payment', 'graph', 'info', 'invoice', 'peer', 'watchtower']; -function isPlainObject(value: unknown): value is Record { - return value != null && typeof value === 'object' && !Array.isArray(value); -} - -// Deep-merge per-node overrides onto the generated config: objects merge -// recursively, lists replace wholesale (matching FNN's own config layering). -export function mergeNodeConfig( - base: Record, - override: Record, -): Record { - const result: Record = { ...base }; - for (const [key, value] of Object.entries(override)) { - const existing = result[key]; - if (isPlainObject(existing) && isPlainObject(value)) { - result[key] = mergeNodeConfig(existing, value); - } else { - result[key] = value; - } - } - return result; -} - /** * Generate one node's config.yml. The template is the testnet config bundled * with the FNN release, parsed as a generic mapping so config fields added by * future FNN versions survive the round trip. Chain- and environment-specific * values are replaced with the devnet ones; everything else is kept. * - * Hand edits to config.yml do not survive regeneration — persistent - * customization belongs in fiber/nodes.yml. + * Called only while preparing a new node; startup never writes configs. */ -export function generateNodeConfig(options: { +export function defaultNodeConfig(options: { node: FiberNodeEntry; chainScripts: FiberChainScripts; testnetConfigPath: string; settings?: Settings; -}): string { +}): Record { const settings = options.settings ?? readSettings(); const nodeId = options.node.id; @@ -60,11 +40,11 @@ export function generateNodeConfig(options: { } catch (error) { throw new Error(`Failed to parse FNN testnet config ${options.testnetConfigPath}: ${(error as Error).message}`); } - if (!isPlainObject(template)) { + if (!isMapping(template)) { throw new Error(`FNN testnet config ${options.testnetConfigPath} is not a YAML mapping.`); } - const fiber = isPlainObject(template.fiber) ? { ...template.fiber } : {}; + const fiber = isMapping(template.fiber) ? { ...template.fiber } : {}; fiber.chain = DEV_TOML_RELATIVE_TO_NODE; fiber.listening_addr = fiberP2pAddr(nodeId); fiber.bootnode_addrs = []; @@ -79,26 +59,111 @@ export function generateNodeConfig(options: { fiber.announced_node_name = nodeName; fiber.scripts = options.chainScripts.fiberScripts; - const rpc = isPlainObject(template.rpc) ? { ...template.rpc } : {}; + const rpc = isMapping(template.rpc) ? { ...template.rpc } : {}; rpc.listening_addr = `127.0.0.1:${fiberRpcPort(nodeId)}`; rpc.enabled_modules = ENABLED_RPC_MODULES; rpc.cors_enabled = false; - const ckb = isPlainObject(template.ckb) ? { ...template.ckb } : {}; + const ckb = isMapping(template.ckb) ? { ...template.ckb } : {}; ckb.rpc_url = settings.devnet.rpcUrl; ckb.udt_whitelist = options.chainScripts.udtWhitelist; - let config: Record = { + const config: Record = { ...template, fiber, rpc, ckb, services: ['fiber', 'rpc', 'ckb'], }; - config = mergeNodeConfig(config, options.node.config); + return config; +} - const paths = fiberNodePaths(nodeId, settings); - fs.mkdirSync(paths.dir, { recursive: true }); - fs.writeFileSync(paths.configFile, yaml.dump(config, { noRefs: true, lineWidth: -1 })); - return paths.configFile; +export function generateNodeConfig(options: Parameters[0]): string { + const settings = options.settings ?? readSettings(); + const node = options.node; + const config = defaultNodeConfig(options); + const file = resolveNodeFile(node.config_file, settings); + const baseline = defaultConfigFile(node.id, settings); + if (!fs.existsSync(baseline)) writePrivateFile(baseline, yaml.dump(config, { noRefs: true, lineWidth: -1 })); + if (!fs.existsSync(file)) writePrivateFile(file, readRegularConfigText(baseline)); + return file; +} +function readRegularConfigText(file: string): string { + const config = readYaml(file); + if (!isMapping(config)) throw new Error(`Invalid FNN config: ${file}`); + return yaml.dump(config, { noRefs: true, lineWidth: -1 }); +} +export function defaultConfigFile(id: number, settings: Settings = readSettings()): string { + return path.join(fiberNodePaths(id, settings).dir, 'config.default.yml'); +} +export type CustomConfig = 'yes' | 'no' | 'unknown'; +export function customConfig(node: FiberNodeEntry, settings: Settings = readSettings()): CustomConfig { + try { + const baseline = readYaml(defaultConfigFile(node.id, settings)); + const actual = readYaml(resolveNodeFile(node.config_file, settings)); + if ( + !isMapping(baseline) || + !isMapping(actual) || + !isMapping(baseline.fiber) || + !isMapping(baseline.rpc) || + !isMapping(baseline.ckb) || + !Array.isArray(baseline.services) + ) + return 'unknown'; + return isDeepStrictEqual(baseline, actual) ? 'no' : 'yes'; + } catch { + return 'unknown'; + } +} +export function readNodeConfig(node: FiberNodeEntry, settings: Settings = readSettings()): Record { + const file = resolveNodeFile(node.config_file, settings); + const config = readYaml(file); + if (!isMapping(config)) throw new Error(`Fiber node ${node.id}: ${file} must be a YAML mapping.`); + return config; +} +export function validateNodeConfig(node: FiberNodeEntry, chain: FiberChainScripts, settings: Settings) { + const config = readNodeConfig(node, settings); + const file = resolveNodeFile(node.config_file, settings); + const fail = (field: string): never => { + throw new Error( + `Fiber node ${node.id}: invalid ${field} in ${file}; it must support OffCKB's local environment and management RPC.`, + ); + }; + const fiber = isMapping(config.fiber) ? config.fiber : fail('fiber'); + const rpc = isMapping(config.rpc) ? config.rpc : fail('rpc'); + const ckb = isMapping(config.ckb) ? config.ckb : fail('ckb'); + if ( + !Array.isArray(config.services) || + !['fiber', 'rpc', 'ckb'].every((service) => (config.services as unknown[]).includes(service)) + ) + fail('services'); + if ( + !Array.isArray(rpc.enabled_modules) || + !['info', 'peer'].every((name) => (rpc.enabled_modules as unknown[]).includes(name)) + ) + fail('rpc.enabled_modules'); + if (rpc.listening_addr !== `127.0.0.1:${fiberRpcPort(node.id)}`) fail('rpc.listening_addr'); + if (fiber.listening_addr !== fiberP2pAddr(node.id)) fail('fiber.listening_addr'); + if ( + typeof fiber.chain !== 'string' || + path.resolve(fiberNodePaths(node.id, settings).dir, fiber.chain) !== + path.join(settings.devnet.configPath, 'specs/dev.toml') + ) + fail('fiber.chain'); + if (!isDeepStrictEqual(fiber.scripts, chain.fiberScripts)) fail('fiber.scripts'); + if (!isDeepStrictEqual(fiber.bootnode_addrs, [])) fail('fiber.bootnode_addrs'); + if ( + fiber.store_path != null && + (typeof fiber.store_path !== 'string' || + path.resolve(fiberNodePaths(node.id, settings).dir, fiber.store_path) !== + fiberNodePaths(node.id, settings).fiberStoreDir) + ) + fail('fiber.store_path'); + if (ckb.rpc_url !== settings.devnet.rpcUrl) fail('ckb.rpc_url'); + if ( + rpc.biscuit_public_key != null && + (typeof rpc.biscuit_public_key !== 'string' || !/^ed25519\/[0-9a-f]{64}$/i.test(rpc.biscuit_public_key)) + ) + fail('rpc.biscuit_public_key'); + return { config, publicKey: rpc.biscuit_public_key as string | undefined }; } diff --git a/src/fiber/files.ts b/src/fiber/files.ts new file mode 100644 index 0000000..b6caa84 --- /dev/null +++ b/src/fiber/files.ts @@ -0,0 +1,34 @@ +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; +import yaml from 'js-yaml'; + +export function readRegularFile(file: string): Buffer { + if (!fs.statSync(file).isFile()) throw new Error(`Expected a regular file: ${file}`); + return fs.readFileSync(file); +} + +export function readYaml(file: string): unknown { + const text = readRegularFile(file).toString('utf8'); + try { + return yaml.load(text); + } catch { + // Parser errors include source snippets, potentially private keys. + throw new Error(`Invalid YAML in ${file}; check its syntax.`); + } +} + +export function writePrivateFile(file: string, content: string | Buffer) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const temp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`; + try { + fs.writeFileSync(temp, content, { flag: 'wx', mode: 0o600 }); + fs.renameSync(temp, file); + } finally { + fs.rmSync(temp, { force: true }); + } +} + +export function isMapping(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/fiber/init.ts b/src/fiber/init.ts new file mode 100644 index 0000000..10b5f87 --- /dev/null +++ b/src/fiber/init.ts @@ -0,0 +1,305 @@ +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; +import yaml from 'js-yaml'; +import { readSettings, Settings } from '../cfg/setting'; +import { logger } from '../util/logger'; +import { resolveFnnBinary } from './install'; +import { + FiberManifest, + FnnProgram, + newNodeEntry, + readFiberManifest, + resolveNodeFile, + validateManifest, + validateNodeCount, + writeManifest, +} from './nodes-yml'; +import { + DEFAULT_FIBER_NODES, + MAX_FIBER_NODES, + fiberNodeIds, + fiberNodePaths, + fiberRootPath, + nodesYmlPath, +} from './paths'; +import { + assertNodeFundingKey, + ensureNodeKeyMaterial, + resolveFundingAccount, + warnSharedAccounts, + readNodePassword, + readFiberNodeSecretKey, + fiberPublicKeyFromSecret, +} from './accounts'; +import { biscuitPaths, prepareBiscuit, validateBiscuitKeyPair, validateRpcToken } from './auth'; +import { generateNodeConfig, validateNodeConfig, defaultConfigFile } from './config-gen'; +import { readRegularFile, readYaml, writePrivateFile } from './files'; +import { FiberChainScripts } from './scripts'; + +export interface FiberInitOptions { + nodes?: number; + add?: boolean; + accountIndex?: number; + randomAccount?: boolean; + version?: string; + binaryPath?: string; +} +export type PreparedFiberManifest = FiberManifest & { fnn: FnnProgram }; +export function pendingInitFile(settings: Settings): string { + return path.join(fiberRootPath(settings), 'init.pending.yml'); +} + +function assertProgram(program: FnnProgram) { + try { + if (!fs.statSync(program.binary_path).isFile()) throw new Error('not a file'); + fs.accessSync(program.binary_path, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK); + } catch { + throw new Error( + `Saved FNN program is missing or not executable: ${program.binary_path}. Restore it; OffCKB will not replace it automatically.`, + ); + } +} +function assertProgramOptions(program: FnnProgram, options: FiberInitOptions) { + if ( + (options.binaryPath && path.resolve(options.binaryPath) !== program.binary_path) || + (options.version && options.version !== program.version) + ) { + throw new Error( + `FNN selection conflicts with the initialized environment (${program.binary_path}, version ${program.version ?? 'custom'}). Existing environments retain their saved program.`, + ); + } +} +function assertDistinctPaths(manifest: FiberManifest, settings: Settings) { + const owners = new Map(); + for (const file of [nodesYmlPath(settings), pendingInitFile(settings)]) + owners.set(path.resolve(file), 'environment metadata'); + for (const node of manifest.nodes) { + const paths = fiberNodePaths(node.id, settings); + for (const file of [ + resolveNodeFile(node.config_file, settings), + resolveNodeFile(node.rpc_token_file, settings), + defaultConfigFile(node.id, settings), + paths.ckbKeyFile, + paths.passwordFile, + paths.fiberSkFile, + ...Object.values(biscuitPaths(node.id, settings)), + ]) { + const resolved = path.resolve(file); + if (owners.has(resolved)) + throw new Error(`Fiber node ${node.id}: file path collides with ${owners.get(resolved)}: ${resolved}`); + owners.set(resolved, `node ${node.id}`); + } + } +} +export async function validatePreparedNodes( + manifest: PreparedFiberManifest, + chain: FiberChainScripts, + settings: Settings, +) { + assertProgram(manifest.fnn); + assertDistinctPaths(manifest, settings); + for (const node of manifest.nodes) { + if (!node.initialized) throw new Error(`Fiber node ${node.id} is not initialized. Run offckb fiber init.`); + const required = [ + resolveNodeFile(node.config_file, settings), + resolveNodeFile(node.rpc_token_file, settings), + fiberNodePaths(node.id, settings).ckbKeyFile, + fiberNodePaths(node.id, settings).passwordFile, + ...(fs.existsSync(fiberNodePaths(node.id, settings).fiberStoreDir) + ? [fiberNodePaths(node.id, settings).fiberSkFile] + : []), + ...Object.values(biscuitPaths(node.id, settings)), + ]; + for (const file of required) { + try { + readRegularFile(file); + } catch { + throw new Error( + `Fiber node ${node.id}: required file missing or unreadable: ${file}. Restore the original file; it will not be regenerated.`, + ); + } + } + assertNodeFundingKey(node, settings); + readNodePassword(node.id, settings); + if (fs.existsSync(fiberNodePaths(node.id, settings).fiberSkFile)) { + try { + const identity = readFiberNodeSecretKey(node.id, settings); + if (!identity) throw new Error('missing identity'); + fiberPublicKeyFromSecret(identity); + } catch { + throw new Error( + `Fiber node ${node.id}: invalid network identity key in ${fiberNodePaths(node.id, settings).fiberSkFile}. Restore the original file.`, + ); + } + } + const { publicKey } = validateNodeConfig(node, chain, settings); + const storedPublicKey = await validateBiscuitKeyPair(node.id, settings); + await validateRpcToken(resolveNodeFile(node.rpc_token_file, settings), publicKey ?? storedPublicKey); + } + warnSharedAccounts(manifest.nodes); +} + +// Callers hold the devnet environment lock. A durable pending manifest owns +// only unfinished new nodes; completed nodes never have files repaired here. +export async function initializeFiberEnvironment( + options: FiberInitOptions, + chain: FiberChainScripts, + settings: Settings = readSettings(), + forStart = false, +): Promise { + if (options.nodes != null && options.add) throw new Error('--nodes and --add are mutually exclusive.'); + if (options.accountIndex != null && options.randomAccount) + throw new Error('--account-index and --random-account are mutually exclusive.'); + if ((options.accountIndex != null || options.randomAccount) && !options.add) + throw new Error('Account flags require --add.'); + if (options.nodes != null) validateNodeCount(options.nodes); + let manifest = readFiberManifest(settings); + const pendingFile = pendingInitFile(settings); + const resuming = fs.existsSync(pendingFile); + if (resuming) { + const pending = validateManifest(readYaml(pendingFile), pendingFile); + if ( + options.nodes != null && + (pending.nodes.length !== options.nodes || pending.nodes.some((node) => node.id > options.nodes!)) + ) { + throw new Error(`Requested node count conflicts with pending initialization. Resume it with offckb fiber init.`); + } + const additions = pending.nodes.filter((node) => !node.initialized); + if ( + options.accountIndex != null && + (additions.length !== 1 || + additions[0].account.type !== 'builtin' || + additions[0].account.index !== options.accountIndex) + ) { + throw new Error('Requested account conflicts with pending initialization; the saved account will be retained.'); + } + if (options.randomAccount && (additions.length !== 1 || additions[0].account.type !== 'random')) { + throw new Error('Requested account conflicts with pending initialization; the saved account will be retained.'); + } + // The registry may already have been committed before a crash removed + // the journal. In that case finish the same operation, never append twice. + if ( + manifest && + manifest.nodes.every((node) => node.initialized) && + JSON.stringify(manifest) === + JSON.stringify({ ...pending, nodes: pending.nodes.map((node) => ({ ...node, initialized: true })) }) + ) { + fs.unlinkSync(pendingFile); + assertProgramOptions(manifest.fnn!, options); + await validatePreparedNodes(manifest as PreparedFiberManifest, chain, settings); + return manifest as PreparedFiberManifest; + } + if ( + manifest && + JSON.stringify(manifest.nodes.filter((node) => node.initialized)) !== + JSON.stringify(pending.nodes.filter((node) => node.initialized)) + ) { + throw new Error( + `Existing node records changed during initialization. Restore the original nodes.yml before resuming ${pendingFile}.`, + ); + } + manifest = pending; + } + const existingIds = fiberNodeIds(settings); + if (!manifest && existingIds.length) + throw new Error('Fiber node directories exist without nodes.yml; refusing to import or overwrite them.'); + if (options.add && (!manifest || !manifest.nodes.some((node) => node.initialized))) + throw new Error('No Fiber environment. Run offckb fiber init or fiber start first.'); + if (!resuming && manifest) { + for (const node of manifest.nodes.filter((entry) => !entry.initialized)) { + if (existingIds.includes(node.id)) + throw new Error( + `Unregistered node directory ${fiberNodePaths(node.id, settings).dir}; refusing to overwrite it.`, + ); + } + } + if (!manifest) + manifest = { nodes: Array.from({ length: options.nodes ?? DEFAULT_FIBER_NODES }, (_, i) => newNodeEntry(i + 1)) }; + else if (!resuming) { + if ( + forStart && + options.nodes != null && + (manifest.nodes.length !== options.nodes || manifest.nodes.some((node) => node.id > options.nodes!)) + ) { + throw new Error( + 'Requested node count conflicts with the initialized environment. Add nodes with offckb fiber init.', + ); + } + if (options.nodes != null) { + if (manifest.nodes.some((node) => node.id > options.nodes!)) + throw new Error('--nodes cannot remove existing nodes.'); + for (let id = 1; id <= options.nodes; id++) + if (!manifest.nodes.some((node) => node.id === id)) manifest.nodes.push(newNodeEntry(id)); + } + if (options.add) { + const id = Math.max(...manifest.nodes.map((node) => node.id), ...existingIds) + 1; + if (id > MAX_FIBER_NODES || manifest.nodes.length >= MAX_FIBER_NODES) + throw new Error('Fiber supports node ids 1 to 16; cannot append another node.'); + manifest.nodes.push( + newNodeEntry( + id, + options.accountIndex != null + ? { type: 'builtin', index: options.accountIndex } + : options.randomAccount + ? { type: 'random' } + : undefined, + ), + ); + } + } + manifest.nodes.sort((a, b) => a.id - b.id); + if (manifest.fnn) assertProgramOptions(manifest.fnn, options); + else { + const fnn = await resolveFnnBinary({ version: options.version, binaryPath: options.binaryPath }, settings); + manifest.fnn = { + binary_path: path.resolve(fnn.fnnPath), + template_file: path.resolve(fnn.testnetConfigPath), + version: fnn.version, + }; + } + assertProgram(manifest.fnn); + assertDistinctPaths(manifest, settings); + const prepared = manifest as PreparedFiberManifest; + const newNodes = manifest.nodes.filter((node) => !node.initialized); + // Check all completed nodes before making a provisioning journal. + if (manifest.nodes.some((node) => node.initialized)) + await validatePreparedNodes( + { ...prepared, nodes: manifest.nodes.filter((node) => node.initialized) }, + chain, + settings, + ); + if (!newNodes.length) return prepared; + if (!resuming) { + for (const node of newNodes) { + if (existingIds.includes(node.id)) + throw new Error(`Node directory already exists: ${fiberNodePaths(node.id, settings).dir}`); + if (node.account.type === 'random' && !node.account.key) + node.account.key = `0x${crypto.randomBytes(32).toString('hex')}`; + resolveFundingAccount(node.account); + // New files must not overwrite unrelated configs or credentials. + for (const file of [node.config_file, node.rpc_token_file]) + if (fs.existsSync(resolveNodeFile(file, settings))) + throw new Error(`Refusing to overwrite existing file: ${resolveNodeFile(file, settings)}`); + } + writePrivateFile(pendingFile, yaml.dump(prepared, { noRefs: true })); + } else assertProgramOptions(prepared.fnn, options); + for (const node of newNodes) { + ensureNodeKeyMaterial(node.id, settings, node.account, true); + assertNodeFundingKey(node, settings); + await prepareBiscuit(node, settings); + generateNodeConfig({ node, chainScripts: chain, testnetConfigPath: prepared.fnn.template_file, settings }); + } + const completed: PreparedFiberManifest = { + ...prepared, + nodes: prepared.nodes.map((node) => ({ ...node, initialized: true })), + }; + await validatePreparedNodes(completed, chain, settings); + writeManifest(completed, settings); + fs.unlinkSync(pendingFile); + for (const node of newNodes) + logger.info( + `Prepared Fiber node ${node.id}: config ${resolveNodeFile(node.config_file, settings)}, ${node.account.type === 'builtin' ? `account #${node.account.index}` : 'random account'} ${resolveFundingAccount(node.account).address}`, + ); + return completed; +} diff --git a/src/fiber/manager.ts b/src/fiber/manager.ts index 9b8dd9a..7349495 100644 --- a/src/fiber/manager.ts +++ b/src/fiber/manager.ts @@ -4,16 +4,13 @@ import { ccc } from '@ckb-ccc/core'; import { callJsonRpc } from '../util/json-rpc'; import { logger } from '../util/logger'; import { readSettings, Settings } from '../cfg/setting'; -import { fiberNodePaths, fiberRpcUrl, fiberRpcPort, fiberP2pPort, fiberAccountIndex } from './paths'; -import { ensureNodesYml, FiberNodeEntry } from './nodes-yml'; -import { - ensureNodeKeyMaterial, - fiberNodeAccount, - readNodePassword, - fiberPublicKeyFromSecret, - readFiberNodeSecretKey, -} from './accounts'; -import { generateNodeConfig } from './config-gen'; +import { fiberNodePaths, fiberRpcUrl, fiberRpcPort, fiberP2pPort } from './paths'; +import { FiberNodeEntry, resolveNodeFile } from './nodes-yml'; +import { initializeFiberEnvironment } from './init'; +import { FiberAuthError, FiberRpcAuth } from './auth'; +import { resolveFundingAccount, readNodePassword, fiberPublicKeyFromSecret, readFiberNodeSecretKey } from './accounts'; +import { customConfig, readNodeConfig } from './config-gen'; +import { isMapping } from './files'; import { FiberChainScripts } from './scripts'; import { fnnNodeInfo, fnnConnectPeer, fnnListPeers, checkPortFree, FnnNodeInfo } from './rpc'; import { lockMatches } from './status'; @@ -35,6 +32,9 @@ export interface FnnProcessHandle { rpcUrl: string; dir: string; logFile: string; + configFile?: string; + rpcAuth?: FiberRpcAuth; + entry?: FiberNodeEntry; } export interface FiberEnvironment { @@ -97,7 +97,16 @@ function spawnFnn(node: FiberNodeEntry, fnnPath: string, settings: Settings): Fn fs.mkdirSync(paths.dir, { recursive: true }); const logFd = fs.openSync(paths.logFile, 'a'); const password = readNodePassword(node.id, settings); - const child = spawn(fnnPath, ['-d', paths.dir], { + const configFile = resolveNodeFile(node.config_file, settings); + const config = readNodeConfig(node, settings); + const rpcAuth = + isMapping(config.rpc) && config.rpc.biscuit_public_key + ? { tokenFile: resolveNodeFile(node.rpc_token_file, settings) } + : undefined; + logger.info( + `Starting Fiber node ${node.id}${customConfig(node, settings) === 'yes' ? ' [custom config]' : ''}: ${fnnPath}, config ${configFile}`, + ); + const child = spawn(fnnPath, ['-c', configFile, '-d', paths.dir], { stdio: ['ignore', logFd, logFd], env: { ...process.env, @@ -114,7 +123,16 @@ function spawnFnn(node: FiberNodeEntry, fnnPath: string, settings: Settings): Fn // The child's stdio owns the fd now; close our copy so the file is only // held open by the FNN process. closeFileDescriptors(logFd); - return { id: node.id, process: child, rpcUrl: fiberRpcUrl(node.id), dir: paths.dir, logFile: paths.logFile }; + return { + id: node.id, + process: child, + rpcUrl: fiberRpcUrl(node.id), + dir: paths.dir, + logFile: paths.logFile, + configFile, + rpcAuth, + entry: node, + }; } async function waitForAllNodeInfo(nodes: FnnProcessHandle[], timeoutMs: number): Promise> { @@ -139,10 +157,11 @@ async function waitForAllNodeInfo(nodes: FnnProcessHandle[], timeoutMs: number): } const node = nodes.find((n) => n.id === id)!; try { - const info = await fnnNodeInfo(node.rpcUrl, 2000); + const info = await fnnNodeInfo(node.rpcUrl, 2000, node.rpcAuth); infos.set(id, info); pending.delete(id); - } catch { + } catch (error) { + if (error instanceof FiberAuthError) throw new FiberStartupError(`Fiber node ${id}: ${error.message}`, nodes); // RPC not up yet; keep polling while the child is alive. } } @@ -217,12 +236,12 @@ async function assertNodeIdentitiesAndFunds( ); } - const account = fiberNodeAccount(node.id); + const account = resolveFundingAccount(node.entry!.account); const expectedLock = account.lockScript; const actualLock = info.default_funding_lock_script; if (!lockMatches(actualLock, expectedLock)) { throw new FiberStartupError( - `Fiber node ${node.id} funds account mismatch: expected built-in account #${fiberAccountIndex(node.id)} ` + + `Fiber node ${node.id} funds account mismatch: expected ${account.address} ` + `(lock args ${expectedLock.args}) but the node reports ${JSON.stringify(actualLock)}.`, nodes, ); @@ -237,15 +256,8 @@ async function assertNodeIdentitiesAndFunds( nodes, ); } - if (balance <= BigInt(0)) { - throw new FiberStartupError( - `Fiber node ${node.id}'s CKB account #${fiberAccountIndex(node.id)} has no available CKB. ` + - 'Fund the account before starting Fiber.', - nodes, - ); - } logger.info( - `Fiber node ${node.id}: account #${fiberAccountIndex(node.id)} balance ${ccc.fixedPointToString(balance)} CKB.`, + `Fiber node ${node.id}: funding address ${account.address}, balance ${ccc.fixedPointToString(balance)} CKB${balance === BigInt(0) ? ' (balance is 0, waiting for funding)' : ''}.`, ); } } @@ -260,7 +272,7 @@ async function connectFiberPeers(nodes: FnnProcessHandle[], nodeInfos: Map setTimeout(resolve, 3000)); - const peers = await fnnListPeers(first.rpcUrl); + const peers = await fnnListPeers(first.rpcUrl, 3000, first.rpcAuth); if (peers.length < rest.length) { throw new FiberStartupError( `Fiber node ${first.id} has ${peers.length} peer(s) after connect_peer, expected at least ${rest.length}.`, @@ -282,8 +294,8 @@ async function connectFiberPeers(nodes: FnnProcessHandle[], nodeInfos: Map; + initialized: boolean; + account: FundingAccount; + config_file: string; + rpc_token_file: string; } - -// These fields are owned by offckb; setting them per node would break the -// environment in ways the startup checks cannot recover from. fiber.store_path -// is managed so the store always lives where fiberNodePaths points — a -// relocated store would make clean's RocksDB LOCK check inspect the wrong -// path and silently lose its fail-closed property. -export const MANAGED_CONFIG_PATHS = [ - 'fiber.chain', - 'fiber.scripts', - 'fiber.listening_addr', - 'fiber.bootnode_addrs', - 'fiber.store_path', - 'rpc.listening_addr', - 'ckb.rpc_url', - 'ckb.udt_whitelist', - 'services', -]; - -function isPlainObject(value: unknown): value is Record { - return value != null && typeof value === 'object' && !Array.isArray(value); +export interface FnnProgram { + binary_path: string; + template_file: string; + version: string | null; +} +export interface FiberManifest { + fnn?: FnnProgram; + nodes: FiberNodeEntry[]; } export function validateNodeCount(count: number): number { @@ -40,117 +31,85 @@ export function validateNodeCount(count: number): number { return count; } -function assertNoManagedFields(config: Record, nodeId: number) { - for (const dottedPath of MANAGED_CONFIG_PATHS) { - const segments = dottedPath.split('.'); - let current: unknown = config; - for (const segment of segments) { - if (!isPlainObject(current)) { - current = undefined; - break; - } - current = current[segment]; - } - if (current !== undefined) { - throw new Error( - `nodes.yml: node ${nodeId} sets "${dottedPath}", which is managed by offckb and cannot be overridden. ` + - `Managed fields: ${MANAGED_CONFIG_PATHS.join(', ')}.`, - ); - } - } +export function newNodeEntry(id: number, account?: FundingAccount): FiberNodeEntry { + return { + id, + initialized: false, + account: account ?? { type: 'builtin', index: fiberAccountIndex(id) }, + config_file: `nodes/${id}/config.yml`, + rpc_token_file: `nodes/${id}/rpc-token`, + }; } -export function readNodesYml(settings: Settings = readSettings()): FiberNodeEntry[] | null { - const file = nodesYmlPath(settings); - if (!fs.existsSync(file)) return null; - let parsed: unknown; - try { - parsed = yaml.load(fs.readFileSync(file, 'utf8')); - } catch (error) { - throw new Error(`Failed to parse ${file}: ${(error as Error).message}`); - } - if (!isPlainObject(parsed) || !Array.isArray(parsed.nodes)) { - throw new Error(`Invalid ${file}: expected a "nodes" list. Fix the file or remove the fiber environment.`); - } - const entries: FiberNodeEntry[] = parsed.nodes.map((raw: unknown, index: number) => { - if (!isPlainObject(raw) || !Number.isInteger(raw.id) || (raw.id as number) <= 0) { - throw new Error(`Invalid ${file}: nodes[${index}] must have a positive integer "id".`); - } - const config = raw.config == null ? {} : raw.config; - if (!isPlainObject(config)) { - throw new Error(`Invalid ${file}: nodes[${index}].config must be a mapping of FNN config fields.`); - } - return { id: raw.id as number, config: config as Record }; - }); +export function validateManifest(raw: unknown, file: string): FiberManifest { + const fail = (reason: string): never => { + throw new Error(`Invalid ${file}: ${reason}`); + }; + if (!isMapping(raw) || !Array.isArray(raw.nodes)) return fail('expected a nodes list.'); + if (raw.nodes.length < 1 || raw.nodes.length > MAX_FIBER_NODES) return fail('expected 1 to 16 nodes.'); const ids = new Set(); - for (const entry of entries) { - if (ids.has(entry.id)) { - throw new Error(`Invalid ${file}: duplicate node id ${entry.id}.`); + const nodes = raw.nodes.map((node: unknown, i: number): FiberNodeEntry => { + if (!isMapping(node) || !Number.isInteger(node.id) || Number(node.id) < 1 || Number(node.id) > MAX_FIBER_NODES) { + return fail(`nodes[${i}].id must be an integer between 1 and 16.`); } - if (entry.id > MAX_FIBER_NODES) { - throw new Error(`Invalid ${file}: node id ${entry.id} exceeds the maximum of ${MAX_FIBER_NODES}.`); + const id = Number(node.id); + if (ids.has(id)) return fail(`duplicate node id ${id}.`); + ids.add(id); + if ('config' in node) + return fail(`node ${id}: nodes[].config is unsupported; use config_file (no legacy migration).`); + if (typeof node.initialized !== 'boolean') return fail(`node ${id}: initialized must be true or false.`); + const defaults = newNodeEntry(id); + let account: FundingAccount; + if (node.account == null && !node.initialized) account = defaults.account; + else if (isMapping(node.account) && node.account.type === 'builtin') { + if (!Number.isInteger(node.account.index) || 'key' in node.account) + return fail(`node ${id}: invalid builtin account.index/key.`); + const index = Number(node.account.index); + getBuiltinAccount(index); + account = { type: 'builtin', index }; + } else if (isMapping(node.account) && node.account.type === 'random') { + if ('index' in node.account) return fail(`node ${id}: random account cannot have index.`); + if (node.account.key != null) validatePrivateKey(node.account.key, `node ${id} account.key`); + if (node.initialized && node.account.key == null) return fail(`node ${id}: missing account.key.`); + account = { type: 'random', ...(node.account.key == null ? {} : { key: String(node.account.key) }) }; + } else return fail(`node ${id}: invalid or missing account.`); + const config = node.config_file ?? (!node.initialized ? defaults.config_file : undefined); + const token = node.rpc_token_file ?? (!node.initialized ? defaults.rpc_token_file : undefined); + if (typeof config !== 'string' || !config.trim()) return fail(`node ${id}: missing config_file.`); + if (typeof token !== 'string' || !token.trim()) return fail(`node ${id}: missing rpc_token_file.`); + return { id, initialized: node.initialized, account, config_file: config, rpc_token_file: token }; + }); + let fnn: FnnProgram | undefined; + if (raw.fnn != null) { + if ( + !isMapping(raw.fnn) || + typeof raw.fnn.binary_path !== 'string' || + !path.isAbsolute(raw.fnn.binary_path) || + typeof raw.fnn.template_file !== 'string' || + !path.isAbsolute(raw.fnn.template_file) + ) { + return fail('fnn.binary_path and fnn.template_file must be absolute paths.'); } - ids.add(entry.id); - assertNoManagedFields(entry.config, entry.id); - } - if (entries.length < MIN_FIBER_NODES) { - throw new Error(`Invalid ${file}: at least ${MIN_FIBER_NODES} node must be configured.`); - } - if (entries.length > MAX_FIBER_NODES) { - throw new Error(`Invalid ${file}: ${entries.length} nodes configured, at most ${MAX_FIBER_NODES} are supported.`); - } - return entries.sort((a, b) => a.id - b.id); + if (raw.fnn.version != null && typeof raw.fnn.version !== 'string') + return fail('fnn.version must be a string or null.'); + fnn = { + binary_path: raw.fnn.binary_path, + template_file: raw.fnn.template_file, + version: (raw.fnn.version as string | null) ?? null, + }; + } else if (nodes.some((node) => node.initialized)) return fail('missing fnn program record.'); + return { ...(fnn ? { fnn } : {}), nodes: nodes.sort((a, b) => a.id - b.id) }; } - -export function writeNodesYml(entries: FiberNodeEntry[], settings: Settings = readSettings()) { +export function readFiberManifest(settings: Settings = readSettings()): FiberManifest | null { const file = nodesYmlPath(settings); - fs.mkdirSync(path.dirname(file), { recursive: true }); - const doc = { - nodes: entries.map((entry) => ({ id: entry.id, config: entry.config })), - }; - fs.writeFileSync(file, yaml.dump(doc, { noRefs: true })); + return fs.existsSync(file) ? validateManifest(readYaml(file), file) : null; } - -/** - * Resolve the node list for this start. Without an existing nodes.yml one is - * created from the requested count (default 2). With an existing file the - * stored list wins unless --nodes asks for a different count: surviving ids - * keep their per-node config, new ids start empty, and removed ids are - * reported (their directories are never deleted automatically). - */ -export function ensureNodesYml( - requestedCount: number | undefined, - settings: Settings = readSettings(), -): FiberNodeEntry[] { - const existing = readNodesYml(settings); - if (existing == null) { - const count = requestedCount == null ? DEFAULT_FIBER_NODES : validateNodeCount(requestedCount); - const entries = Array.from({ length: count }, (_, i) => ({ id: i + 1, config: {} })); - writeNodesYml(entries, settings); - logger.debug(`Created ${nodesYmlPath(settings)} with ${count} node(s).`); - return entries; - } - - if (requestedCount == null) return existing; - - const count = validateNodeCount(requestedCount); - if (count === existing.length && existing.every((entry, i) => entry.id === i + 1)) { - return existing; - } - - const byId = new Map(existing.map((entry) => [entry.id, entry])); - const next: FiberNodeEntry[] = []; - for (let id = 1; id <= count; id++) { - next.push(byId.get(id) ?? { id, config: {} }); - } - const removed = existing.filter((entry) => entry.id > count); - for (const entry of removed) { - logger.warn( - `Node ${entry.id} is removed from nodes.yml; its per-node config overrides are discarded. ` + - `Its directory ${fiberNodeDir(entry.id, settings)} is kept; ` + - 'delete it manually or run `offckb fiber clean` to remove it.', - ); - } - writeNodesYml(next, settings); - return next; +export function readNodesYml(settings: Settings = readSettings()): FiberNodeEntry[] | null { + return readFiberManifest(settings)?.nodes ?? null; +} +export function writeManifest(manifest: FiberManifest, settings: Settings = readSettings()) { + writePrivateFile(nodesYmlPath(settings), yaml.dump(manifest, { noRefs: true })); +} +export function resolveNodeFile(file: string, settings: Settings = readSettings()): string { + return path.resolve(path.dirname(nodesYmlPath(settings)), file); } diff --git a/src/fiber/paths.ts b/src/fiber/paths.ts index 4a93da5..d69aa2f 100644 --- a/src/fiber/paths.ts +++ b/src/fiber/paths.ts @@ -3,14 +3,14 @@ import * as path from 'path'; import { packageRootPath, readSettings, Settings } from '../cfg/setting'; import { isFolderExists } from '../util/fs'; -// Directory layout of a Fiber devnet environment (see docs/fiber.md): +// Directory layout of a Fiber devnet environment (see docs/fiber-devnet.md): // // /devnet/fiber/ -// ├── nodes.yml node list and per-node FNN config overrides +// ├── nodes.yml saved program, node accounts, config/token paths // ├── runtime.json current manager process and node addresses // ├── logs/ fiber start --daemon manager log + pid // └── nodes// -// ├── config.yml generated on every start, do not hand-edit +// ├── config.yml persistent full FNN config, editable after init // ├── ckb/key CKB secret key (FNN encrypts it on first start) // ├── fiber/sk Fiber network identity key, generated by FNN // ├── fiber/store/ FNN database @@ -88,9 +88,9 @@ export function fiberNodeIds(settings: Settings = readSettings()): number[] { const nodesDir = path.join(fiberRootPath(settings), 'nodes'); if (!isFolderExists(nodesDir)) return []; return fs - .readdirSync(nodesDir) - .filter((entry) => /^\d+$/.test(entry)) - .map((entry) => Number(entry)) + .readdirSync(nodesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && /^\d+$/.test(entry.name)) + .map((entry) => Number(entry.name)) .sort((a, b) => a - b); } diff --git a/src/fiber/rpc.ts b/src/fiber/rpc.ts index 9f51f8e..8b9498d 100644 --- a/src/fiber/rpc.ts +++ b/src/fiber/rpc.ts @@ -1,5 +1,6 @@ import * as net from 'net'; -import { callJsonRpc } from '../util/json-rpc'; +import { callJsonRpc, JsonRpcError } from '../util/json-rpc'; +import { FiberAuthError, FiberRpcAuth, readRpcToken } from './auth'; export interface FnnNodeInfo { version: string; @@ -22,16 +23,45 @@ export interface FnnPeerInfo { address: string; } -export async function fnnNodeInfo(rpcUrl: string, timeoutMs = 3000): Promise { - return (await callJsonRpc(rpcUrl, 'node_info', [], timeoutMs)) as FnnNodeInfo; +async function fiberRpc( + rpcUrl: string, + method: string, + params: unknown[], + timeout: number, + auth?: FiberRpcAuth, +): Promise { + const token = readRpcToken(auth); + try { + return await callJsonRpc(rpcUrl, method, params, timeout, token ? { Authorization: `Bearer ${token}` } : {}); + } catch (error) { + if ( + (error instanceof JsonRpcError && (error.status === 401 || error.status === 403)) || + /unauthorized|permission denied|biscuit|authentication/i.test((error as Error).message) + ) { + throw new FiberAuthError( + `Fiber RPC authentication failed or permission denied at ${rpcUrl}; check ${auth?.tokenFile ?? 'the configured token'}.`, + ); + } + if (token && error instanceof Error) error.message = error.message.split(token).join('[redacted]'); + throw error; + } } - -export async function fnnConnectPeer(rpcUrl: string, address: string, save = true, timeoutMs = 10000): Promise { - await callJsonRpc(rpcUrl, 'connect_peer', [{ address, save }], timeoutMs); +export async function fnnNodeInfo(rpcUrl: string, timeoutMs = 3000, auth?: FiberRpcAuth): Promise { + const result = await fiberRpc(rpcUrl, 'node_info', [], timeoutMs, auth); + if (!result || typeof result !== 'object') throw new JsonRpcError(`Invalid node_info response from ${rpcUrl}`); + return result as FnnNodeInfo; } - -export async function fnnListPeers(rpcUrl: string, timeoutMs = 3000): Promise { - const result = (await callJsonRpc(rpcUrl, 'list_peers', [], timeoutMs)) as { peers?: FnnPeerInfo[] }; +export async function fnnConnectPeer( + rpcUrl: string, + address: string, + save = true, + timeoutMs = 10000, + auth?: FiberRpcAuth, +): Promise { + await fiberRpc(rpcUrl, 'connect_peer', [{ address, save }], timeoutMs, auth); +} +export async function fnnListPeers(rpcUrl: string, timeoutMs = 3000, auth?: FiberRpcAuth): Promise { + const result = (await fiberRpc(rpcUrl, 'list_peers', [], timeoutMs, auth)) as { peers?: FnnPeerInfo[] }; return result?.peers ?? []; } diff --git a/src/fiber/runtime.ts b/src/fiber/runtime.ts index b66caf3..a7ea615 100644 --- a/src/fiber/runtime.ts +++ b/src/fiber/runtime.ts @@ -4,12 +4,20 @@ import { runtimeJsonPath } from './paths'; import { readSettings, Settings } from '../cfg/setting'; import { isProcessAlive } from '../util/daemon'; import { logger } from '../util/logger'; +import type { CustomConfig } from './config-gen'; export interface RuntimeNodeInfo { id: number; pid: number; dir: string; rpcUrl: string; + configFile?: string; + tokenFile?: string; + authEnabled?: boolean; + accountIndex?: number; + fundingAddress?: string; + fundingLock?: { codeHash: string; hashType: string; args: string }; + customConfig?: CustomConfig; } export interface FiberRuntime { @@ -45,6 +53,13 @@ export function readRuntime(settings: Settings = readSettings()): FiberRuntime | pid: Number(node.pid), dir: String(node.dir), rpcUrl: String(node.rpcUrl), + configFile: node.configFile, + tokenFile: node.tokenFile, + authEnabled: node.authEnabled, + accountIndex: node.accountIndex, + fundingAddress: node.fundingAddress, + fundingLock: node.fundingLock, + customConfig: node.customConfig, })), }; } catch { diff --git a/src/fiber/status.ts b/src/fiber/status.ts index ee9c03f..eaca62b 100644 --- a/src/fiber/status.ts +++ b/src/fiber/status.ts @@ -7,12 +7,15 @@ import { verifyDaemonIdentity, } from '../util/daemon'; import { readSettings, Settings } from '../cfg/setting'; -import { fiberAccountIndex, fiberDaemonPaths, fiberP2pAddr, fiberRpcUrl } from './paths'; -import { readNodesYml } from './nodes-yml'; -import { fiberNodeAccount, fiberPublicKeyFromSecret, readFiberNodeSecretKey } from './accounts'; +import { fiberDaemonPaths, fiberP2pAddr, fiberRpcUrl } from './paths'; +import { readNodesYml, resolveNodeFile } from './nodes-yml'; +import { resolveFundingAccount, fiberPublicKeyFromSecret, readFiberNodeSecretKey } from './accounts'; import { readRuntime, FiberRuntime } from './runtime'; -import { fnnNodeInfo, FnnNodeInfo } from './rpc'; +import { fnnNodeInfo } from './rpc'; import { logger } from '../util/logger'; +import { customConfig, CustomConfig, readNodeConfig } from './config-gen'; +import { isMapping } from './files'; +import { FiberAuthError } from './auth'; export type FiberNodeStatus = 'starting' | 'running' | 'stopped' | 'unknown' | 'conflict'; export type OffckbManaged = 'yes' | 'no' | 'unknown'; @@ -23,7 +26,11 @@ export interface FiberNodeStatusEntry { offckb: OffckbManaged; rpcUrl: string; p2pAddr: string; - accountIndex: number; + accountIndex?: number; + fundingAddress?: string; + customConfig: CustomConfig; + configFile?: string; + tokenFile?: string; reasons: string[]; version?: string; commitHash?: string; @@ -107,79 +114,91 @@ export async function collectFiberStatus(settings: Settings = readSettings()): P nodes: [], }; - const entries = readNodesYml(settings); - if (entries == null) return report; - const runtime = readRuntime(settings); const offckb = await resolveOffckbManaged(runtime, settings); - const managerStarting = runtime != null && offckb !== 'no' && runtime.status === 'starting'; - - // Probe every node concurrently: a stopped node costs the full 2s timeout, - // and a sequential loop would block `fiber status` for 2s per down node. - const infos = await Promise.all( - entries.map((entry) => fnnNodeInfo(fiberRpcUrl(entry.id), 2000).catch(() => null as FnnNodeInfo | null)), - ); - - for (const [index, entry] of entries.entries()) { - const statusEntry: FiberNodeStatusEntry = { - id: entry.id, - status: 'unknown', - offckb, - rpcUrl: fiberRpcUrl(entry.id), - p2pAddr: fiberP2pAddr(entry.id), - accountIndex: fiberAccountIndex(entry.id), - reasons: [], - }; - report.nodes.push(statusEntry); - - const info: FnnNodeInfo | null = infos[index]; - - if (info == null) { - statusEntry.status = managerStarting ? 'starting' : 'stopped'; - continue; - } - - statusEntry.version = info.version; - statusEntry.commitHash = info.commit_hash; - statusEntry.chainHash = info.chain_hash; - statusEntry.pubkey = typeof info.pubkey === 'string' ? info.pubkey : undefined; - - const secret = readFiberNodeSecretKey(entry.id, settings); - if (secret == null) { - statusEntry.status = 'unknown'; - statusEntry.reasons.push('cannot read the node identity key (fiber/sk); node may not have started yet'); - continue; - } - const expectedPubkey = fiberPublicKeyFromSecret(secret); - const account = fiberNodeAccount(entry.id); - - let conflict = false; - if (typeof info.pubkey !== 'string' || info.pubkey.length === 0) { - statusEntry.reasons.push('node_info did not return a node public key'); - } else if (info.pubkey.toLowerCase() !== expectedPubkey) { - conflict = true; - statusEntry.reasons.push( - `node public key mismatch: expected ${expectedPubkey} (from fiber/sk), got ${info.pubkey}`, - ); - } - if (!info.default_funding_lock_script) { - statusEntry.reasons.push('node_info did not return default_funding_lock_script'); - } else if (!lockMatches(info.default_funding_lock_script, account.lockScript)) { - conflict = true; - statusEntry.reasons.push( - `CKB account mismatch: expected account #${statusEntry.accountIndex} (lock args ${account.lockScript.args}), ` + - `got ${JSON.stringify(info.default_funding_lock_script)}`, - ); - } - - if (conflict) { - statusEntry.status = 'conflict'; - } else if (statusEntry.reasons.length > 0) { - statusEntry.status = 'unknown'; - } else { - statusEntry.status = 'running'; - } + const active = runtime != null && offckb !== 'no' ? runtime : null; + // A live manager's snapshot wins even if files were edited or damaged. + let entries: ReturnType = null; + let registryError: string | undefined; + try { + entries = readNodesYml(settings); + } catch (error) { + if (!active) throw error; + registryError = (error as Error).message; } + const ids = [ + ...new Set([...(active?.nodes.map((node) => node.id) ?? []), ...(entries?.map((node) => node.id) ?? [])]), + ].sort((a, b) => a - b); + await Promise.all( + ids.map(async (id) => { + const running = active?.nodes.find((node) => node.id === id); + const entry = entries?.find((node) => node.id === id); + const statusEntry: FiberNodeStatusEntry = { + id, + status: 'unknown', + offckb: running ? offckb : 'no', + rpcUrl: running?.rpcUrl ?? fiberRpcUrl(id), + p2pAddr: fiberP2pAddr(id), + accountIndex: running + ? running.accountIndex + : entry?.account.type === 'builtin' + ? entry.account.index + : undefined, + fundingAddress: running?.fundingAddress, + customConfig: running?.customConfig ?? (entry ? customConfig(entry, settings) : 'unknown'), + configFile: running?.configFile ?? (entry ? resolveNodeFile(entry.config_file, settings) : undefined), + tokenFile: running?.tokenFile ?? (entry ? resolveNodeFile(entry.rpc_token_file, settings) : undefined), + reasons: [], + }; + report.nodes.push(statusEntry); + if (!running) + statusEntry.reasons.push('configuration has not taken effect; it will be used on the next successful start'); + if (registryError) statusEntry.reasons.push(registryError); + if (statusEntry.customConfig === 'unknown') + statusEntry.reasons.push('cannot determine custom config: default config is missing or invalid'); + let expectedLock = running?.fundingLock; + let auth = running?.authEnabled ? { tokenFile: running.tokenFile! } : undefined; + try { + if (!running && entry) { + const account = resolveFundingAccount(entry.account); + expectedLock = account.lockScript; + statusEntry.fundingAddress = account.address; + if (!entry.initialized) { + statusEntry.status = 'stopped'; + return; + } + const config = readNodeConfig(entry, settings); + auth = + isMapping(config.rpc) && config.rpc.biscuit_public_key ? { tokenFile: statusEntry.tokenFile! } : undefined; + } + const info = await fnnNodeInfo(statusEntry.rpcUrl, 2000, auth); + statusEntry.version = info.version; + statusEntry.commitHash = info.commit_hash; + statusEntry.chainHash = info.chain_hash; + statusEntry.pubkey = info.pubkey; + const secret = readFiberNodeSecretKey(id, settings); + if (!secret) { + statusEntry.reasons.push('cannot read the node identity key (fiber/sk)'); + return; + } + const expectedPubkey = fiberPublicKeyFromSecret(secret); + if (typeof info.pubkey !== 'string' || info.pubkey.toLowerCase() !== expectedPubkey) { + statusEntry.status = 'conflict'; + statusEntry.reasons.push('node public key does not match fiber/sk'); + } else if (!expectedLock || !lockMatches(info.default_funding_lock_script, expectedLock)) { + statusEntry.status = 'conflict'; + statusEntry.reasons.push('CKB funding lock does not match the saved account binding'); + } else statusEntry.status = 'running'; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (error instanceof FiberAuthError) statusEntry.reasons.push(error.message); + else if (code === 'ECONNREFUSED' || /timed out/.test((error as Error).message)) { + statusEntry.status = running && active?.status === 'starting' ? 'starting' : 'stopped'; + } else statusEntry.reasons.push((error as Error).message); + } + }), + ); + report.nodes.sort((a, b) => a.id - b.id); return report; } @@ -206,19 +225,20 @@ export function printFiberStatus(report: FiberStatusReport) { return; } - const header = ['NODE', 'STATUS', 'OFFCKB', 'RPC', 'P2P', 'ACCOUNT', 'VERSION', 'COMMIT']; - const widths = [6, 9, 8, 26, 26, 9, 12, 10]; + const header = ['NODE', 'STATUS', 'OFFCKB', 'CUSTOM_CONFIG', 'RPC', 'P2P', 'ACCOUNT', 'VERSION', 'COMMIT']; + const widths = [6, 9, 8, 15, 26, 26, 9, 12, 10]; logger.info(header.map((cell, i) => pad(cell, widths[i])).join(' ')); for (const node of report.nodes) { const row = [ pad(String(node.id), widths[0]), pad(node.status, widths[1]), pad(node.offckb, widths[2]), - pad(node.rpcUrl, widths[3]), - pad(node.p2pAddr, widths[4]), - pad(String(node.accountIndex), widths[5]), - pad(node.version ?? '-', widths[6]), - pad(node.commitHash ? node.commitHash.slice(0, 7) : '-', widths[7]), + pad(node.customConfig, widths[3]), + pad(node.rpcUrl, widths[4]), + pad(node.p2pAddr, widths[5]), + pad(node.accountIndex == null ? 'random' : String(node.accountIndex), widths[6]), + pad(node.version ?? '-', widths[7]), + pad(node.commitHash ? node.commitHash.slice(0, 7) : '-', widths[8]), ].join(' '); logger.info(row); for (const reason of node.reasons) { @@ -240,6 +260,10 @@ export async function fiberStatus(settings: Settings = readSettings()) { rpcUrl: node.rpcUrl, p2pAddr: node.p2pAddr, accountIndex: node.accountIndex, + fundingAddress: node.fundingAddress, + customConfig: node.customConfig, + configFile: node.configFile, + rpcTokenFile: node.tokenFile, version: node.version, commitHash: node.commitHash, chainHash: node.chainHash, diff --git a/src/util/json-rpc.ts b/src/util/json-rpc.ts index da5ac64..79bace3 100644 --- a/src/util/json-rpc.ts +++ b/src/util/json-rpc.ts @@ -1,6 +1,16 @@ import http from 'http'; import https from 'https'; +export class JsonRpcError extends Error { + constructor( + message: string, + public readonly status?: number, + public readonly rpcCode?: number, + ) { + super(message); + } +} + // Minimal raw JSON-RPC caller. Unlike Request.send it never goes through the // configured proxy, which makes it safe for talking to the local devnet node. export async function callJsonRpc( @@ -8,6 +18,7 @@ export async function callJsonRpc( method: string, params: unknown[], timeoutMs = 30000, + headers: Record = {}, // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise { const body = JSON.stringify({ id: 1, jsonrpc: '2.0', method, params }); @@ -24,6 +35,7 @@ export async function callJsonRpc( headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), + ...headers, }, timeout: timeoutMs, }, @@ -41,10 +53,20 @@ export async function callJsonRpc( } }); res.on('end', () => { + if (res.statusCode === 401 || res.statusCode === 403) { + reject(new JsonRpcError(`JSON-RPC ${method}: authentication failed or permission denied`, res.statusCode)); + return; + } try { const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')); if (parsed.error) { - reject(new Error(`JSON-RPC ${method} failed: ${JSON.stringify(parsed.error)}`)); + reject( + new JsonRpcError( + `JSON-RPC ${method} failed: ${JSON.stringify(parsed.error)}`, + res.statusCode, + parsed.error.code, + ), + ); return; } resolve(parsed.result); diff --git a/tests/fiber-accounts.test.ts b/tests/fiber-accounts.test.ts index 5e23aa6..12b0d98 100644 --- a/tests/fiber-accounts.test.ts +++ b/tests/fiber-accounts.test.ts @@ -1,10 +1,14 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import crypto from 'crypto'; +import { newNodeEntry } from '../src/fiber/nodes-yml'; import { defaultSettings, Settings } from '../src/cfg/setting'; import { fiberNodePaths } from '../src/fiber/paths'; import { ensureNodeKeyMaterial, + assertNodeFundingKey, + resolveFundingAccount, fiberNodeAccount, fiberPublicKeyFromSecret, readFiberNodeSecretKey, @@ -95,3 +99,39 @@ describe('fiber identity key', () => { expect(readFiberNodeSecretKey(1, settings)).toEqual(Buffer.alloc(32, 7)); }); }); + +it('derives the same funding lock when importing a built-in private key as a random account', () => { + const builtin = fiberNodeAccount(1); + const imported = resolveFundingAccount({ type: 'random', key: builtin.privkey }); + expect(imported.lockScript).toEqual(builtin.lockScript); + expect(() => resolveFundingAccount({ type: 'random', key: `0x${'0'.repeat(64)}` })).toThrow('private key'); +}); +it('validates raw and FNN-encrypted keys and rejects changed bindings/passwords', () => { + const settings = fixture(); + const node = { ...newNodeEntry(1), initialized: true }; + ensureNodeKeyMaterial(1, settings); + expect(() => assertNodeFundingKey(node, settings)).not.toThrow(); + const salt = crypto.randomBytes(16); + const nonce = crypto.randomBytes(12); + const key = crypto.scryptSync(readNodePassword(1, settings), salt, 32, { + N: 131072, + r: 8, + p: 1, + maxmem: 256 * 1024 * 1024, + }); + const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce); + const ciphertext = Buffer.concat([ + cipher.update(Buffer.from(fiberNodeAccount(1).privkey.slice(2), 'hex')), + cipher.final(), + ]); + fs.writeFileSync( + fiberNodePaths(1, settings).ckbKeyFile, + Buffer.concat([Buffer.from([0]), salt, nonce, ciphertext, cipher.getAuthTag()]), + ); + expect(() => assertNodeFundingKey(node, settings)).not.toThrow(); + expect(() => assertNodeFundingKey({ ...node, account: { type: 'builtin', index: 4 } }, settings)).toThrow( + 'does not match', + ); + fs.writeFileSync(fiberNodePaths(1, settings).passwordFile, 'wrong password'); + expect(() => assertNodeFundingKey(node, settings)).toThrow('cannot decrypt'); +}); diff --git a/tests/fiber-auth.test.ts b/tests/fiber-auth.test.ts new file mode 100644 index 0000000..cd5eef6 --- /dev/null +++ b/tests/fiber-auth.test.ts @@ -0,0 +1,114 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import http from 'http'; +import { AddressInfo } from 'net'; +import { defaultSettings, Settings } from '../src/cfg/setting'; +import { newNodeEntry, resolveNodeFile } from '../src/fiber/nodes-yml'; +import { biscuitPaths, FiberAuthError, loadBiscuit, prepareBiscuit, validateRpcToken } from '../src/fiber/auth'; +import { fnnNodeInfo, fnnListPeers, fnnConnectPeer } from '../src/fiber/rpc'; + +let root: string; +let settings: Settings; +let server: http.Server | undefined; +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fiber-auth-')); + settings = structuredClone(defaultSettings); + settings.devnet.configPath = path.join(root, 'devnet'); +}); +afterEach(async () => { + if (server?.listening) await new Promise((resolve) => server!.close(() => resolve())); + fs.rmSync(root, { recursive: true, force: true }); +}); +it('creates tokens accepted by the configured key with node and independent peer read/write grants', async () => { + const node = newNodeEntry(1); + await prepareBiscuit(node, settings); + const key = fs.readFileSync(biscuitPaths(1, settings).publicKey, 'utf8').trim(); + const file = resolveNodeFile(node.rpc_token_file, settings); + await expect(validateRpcToken(file, key)).resolves.toBeUndefined(); + const api = await loadBiscuit(); + const pubkey = api.PublicKey.fromString(key.split('/')[1]); + const token = api.Biscuit.fromBase64(fs.readFileSync(file, 'utf8').trim(), pubkey); + const authorizer = new api.Authorizer(); + try { + authorizer.addToken(token); + authorizer.addCode('allow if write("channels");'); + expect(() => authorizer.authorize()).toThrow(); + } finally { + authorizer.free(); + token.free(); + pubkey.free(); + } + await prepareBiscuit(newNodeEntry(2), settings); + const other = fs.readFileSync(biscuitPaths(2, settings).publicKey, 'utf8').trim(); + await expect(validateRpcToken(file, other)).rejects.toBeInstanceOf(FiberAuthError); +}); +it('rejects insufficient and expired permissions without exposing token text', async () => { + const api = await loadBiscuit(); + const pair = new api.KeyPair(); + const privateKey = pair.getPrivateKey(); + const publicKey = pair.getPublicKey(); + const file = path.join(root, 'token'); + try { + for (const code of [ + 'read("node");', + 'read("node"); read("peers"); write("peers"); check if time($t), $t < 2000-01-01T00:00:00Z;', + ]) { + const builder = api.Biscuit.builder(); + builder.addCode(code); + const token = builder.build(privateKey); + const text = token.toBase64(); + token.free(); + fs.writeFileSync(file, text); + try { + await validateRpcToken(file, `ed25519/${publicKey.toString()}`); + throw new Error('expected rejection'); + } catch (error) { + expect(error).toBeInstanceOf(FiberAuthError); + expect((error as Error).message).not.toContain(text); + } + } + } finally { + publicKey.free(); + privateKey.free(); + pair.free(); + } +}); +it('uses each token file for all management RPCs and reloads replacement credentials', async () => { + const file = path.join(root, 'token'); + fs.writeFileSync(file, 'first-token'); + const received: string[] = []; + server = http.createServer((req, res) => { + received.push(String(req.headers.authorization)); + let data = ''; + req.on('data', (part) => { + data += part; + }); + req.on('end', () => { + const method = JSON.parse(data).method; + res.end( + JSON.stringify({ + result: method === 'node_info' ? { version: '0.9.0' } : method === 'list_peers' ? { peers: [] } : null, + }), + ); + }); + }); + await new Promise((resolve) => server!.listen(0, '127.0.0.1', resolve)); + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + await fnnNodeInfo(url, 1000, { tokenFile: file }); + fs.writeFileSync(file, 'replacement-token'); + await fnnListPeers(url, 1000, { tokenFile: file }); + await fnnConnectPeer(url, '/ip4/127.0.0.1/tcp/1', true, 1000, { tokenFile: file }); + expect(received).toEqual(['Bearer first-token', 'Bearer replacement-token', 'Bearer replacement-token']); + fs.unlinkSync(file); + await expect(fnnNodeInfo(url, 1000, { tokenFile: file })).rejects.toBeInstanceOf(FiberAuthError); +}); +it('recognizes non-JSON HTTP authentication failures', async () => { + server = http.createServer((_req, res) => { + res.writeHead(403); + res.end('permission denied'); + }); + await new Promise((resolve) => server!.listen(0, '127.0.0.1', resolve)); + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + await expect(fnnNodeInfo(url)).rejects.toBeInstanceOf(FiberAuthError); +}); diff --git a/tests/fiber-config-gen.test.ts b/tests/fiber-config-gen.test.ts index 1ef448e..8f7a4de 100644 --- a/tests/fiber-config-gen.test.ts +++ b/tests/fiber-config-gen.test.ts @@ -4,7 +4,8 @@ import * as path from 'path'; import yaml from 'js-yaml'; import { defaultSettings, Settings } from '../src/cfg/setting'; import { fiberNodePaths } from '../src/fiber/paths'; -import { generateNodeConfig, mergeNodeConfig } from '../src/fiber/config-gen'; +import { generateNodeConfig, customConfig, defaultConfigFile, validateNodeConfig } from '../src/fiber/config-gen'; +import { newNodeEntry } from '../src/fiber/nodes-yml'; import { FiberChainScripts } from '../src/fiber/scripts'; const tempRoots: string[] = []; @@ -83,7 +84,7 @@ describe('generateNodeConfig', () => { it('replaces chain-related fields and keeps unknown ones', () => { const { settings, testnetConfigPath } = fixture(); const configFile = generateNodeConfig({ - node: { id: 2, config: {} }, + node: newNodeEntry(2), chainScripts: chainScripts(), testnetConfigPath, settings, @@ -107,7 +108,15 @@ describe('generateNodeConfig', () => { expect(config.fiber.future_field_from_new_fnn).toBe('keep-me'); expect(config.rpc.listening_addr).toBe('127.0.0.1:21715'); - expect(config.rpc.enabled_modules).toEqual(['channel', 'payment', 'graph', 'info', 'invoice', 'peer', 'watchtower']); + expect(config.rpc.enabled_modules).toEqual([ + 'channel', + 'payment', + 'graph', + 'info', + 'invoice', + 'peer', + 'watchtower', + ]); expect(config.rpc.cors_enabled).toBe(false); expect(config.ckb.rpc_url).toBe('http://127.0.0.1:8114'); @@ -117,36 +126,33 @@ describe('generateNodeConfig', () => { expect(config.services).toEqual(['fiber', 'rpc', 'ckb']); }); - it('merges per-node config recursively and replaces lists', () => { + it('does not overwrite user configuration, and compares parsed values', () => { const { settings, testnetConfigPath } = fixture(); - const configFile = generateNodeConfig({ - node: { - id: 1, - config: { - fiber: { auto_accept_channel_ckb_funding_amount: 99, announced_node_name: 'custom-name' }, - rpc: { enabled_modules: ['info'] }, - }, - }, - chainScripts: chainScripts(), - testnetConfigPath, - settings, - }); - const config = yaml.load(fs.readFileSync(configFile, 'utf8')) as Record; - expect(config.fiber.auto_accept_channel_ckb_funding_amount).toBe(99); - expect(config.fiber.announced_node_name).toBe('custom-name'); - // managed values merged around the override stay - expect(config.fiber.chain).toBe('../../../specs/dev.toml'); - // lists replace - expect(config.rpc.enabled_modules).toEqual(['info']); + const node = newNodeEntry(1); + const file = generateNodeConfig({ node, chainScripts: chainScripts(), testnetConfigPath, settings }); + const initial = fs.readFileSync(file, 'utf8'); + fs.writeFileSync(file, `# comment\n${initial}`); + expect(customConfig(node, settings)).toBe('no'); + const config = yaml.load(initial) as any; + config.services.push('lsp'); + config.lsp = { enabled: true }; + fs.writeFileSync(file, yaml.dump(config)); + const before = fs.readFileSync(file); + generateNodeConfig({ node, chainScripts: chainScripts(), testnetConfigPath, settings }); + expect(fs.readFileSync(file)).toEqual(before); + expect(customConfig(node, settings)).toBe('yes'); + expect(() => validateNodeConfig(node, chainScripts(), settings)).not.toThrow(); + config.rpc.enabled_modules = ['info']; + fs.writeFileSync(file, yaml.dump(config)); + expect(() => validateNodeConfig(node, chainScripts(), settings)).toThrow('rpc.enabled_modules'); }); }); -describe('mergeNodeConfig', () => { - it('merges objects deeply and replaces scalars and arrays', () => { - const merged = mergeNodeConfig( - { a: { b: 1, c: [1, 2], d: { e: 1 } }, x: 1 }, - { a: { c: [3], d: { f: 2 } }, y: 2 }, - ); - expect(merged).toEqual({ a: { b: 1, c: [3], d: { e: 1, f: 2 } }, x: 1, y: 2 }); - }); +it('reports unknown for a damaged baseline without blocking the active configuration', () => { + const { settings, testnetConfigPath } = fixture(); + const node = newNodeEntry(1); + generateNodeConfig({ node, chainScripts: chainScripts(), testnetConfigPath, settings }); + fs.writeFileSync(defaultConfigFile(1, settings), '{}'); + expect(customConfig(node, settings)).toBe('unknown'); + expect(() => validateNodeConfig(node, chainScripts(), settings)).not.toThrow(); }); diff --git a/tests/fiber-lifecycle.test.ts b/tests/fiber-lifecycle.test.ts index b8416f5..7d4587c 100644 --- a/tests/fiber-lifecycle.test.ts +++ b/tests/fiber-lifecycle.test.ts @@ -5,12 +5,7 @@ import * as os from 'os'; import * as path from 'path'; import { defaultSettings, Settings } from '../src/cfg/setting'; import * as daemonUtil from '../src/util/daemon'; -import { - isProcessAlive, - PidMetadata, - verifyDaemonIdentity, - writePidFile, -} from '../src/util/daemon'; +import { isProcessAlive, PidMetadata, verifyDaemonIdentity, writePidFile } from '../src/util/daemon'; import { isStoreLockHeld } from '../src/fiber/store-lock'; import { assertFiberFullyStopped } from '../src/fiber/clean'; import { startFiberEnvironment, stopFiberNodes, FnnProcessHandle } from '../src/fiber/manager'; @@ -18,7 +13,9 @@ import { fiberDaemonPaths, fiberNodePaths, fiberRootPath, runtimeJsonPath } from import { writeRuntime, isRuntimeStale, FiberRuntime } from '../src/fiber/runtime'; import { assertNodeStopDoesNotOrphanFiber, startFiberDaemon, stopFiber } from '../src/fiber/daemon'; import { fiberClean } from '../src/fiber/clean'; +import * as fiberInstall from '../src/fiber/install'; import { FiberChainScripts } from '../src/fiber/scripts'; +import { installSessionSignalHandlers } from '../src/util/session-signals'; /** * Safety-property tests for the fiber lifecycle layer: daemon identity @@ -34,6 +31,7 @@ const tempRoots: string[] = []; const children: ChildProcess[] = []; afterEach(async () => { + jest.restoreAllMocks(); while (children.length) { const child = children.pop() as ChildProcess; if (child.exitCode == null && child.signalCode == null) { @@ -273,9 +271,7 @@ describe('assertNodeStopDoesNotOrphanFiber', () => { it('permits the stop when the fiber manager is the CKB daemon being stopped (node --fiber --daemon)', () => { const settings = makeSettings(tempRoot()); writeRuntime(liveRuntime(process.pid), settings); - expect(() => - assertNodeStopDoesNotOrphanFiber({ ckbDaemonPid: process.pid }, settings), - ).not.toThrow(); + expect(() => assertNodeStopDoesNotOrphanFiber({ ckbDaemonPid: process.pid }, settings)).not.toThrow(); }); it('permits the stop with --force even while a foreground fiber manager is live', () => { @@ -350,10 +346,7 @@ describe('stopFiber', () => { it('discards a stale runtime record whose manager is dead, signaling nothing', async () => { const settings = makeSettings(tempRoot()); - writeRuntime( - { managerPid: 99999999, startedAt: new Date().toISOString(), status: 'running', nodes: [] }, - settings, - ); + writeRuntime({ managerPid: 99999999, startedAt: new Date().toISOString(), status: 'running', nodes: [] }, settings); await stopFiber(settings); expect(fs.existsSync(runtimeJsonPath(settings))).toBe(false); }); @@ -546,10 +539,7 @@ describe('stopFiberNodes', () => { it('keeps a runtime record owned by another manager', async () => { const settings = makeSettings(tempRoot()); - writeRuntime( - { managerPid: 99999999, startedAt: new Date().toISOString(), status: 'running', nodes: [] }, - settings, - ); + writeRuntime({ managerPid: 99999999, startedAt: new Date().toISOString(), status: 'running', nodes: [] }, settings); await stopFiberNodes([], settings, 100); expect(fs.existsSync(runtimeJsonPath(settings))).toBe(true); }); @@ -600,9 +590,45 @@ describe('startFiberEnvironment signal handling', () => { const settings = makeSettings(root); const testnetConfigPath = path.join(root, 'testnet-config.yml'); fs.writeFileSync(testnetConfigPath, 'fiber:\n tlc_expiry_delta: 86400000\n'); + jest.spyOn(fiberInstall, 'resolveFnnBinary').mockImplementation(async (options) => ({ + fnnPath: options.binaryPath!, + testnetConfigPath, + version: null, + source: 'binary-path', + })); return { settings, testnetConfigPath }; } + it('handles a delegated SIGTERM while initialization is waiting for the FNN binary', async () => { + const { settings } = fiberFixture(); + let cancelDownload!: (error: Error) => void; + jest.mocked(fiberInstall.resolveFnnBinary).mockImplementationOnce( + () => + new Promise((_, reject) => { + cancelDownload = reject; + }), + ); + const before = process.listeners('SIGTERM'); + const session = installSessionSignalHandlers(); + const endDelegation = session.delegate(); + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + const started = startFiberEnvironment({ binaryPath: process.execPath, chainScripts, nodeCount: 1, settings }); + try { + expect(cancelDownload).toBeDefined(); + process.emit('SIGTERM'); + await new Promise((resolve) => setImmediate(resolve)); + expect(exitSpy).toHaveBeenCalledWith(143); + expect(fs.existsSync(runtimeJsonPath(settings))).toBe(false); + } finally { + cancelDownload(new Error('download cancelled')); + await expect(started).rejects.toThrow('download cancelled'); + endDelegation(); + session.dispose(); + exitSpy.mockRestore(); + } + expect(process.listeners('SIGTERM')).toEqual(before); + }); + it('removes its startup signal handlers after a failed start', async () => { const { settings, testnetConfigPath } = fiberFixture(); const baselineSigint = process.listenerCount('SIGINT'); @@ -611,8 +637,7 @@ describe('startFiberEnvironment signal handling', () => { // -d), driving the startup down the failure path. await expect( startFiberEnvironment({ - fnnPath: process.execPath, - testnetConfigPath, + binaryPath: process.execPath, chainScripts, nodeCount: 1, settings, @@ -630,12 +655,14 @@ describe('startFiberEnvironment signal handling', () => { // A stub FNN that stays alive but never serves RPC, so startup parks in // the readiness wait — the window the signal must cover. const stubFnn = path.join(tempRoot(), 'fnn-stub.sh'); - fs.writeFileSync(stubFnn, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} -e "setInterval(() => {}, 1000)"\n`); + fs.writeFileSync( + stubFnn, + `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} -e "setInterval(() => {}, 1000)"\n`, + ); fs.chmodSync(stubFnn, 0o755); const started = startFiberEnvironment({ - fnnPath: stubFnn, - testnetConfigPath, + binaryPath: stubFnn, chainScripts, nodeCount: 1, settings, diff --git a/tests/fiber-nodes-yml.test.ts b/tests/fiber-nodes-yml.test.ts index cd34131..14d3d92 100644 --- a/tests/fiber-nodes-yml.test.ts +++ b/tests/fiber-nodes-yml.test.ts @@ -1,130 +1,206 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import yaml from 'js-yaml'; import { defaultSettings, Settings } from '../src/cfg/setting'; -import { nodesYmlPath } from '../src/fiber/paths'; -import { ensureNodesYml, readNodesYml, validateNodeCount } from '../src/fiber/nodes-yml'; - -const tempRoots: string[] = []; -afterEach(() => { - while (tempRoots.length) fs.rmSync(tempRoots.pop() as string, { recursive: true, force: true }); -}); - -function fixture(): Settings { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fiber-nodes-')); - tempRoots.push(root); - const settings = JSON.parse(JSON.stringify(defaultSettings)) as Settings; +import { nodesYmlPath, fiberNodePaths } from '../src/fiber/paths'; +import { + newNodeEntry, + readFiberManifest, + validateNodeCount, + writeManifest, + resolveNodeFile, +} from '../src/fiber/nodes-yml'; +import { initializeFiberEnvironment, pendingInitFile } from '../src/fiber/init'; +import * as install from '../src/fiber/install'; +import { FiberChainScripts } from '../src/fiber/scripts'; +import { biscuitPaths } from '../src/fiber/auth'; +import childProcess from 'child_process'; +import { startFiberEnvironment } from '../src/fiber/manager'; +import { customConfig } from '../src/fiber/config-gen'; + +const roots: string[] = []; +const chain: FiberChainScripts = { genesisHash: '0xgenesis', fiberScripts: [], udtWhitelist: [] }; +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fiber-init-')); + roots.push(root); + const settings = structuredClone(defaultSettings) as Settings; settings.devnet.configPath = path.join(root, 'devnet'); + settings.devnet.dataPath = path.join(root, 'devnet/data'); + const template = path.join(root, 'template.yml'); + fs.writeFileSync(template, 'fiber: {}\nrpc: {}\nckb: {}\n'); + jest.spyOn(install, 'resolveFnnBinary').mockResolvedValue({ + fnnPath: process.execPath, + testnetConfigPath: template, + source: 'binary-path', + version: '0.9.0', + }); return settings; } - -describe('validateNodeCount', () => { - it('accepts 1-16 and rejects everything else', () => { - expect(validateNodeCount(1)).toBe(1); - expect(validateNodeCount(16)).toBe(16); - expect(() => validateNodeCount(0)).toThrow('between 1 and 16'); - expect(() => validateNodeCount(17)).toThrow('between 1 and 16'); - expect(() => validateNodeCount(2.5)).toThrow('between 1 and 16'); - }); +afterEach(() => { + jest.restoreAllMocks(); + while (roots.length) fs.rmSync(roots.pop()!, { recursive: true, force: true }); }); -describe('ensureNodesYml', () => { - it('creates two nodes by default', () => { - const settings = fixture(); - const entries = ensureNodesYml(undefined, settings); - expect(entries).toEqual([ - { id: 1, config: {} }, - { id: 2, config: {} }, - ]); - expect(fs.existsSync(nodesYmlPath(settings))).toBe(true); - }); - - it('creates the requested number of nodes on first start', () => { - const settings = fixture(); - const entries = ensureNodesYml(4, settings); - expect(entries.map((e) => e.id)).toEqual([1, 2, 3, 4]); - }); - - it('keeps per-node config when growing and warns when shrinking', () => { - const settings = fixture(); - ensureNodesYml(3, settings); - const file = nodesYmlPath(settings); - const doc = yaml.load(fs.readFileSync(file, 'utf8')) as { nodes: { id: number; config: object }[] }; - doc.nodes[1].config = { fiber: { auto_accept_channel_ckb_funding_amount: 99 } }; - fs.writeFileSync(file, yaml.dump(doc)); - - const grown = ensureNodesYml(4, settings); - expect(grown.map((e) => e.id)).toEqual([1, 2, 3, 4]); - expect(grown[1].config).toEqual({ fiber: { auto_accept_channel_ckb_funding_amount: 99 } }); - expect(grown[3].config).toEqual({}); - - const shrunk = ensureNodesYml(2, settings); - expect(shrunk.map((e) => e.id)).toEqual([1, 2]); - expect(shrunk[1].config).toEqual({ fiber: { auto_accept_channel_ckb_funding_amount: 99 } }); - }); - - it('uses the stored list when no count is requested', () => { - const settings = fixture(); - ensureNodesYml(5, settings); - const entries = ensureNodesYml(undefined, settings); - expect(entries.map((e) => e.id)).toEqual([1, 2, 3, 4, 5]); - }); +it('validates node counts', () => { + expect(validateNodeCount(1)).toBe(1); + expect(validateNodeCount(16)).toBe(16); + for (const count of [0, 17, 1.5, NaN]) expect(() => validateNodeCount(count)).toThrow(); }); - -describe('readNodesYml', () => { - it('returns null when the file does not exist', () => { - expect(readNodesYml(fixture())).toBeNull(); - }); - - it('rejects duplicate ids', () => { - const settings = fixture(); - fs.mkdirSync(path.dirname(nodesYmlPath(settings)), { recursive: true }); - fs.writeFileSync(nodesYmlPath(settings), yaml.dump({ nodes: [{ id: 1 }, { id: 1 }] })); - expect(() => readNodesYml(settings)).toThrow('duplicate node id 1'); - }); - - it('rejects an empty node list', () => { - // An empty stored list must not bypass the minimum-node constraint that - // validateNodeCount enforces for the --nodes flag. - const settings = fixture(); - fs.mkdirSync(path.dirname(nodesYmlPath(settings)), { recursive: true }); - fs.writeFileSync(nodesYmlPath(settings), yaml.dump({ nodes: [] })); - expect(() => readNodesYml(settings)).toThrow('at least 1 node must be configured'); - }); - - it('rejects managed config fields', () => { - const settings = fixture(); - fs.mkdirSync(path.dirname(nodesYmlPath(settings)), { recursive: true }); - fs.writeFileSync( - nodesYmlPath(settings), - yaml.dump({ nodes: [{ id: 1, config: { fiber: { chain: 'evil.toml' } } }] }), - ); - expect(() => readNodesYml(settings)).toThrow('fiber.chain'); - }); - - it('rejects a per-node fiber.store_path override', () => { - // A relocated store would make clean's RocksDB LOCK check watch the wrong - // path, silently dropping its fail-closed property. - const settings = fixture(); - fs.mkdirSync(path.dirname(nodesYmlPath(settings)), { recursive: true }); - fs.writeFileSync( - nodesYmlPath(settings), - yaml.dump({ nodes: [{ id: 1, config: { fiber: { store_path: '/tmp/elsewhere' } } }] }), - ); - expect(() => readNodesYml(settings)).toThrow('fiber.store_path'); +it('prepares two nodes by default, with private independent keys and no FNN store', async () => { + const settings = fixture(); + const manifest = await initializeFiberEnvironment({}, chain, settings); + expect(manifest.nodes.map((node) => node.id)).toEqual([1, 2]); + expect(manifest.nodes.map((node) => node.account)).toEqual([ + { type: 'builtin', index: 3 }, + { type: 'builtin', index: 4 }, + ]); + const tokens = manifest.nodes.map((node) => fs.readFileSync(resolveNodeFile(node.rpc_token_file, settings), 'utf8')); + expect(tokens[0]).not.toBe(tokens[1]); + expect(fs.existsSync(fiberNodePaths(1, settings).fiberStoreDir)).toBe(false); + expect(customConfig(manifest.nodes[0], settings)).toBe('no'); + expect(fs.existsSync(pendingInitFile(settings))).toBe(false); + if (process.platform !== 'win32') expect(fs.statSync(nodesYmlPath(settings)).mode & 0o777).toBe(0o600); +}); +it('keeps configs, keys and tokens byte-for-byte on repeated init and startup preparation', async () => { + const settings = fixture(); + const first = await initializeFiberEnvironment({ nodes: 3 }, chain, settings); + const configFile = resolveNodeFile(first.nodes[2].config_file, settings); + const config = yaml.load(fs.readFileSync(configFile, 'utf8')) as any; + config.services.push('lsp'); + config.lsp = { enabled: true }; + config.rpc.enabled_modules.push('lsp'); + fs.writeFileSync(configFile, yaml.dump(config)); + const files = [ + nodesYmlPath(settings), + configFile, + fiberNodePaths(3, settings).ckbKeyFile, + fiberNodePaths(3, settings).passwordFile, + biscuitPaths(3, settings).privateKey, + resolveNodeFile(first.nodes[2].rpc_token_file, settings), + ]; + const before = files.map((file) => fs.readFileSync(file)); + await initializeFiberEnvironment({}, chain, settings, true); + await initializeFiberEnvironment({ nodes: 3 }, chain, settings); + expect(files.map((file) => fs.readFileSync(file))).toEqual(before); + expect(customConfig(first.nodes[2], settings)).toBe('yes'); + expect(install.resolveFnnBinary).toHaveBeenCalledTimes(1); +}); +it('appends explicit and random accounts without reassigning existing nodes', async () => { + const settings = fixture(); + await initializeFiberEnvironment({}, chain, settings); + const third = await initializeFiberEnvironment({ add: true, accountIndex: 12 }, chain, settings); + expect(third.nodes[2].account).toEqual({ type: 'builtin', index: 12 }); + const fourth = await initializeFiberEnvironment({ add: true, randomAccount: true }, chain, settings); + expect(fourth.nodes[3].account).toEqual({ type: 'random', key: expect.stringMatching(/^0x[0-9a-f]{64}$/) }); + expect((await initializeFiberEnvironment({}, chain, settings)).nodes[3].account).toEqual(fourth.nodes[3].account); +}); +it('supports seeded new accounts and resolves paths from nodes.yml', async () => { + const settings = fixture(); + const node = newNodeEntry(1, { type: 'random' }); + node.config_file = 'custom/one.yml'; + writeManifest({ nodes: [node] }, settings); + const manifest = await initializeFiberEnvironment({}, chain, settings); + expect(manifest.nodes[0].account).toHaveProperty('key'); + expect(resolveNodeFile(node.config_file, settings)).toBe( + path.join(settings.devnet.configPath, 'fiber/custom/one.yml'), + ); +}); +it('rejects missing registered files without replacing any keys', async () => { + const settings = fixture(); + const manifest = await initializeFiberEnvironment({}, chain, settings); + const token = resolveNodeFile(manifest.nodes[0].rpc_token_file, settings); + fs.unlinkSync(token); + await expect(initializeFiberEnvironment({}, chain, settings)).rejects.toThrow(token); + expect(fs.existsSync(token)).toBe(false); +}); +it('refuses orphan directories and conflicting flags before writing', async () => { + const settings = fixture(); + await expect(initializeFiberEnvironment({ add: true }, chain, settings)).rejects.toThrow('No Fiber environment'); + await expect(initializeFiberEnvironment({ nodes: 2, add: true }, chain, settings)).rejects.toThrow( + 'mutually exclusive', + ); + await expect(initializeFiberEnvironment({ randomAccount: true }, chain, settings)).rejects.toThrow('require --add'); + fs.mkdirSync(fiberNodePaths(1, settings).dir, { recursive: true }); + await expect(initializeFiberEnvironment({}, chain, settings)).rejects.toThrow('without nodes.yml'); + expect(fs.existsSync(nodesYmlPath(settings))).toBe(false); +}); +it('does not shrink or change node count/program while starting an existing environment', async () => { + const settings = fixture(); + await initializeFiberEnvironment({ nodes: 3 }, chain, settings); + await expect(initializeFiberEnvironment({ nodes: 2 }, chain, settings)).rejects.toThrow('cannot remove'); + await expect(initializeFiberEnvironment({ nodes: 4 }, chain, settings, true)).rejects.toThrow('conflicts'); + await expect(initializeFiberEnvironment({ version: '1.0.0' }, chain, settings)).rejects.toThrow('conflicts'); + expect(readFiberManifest(settings)!.nodes).toHaveLength(3); +}); +it('skips reserved directories when appending and rejects id overflow without mutations', async () => { + const settings = fixture(); + await initializeFiberEnvironment({}, chain, settings); + fs.mkdirSync(fiberNodePaths(15, settings).dir, { recursive: true }); + const next = await initializeFiberEnvironment({ add: true }, chain, settings); + expect(next.nodes.map((node) => node.id)).toEqual([1, 2, 16]); + const before = fs.readFileSync(nodesYmlPath(settings)); + await expect(initializeFiberEnvironment({ add: true }, chain, settings)).rejects.toThrow('1 to 16'); + expect(fs.readFileSync(nodesYmlPath(settings))).toEqual(before); +}); +it('resumes a failed provision with exactly the saved random key', async () => { + const settings = fixture(); + await initializeFiberEnvironment({}, chain, settings); + const rename = fs.renameSync; + const spy = jest.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + if (String(to).endsWith('nodes/3/rpc-token')) throw new Error('simulated disk full'); + return rename(from, to); }); + await expect(initializeFiberEnvironment({ add: true, randomAccount: true }, chain, settings)).rejects.toThrow( + 'disk full', + ); + spy.mockRestore(); + const key = fs.readFileSync(fiberNodePaths(3, settings).ckbKeyFile); + const recovered = await initializeFiberEnvironment({ add: true, randomAccount: true }, chain, settings); + expect(recovered.nodes).toHaveLength(3); + expect(fs.readFileSync(fiberNodePaths(3, settings).ckbKeyFile)).toEqual(key); + expect(fs.existsSync(pendingInitFile(settings))).toBe(false); +}); +it('rejects aliases between a token and a funding key before creating directories', async () => { + const settings = fixture(); + const node = newNodeEntry(1); + node.rpc_token_file = 'nodes/1/ckb/key'; + writeManifest({ nodes: [node] }, settings); + await expect(initializeFiberEnvironment({}, chain, settings)).rejects.toThrow('collides'); + expect(fs.existsSync(fiberNodePaths(1, settings).ckbKeyFile)).toBe(false); +}); +it('rejects legacy formats and never exposes private values in YAML diagnostics', () => { + const settings = fixture(); + fs.mkdirSync(path.dirname(nodesYmlPath(settings)), { recursive: true }); + fs.writeFileSync(nodesYmlPath(settings), yaml.dump({ nodes: [{ id: 1, config: {} }] })); + expect(() => readFiberManifest(settings)).toThrow('unsupported'); + fs.writeFileSync(nodesYmlPath(settings), 'nodes: [SECRET_PRIVATE_KEY'); + try { + readFiberManifest(settings); + throw new Error('expected error'); + } catch (error) { + expect((error as Error).message).not.toContain('SECRET_PRIVATE_KEY'); + } +}); - it('rejects managed rpc and ckb fields', () => { - const settings = fixture(); - fs.mkdirSync(path.dirname(nodesYmlPath(settings)), { recursive: true }); - fs.writeFileSync( - nodesYmlPath(settings), - yaml.dump({ nodes: [{ id: 1, config: { rpc: { listening_addr: '0.0.0.0:1' } } }] }), - ); - expect(() => readNodesYml(settings)).toThrow('rpc.listening_addr'); - - fs.writeFileSync(nodesYmlPath(settings), yaml.dump({ nodes: [{ id: 1, config: { services: ['cch'] } }] })); - expect(() => readNodesYml(settings)).toThrow('services'); - }); +it('validates every node before spawning any process', async () => { + const settings = fixture(); + const manifest = await initializeFiberEnvironment({}, chain, settings); + const file = resolveNodeFile(manifest.nodes[1].config_file, settings); + const config = yaml.load(fs.readFileSync(file, 'utf8')) as any; + config.rpc.enabled_modules = ['channel']; + fs.writeFileSync(file, yaml.dump(config)); + const spawn = jest.spyOn(childProcess, 'spawn'); + await expect(startFiberEnvironment({ chainScripts: chain, settings })).rejects.toThrow('rpc.enabled_modules'); + expect(spawn).not.toHaveBeenCalled(); + config.rpc.enabled_modules = ['info', 'peer']; + fs.writeFileSync(file, yaml.dump(config)); + const passwordFile = fiberNodePaths(2, settings).passwordFile; + const password = fs.readFileSync(passwordFile); + fs.writeFileSync(passwordFile, ''); + await expect(startFiberEnvironment({ chainScripts: chain, settings })).rejects.toThrow('password'); + fs.writeFileSync(passwordFile, password); + fs.mkdirSync(fiberNodePaths(2, settings).fiberStoreDir, { recursive: true }); + await expect(startFiberEnvironment({ chainScripts: chain, settings })).rejects.toThrow('fiber/sk'); + expect(spawn).not.toHaveBeenCalled(); }); diff --git a/tests/fiber-reset.test.ts b/tests/fiber-reset.test.ts new file mode 100644 index 0000000..971630a --- /dev/null +++ b/tests/fiber-reset.test.ts @@ -0,0 +1,50 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import * as settingsModule from '../src/cfg/setting'; +import { fiberNodePaths } from '../src/fiber/paths'; +import { fiberClean } from '../src/fiber/clean'; +import { clean } from '../src/cmd/clean'; + +it('separates Fiber reset, chain reset and full environment deletion', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fiber-reset-')); + const settings = structuredClone(settingsModule.defaultSettings); + settings.devnet.configPath = path.join(root, 'devnet'); + settings.devnet.dataPath = path.join(root, 'devnet/data'); + jest.spyOn(settingsModule, 'readSettings').mockReturnValue(settings); + const paths = fiberNodePaths(1, settings); + const keep = [ + paths.ckbKeyFile, + paths.passwordFile, + paths.fiberSkFile, + paths.configFile, + path.join(paths.dir, 'config.default.yml'), + path.join(paths.dir, 'biscuit/private.key'), + path.join(paths.dir, 'biscuit/public.key'), + path.join(paths.dir, 'rpc-token'), + ]; + try { + for (const file of keep) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, 'keep'); + } + fs.mkdirSync(paths.fiberStoreDir, { recursive: true }); + fs.writeFileSync(path.join(paths.fiberStoreDir, 'channel'), 'data'); + fs.mkdirSync(settings.devnet.dataPath, { recursive: true }); + fs.writeFileSync(path.join(settings.devnet.dataPath, 'block'), 'data'); + await fiberClean({ data: true, yes: true }, settings); + expect(fs.existsSync(paths.fiberStoreDir)).toBe(false); + expect(fs.existsSync(settings.devnet.dataPath)).toBe(true); + expect(keep.map((file) => fs.readFileSync(file, 'utf8'))).toEqual(keep.map(() => 'keep')); + fs.mkdirSync(paths.fiberStoreDir, { recursive: true }); + clean({ data: true }); + expect(fs.existsSync(paths.fiberStoreDir)).toBe(false); + expect(fs.existsSync(settings.devnet.dataPath)).toBe(false); + expect(keep.map((file) => fs.readFileSync(file, 'utf8'))).toEqual(keep.map(() => 'keep')); + clean(); + expect(fs.existsSync(settings.devnet.configPath)).toBe(false); + } finally { + jest.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/fiber-status.test.ts b/tests/fiber-status.test.ts index 5ab1779..d257af2 100644 --- a/tests/fiber-status.test.ts +++ b/tests/fiber-status.test.ts @@ -1,4 +1,16 @@ -import { lockMatches } from '../src/fiber/status'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import * as readiness from '../src/devnet/readiness'; +import * as daemon from '../src/util/daemon'; +import * as rpc from '../src/fiber/rpc'; +import { FiberAuthError } from '../src/fiber/auth'; +import { newNodeEntry, writeManifest } from '../src/fiber/nodes-yml'; +import { writeRuntime } from '../src/fiber/runtime'; +import { defaultSettings } from '../src/cfg/setting'; +import { fiberNodePaths, nodesYmlPath } from '../src/fiber/paths'; +import { fiberNodeAccount } from '../src/fiber/accounts'; +import { collectFiberStatus, lockMatches } from '../src/fiber/status'; /** * The funding-lock comparison shared by `fiber status` and the manager's @@ -27,3 +39,80 @@ describe('lockMatches', () => { expect(lockMatches({ code_hash: '0xabc123', hash_type: 'type', args: '0x1234' }, expected)).toBe(false); }); }); + +it('queries the live startup snapshot after current config and registry edits', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fiber-status-')); + const settings = structuredClone(defaultSettings); + settings.devnet.configPath = path.join(root, 'devnet'); + const paths = fiberNodePaths(1, settings); + const account = fiberNodeAccount(1); + fs.mkdirSync(paths.fiberDir, { recursive: true }); + fs.writeFileSync(paths.fiberSkFile, Buffer.from(account.privkey.slice(2), 'hex')); + jest.spyOn(readiness, 'checkNodeReadiness').mockResolvedValue({ ready: true } as never); + jest.spyOn(daemon, 'getProcessCommandLine').mockResolvedValue('offckb node --fiber'); + const probe = jest + .spyOn(rpc, 'fnnNodeInfo') + .mockResolvedValue({ + version: '0.9.0', + pubkey: account.pubkey.slice(2), + default_funding_lock_script: { + code_hash: account.lockScript.codeHash, + hash_type: account.lockScript.hashType, + args: account.lockScript.args, + }, + } as never); + try { + writeRuntime( + { + managerPid: process.pid, + status: 'running', + startedAt: new Date().toISOString(), + nodes: [ + { + id: 1, + pid: process.pid, + dir: paths.dir, + rpcUrl: 'http://127.0.0.1:21714', + configFile: paths.configFile, + tokenFile: '/original/token', + authEnabled: true, + accountIndex: 3, + fundingLock: account.lockScript, + fundingAddress: account.address, + customConfig: 'no', + }, + ], + }, + settings, + ); + // Even invalid on-disk configuration must not replace the running binding. + fs.writeFileSync(nodesYmlPath(settings), 'nodes: [secret-value'); + let report = await collectFiberStatus(settings); + expect(probe).toHaveBeenCalledWith('http://127.0.0.1:21714', 2000, { tokenFile: '/original/token' }); + expect(report.nodes[0]).toMatchObject({ status: 'running', accountIndex: 3, customConfig: 'no' }); + expect(report.nodes[0].reasons.join(' ')).not.toContain('secret-value'); + const edited = { + ...newNodeEntry(1, { type: 'builtin', index: 12 }), + initialized: true, + rpc_token_file: '/edited/token', + }; + writeManifest( + { fnn: { binary_path: process.execPath, template_file: '/template.yml', version: null }, nodes: [edited] }, + settings, + ); + report = await collectFiberStatus(settings); + expect(report.nodes[0]).toMatchObject({ + status: 'running', + accountIndex: 3, + customConfig: 'no', + tokenFile: '/original/token', + }); + probe.mockRejectedValue(new FiberAuthError('authentication failed: token is missing or insufficient')); + report = await collectFiberStatus(settings); + expect(report.nodes[0].status).toBe('unknown'); + expect(report.nodes[0].reasons.join(' ')).toContain('authentication'); + } finally { + jest.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); From eb4152706e471084fde039505a89bec610b34a21 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Tue, 27 Oct 2026 10:17:10 +0800 Subject: [PATCH 2/6] docs(fiber): clarify node management is independent of LSP --- README.md | 5 +++-- docs/fiber-devnet.md | 16 +++++++++------- docs/fiber-validation.md | 2 +- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7105422..e6e7a43 100644 --- a/README.md +++ b/README.md @@ -480,13 +480,14 @@ offckb fiber clean # delete the whole fiber environment ``` - Only the plain local devnet is supported: no mainnet/testnet, and no forked devnet (a `fork.json` present in the devnet directory rejects Fiber startup). +- OffCKB manages FNN processes, configuration, accounts and management RPC. It does not depend on LSP or `lsp-cli`; extension features are optional and must be supported by the selected FNN executable. - A devnet created by an offckb version without Fiber support does not have the Fiber contracts in its genesis. `fiber start` / `node --fiber` on such a devnet refuse with migration guidance: rebuild with `offckb clean` (which deletes the local chain data) and start again; a plain `offckb node` keeps working on the old devnet unchanged. - New node `N` defaults to built-in CKB account `N+2`, RPC port `21713+N` and P2P port `8343+N`. The account binding is saved, not recalculated on restart. Up to 16 nodes: first start with `offckb fiber start --nodes 4`, or prepare them without starting with `offckb fiber init --nodes 4`. Append a node with `offckb fiber init --add`; optionally choose `--account-index 12` or `--random-account` (unfunded). - First initialization downloads a tested FNN release (currently `0.9.0`), unless a version or `--binary-path ` is supplied (`--fnn-binary-path` with `node --fiber`). Downloads are verified against pinned SHA-256 digests. The absolute program and template paths are saved in `fiber/nodes.yml`; subsequent starts reuse that program without downloading or upgrading. Startup selection flags must agree with an existing environment. Use `fiber init --add` or `fiber init --nodes N` to prepare additional nodes. - Every FNN writes stdout/stderr to `devnet/fiber/nodes//fnn.log`. Each node has a persistent full `config.yml`, generated once by initialization and never overwritten by startup. `nodes.yml` records its `config_file`, funding `account`, and `rpc_token_file`; relative paths resolve from `nodes.yml`. Keep the local chain/scripts, fixed ports, node store location and required `fiber`/`rpc`/`ckb` services and `info`/`peer` RPC modules. Other services and modules may be added. The old `nodes[].config` format is unsupported; there is no legacy environment migration. - Startup checks every registered config and key before creating FNN processes, then verifies chain, network identity and funding lock. Zero-balance accounts can start and wait for external funding; initialization does not pre-fund random accounts. Shared funding locks are permitted with a warning about Cell contention. Missing files or changed account bindings fail explicitly instead of generating replacement keys. - One fiber environment per machine: the RPC/P2P ports are fixed per node id, so a second concurrent fiber environment fails its port check. Note the CKB side of the check is the chain's genesis hash, and every plain offckb devnet shares the same genesis — if you run several offckb environments on one machine (e.g. separate `XDG_DATA_HOME`), make sure `fiber start` attaches to the CKB you actually started for it; when in doubt, check `offckb fiber status` against the environment you mean to use. -- Initialization also prepares independent Biscuit keys (`nodes//biscuit/private.key`, `public.key`) and a management `rpc-token` for each node. Authentication is off by default; to enable it, put the generated public key in `rpc.biscuit_public_key`. OffCKB automatically sends the node's token on internal RPC calls and reports authentication errors separately from stopped nodes. LSP tools must reuse the same Biscuit keys, or provide a token matching their replacement public key. These credentials do not grant channel/payment access. +- Initialization also prepares independent Biscuit keys (`nodes//biscuit/private.key`, `public.key`) and a management `rpc-token` for each node. Authentication is off by default; to enable it, put the generated public key in `rpc.biscuit_public_key`. OffCKB automatically sends the node's token on internal RPC calls and reports authentication errors separately from stopped nodes. If you or an external tool replace the RPC public key, also supply a matching management token with node/peer permissions. These credentials do not grant channel/payment access. - `fiber status` includes `CUSTOM_CONFIG` (`yes`, `no`, `unknown`) compared with the node's initialization baseline `config.default.yml`. Running nodes use their startup snapshot until restarted; stopped/new nodes report pending config. Missing baselines do not overwrite user files. Runtime records contain public account information and credential paths, never private keys or token contents. - Stop/restart preserves chain and Fiber data. `fiber clean --data` resets only FNN stores and keeps configs, accounts, passwords, Biscuit keys and tokens. `fiber clean` deletes the complete Fiber environment. `clean --data` resets the chain and dependent FNN stores; `clean` deletes the entire devnet. None of the clean commands is a normal restart. @@ -494,7 +495,7 @@ To customize a node before its first launch, after CKB is running: ```sh offckb fiber init --nodes 3 --binary-path /absolute/path/to/fnn -# Edit the printed node config path, or prepare LSP with its matching lsp-cli. +# Edit the printed node config for features supported by your FNN executable. offckb fiber start --daemon # After editing config: stop and start Fiber to apply changes. offckb fiber stop diff --git a/docs/fiber-devnet.md b/docs/fiber-devnet.md index 6db8873..be14661 100644 --- a/docs/fiber-devnet.md +++ b/docs/fiber-devnet.md @@ -2,7 +2,9 @@ 目标是让默认节点和改过配置的节点都能由 OffCKB 统一管理:首次直接启动就能用,需要提前配置时才单独初始化,日常重启保留配置和数据。 -本次功能面向尚未正式发布的 Fiber devnet,不兼容旧 `nodes[].config` 格式,不自动导入旧目录或迁移旧环境。只管理一套普通 OffCKB 本地链;不支持 fork、主网、测试网或并行的多套环境。LSP 与 Fiber 在同一个 FNN 进程内运行。 +本次功能面向尚未正式发布的 Fiber devnet,不兼容旧 `nodes[].config` 格式,不自动导入旧目录或迁移旧环境。只管理一套普通 OffCKB 本地链;不支持 fork、主网、测试网或并行的多套环境。 + +OffCKB 管理的是 FNN 节点进程、配置、账户和管理 RPC,不依赖 LSP 或 `lsp-cli`,也不调用这些工具。LSP 只是完整配置可保留的一种使用场景;是否启用、支持哪些配置,由用户选择的 FNN 决定。普通节点无需安装或配置 LSP。 ## 使用方式 @@ -15,11 +17,11 @@ offckb node --daemon offckb fiber start --daemon ``` -`offckb fiber init [FNN-Version]` 是可选的“只准备、不启动”步骤。CKB 本地链准备好后,可以先准备配置和密钥,修改配置或使用配套的 `lsp-cli` 配置 LSP,最后执行 `fiber start`。 +`offckb fiber init [FNN-Version]` 是可选的“只准备、不启动”步骤。CKB 本地链准备好后,可以先准备配置和密钥,手动或用外部工具修改 FNN 配置,最后执行 `fiber start`。 ```sh offckb fiber init --nodes 3 --binary-path /absolute/path/to/fnn -# 编辑输出的节点 3 config.yml,或用 lsp-cli 准备 LSP +# 按所选 FNN 支持的功能编辑输出的节点 3 config.yml offckb fiber start --daemon offckb fiber init --add --account-index 12 # 或:offckb fiber init --add --random-account @@ -54,7 +56,7 @@ nodes: 配置和 token 的相对路径均相对 `nodes.yml` 所在目录解析。每个节点只使用 `config_file` 指向的完整 FNN 配置;初始化只生成一次,重复初始化和启动不得改写。初始化的默认配置另外保存在节点的 `config.default.yml` 中,用于 `CUSTOM_CONFIG` 判定。 -允许添加 LSP 服务、RPC 模块及 Biscuit 认证等 FNN 配置。OffCKB 只检查其管理所需条件:保留 `fiber`、`rpc`、`ckb` 服务及 `info`、`peer` RPC 模块;使用本环境的链、合约、CKB RPC、固定节点端口和节点内的数据目录。其他 FNN 字段由 FNN 校验,不复制整套 FNN 配置规则。所有节点通过预检查后才创建进程。 +允许添加所选 FNN 支持的服务、RPC 模块及 Biscuit 认证等配置,例如启用 LSP。OffCKB 只检查其管理所需条件:保留 `fiber`、`rpc`、`ckb` 服务及 `info`、`peer` RPC 模块;使用本环境的链、合约、CKB RPC、固定节点端口和节点内的数据目录。其他 FNN 字段由 FNN 校验,不复制整套 FNN 配置规则。所有节点通过预检查后才创建进程。 默认账户只在首次准备时按“节点编号 + 2”计算并写入。内置账户显式保存 `type: builtin`、`index`;自选或生成私钥使用 `type: random`、`key: "0x…"`。新的随机账户可省略 `key`,由初始化生成并保存;重复初始化、列表重排及重启不重新生成。随机账户不加入创世预分配、不自动注资。零余额允许启动,并输出等待注资的提示。 @@ -64,7 +66,7 @@ nodes: ## RPC 认证与状态 -每个新节点独立生成 Biscuit 密钥对和覆盖 `node_info`、`list_peers`、`connect_peer` 所需 node/peer 权限的管理 token(不授予通道、支付操作权限)。密钥保存为节点目录下 `biscuit/private.key`、`biscuit/public.key`,token 保存为 `rpc-token`。默认不启用 RPC 认证;用户启用时把对应公钥配置到 `rpc.biscuit_public_key`。外部 LSP 工具必须复用这对密钥;若主动换了认证公钥,则需要提供匹配的管理 token。不会自动覆盖外部工具的密钥或配置。 +每个新节点独立生成 Biscuit 密钥对和覆盖 `node_info`、`list_peers`、`connect_peer` 所需 node/peer 权限的管理 token(不授予通道、支付操作权限)。密钥保存为节点目录下 `biscuit/private.key`、`biscuit/public.key`,token 保存为 `rpc-token`。默认不启用 RPC 认证;用户启用时把对应公钥配置到 `rpc.biscuit_public_key`。无论手动还是用外部工具配置认证,管理 token 都需要与生效的 RPC 公钥匹配,并具有上述权限。更换公钥后需同时提供匹配的管理 token;OffCKB 不自动覆盖用户或外部工具的密钥、配置。 内部 RPC 在认证开启时自动读取对应 token,并发送 `Authorization: Bearer …`。认证失败、权限不足、文件丢失与节点停止分别报告,不输出 token 或私钥。 @@ -85,10 +87,10 @@ nodes: 后台节点用 `node stop` / `fiber stop` 停止;前台用 Ctrl+C。`node --fiber --daemon` 由同一个管理进程启动,停止该管理进程会停止整套环境。清理前沿用进程身份和数据库锁检查。 -CKB 配置编辑器不再把 `clean -d` 当作普通重启步骤。普通运行配置修改后停止并重新启动;修改创世链规格才需要明确的重置,并告知会丢失链和 Fiber 数据。Fiber 数据重置清理 OffCKB 管理的 `fiber/store`;配套 LSP 如使用独立存储,其清理边界需按该工具版本单独验证,不把密钥目录当作数据库删除。 +CKB 配置编辑器不再把 `clean -d` 当作普通重启步骤。普通运行配置修改后停止并重新启动;修改创世链规格才需要明确的重置,并告知会丢失链和 Fiber 数据。Fiber 数据重置清理 OffCKB 管理的 `fiber/store`;扩展功能自行配置的其他存储不在该操作的清理范围内,不把密钥目录当作数据库删除。 ## 验证与交付 自动化覆盖默认自动初始化、显式初始化、追加与编号边界、配置保留、准备中断重试、密钥一致性、零余额、RPC 认证错误、运行状态记录以及重启与重置边界。检查文档和 CLI 帮助一致,并运行类型检查、lint、构建和测试。 -实际 CKB/FNN 验证:默认启动、三个节点、认证、停止重启及外部注资。LSP 验证使用调用方提供的配套 FNN / lsp-cli 版本,检查初始化前配置、Biscuit 复用和重启后的数据保留。交付和 PR 如实记录已验证与未验证项。 +实际 CKB/FNN 验证:默认启动、三个节点、自定义配置保留、认证、停止重启及外部注资。基础节点管理以普通 FNN 验证,不以 LSP 或其他扩展工具的联调作为交付前提。特定扩展的端到端行为属于可选的集成验证,不能仅凭配置保留就宣称兼容。交付和 PR 如实记录已验证与未验证项。 diff --git a/docs/fiber-validation.md b/docs/fiber-validation.md index f52fc84..265330d 100644 --- a/docs/fiber-validation.md +++ b/docs/fiber-validation.md @@ -33,6 +33,6 @@ ## 未验证项与既有问题 -- 未提供与 LSP 配套的 FNN 分支/版本和 `lsp-cli` 程序,尚未实际验证 LSP 初始化、Biscuit 文件约定及 LSP 独立存储。普通 FNN 自定义配置与 RPC 认证已验证,不能据此视为 LSP 联调通过。 +- 普通 FNN 的节点管理、自定义配置保留与 RPC 认证已验证。LSP 及其他扩展工具的端到端行为未验证,也不属于本次基础节点管理的交付前提;OffCKB 没有 LSP 或 `lsp-cli` 运行依赖,不能仅凭配置保留宣称特定扩展兼容。 - 没有在 Windows/Linux 或 Node 20 上运行真实程序;自动化测试为本机结果,其他平台交由 PR CI。 - 发现既有 macOS 后台进程身份检查限制:`ps` 返回未加引号的含空格脚本路径时,原有检查拒绝停止该进程。本任务路径含空格,真实联调用隔离包装脚本保留进程标题中的路径引号后完成后台停止与重启;未放宽生产代码的进程身份检查。最初的测试进程已按确切 PID/进程组清理。该问题需要单独处理。 From 9cbe236876e10a653236494bb4f54698b93570d6 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Tue, 27 Oct 2026 10:26:45 +0800 Subject: [PATCH 3/6] docs(fiber): record checks after rebasing onto develop --- docs/fiber-validation.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/fiber-validation.md b/docs/fiber-validation.md index 265330d..363d61e 100644 --- a/docs/fiber-validation.md +++ b/docs/fiber-validation.md @@ -13,11 +13,11 @@ - `pnpm install --frozen-lockfile`:通过;仅新增官方 Biscuit WASM 依赖,保留原有其他依赖锁定记录。 - `pnpm typecheck`、`pnpm build`:通过。 - `pnpm lint`:通过,保留 `src/node/install.ts` 的一个既有 `any` 警告。 -- `pnpm test:ci --runInBand --silent`:51 个测试套件通过,464 项通过,7 项按平台/原有条件跳过;覆盖率阈值通过。 +- Rebase 到 `develop`(`b444332`)后重新运行 `pnpm test:ci --runInBand --silent`:57 个测试套件通过,547 项通过,7 项按平台/原有条件跳过;覆盖率阈值通过。类型检查、lint、构建和锁定依赖安装也重新通过。 - `git diff --check`:通过。 - 发行构建额外验证:运行 `build/index.js` 的隔离入口执行已存在环境的 `fiber init`,加载随包发布的 Biscuit WASM/ESM 文件成功;无需启用 Node 的实验性 WASM 模块参数。 -自动化重点包括:首次默认准备、显式准备、追加与编号上限、账户导入和随机账户、配置不覆盖、准备中断后复用密钥、FNN 加密密钥比对、启动前检查所有节点、Biscuit 签名和权限、凭证文件替换、HTTP 认证失败、运行信息快照、默认配置损坏及数据重置边界。 +自动化重点包括:首次默认准备、显式准备、追加与编号上限、账户导入和随机账户、配置不覆盖、准备中断后复用密钥、FNN 加密密钥比对、启动前检查所有节点、Biscuit 签名和权限、凭证文件替换、HTTP 认证失败、运行信息快照、默认配置损坏及数据重置边界。Rebase 时新增检查:CKB 管理进程将信号交给 Fiber 后,初始化等待 FNN 程序下载期间收到 SIGTERM 也会退出,不吞掉信号。 ## 实际 CKB/FNN 流程 @@ -30,9 +30,9 @@ - 在自动互连使用的第一个节点上启用认证:启动完成并互连成功,实际覆盖带 token 的 `node_info`、`connect_peer` 和 `list_peers`。 - 停止并再次启动四节点环境:逐文件 SHA-256 核对配置、FNN 加密后的 CKB 密钥、密码、Fiber 身份、Biscuit 密钥及 token,均未改变。 - 全新环境直接 `node --fiber --daemon --fnn-binary-path …`:无需先执行 `fiber init`,自动准备并启动默认两个节点。`node stop` 停止整套环境。 +- Rebase 后使用真实 CKB/FNN 重跑已有双节点环境的 `node --fiber --daemon`、`fiber status` 和 `node stop`,认证节点与默认节点均正常,停止后无测试 CKB、矿工或 FNN 进程残留。新基线已包含含空格 CLI 路径的进程识别修复(#533);本次移除初次验证使用的进程标题引号包装,在含空格的任务路径下直接通过后台停止检查。 ## 未验证项与既有问题 - 普通 FNN 的节点管理、自定义配置保留与 RPC 认证已验证。LSP 及其他扩展工具的端到端行为未验证,也不属于本次基础节点管理的交付前提;OffCKB 没有 LSP 或 `lsp-cli` 运行依赖,不能仅凭配置保留宣称特定扩展兼容。 - 没有在 Windows/Linux 或 Node 20 上运行真实程序;自动化测试为本机结果,其他平台交由 PR CI。 -- 发现既有 macOS 后台进程身份检查限制:`ps` 返回未加引号的含空格脚本路径时,原有检查拒绝停止该进程。本任务路径含空格,真实联调用隔离包装脚本保留进程标题中的路径引号后完成后台停止与重启;未放宽生产代码的进程身份检查。最初的测试进程已按确切 PID/进程组清理。该问题需要单独处理。 From 13ef0d9e74dae97c8ae8b139207d50815bc9fea6 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Tue, 27 Oct 2026 10:36:07 +0800 Subject: [PATCH 4/6] test(fiber): use native paths in initialization checks --- tests/fiber-nodes-yml.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/fiber-nodes-yml.test.ts b/tests/fiber-nodes-yml.test.ts index 14d3d92..748a71c 100644 --- a/tests/fiber-nodes-yml.test.ts +++ b/tests/fiber-nodes-yml.test.ts @@ -146,9 +146,10 @@ it('skips reserved directories when appending and rejects id overflow without mu it('resumes a failed provision with exactly the saved random key', async () => { const settings = fixture(); await initializeFiberEnvironment({}, chain, settings); + const tokenFile = resolveNodeFile(newNodeEntry(3).rpc_token_file, settings); const rename = fs.renameSync; const spy = jest.spyOn(fs, 'renameSync').mockImplementation((from, to) => { - if (String(to).endsWith('nodes/3/rpc-token')) throw new Error('simulated disk full'); + if (String(to) === tokenFile) throw new Error('simulated disk full'); return rename(from, to); }); await expect(initializeFiberEnvironment({ add: true, randomAccount: true }, chain, settings)).rejects.toThrow( @@ -201,6 +202,8 @@ it('validates every node before spawning any process', async () => { await expect(startFiberEnvironment({ chainScripts: chain, settings })).rejects.toThrow('password'); fs.writeFileSync(passwordFile, password); fs.mkdirSync(fiberNodePaths(2, settings).fiberStoreDir, { recursive: true }); - await expect(startFiberEnvironment({ chainScripts: chain, settings })).rejects.toThrow('fiber/sk'); + await expect(startFiberEnvironment({ chainScripts: chain, settings })).rejects.toThrow( + fiberNodePaths(2, settings).fiberSkFile, + ); expect(spawn).not.toHaveBeenCalled(); }); From 4b0241fe5d8e3faf5cf89fda2b87819818171f6a Mon Sep 17 00:00:00 2001 From: RetricSu Date: Tue, 27 Oct 2026 16:10:17 +0800 Subject: [PATCH 5/6] fix(fiber): preserve referenced files during data reset --- README.md | 10 +-- docs/fiber-devnet.md | 15 +++++ docs/fiber-validation.md | 7 ++- src/cmd/clean.ts | 10 +-- src/cmd/devnet-config.ts | 14 +++-- src/fiber/clean.ts | 42 ++++++++++++- tests/fiber-reset.test.ts | 129 +++++++++++++++++++++++++++++++++++++- 7 files changed, 208 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index e6e7a43..bb16bc3 100644 --- a/README.md +++ b/README.md @@ -395,10 +395,12 @@ If your terminal is non-interactive (no TTY, e.g. CI/remote pipeline), use `--se ```sh offckb node stop -offckb node +offckb node # reuse the original options, e.g. --fiber --daemon ``` -Ordinary runtime config changes do not require deleting data. Changes to the genesis/spec may require an explicit reset with `offckb clean -d`: this deletes chain history and Fiber channel/payment stores, while preserving configs and keys. `offckb clean` removes the whole environment, including Fiber keys. +Restart with the same options used originally, including `--fiber` when applicable, so Fiber starts with CKB again. If Fiber was started separately with `fiber start`, stop it before CKB and restart it separately afterward. + +Ordinary runtime config changes do not require deleting data. Changes to the genesis/spec may require an explicit reset with `offckb clean -d`: this deletes chain history and Fiber channel/payment stores, while preserving configs and keys. If system-cell hashes changed, update the retained Fiber contract references before starting Fiber; see [recovery after genesis or contract changes](docs/fiber-devnet.md#修改创世或系统合约后的恢复). `offckb clean` removes the environment under the devnet directory, including Fiber keys; externally referenced files remain. 1. (Advanced) Locate your Devnet config folder for manual edits: @@ -421,7 +423,7 @@ Example result: Pay attention to the `devnet.configPath` and `devnet.dataPath`. 1. `cd` into the `devnet.configPath` . Modify the config files as needed. See [Custom Devnet Setup](https://docs.nervos.org/docs/node/run-devnet-node#custom-devnet-setup) and [Configure CKB](https://github.com/nervosnetwork/ckb/blob/develop/docs/configure.md) for details. -2. Stop and restart the local blockchain with `offckb node` to apply runtime changes. +2. Stop and restart the local blockchain with `offckb node` and the original options (including `--fiber` if used) to apply runtime changes. 3. Only when rebuilding the genesis/spec, explicitly reset with `offckb clean -d`; it deletes chain history and Fiber channel/payment stores while keeping the updated configs and keys. ### 7. Fork Mainnet/Testnet Into Your Devnet {#fork-devnet} @@ -489,7 +491,7 @@ offckb fiber clean # delete the whole fiber environment - One fiber environment per machine: the RPC/P2P ports are fixed per node id, so a second concurrent fiber environment fails its port check. Note the CKB side of the check is the chain's genesis hash, and every plain offckb devnet shares the same genesis — if you run several offckb environments on one machine (e.g. separate `XDG_DATA_HOME`), make sure `fiber start` attaches to the CKB you actually started for it; when in doubt, check `offckb fiber status` against the environment you mean to use. - Initialization also prepares independent Biscuit keys (`nodes//biscuit/private.key`, `public.key`) and a management `rpc-token` for each node. Authentication is off by default; to enable it, put the generated public key in `rpc.biscuit_public_key`. OffCKB automatically sends the node's token on internal RPC calls and reports authentication errors separately from stopped nodes. If you or an external tool replace the RPC public key, also supply a matching management token with node/peer permissions. These credentials do not grant channel/payment access. - `fiber status` includes `CUSTOM_CONFIG` (`yes`, `no`, `unknown`) compared with the node's initialization baseline `config.default.yml`. Running nodes use their startup snapshot until restarted; stopped/new nodes report pending config. Missing baselines do not overwrite user files. Runtime records contain public account information and credential paths, never private keys or token contents. -- Stop/restart preserves chain and Fiber data. `fiber clean --data` resets only FNN stores and keeps configs, accounts, passwords, Biscuit keys and tokens. `fiber clean` deletes the complete Fiber environment. `clean --data` resets the chain and dependent FNN stores; `clean` deletes the entire devnet. None of the clean commands is a normal restart. +- Stop/restart preserves chain and Fiber data. `fiber clean --data` resets only FNN stores and keeps configs, accounts, passwords, Biscuit keys and tokens. Both data-only clean commands refuse before deleting anything if a registered config/token file is inside a deletion target; relocate the file and update `nodes.yml` first. `fiber clean` deletes the Fiber directory; `clean --data` resets the chain and dependent FNN stores; `clean` deletes the devnet directory. Referenced files outside the directory being removed remain. None of the clean commands is a normal restart. To customize a node before its first launch, after CKB is running: diff --git a/docs/fiber-devnet.md b/docs/fiber-devnet.md index be14661..e87d7d5 100644 --- a/docs/fiber-devnet.md +++ b/docs/fiber-devnet.md @@ -85,10 +85,25 @@ nodes: | `clean --data` | 重置 CKB 链及依赖它的 Fiber 运行数据;保留配置和密钥 | | `clean` | 删除整套 devnet,含 Fiber 配置、密钥和数据 | +数据重置在删除前检查所有节点的 `config_file`、`rpc_token_file`,不允许它们位于任意待删除目录内,包括其他节点的 store;`clean --data` 还检查 CKB data 目录。发现冲突时指出节点、字段和目录,拒绝整个操作,不删除任何数据。用户需先移出文件并更新 `nodes.yml`,OffCKB 不自动移动或重新生成。全量清理只删除相应的 Fiber 或 devnet 目录,引用在目录外的文件仍保留。 + 后台节点用 `node stop` / `fiber stop` 停止;前台用 Ctrl+C。`node --fiber --daemon` 由同一个管理进程启动,停止该管理进程会停止整套环境。清理前沿用进程身份和数据库锁检查。 +重启使用原来的启动选项,包括适用的 `--fiber`、`--daemon`。仅运行 `offckb node` 不会恢复由 `node --fiber` 启动的 Fiber。若原来由 `fiber start` 单独管理 Fiber,则先停止 Fiber 再停止 CKB,重启 CKB 后再执行 `fiber start`。 + CKB 配置编辑器不再把 `clean -d` 当作普通重启步骤。普通运行配置修改后停止并重新启动;修改创世链规格才需要明确的重置,并告知会丢失链和 Fiber 数据。Fiber 数据重置清理 OffCKB 管理的 `fiber/store`;扩展功能自行配置的其他存储不在该操作的清理范围内,不把密钥目录当作数据库删除。 +### 修改创世或系统合约后的恢复 + +修改 `specs/dev.toml` 或其中的系统合约可能改变系统 Cell 的交易哈希、索引或脚本哈希。数据重置仍保留完整配置,所以其中的旧 `fiber.scripts` 可能被启动校验拒绝。这时应更新合约引用,保留其余自定义配置和密钥: + +1. 备份 `nodes.yml`、各节点完整配置和密钥,停止 CKB 和全部 FNN,明确接受链历史与通道、支付数据丢失后执行 `offckb clean --data`。 +2. 用原有 CKB 选项启动 `offckb node`,暂不加 `--fiber`。执行 `offckb system-scripts`,取得新链的 `funding_lock`、`commitment_lock`、`auth` 及使用中的 UDT 合约信息。 +3. 按 FNN 配置格式,手动更新每个已登记配置中 `fiber.scripts` 的 `FundingLock`、`CommitmentLock`:对应合约的 `code_hash`、`hash_type` 和 `cell_deps`,两者均包含 `auth` 的 code cell dep。交易哈希和索引使用新输出,FNN 的 `out_point.index` 使用带 `0x` 前缀的十六进制字符串。若 UDT 合约引用也变化,同步更新 `ckb.udt_whitelist` 中受影响条目的脚本哈希和依赖;保留账户、认证设置和其他自定义字段。 +4. 执行 `offckb fiber start`(按需加 `--daemon`),重新检查所有节点后启动。之后可继续用原来的组合命令重启。 + +`fiber init` 不会改写已有配置,`fiber clean --data` 也保留配置,不能用来修复过期引用。`fiber clean` 会删除整个 Fiber 目录及其中的配置、身份和凭证,不适用于上述保留配置与密钥的恢复方式。链重置后旧通道和支付数据不会恢复。 + ## 验证与交付 自动化覆盖默认自动初始化、显式初始化、追加与编号边界、配置保留、准备中断重试、密钥一致性、零余额、RPC 认证错误、运行状态记录以及重启与重置边界。检查文档和 CLI 帮助一致,并运行类型检查、lint、构建和测试。 diff --git a/docs/fiber-validation.md b/docs/fiber-validation.md index 363d61e..1deec07 100644 --- a/docs/fiber-validation.md +++ b/docs/fiber-validation.md @@ -13,12 +13,14 @@ - `pnpm install --frozen-lockfile`:通过;仅新增官方 Biscuit WASM 依赖,保留原有其他依赖锁定记录。 - `pnpm typecheck`、`pnpm build`:通过。 - `pnpm lint`:通过,保留 `src/node/install.ts` 的一个既有 `any` 警告。 -- Rebase 到 `develop`(`b444332`)后重新运行 `pnpm test:ci --runInBand --silent`:57 个测试套件通过,547 项通过,7 项按平台/原有条件跳过;覆盖率阈值通过。类型检查、lint、构建和锁定依赖安装也重新通过。 +- Rebase 到 `develop`(`b444332`)并修复清理边界的 review 意见后,重新运行 `pnpm test:ci --runInBand --silent`:57 个测试套件通过,559 项通过,7 项按平台/原有条件跳过;覆盖率阈值通过。类型检查、lint 和构建也重新通过。 - `git diff --check`:通过。 - 发行构建额外验证:运行 `build/index.js` 的隔离入口执行已存在环境的 `fiber init`,加载随包发布的 Biscuit WASM/ESM 文件成功;无需启用 Node 的实验性 WASM 模块参数。 自动化重点包括:首次默认准备、显式准备、追加与编号上限、账户导入和随机账户、配置不覆盖、准备中断后复用密钥、FNN 加密密钥比对、启动前检查所有节点、Biscuit 签名和权限、凭证文件替换、HTTP 认证失败、运行信息快照、默认配置损坏及数据重置边界。Rebase 时新增检查:CKB 管理进程将信号交给 Fiber 后,初始化等待 FNN 程序下载期间收到 SIGTERM 也会退出,不吞掉信号。 +Review 修复新增 12 项回归,覆盖两个数据清理入口、自定义配置/token 指向其他节点 store 或 CKB data、目录链接、相似目录名前缀、确认期间编辑登记,以及完整清理后保留外部文件。隔离复现另外使用合法初始化后的配置和真实 Biscuit 凭证:三种原先会误删文件的情况现在均在删除前拒绝,文件保持可用;移出文件并更新登记后,可以正常重置并通过再次启动准备。 + ## 实际 CKB/FNN 流程 - CKB 已运行时显式 `fiber init --nodes 3 --binary-path …`:准备三个节点,未创建 FNN 进程或数据库。 @@ -31,8 +33,9 @@ - 停止并再次启动四节点环境:逐文件 SHA-256 核对配置、FNN 加密后的 CKB 密钥、密码、Fiber 身份、Biscuit 密钥及 token,均未改变。 - 全新环境直接 `node --fiber --daemon --fnn-binary-path …`:无需先执行 `fiber init`,自动准备并启动默认两个节点。`node stop` 停止整套环境。 - Rebase 后使用真实 CKB/FNN 重跑已有双节点环境的 `node --fiber --daemon`、`fiber status` 和 `node stop`,认证节点与默认节点均正常,停止后无测试 CKB、矿工或 FNN 进程残留。新基线已包含含空格 CLI 路径的进程识别修复(#533);本次移除初次验证使用的进程标题引号包装,在含空格的任务路径下直接通过后台停止检查。 +- Review 修复后,在全新的隔离双节点环境中修改创世 message,执行 `clean --data` 并单独启动 CKB。旧配置按预期因 `fiber.scripts` 过期而拒绝启动;根据新链系统脚本信息仅更新合约引用后,两个真实 FNN 恢复运行,包括开启认证的节点。逐文件 SHA-256 核对 CKB 密钥、密码、Fiber 身份、Biscuit 密钥及 token 均未改变;除合约引用外的配置值也未改变。最后分别停止 Fiber 与 CKB。 ## 未验证项与既有问题 - 普通 FNN 的节点管理、自定义配置保留与 RPC 认证已验证。LSP 及其他扩展工具的端到端行为未验证,也不属于本次基础节点管理的交付前提;OffCKB 没有 LSP 或 `lsp-cli` 运行依赖,不能仅凭配置保留宣称特定扩展兼容。 -- 没有在 Windows/Linux 或 Node 20 上运行真实程序;自动化测试为本机结果,其他平台交由 PR CI。 +- 专门的 CKB/FNN 实程序联调仅在 macOS / Node 24 上运行。此前提交 `13ef0d9` 的仓库 CI 已在 Linux、Windows、macOS / Node 20 上通过自动化测试、构建及通用 CLI 集成检查;本轮修复的跨平台结果以 PR CI 为准。 diff --git a/src/cmd/clean.ts b/src/cmd/clean.ts index 453b2ba..9a2e57c 100644 --- a/src/cmd/clean.ts +++ b/src/cmd/clean.ts @@ -4,7 +4,7 @@ import { readSettings } from '../cfg/setting'; import { logger } from '../util/logger'; import { isProcessAlive, nodeDaemonPaths, readPidFile } from '../util/daemon'; import { acquireEnvLock } from '../fiber/env-lock'; -import { assertFiberFullyStopped } from '../fiber/clean'; +import { assertFiberFilesRetained, assertFiberFullyStopped } from '../fiber/clean'; import { fiberNodeIds, fiberNodePaths } from '../fiber/paths'; import { removeRuntimeFileIfStale } from '../fiber/runtime'; @@ -44,11 +44,13 @@ export function clean(options?: CleanOptions) { assertFiberFullyStopped(settings); if (dataOnly) { + const chainDataPath = settings.devnet.dataPath; + const stores = fiberStoreDirs(settings); + assertFiberFilesRetained([chainDataPath, ...stores], settings); logger.warn( 'Resetting devnet: chain history and Fiber channel/payment stores will be deleted. Configs, accounts, identity keys, Biscuit keys and tokens are kept. This is not a restart.', ); // Only clean the chain data subdirectory - const chainDataPath = settings.devnet.dataPath; if (isFolderExists(chainDataPath)) { try { fs.rmSync(chainDataPath, { recursive: true }); @@ -64,13 +66,13 @@ export function clean(options?: CleanOptions) { // onto the reset chain and are removed too; node configs, keys and // passwords are kept. removeRuntimeFileIfStale(settings); - for (const storeDir of fiberStoreDirs(settings)) { + for (const storeDir of stores) { fs.rmSync(storeDir, { recursive: true, force: true }); logger.info(`Fiber store cleaned: ${storeDir}`); } } else { logger.warn( - 'Resetting the whole devnet: chain history and the entire Fiber environment (including configs and keys) will be deleted. This is not a restart.', + 'Resetting the whole devnet: chain history and the Fiber environment under the devnet directory (including configs and keys) will be deleted. Files referenced outside the devnet directory will not be deleted. This is not a restart.', ); // Clean everything - the original behavior // this is the root folder of devnet, it contains config, data, debugFullTransactions, transactions, failed-transactions, contracts diff --git a/src/cmd/devnet-config.ts b/src/cmd/devnet-config.ts index 1f307c3..a3bcd9d 100644 --- a/src/cmd/devnet-config.ts +++ b/src/cmd/devnet-config.ts @@ -12,6 +12,12 @@ export interface ParsedSetItem { value: string; } +const RESTART_GUIDANCE = + 'Stop devnet (Ctrl+C or offckb node stop), then rerun offckb node with the same options (including --fiber if used) to apply runtime config changes. ' + + 'Only reset with offckb clean -d for genesis/spec changes; reset deletes chain and Fiber channel/payment data. ' + + 'If system-cell hashes changed, reconcile the retained Fiber contract references before starting Fiber; see docs/fiber-devnet.md. ' + + 'fiber clean --data keeps configs; fiber clean deletes the Fiber environment, including configs and credentials.'; + export function parseSetItem(item: string): ParsedSetItem { const separator = item.indexOf('='); if (separator <= 0 || separator === item.length - 1) { @@ -54,9 +60,7 @@ export async function devnetConfig(options: DevnetConfigOptions = {}) { logger.info( `Applied ${parsedItems.length} setting(s): ${parsedItems.map((item) => `${item.key}=${item.value}`).join(', ')}`, ); - logger.info( - 'Stop devnet (Ctrl+C or offckb node stop), then run offckb node to apply runtime config changes. Only reset with offckb clean -d for genesis/spec changes; reset deletes chain and Fiber channel/payment data.', - ); + logger.info(RESTART_GUIDANCE); return; } @@ -71,9 +75,7 @@ export async function devnetConfig(options: DevnetConfigOptions = {}) { if (isSaved) { logger.success(`Devnet config updated at: ${configPath}`); - logger.info( - 'Stop devnet (Ctrl+C or offckb node stop), then run offckb node to apply runtime config changes. Only reset with offckb clean -d for genesis/spec changes; reset deletes chain and Fiber channel/payment data.', - ); + logger.info(RESTART_GUIDANCE); return; } diff --git a/src/fiber/clean.ts b/src/fiber/clean.ts index f97beac..a5bd633 100644 --- a/src/fiber/clean.ts +++ b/src/fiber/clean.ts @@ -1,4 +1,5 @@ import * as fs from 'fs'; +import * as path from 'path'; import { confirm } from '@inquirer/prompts'; import { acquireEnvLock } from './env-lock'; import { envLockPath, fiberDaemonPaths, fiberNodeIds, fiberNodePaths, fiberRootPath } from './paths'; @@ -8,6 +9,7 @@ import { isStoreLockHeld } from './store-lock'; import { readSettings, Settings } from '../cfg/setting'; import { isFolderExists } from '../util/fs'; import { logger } from '../util/logger'; +import { readFiberManifest, resolveNodeFile } from './nodes-yml'; export interface FiberCleanOptions { data?: boolean; @@ -49,6 +51,40 @@ export function assertFiberFullyStopped(settings: Settings = readSettings()) { } } +function realPathOrResolved(file: string): string { + try { + return fs.realpathSync(file); + } catch { + return path.resolve(file); + } +} + +function containsPath(directory: string, file: string): boolean { + const relative = path.relative(directory, file); + return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +/** Check every node against every deletion target before a data-only reset deletes anything. */ +export function assertFiberFilesRetained(targets: string[], settings: Settings = readSettings()) { + const manifest = readFiberManifest(settings); + for (const node of manifest?.nodes ?? []) { + for (const field of ['config_file', 'rpc_token_file'] as const) { + const file = resolveNodeFile(node[field], settings); + for (const target of targets) { + if ( + containsPath(path.resolve(target), file) || + containsPath(realPathOrResolved(target), realPathOrResolved(file)) + ) { + throw new Error( + `Cannot reset data: Fiber node ${node.id} ${field} (${file}) is inside deletion target ${target}. ` + + 'Move the file outside the data directories and update nodes.yml before retrying. No data was deleted.', + ); + } + } + } + } +} + async function confirmOrAbort(message: string, yes?: boolean) { if (yes) return; const answer = await confirm({ message, default: false }); @@ -73,6 +109,7 @@ export async function fiberClean(options: FiberCleanOptions, settings: Settings const stores = fiberNodeIds(settings) .map((id) => fiberNodePaths(id, settings).fiberStoreDir) .filter((storeDir) => isFolderExists(storeDir)); + assertFiberFilesRetained(stores, settings); logger.warn( 'This permanently deletes every FNN store (channels, payments and other node data). ' + 'Deleted data cannot be recovered. Node accounts, identity keys, passwords, Biscuit keys, RPC tokens, configs and logs are kept. This is a data reset, not a restart.', @@ -85,6 +122,7 @@ export async function fiberClean(options: FiberCleanOptions, settings: Settings // The confirmation prompt can sit open for an arbitrary time; re-verify // nothing started in that window before deleting anything. assertFiberFullyStopped(settings); + assertFiberFilesRetained(stores, settings); removeRuntimeFileIfStale(settings); for (const store of stores) { fs.rmSync(store, { recursive: true, force: true }); @@ -96,9 +134,9 @@ export async function fiberClean(options: FiberCleanOptions, settings: Settings } logger.warn( - 'This deletes the whole fiber environment, including node configs, the CKB account keys, ' + + 'This deletes the Fiber environment under its directory, including node configs, the CKB account keys, ' + 'the Fiber network identity keys and passwords of every node. Restarting creates NEW node identities. ' + - 'The downloaded FNN binary and the devnet CKB data are kept.', + 'Files referenced outside this directory, the downloaded FNN binary and the devnet CKB data are kept.', ); logger.info(` will delete: ${root}`); await confirmOrAbort('Delete the whole fiber environment?', options.yes); diff --git a/tests/fiber-reset.test.ts b/tests/fiber-reset.test.ts index 971630a..f7fcd44 100644 --- a/tests/fiber-reset.test.ts +++ b/tests/fiber-reset.test.ts @@ -2,9 +2,13 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import * as settingsModule from '../src/cfg/setting'; -import { fiberNodePaths } from '../src/fiber/paths'; +import { fiberNodePaths, nodesYmlPath } from '../src/fiber/paths'; import { fiberClean } from '../src/fiber/clean'; import { clean } from '../src/cmd/clean'; +import { FiberManifest, newNodeEntry, resolveNodeFile, writeManifest } from '../src/fiber/nodes-yml'; +import { confirm } from '@inquirer/prompts'; + +jest.mock('@inquirer/prompts', () => ({ confirm: jest.fn() })); it('separates Fiber reset, chain reset and full environment deletion', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fiber-reset-')); @@ -48,3 +52,126 @@ it('separates Fiber reset, chain reset and full environment deletion', async () fs.rmSync(root, { recursive: true, force: true }); } }); + +describe('data reset with custom config and token paths', () => { + let root: string; + let settings: settingsModule.Settings; + let manifest: FiberManifest; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fiber-reset-paths-')); + settings = structuredClone(settingsModule.defaultSettings); + settings.devnet.configPath = path.join(root, 'devnet'); + settings.devnet.dataPath = path.join(settings.devnet.configPath, 'data'); + jest.spyOn(settingsModule, 'readSettings').mockReturnValue(settings); + manifest = { nodes: [newNodeEntry(1), newNodeEntry(2)] }; + for (const node of manifest.nodes) { + for (const field of ['config_file', 'rpc_token_file'] as const) { + const file = resolveNodeFile(node[field], settings); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `keep ${field}`); + } + const store = fiberNodePaths(node.id, settings).fiberStoreDir; + fs.mkdirSync(store, { recursive: true }); + fs.writeFileSync(path.join(store, 'channel'), 'keep channel'); + } + fs.mkdirSync(settings.devnet.dataPath, { recursive: true }); + fs.writeFileSync(path.join(settings.devnet.dataPath, 'block'), 'keep block'); + writeManifest(manifest, settings); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + function moveFile(field: 'config_file' | 'rpc_token_file', file: string) { + const node = manifest.nodes[0]; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.renameSync(resolveNodeFile(node[field], settings), file); + node[field] = path.relative(path.dirname(nodesYmlPath(settings)), file); + writeManifest(manifest, settings); + } + + function expectAllDataKept() { + expect(fs.readFileSync(path.join(settings.devnet.dataPath, 'block'), 'utf8')).toBe('keep block'); + for (const node of manifest.nodes) { + expect(fs.readFileSync(path.join(fiberNodePaths(node.id, settings).fiberStoreDir, 'channel'), 'utf8')).toBe( + 'keep channel', + ); + for (const field of ['config_file', 'rpc_token_file'] as const) { + expect(fs.readFileSync(resolveNodeFile(node[field], settings), 'utf8')).toBe(`keep ${field}`); + } + } + } + + async function reset(command: 'fiber' | 'devnet') { + if (command === 'fiber') await fiberClean({ data: true, yes: true }, settings); + else clean({ data: true }); + } + + describe.each(['fiber', 'devnet'] as const)('%s data-only reset', (command) => { + it.each(['config_file', 'rpc_token_file'] as const)( + 'refuses %s in another node store before deleting any data', + async (field) => { + moveFile(field, path.join(fiberNodePaths(2, settings).fiberStoreDir, field)); + await expect(reset(command)).rejects.toThrow(`Fiber node 1 ${field}`); + expectAllDataKept(); + }, + ); + + it('detects a retained file reached through a directory link', async () => { + const target = fiberNodePaths(2, settings).fiberStoreDir; + moveFile('config_file', path.join(target, 'config.yml')); + const alias = path.join(root, 'store-link'); + fs.symlinkSync(target, alias, 'junction'); + manifest.nodes[0].config_file = path.join(alias, 'config.yml'); + writeManifest(manifest, settings); + await expect(reset(command)).rejects.toThrow('Fiber node 1 config_file'); + expectAllDataKept(); + }); + + it('allows custom files beside the store and preserves their contents', async () => { + const directory = `${fiberNodePaths(1, settings).fiberStoreDir}-keep`; + moveFile('config_file', path.join(directory, 'custom.yml')); + moveFile('rpc_token_file', path.join(directory, 'custom-token')); + await reset(command); + for (const node of manifest.nodes) { + expect(fs.existsSync(fiberNodePaths(node.id, settings).fiberStoreDir)).toBe(false); + for (const field of ['config_file', 'rpc_token_file'] as const) { + expect(fs.readFileSync(resolveNodeFile(node[field], settings), 'utf8')).toBe(`keep ${field}`); + } + } + expect(fs.existsSync(settings.devnet.dataPath)).toBe(command === 'fiber'); + }); + }); + + it.each(['config_file', 'rpc_token_file'] as const)( + 'refuses %s in CKB data before deleting chain or Fiber data', + async (field) => { + moveFile(field, path.join(settings.devnet.dataPath, field)); + await expect(reset('devnet')).rejects.toThrow(`Fiber node 1 ${field}`); + expectAllDataKept(); + }, + ); + + it('rechecks retained paths after confirmation before deleting any stores', async () => { + jest.mocked(confirm).mockImplementationOnce(async () => { + moveFile('config_file', path.join(fiberNodePaths(2, settings).fiberStoreDir, 'custom.yml')); + return true; + }); + await expect(fiberClean({ data: true }, settings)).rejects.toThrow('Fiber node 1 config_file'); + expectAllDataKept(); + }); + + it('leaves externally referenced files after a full devnet cleanup', () => { + const directory = path.join(root, 'external'); + moveFile('config_file', path.join(directory, 'custom.yml')); + moveFile('rpc_token_file', path.join(directory, 'custom-token')); + clean(); + expect(fs.existsSync(settings.devnet.configPath)).toBe(false); + for (const field of ['config_file', 'rpc_token_file'] as const) { + expect(fs.readFileSync(resolveNodeFile(manifest.nodes[0][field], settings), 'utf8')).toBe(`keep ${field}`); + } + }); +}); From 7df9a67b7323b3ffdc0fcab6f2a26fafc4b0717f Mon Sep 17 00:00:00 2001 From: RetricSu Date: Wed, 28 Oct 2026 21:28:26 +0800 Subject: [PATCH 6/6] fix(build): avoid shipping Biscuit runtime twice --- docs/fiber-validation.md | 28 ++++++++++++++++++++++++---- package.json | 2 +- pnpm-lock.yaml | 6 +++--- scripts/build.js | 22 ++++++++++++++++++++-- 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/docs/fiber-validation.md b/docs/fiber-validation.md index 1deec07..23dd84a 100644 --- a/docs/fiber-validation.md +++ b/docs/fiber-validation.md @@ -4,9 +4,9 @@ ## 环境 -- 2026-10-27,macOS arm64,Node.js 24.8.0。 +- 2026-10-27 至 2026-10-28,macOS arm64,Node.js 24.8.0。 - 实际 CKB 0.208.0、FNN 0.9.0(e6cb7ac)。FNN 发布包 SHA-256 与仓库已有固定值一致。 -- 测试数据和下载文件放在本任务工作树的忽略目录中。独立配置指向测试链目录,没有重置用户原有本地链。 +- 测试数据和下载文件放在本任务工作树的忽略目录中;发布包隔离检查另使用系统临时目录。独立配置指向测试链目录,没有重置用户原有本地链。 ## 自动化检查 @@ -15,7 +15,7 @@ - `pnpm lint`:通过,保留 `src/node/install.ts` 的一个既有 `any` 警告。 - Rebase 到 `develop`(`b444332`)并修复清理边界的 review 意见后,重新运行 `pnpm test:ci --runInBand --silent`:57 个测试套件通过,559 项通过,7 项按平台/原有条件跳过;覆盖率阈值通过。类型检查、lint 和构建也重新通过。 - `git diff --check`:通过。 -- 发行构建额外验证:运行 `build/index.js` 的隔离入口执行已存在环境的 `fiber init`,加载随包发布的 Biscuit WASM/ESM 文件成功;无需启用 Node 的实验性 WASM 模块参数。 +- 发行构建额外验证:运行 `build/index.js` 的隔离入口执行已存在环境的 `fiber init`,加载随包发布的 Biscuit WASM/ESM 文件成功;无需启用 Node 的实验性 WASM 模块参数。去除重复打包后又执行了下文更严格的解包检查。 自动化重点包括:首次默认准备、显式准备、追加与编号上限、账户导入和随机账户、配置不覆盖、准备中断后复用密钥、FNN 加密密钥比对、启动前检查所有节点、Biscuit 签名和权限、凭证文件替换、HTTP 认证失败、运行信息快照、默认配置损坏及数据重置边界。Rebase 时新增检查:CKB 管理进程将信号交给 Fiber 后,初始化等待 FNN 程序下载期间收到 SIGTERM 也会退出,不吞掉信号。 @@ -35,7 +35,27 @@ Review 修复新增 12 项回归,覆盖两个数据清理入口、自定义配 - Rebase 后使用真实 CKB/FNN 重跑已有双节点环境的 `node --fiber --daemon`、`fiber status` 和 `node stop`,认证节点与默认节点均正常,停止后无测试 CKB、矿工或 FNN 进程残留。新基线已包含含空格 CLI 路径的进程识别修复(#533);本次移除初次验证使用的进程标题引号包装,在含空格的任务路径下直接通过后台停止检查。 - Review 修复后,在全新的隔离双节点环境中修改创世 message,执行 `clean --data` 并单独启动 CKB。旧配置按预期因 `fiber.scripts` 过期而拒绝启动;根据新链系统脚本信息仅更新合约引用后,两个真实 FNN 恢复运行,包括开启认证的节点。逐文件 SHA-256 核对 CKB 密钥、密码、Fiber 身份、Biscuit 密钥及 token 均未改变;除合约引用外的配置值也未改变。最后分别停止 Fiber 与 CKB。 +## 发布包与重复依赖 + +保留官方 `@biscuit-auth/biscuit-wasm` 0.5.0,改为构建依赖。发布包已携带运行资源,生产安装无需再单独安装一份库。构建根据 WASM 声明的导入选择 JS 绑定与 snippet,保留 WASM、ESM 标记和许可证,移除未用的入口、类型文件和其他 snippet;复制前删除旧资源目录,避免重复构建残留。签名及鉴权代码没有改动。 + +2026-10-28 使用相同本地依赖,对比优化前提交 `4b0241f` 与优化后的 `pnpm build`、`npm pack --ignore-scripts`(单位:字节): + +| 项目 | 优化前 | 优化后 | 减少 | +| --- | ---: | ---: | ---: | +| `build/` 全部文件 | 9,181,580 | 9,158,693 | 22,887 | +| npm 压缩包 | 3,378,306 | 3,374,090 | 4,216 | +| npm 包解压内容 | 10,920,669 | 10,897,782 | 22,887 | +| 单独安装的 Biscuit 依赖内容 | 2,160,865 | 0 | 2,160,865 | + +合计少分发约 2.18 MB 文件内容;这不代表压缩包减少 2.18 MB,也不计算包管理器缓存共享和文件系统开销。相对 `develop` 基线 `b444332`,整个 Fiber 改动的 npm 包解压内容仍增加 2,184,459 字节,压缩包增加 777,448 字节。 + +- 再次通过冻结锁文件离线安装、类型检查、lint、构建和完整测试/覆盖率检查:57 个套件、559 项通过,7 项跳过;lint 仍只有原有警告。 +- 将真实 npm 包解压到工作树之外的全新临时目录,确认该入口及其上级目录无法解析开发用的 Biscuit 依赖,资源目录只有实际需要的五个文件,WASM 与官方依赖逐字节一致。 +- 使用解包后的 CLI 和独立设置,在真实 CKB/FNN 上完成首次自动准备双节点、生成密钥/token、重复 `fiber init` 校验、启用节点 1 的 RPC 认证并重新启动、自动互连和状态查询。未携带 token 的调用返回 `Unauthorized`;OffCKB 管理调用成功。CKB 密钥、密码、节点身份、Biscuit 密钥及 token 的哈希在重启前后一致。未使用实验性 WASM 模块参数。 +- 测试结束后停止所有测试节点,检查相关本地端口均已释放。该发布包联调仍限于 macOS / Node 24;本轮打包调整的跨平台结果以 PR CI 为准。 + ## 未验证项与既有问题 - 普通 FNN 的节点管理、自定义配置保留与 RPC 认证已验证。LSP 及其他扩展工具的端到端行为未验证,也不属于本次基础节点管理的交付前提;OffCKB 没有 LSP 或 `lsp-cli` 运行依赖,不能仅凭配置保留宣称特定扩展兼容。 -- 专门的 CKB/FNN 实程序联调仅在 macOS / Node 24 上运行。此前提交 `13ef0d9` 的仓库 CI 已在 Linux、Windows、macOS / Node 20 上通过自动化测试、构建及通用 CLI 集成检查;本轮修复的跨平台结果以 PR CI 为准。 +- 专门的 CKB/FNN 实程序联调仅在 macOS / Node 24 上运行。此前提交 `4b0241f` 的仓库 CI 已在 Linux、Windows、macOS / Node 20 上通过自动化测试、构建及通用 CLI 集成检查;本轮打包调整的跨平台结果以 PR CI 为准。 diff --git a/package.json b/package.json index 7b8ee95..1301ae8 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ ] }, "devDependencies": { + "@biscuit-auth/biscuit-wasm": "0.5.0", "@changesets/cli": "^2.29.8", "@types/adm-zip": "^0.5.5", "@types/blessed": "0.1.27", @@ -75,7 +76,6 @@ "typescript": "^5.3.3" }, "dependencies": { - "@biscuit-auth/biscuit-wasm": "0.5.0", "@ckb-ccc/core": "1.14.0", "@iarna/toml": "^2.2.5", "@inquirer/prompts": "^7.8.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afc1b00..17dfeac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,9 +24,6 @@ importers: .: dependencies: - '@biscuit-auth/biscuit-wasm': - specifier: 0.5.0 - version: 0.5.0 '@ckb-ccc/core': specifier: 1.14.0 version: 1.14.0(typescript@5.8.2)(zod@3.25.76) @@ -76,6 +73,9 @@ importers: specifier: ^3.17.0 version: 3.17.0 devDependencies: + '@biscuit-auth/biscuit-wasm': + specifier: 0.5.0 + version: 0.5.0 '@changesets/cli': specifier: ^2.29.8 version: 2.29.8(@types/node@20.17.24) diff --git a/scripts/build.js b/scripts/build.js index f5fb2cd..60641fe 100755 --- a/scripts/build.js +++ b/scripts/build.js @@ -2,6 +2,7 @@ const { execSync } = require('child_process'); const fs = require('fs'); +const path = require('path'); console.log('🏗️ Building offckb CLI...\n'); @@ -14,14 +15,31 @@ try { // Step 2: Bundle with NCC console.log('🔗 Bundling with NCC...'); execSync('ncc build dist/cli.js -o build --external cpu-features', { stdio: 'inherit' }); - fs.cpSync('node_modules/@biscuit-auth/biscuit-wasm/module', 'build/biscuit', { recursive: true }); + // Ship the official runtime once, without its unused entry point, types or snippets. + const biscuitSource = 'node_modules/@biscuit-auth/biscuit-wasm/module'; + const biscuitModule = new WebAssembly.Module(fs.readFileSync(path.join(biscuitSource, 'biscuit_bg.wasm'))); + const biscuitFiles = [ + 'biscuit_bg.wasm', + ...new Set(WebAssembly.Module.imports(biscuitModule).map((entry) => entry.module)), + ]; + fs.rmSync('build/biscuit', { recursive: true, force: true }); + for (const file of biscuitFiles) { + const target = path.join('build/biscuit', file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(path.join(biscuitSource, file), target); + } fs.writeFileSync('build/biscuit/package.json', JSON.stringify({ type: 'module' })); fs.copyFileSync('node_modules/@biscuit-auth/biscuit-wasm/LICENSE', 'build/biscuit/LICENSE'); console.log('✅ NCC bundling complete\n'); // Step 3: Verify build output console.log('🔍 Verifying build output...'); - const buildFiles = ['build/index.js']; + const buildFiles = [ + 'build/index.js', + ...biscuitFiles.map((file) => path.join('build/biscuit', file)), + 'build/biscuit/package.json', + 'build/biscuit/LICENSE', + ]; let allFilesExist = true; for (const file of buildFiles) {