Skill: dalingo81/fn-fpk (version 1.0.0)

--- name: fn-fpk description: 飞牛NAS (fnOS) FPK 应用打包开发技能。使用此技能开发和打包飞牛NAS第三方应用（.fpk），包括：Native 应用（Node.js/Python/Java等）和 Docker 应用。涵盖整个开发周期：项目创建、manifest配置、权限/资源配置、生命周期脚本编写（cmd/main）、用户入口配置（app/ui/config）、向导配置（wizard）、图标规范、CGI 同源反向代理（proxy.cgi + REQUEST_URI）、localhost 安全白名单、统一网关注册/认证、依赖管理、运行时环境配置、fnpack CLI 打包，到 fpk 文件测试上架。用户提到"飞牛"、"fnOS"、"FPK"、"飞牛应用"等关键词时触发。 --- # fn-fpk - 飞牛NAS FPK 应用开发 ## 应用类型 飞牛 fnOS 支持两种应用类型： | 类型 | 用途 | 模板命令 | |------|------|----------| | **Native 应用** | 直接运行在 fnOS 上的应用（Node.js/Python/Java/Go/shell 等） | `fnpack create <appname>` | | **Docker 应用** | 基于 Docker Compose 容器编排运行 | `fnpack create <appname> --template docker` | 纯服务类型（无 Web UI）：添加 `--without-ui true` 参数。 ## 项目结构 FPK 项目有两种主流结构，视应用类型而定。 ### Docker 应用结构 ``` myapp/ ├── app/ │ ├── docker/ # Docker Compose 文件 │ │ ├── docker-compose.yaml │ │ └── endpoint.sh # 入口脚本（可选占位文件，如 #!/bin/sh） │ └── ui/ # Web UI 入口配置 │ ├── images/ # 入口图标资源（icon_64.png, icon_256.png） │ └── config # 入口配置文件（JSON） ├── manifest # 应用基本信息 ├── cmd/ # 生命周期管理脚本 │ ├── main # 启动/停止/状态检查（必需） │ ├── install_init # 安装前（必需，可仅 exit 0） │ ├── install_callback # 安装后（必需，可仅 exit 0） │ ├── uninstall_init # 卸载前（必需，可仅 exit 0） │ ├── uninstall_callback # 卸载后（必需，可仅 exit 0） │ ├── upgrade_init # 升级前（必需，可仅 exit 0） │ ├── upgrade_callback # 升级后（必需，可仅 exit 0） │ ├── config_init # 配置变更前（必需，可仅 exit 0） │ └── config_callback # 配置变更后（必需，可仅 exit 0） ├── config/ │ ├── privilege # 权限配置（JSON，必需） │ └── resource # 资源配置（JSON，必需） ├── wizard/ # 向导配置（可选，RROrg 多数应用省略） │ ├── install # 安装向导 │ ├── uninstall # 卸载向导 │ └── config # 配置向导 ├── ICON.PNG # 64x64 图标（必选） ├── ICON_256.PNG # 256x256 图标（必选） └── LICENSE # 许可协议（可选） ``` ### Native 应用结构（RROrg 模式） ``` myapp/ ├── app/ │ ├── server/ # 后端服务代码（Node.js/Python/Go 二进制等） │ │ └── .gitkeep # app/ 目录不能为空，空目录用 .gitkeep 占位 │ ├── www/ # Web 前端文件（HTML/JS/CSS，由后端自行 serve） │ │ ├── index.html │ │ ├── css/ │ │ └── js/ │ ├── vendor/ # 捆绑的第三方二进制（可选，如 7zz 解压引擎） │ └── ui/ # 统一的桌面 Web UI 入口（通过 CGI 代理） │ ├── images/ # 入口图标资源 │ ├── config # 入口配置文件 │ └── index.cgi # CGI 代理入口（将请求代理到 www/ + 处理认证） ├── manifest ├── cmd/ │ ├── main │ ├── install_init # 安装前：apt install 依赖包等 │ ├── install_callback # 安装后：chmod +x *.cgi 等 │ ├── uninstall_init │ ├── uninstall_callback │ ├── upgrade_init │ ├── upgrade_callback │ ├── config_init # 通常仅 exit 0 占位 │ └── config_callback ├── config/ │ ├── privilege │ └── resource ├── ICON.PNG └── ICON_256.PNG ``` > **Native 应用结构要点**：`app/server/` 存放服务端代码，`app/www/` 存放前端静态文件，`app/ui/` 存放系统入口配置 + CGI 代理。如果不用 CGI 网关，简化为 `app/server/` + `app/ui/`。 系统安装后目录位于 `/usr/local/apps/@appcenter/{appname}/`（新体系）或 `/var/apps/{appname}/`（旧体系）。应用数据位于 `/usr/local/apps/@appdata/{appname}/`。 ## manifest 文件配置 `manifest` 文件无扩展名，放在应用包根目录。 ### 必选字段 | 字段 | 说明 | 示例 | |------|------|------| | `appname` | 唯一标识符（建议用 `fn-` 前缀标识第三方应用） | `fn-myapp` | | `version` | 版本号 x[.y[.z]][-build] | `1.0.0` / `1.0.6` | | `display_name` | 用户看到的名称 | `我的应用` | | `desc` | 详细介绍（支持 HTML 标签） | 见下方示例 | | `source` | 固定值 | `thirdparty` | **desc 支持丰富 HTML 格式**（xinZip 的 desc 示例）： ``` desc = `<div><strong>分卷解压</strong><br/>专为 fnOS 文件管理器右键场景设计<br/><br/><strong>支持格式</strong><br/>• 7Z 分卷：<code>.001</code><br/>• ZIP 分卷：<code>.zip</code> + <code>.z01</code><br/>• RAR 分卷：<code>.part1.rar</code><br/><br/><strong>核心功能</strong><br/>• 自动识别首卷并校验分卷顺序<br/>• 支持输入密码的压缩包解压</div>` ``` ### 架构声明 | 字段 | 说明 | 取值 | |------|------|------| | `platform` | 架构（V1.1.8+，替代arch） | `x86` / `arm` / `all` | | `arch` | 旧字段（已废弃，但兼容） | `x86_64` | > **`arch` vs `platform`**：新系统推荐 `platform=x86|arm|all`，但 xinZip（分卷解压）等社区应用仍使用 `arch=x86_64`（旧格式）。两种格式都有效。Docker 应用一般设为 `all`。等号两边可有空格。 ### 开发者信息 | 字段 | 说明 | |------|------| | `maintainer` | 开发者/团队名称 | | `maintainer_url` | 开发者网站（如 GitHub） | | `distributor` | 发布者名称 | | `distributor_url` | 发布者网站 | ### 系统兼容与依赖 | 字段 | 说明 | 示例 | |------|------|------| | `os_min_version` | 最低系统版本 | `0.8.0` | | `os_max_version` | 最高系统版本 | `0.9.100` | | `install_dep_apps` | 依赖应用列表 | `mariaDB:redis` / `nodejs_v22` / `python312` | 依赖格式：`app1>2.2.2:app2`（`>` 表示最低版本要求），冒号分隔多个。 ### UI 配置 | 字段 | 说明 | |------|------| | `desktop_uidir` | UI 组件目录路径（相对应用根目录，默认 `ui`，RROrg 所有应用设为 `ui`） | | `desktop_applaunchname` | 应用中心启动入口 entry ID，对应 `{desktop_uidir}/config` 中的某个入口 | ### 端口管理 | 字段 | 说明 | 默认值 | |------|------|--------| | `service_port` | 应用监听端口 | - | | `checkport` | 是否启用端口检查 | `true` | > **端口号必须是字符串**：`config/app/ui/config` 中的 `port` 字段必须用字符串（如 `"8399"`），否则 fnOS 拼接 URL 时可能出错。`manifest` 中的 `service_port` 可以是数字或字符串。 ### 其他控制字段 | 字段 | 说明 | 默认值 | 来源 | |------|------|--------|------| | `ctl_stop` | 是否显示启动/停止按钮和运行状态 | `true` | 官方 | | `install_type` | 安装类型，设为 `root` 安装到系统分区 | 空（用户可选存储位置）| 官方 | | `disable_authorization_path` | 是否禁用授权目录功能 | `false` | 官方 | | `reloadui` | Docker 应用容器重启后刷新 UI 入口 | `yes`（RROrg 所有 Docker 应用设置）| RROrg | | `changelog` | 更新日志 | - | 官方 | **`reloadui=yes`**：用于 Docker 应用，当容器重启时系统会重新加载 UI 入口配置，确保入口状态正确。RROrg 的所有 Docker 应用都使用此字段。 ### manifest 完整示例（Docker 应用） ``` appname = fn-chromium version = 1.0.2 display_name = chromium desc = Chromium 是一个开源的网页浏览器项目，旨在为用户提供更安全、更快速和更稳定的浏览体验。 platform = all source = thirdparty desktop_uidir = ui desktop_applaunchname = fn-chromium.Application maintainer = linuxserver maintainer_url = https://github.com/linuxserver/docker-chromium distributor = linuxserver distributor_url = https://github.com/linuxserver reloadui = yes ``` ### manifest 完整示例（Native 应用） ``` appname = fn-fail2ban version = 1.0.1 display_name = fail2ban desc = fail2ban 是一个开源的入侵防御工具，用于保护 Linux 服务器免受暴力破解攻击。 platform = all source = thirdparty desktop_uidir = ui desktop_applaunchname = fn-fail2ban.Application maintainer = Ing maintainer_url = https://github.com/RROrg distributor = RROrg distributor_url = https://github.com/RROrg/fn-apps install_type = root ``` > **`install_type=root`**：Native 系统级服务（如 fail2ban 需要系统 apt 安装包、管理 systemd 服务）应该使用 `root` 安装类型，确保安装到系统分区。对于可通过存储池安装的普通应用，省略此字段。 ### 等号格式 manifest 中字段等号两侧可以有空格（RROrg 喜用等号两边对齐的格式），两种写法都有效： ``` appname=myapp appname = myapp ``` ## 应用权限（config/privilege） JSON 格式，定义应用运行身份。 ### 默认权限模式（Docker 应用推荐） ```json { "defaults": { "run-as": "package" }, "username": "docker-fn-chromium" } ``` - `run-as`：`package`（应用用户，默认）或 `root` - `username`：指定运行用户（常用于 Docker 应用，如 `docker-{appname}`） - **root 权限**仅限飞牛官方合作企业开发者使用，但部分第三方应用会使用 ### Root 权限（Native 系统服务） ```json { "defaults": { "run-as": "root" } } ``` - 适用于 `install_type=root` 的 Native 应用（如 fail2ban 需要 `systemctl` 管理系统服务） ### 外部文件访问 用户可在应用设置中授权目录，支持：读写权限、只读权限、禁止访问。也可通过 `config/resource` 的 `data-share` 设置默认共享目录。 ## 应用资源（config/resource） JSON 格式，声明应用的扩展能力。 ### 数据共享（data-share） 共享目录在文件管理器的"应用文件"中可见： ```json { "data-share": { "shares": [ { "name": "config", "permission": { "rw": ["docker-fn-chromium"] } } ] } } ``` - `rw`：读写权限 | `ro`：只读权限 - 应用可通过 `$TRIM_DATA_SHARE_PATHS` 环境变量访问共享目录路径 ### Docker 项目（docker-project） Docker 应用必须声明此块： ```json { "docker-project": { "projects": [ { "name": "fn-chromium", "path": "docker" } ] } } ``` - `name`：Docker Compose 项目名 - `path`：docker-compose.yaml 所在子目录（相对于 `app/`） ### 系统集成（usr-local-linker） 启动时自动创建软链接到系统目录： ```json { "usr-local-linker": { "bin": ["bin/myapp-cli"], "lib": ["lib/mylib.so"], "etc": ["etc/myapp.conf"] } } ``` ### Native 应用的 resource 文件 如果 Native 应用没有 data-share 或 docker-project 需求，resource 文件可以为一个空 JSON 对象 `{}`（仅两个字节）。RROrg 的 fn-fail2ban 的 `config/resource` 文件内容为空 `{}`。 ## CGI 代理模式（Native 应用核心模式） Native FPK 应用（非 Docker）通常使用一个关键的 CGI 代理机制：在 `app/ui/` 下放一个 `api.cgi` 或 `index.cgi` 脚本，由 fnOS 系统通过 HTTP 请求调用，进而转发到后端进程（Node.js/Python/Go 等）。 ### CGI 代理脚本示例（Node.js） `app/ui/api.cgi` 负责接收用户请求并代理到 Node.js 后端： ```bash #!/bin/bash APP_NAME="xinZip" SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)" API_SCRIPT="${SCRIPT_DIR%/ui}/server/api.js" export PATH=/var/apps/nodejs_v22/target/bin:$PATH if [ ! -f "$API_SCRIPT" ]; then API_SCRIPT="/var/apps/${APP_NAME}/target/server/api.js" fi send_json_error() { msg="$1" echo "Content-Type: application/json; charset=utf-8" echo "Cache-Control: no-store" echo "" printf '{"success":false,"code":500,"msg":"%s"}\n' "$msg" } if [ ! -f "$API_SCRIPT" ]; then send_json_error "API 脚本不存在" exit 0 fi if ! command -v node >/dev/null 2>&1; then send_json_error "未找到 node 运行环境" exit 0 fi exec node "$API_SCRIPT" ``` **关键要点**： - CGI 脚本必须使用 `#!/bin/bash` shebang 并在第一行 - 用 `SCRIPT_DIR` 自动检测当前路径，兼容开发和生产环境 - 用 `exec` 执行后端进程，传递 stdin/stdout - 返回 HTTP 头（`Content-Type`）和 JSON 响应 - `exit 0` 而非 `exit 1`，因为 HTTP 响应已由脚本自身输出 ### CGI 代理脚本示例（Python） ```python #!/usr/bin/env python3 # -*- coding: utf-8 -*- import sys import json # 添加依赖库路径 sys.path.insert(0, '/var/apps/fnnas.liveplayer/target/server/lib') from api import handle_request # 输出 HTTP 头 print("Content-Type: application/json; charset=utf-8") print("Cache-Control: no-store") print() # 处理请求 result = handle_request() print(json.dumps(result)) ``` ### `app/www/` 前端静态文件 前端文件放在 `app/www/` 目录下，由 CGI 脚本或后端服务直接 serve： ``` app/www/ ├── css/ │ └── app.css ├── js/ │ └── app.js └── index.html ``` > **注意**：`app/www/` 目录并非 fnOS 系统保留关键字，仅作为约定。实际的静态文件服务器路由由后端代码自行实现（如 Express 的 `app.use(express.static('www'))`）。 ### `app/vendor/` 第三方二进制（可选） Native 应用可以捆绑第三方可执行文件到 `app/vendor/` 目录下。安装后位于 `TRIM_APPDEST/vendor/`。 ``` app/vendor/ └── 7zz # 7-Zip 解压引擎 ``` 脚本中通过 `PATH` 或直接引用： ```bash export PATH=$PATH:${TRIM_APPDEST}/vendor 7zz x /path/to/archive.7z ``` ## 应用入口（app/ui/config） 定义应用的访问入口，JSON 格式。 ### 文件右键菜单入口（Native 应用） Native 应用通过 CGI 网关提供功能，用户通过 fnOS 文件管理器的右键菜单触发： ```json { ".url": { "xinZip.Application": { "title": "分卷解压", "icon": "images/icon_{0}.png", "type": "iframe", "protocol": "http", "url": "/cgi/ThirdParty/xinZip/index.cgi", "allUsers": true, "fileTypes": ["001", "rar", "zip"], "noDisplay": true } } } ``` **关键要点**： - `type: "iframe"`：在桌面内嵌打开 - `protocol: "http"`：不需要声明端口，系统自动处理 CGI 路径 - `url: "/cgi/ThirdParty/{appname}/index.cgi"`：fnOS CGI 代理路径，注意**末尾没有 / 斜杠** - `fileTypes`：声明关联的文件扩展名，右键菜单据此显示 ### 桌面图标入口（Docker 应用：端口直连） ```json { ".url": { "fn-chromium.Application": { "title": "Chromium", "desc": "Chromium", "icon": "images/icon_{0}.png", "type": "iframe", "url": "/chromium/", "allUsers": true, "control": { "accessPerm": "readonly" } } } } ``` > Dcoker 应用使用 `type: "iframe"` 和固定路径 `url: "/chromium/"` 将 Docker 容器的子路径嵌入到桌面中。 ### 桌面图标入口（Native 应用：CGI 网关代理） ```json { ".url": { "fn-fail2ban.Application": { "title": "fn-fail2ban", "icon": "images/icon_{0}.png", "type": "iframe", "url": "/cgi/ThirdParty/fn-fail2ban/index.cgi/", "allUsers": false, "control": { "accessPerm": "readonly", "fullUrlPerm": "readonly" } } } } ``` > **CGI 网关方案**：Native 应用通过 `url: "/cgi/ThirdParty/{appname}/index.cgi/"` 路径访问，fnOS 系统将请求代理到 `app/ui/index.cgi`。`fullUrlPerm: "readonly"` 防止用户修改 URL。 ### 统一的桌面入口配置 ```json { ".url": { "myapp.main": { "title": "我的应用", "icon": "images/icon-{0}.png", "type": "url", "protocol": "http", "port": "8080", "url": "/", "allUsers": true } } } ``` > ⚠️ **`url` 只写路径部分**：`url` 字段只应写路径（如 `"/"`），**不要包含端口**。fnOS 系统会用 `http://${host}:${port}${url}` 的格式拼接完整地址。如果 `url` 写成 `":8080/"`，会导致端口重复，如 `http://192.168.1.100:8080:8080/`，应用中心打开入口时页面空白。 ### 文件右键入口 ```json { ".url": { "myapp.editor": { "title": "文本编辑器", "icon": "images/editor-{0}.png", "type": "url", "protocol": "http", "port": "8080", "url": "/edit", "allUsers": true, "fileTypes": ["txt", "md", "json"], "noDisplay": true } } } ``` ### 字段说明 | 字段 | 说明 | 可选值 | |------|------|--------| | `title` | 显示标题 | 字符串 | | `desc` | 描述文字（可选） | 字符串 | | `icon` | 图标路径（相对 UI 目录），`{0}` 替换为尺寸 | `images/icon-{0}.png` | | `type` | 打开方式 | `url`（新标签页） / `iframe`（桌面内嵌） | | `protocol` | 访问协议（V1.1.8+ 支持环境变量 `${variable}`） | `http` / `https` / `""`（自适应） | | `port` | 端口（CGI方案无需声明，V1.1.8+ 支持环境变量占位符） | `8080` / `${wizard_port}` | | `url` | 访问路径 | `/` / `/admin` / CGI 路径 | | `allUsers` | 是否所有用户可见 | `true` / `false` | | `fileTypes` | 文件右键入口关联文件类型 | `["001","rar","zip"]` | | `noDisplay` | 是否在桌面隐藏（`true` 时从桌面图标区隐藏，仅通过右键菜单访问） | `true` / `false` | | `accessPerm` | 桌面访问设置权限 | `editable` / `readonly` / `hidden` | | `fullUrlPerm` | URL 编辑权限 | `readonly` / `editable` | | `control` | 精细控制对象 | `{"accessPerm": "readonly", "fullUrlPerm": "readonly"}` | > **`noDisplay: true` + `fileTypes` 右键菜单应用**：这是 xinZip（分卷解压）、NexPlay（IPTV播放器）等工具类 Native 应用的经典模式。应用不在桌面显示图标，用户通过 fnOS 文件管理器右键点击关联文件类型（如 `.001`、`.rar`、`.zip`）→「打开方式」→选择应用来触发。`fileTypes` 声明了哪些文件类型关联此应用。 > **`fullUrlPerm: "readonly"`**：RROrg 在 Native 应用中使用此字段，防止用户在应用设置中误修改 CGI 代理 URL。 **环境变量支持（V1.1.8+）**：`port` 和 `url` 字段可使用 `${wizard_xxx}` 语法动态获取向导配置。 ## 统一网关注册（进阶） 需要 fnOS V1.1.31+。接入后无需新增端口监听，用户通过系统地址+路径访问。 ```json { ".url": { "trim.app": { "title": "应用A", "icon": "images/icon_{0}.png", "type": "iframe", "protocol": "", "gatewaySocket": "app.sock", "gatewayPrefix": "/app/trim-app", "url": "/app/trim-app", "allUsers": true } } } ``` - `gatewayPrefix`：格式 `/app/{appname}/{customPath}`，不含 `.` - `gatewaySocket`：Socket 文件名，放在 target 目录下 ### 登录认证 网关校验登录态后透传 Header： | Header | 说明 | 示例 | |--------|------|------| | `X-Trim-Uid` | 用户 UID | `1000` | | `X-Trim-Isadmin` | 是否管理员 | `true` | | `X-Trim-Username` | 用户名 | `admin` | 应用侧建议：WebSocket 连接后绑定 X-Trim-Uid，不信任客户端主动上报的用户 ID。 ### 不鉴权接口 应单独设计路径，保持最小暴露范围：只开放必要路径和方法，不返回敏感信息，不提供写入/删除能力。 ## 菜单向导（wizard/） JSON 数组，每个元素为一个步骤（含 stepTitle 和 items）。 > **RROrg 经验**：如果你的应用不需要用户输入任何安装配置（如 fail2ban 在 `install_init` 中自动配置），可以省略 `wizard/` 目录。大部分 RROrg 的应用都没有 wizard。 ### 表单项类型 | 类型 | 用途 | 示例值 | |------|------|--------| | `text` | 文本输入 | `{"type":"text","field":"wizard_username","label":"用户名"}` | | `password` | 密码输入 | `{"type":"password","field":"wizard_password","label":"密码"}` | | `radio` | 单选 | `{"type":"radio","field":"wizard_type","options":[{"label":"标准","value":"standard"}]}` | | `checkbox` | 多选 | `{"type":"checkbox","field":"wizard_modules","options":[...]}` | | `select` | 下拉选择 | `{"type":"select","field":"wizard_db","options":[...]}` | | `switch` | 开关 | `{"type":"switch","field":"wizard_enable_backup","initValue":"true"}` | | `tips` | 提示文本 | `{"type":"tips","helpText":"说明文字"}` | ### 验证规则 | 规则 | 示例 | |------|------| | 必填 | `{"required":true,"message":"不能为空"}` | | 长度范围 | `{"min":3,"max":20}` | | 精确长度 | `{"len":6,"message":"请输入6位验证码"}` | | 正则 | `{"pattern":"^[a-zA-Z0-9_]+$","message":"只能包含字母数字下划线"}` | ### 安装向导示例 ```json [ { "stepTitle": "欢迎安装", "items": [ { "type": "tips", "helpText": "欢迎使用我们的应用！" } ] }, { "stepTitle": "创建管理员账号", "items": [ { "type": "text", "field": "wizard_admin_username", "label": "管理员用户名", "initValue": "admin" }, { "type": "password", "field": "wizard_admin_password", "label": "管理员密码" } ] } ] ``` 向导字段名直接作为环境变量在脚本中使用，例如 `$wizard_admin_username`。Docker compose 中也可使用 `${wizard_xxx}` 语法。 ### 卸载向导示例 ```json [ { "stepTitle": "确认卸载", "items": [ { "type": "radio", "field": "wizard_data_action", "label": "数据保留选项", "initValue": "keep", "options": [ { "label": "保留数据", "value": "keep" }, { "label": "删除所有数据", "value": "delete" } ] } ] } ] ``` ## 生命周期管理脚本（cmd/） ### cmd 脚本必须项 所有 9 个 cmd 脚本都必须存在。fnOS V1.1.31+ 会校验 `cmd/` 目录下是否有全部脚本文件，缺少任何一个都会导致安装失败（[REDACTED_SECRET]）。 必选文件：`main`, `install_init`, `install_callback`, `uninstall_init`, `uninstall_callback`, `upgrade_init`, `upgrade_callback`, `config_init`, `config_callback` ### cmd/main — 启动/停止/状态检查 #### Docker 应用 cmd/main（RROrg 模式） Docker 应用的启停由系统自动管理（compose up/down），cmd/main 只需定义 status 检查： ```bash #!/bin/bash FILE_PATH="${TRIM_APPDEST}/docker/docker-compose.yaml" is_docker_running() { DOCKER_NAME="" if [ -f "$FILE_PATH" ]; then DOCKER_NAME=$(cat $FILE_PATH | grep "container_name" | awk -F ':' '{print $2}' | xargs) echo "DOCKER_NAME is set to: $DOCKER_NAME" fi if [ -n "$DOCKER_NAME" ]; then docker inspect $DOCKER_NAME | grep -q '"Status": "running",' || exit 1 return fi } case $1 in start) # Docker 应用由 appcenter 自动启动 exit 0 ;; stop) # Docker 应用由 appcenter 自动停止 exit 0 ;; status) if is_docker_running; then exit 0 else exit 3 fi ;; *) exit 1 ;; esac ``` #### Native 应用 cmd/main（systemd 服务模式） 对于已通过 `install_init` 安装为 systemd 服务的应用（如 fail2ban），直接使用 systemctl： ```bash #!/bin/bash LOG_FILE="${TRIM_PKGVAR}/info.log" log_msg() { echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >>${LOG_FILE} } start_process() { if status; then return 0; fi log_msg "Starting process ..." systemctl start fail2ban.service >>${LOG_FILE} 2>&1 } stop_process() { log_msg "Stopping process ..." systemctl stop fail2ban.service >>${LOG_FILE} 2>&1 } status() { systemctl status fail2ban.service } case $1 in start) start_process ;; stop) stop_process ;; status) if status; then exit 0; else exit 3; fi ;; *) exit 1 ;; esac ``` #### Native 应用 cmd/main（进程管理模式） ```bash #!/bin/bash LOG_FILE="${TRIM_PKGVAR}/info.log" PID_FILE="${TRIM_PKGVAR}/app.pid" export PATH=/var/apps/nodejs_v22/target/bin:$PATH CMD="node ${TRIM_APPDEST}/server/server.js" kill_old() { if [ -f "${PID_FILE}" ]; then local old_pid=$(head -n 1 "${PID_FILE}") if [ -n "${old_pid}" ] && kill -0 "${old_pid}" 2>/dev/null; then kill -TERM "${old_pid}" 2>/dev/null || true sleep 1 kill -KILL "${old_pid}" 2>/dev/null || true fi fi local pids=$(ps aux | grep '[s]erver.js' | awk '{print $2}') for pid in ${pids}; do kill -TERM "${pid}" 2>/dev/null || true sleep 0.5 done sleep 1 rm -f "${PID_FILE}" } start_process() { kill_old bash -c "${CMD}" >> ${LOG_FILE} 2>&1 & printf "%s" "$!" > ${PID_FILE} local port=${TRIM_SERVICE_PORT:-8080} for i in $(seq 1 15); do if bash -c "echo > /dev/tcp/127.0.0.1/${port}" 2>/dev/null; then echo "Service ready on port ${port}" >> ${LOG_FILE} return 0 fi sleep 1 done echo "Service failed to start on port ${port}" >> ${LOG_FILE} return 1 } stop_process() { if [ -r "${PID_FILE}" ]; then pid=$(head -n 1 "${PID_FILE}") kill -TERM ${pid} >> ${LOG_FILE} 2>&1 local count=0 while kill -0 ${pid} 2>/dev/null && [ $count -lt 10 ]; do sleep 1; count=$((count + 1)) done if kill -0 ${pid} 2>/dev/null; then kill -KILL "${pid}" fi fi rm -f "${PID_FILE}" return 0 } status() { [ -f "${PID_FILE}" ] && pid=$(head -n 1 "${PID_FILE}") && kill -0 "${pid}" 2>/dev/null && return 0 return 1 } case $1 in start) start_process ;; stop) stop_process ;; status) if status; then exit 0; else exit 3; fi ;; *) exit 1 ;; esac ``` > **状态码规则**：运行中=0，未运行=3。系统会定期轮询 status 检查。 ### install_init — 安装前准备 **Docker 应用**：通常仅 exit 0 占位，或检查兼容性冲突。 ```bash #!/bin/bash # 检查与官方商店是否冲突 if [ -d "/var/apps/docker-chromium" ]; then echo "该应用不能与官方商店的浏览器共存,请先卸载官方商店的浏览器后再安装此应用。" >$TRIM_TEMP_LOGFILE exit 1 fi exit 0 ``` **Native 应用**：安装依赖包、配置默认设置。 ```bash #!/bin/bash ### This script is called before the user installs the application. apt update apt install -y --no-install-recommends python3-systemd fail2ban [ $? -ne 0 ] && echo "Failed to install fail2ban package." >$TRIM_TEMP_LOGFILE && exit 1 sed -i "s|#allowipv6.*$|allowipv6 = auto|" /etc/fail2ban/fail2ban.conf rm -f /etc/fail2ban/jail.d/*.conf cat <<EOF >/etc/fail2ban/jail.d/fnOS.conf [sshd] enabled = true filter = sshd action = iptables-multiport backend = systemd logpath = journal maxretry = 5 bantime = 3600 EOF exit 0 ``` ### install_callback — 安装完成后 设置文件权限、初始化配置： ```bash #!/bin/bash ### This script is called after the user installs the application. chmod +x ${TRIM_APPDEST}/ui/*.cgi 2>/dev/null || true chmod +x ${TRIM_APPDEST}/www/*.cgi 2>/dev/null || true exit 0 ``` ### 占位脚本（必需性验证） 以下 6 个脚本如果不需要具体逻辑，必须提供但内容可以只有 `exit 0`（V1.1.31+ 校验所有 9 个文件存在性）： ```bash #!/bin/bash ### This script is called after the user change environment variables in application setting page. exit 0 ``` 占位脚本集合：`uninstall_init`, `uninstall_callback`, `upgrade_init`, `upgrade_callback`, `config_init`, `config_callback` ### 错误处理（V1.1.8+） 错误信息写入 `$TRIM_TEMP_LOGFILE`，然后 `exit 1`： ```bash echo "配置文件不存在，应用启动失败！" > "${TRIM_TEMP_LOGFILE}" exit 1 ``` 不写入环境变量直接 exit 1 时，系统展示：`执行XX脚本出错且原因未知`。 ## Docker Compose 配置示例 docker-compose.yaml 放在 `app/docker/` 目录下，支持常见的 fnOS 模板变量： ```yaml services: chromium: image: linuxserver/chromium:${wizard_base:-latest} container_name: chromium environment: - PUID=${TRIM_UID} - PGID=${TRIM_GID} - TZ=Asia/Shanghai - LC_ALL=zh_CN.UTF-8 - CUSTOM_USER=${wizard_username:-admin} - [REDACTED_SECRET] - SUBFOLDER=/chromium/ devices: - /dev/dri:/dev/dri volumes: - /var/apps/fn-chromium/shares/chromium/config:/config ports: - 3000:3000 - 3001:3001 shm_size: "1gb" restart: unless-stopped networks: - trim-default networks: trim-default: external: true ``` > **关键模式**： > - 使用 `${wizard_xxx:-default}` 语法获取向导输入值（无向导则使用默认值） > - 使用 `$TRIM_UID` / `$TRIM_GID` 系统环境变量 > - 使用 `trim-default` 外部网络让系统管理网络 > - volumes 使用 `/var/apps/{appname}/shares/{share_name}/...` 路径访问系统管理的共享目录 > - `SUBFOLDER` 环境变量需要与 `app/ui/config` 中的 `url` 路径一致 `app/docker/endpoint.sh` 作为可选入口占位文件，简单应用可仅含 `#!/bin/sh`： ```sh #!/bin/sh ``` ## 环境变量（脚本中可用） | 变量 | 说明 | |------|------| | `$TRIM_APPNAME` | 应用名（appname） | | `$TRIM_APPVER` | 应用版本 | | `$TRIM_APPDEST` | 应用可执行文件目录（target） | | `$TRIM_PKGETC` | 配置文件目录（etc） | | `$TRIM_PKGVAR` | 运行时数据目录（var） | | `$TRIM_TEMP_LOGFILE` | 用户可见系统日志文件路径 | | `$TRIM_SERVICE_PORT` | 服务端口（manifest 中配置） | | `$TRIM_USERNAME` | 应用用户名 | | `$TRIM_RUN_USERNAME` | 当前运行用户 | | `$TRIM_DATA_SHARE_PATHS` | 数据共享目录路径 | | `$TRIM_PKG_TARGET` | 同 TRIM_APPDEST | | `$TRIM_UID` | 用户 UID（Docker compose 可用） | | `$TRIM_GID` | 用户 GID（Docker compose 可用） | ## 运行时环境 在 manifest 中通过 `install_dep_apps` 声明依赖，脚本中配置 PATH： ### Node.js ```bash # manifest 中: install_dep_apps=nodejs_v22 # 可选版本: nodejs_v22, nodejs_v20, nodejs_v18, nodejs_v16, nodejs_v14 export PATH=/var/apps/nodejs_v22/target/bin:$PATH ``` ### Python **Python 版本选择**：fnOS 通常自带 Python，但建议显式声明版本依赖（`python3.11` > `python3.10` > `python3`）以提高兼容性。 **Debian 12+ 外部管理环境（externally-managed-environment）**： fnOS 基于 Debian 构建，Debian 12+ 默认阻止系统级 pip 安装（externally-managed-environment）。在 `install_callback` 中安装 Python 包时需要使用 `--break-system-packages` 标志： ```bash ${PYTHON} -m pip install --break-system-packages flask apscheduler 2>/dev/null || \ ${PYTHON} -m pip install flask apscheduler 2>/dev/null || true ``` > 先尝试 `--break-system-packages`（Debian 12+），失败则回退到普通 pip（旧版本兼容）。 ```bash # manifest 中: install_dep_apps=python312 # 可选版本: python312, python311, python310, python39, python38 export PATH=/var/apps/python312/target/bin:$PATH python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt ``` ### Java ```bash # manifest 中: install_dep_apps=java-21-openjdk # 可选版本: java-21-openjdk, java-17-openjdk, java-11-openjdk export PATH=/var/apps/java-21-openjdk/target/bin:$PATH ``` ## 中间件服务 通过 `install_dep_apps` 声明依赖即可使用： | 中间件 | manifest 声明 | 默认连接 | |--------|--------------|----------| | Redis | `redis` | `127.0.0.1:6379` | | MinIO | `minio` | `127.0.0.1:9000` | | RabbitMQ | `rabbitmq` | `127.0.0.1:5672` (guest/guest) | ## 应用依赖管理 - **安装/启用**：自动安装和启用依赖应用 - **停用/卸载**：检查是否有其他应用依赖当前应用 - **嵌套依赖**：不递归检查，需平铺声明所有直接和间接依赖 - **顺序**：从后往前安装（`install_dep_apps=dep2:dep1` 先装 dep1） ## 图标规范 - `ICON.PNG`：64x64 像素，应用中心列表显示 - `ICON_256.PNG`：256x256 像素，应用详情页显示 - 入口图标（images/）：64x64 和 256x256，文件名含 `{0}` 占位符 - 圆角矩形背景图标 PSD 源文件：[下载](https://static.fnnas.com/appcenter-marketing/fnpack_ICON_256.zip) ## 应用创建与打包（fnpack CLI） ### 安装 fnpack 下载对应平台的二进制并加入 PATH： - Windows: [fnpack-1.2.1-windows-amd64](https://static2.fnnas.com/fnpack/fnpack-1.2.1-windows-amd64) - Linux: [fnpack-1.2.1-linux-amd64](https://static2.fnnas.com/fnpack/fnpack-1.2.1-linux-amd64) - macOS Intel: [fnpack-1.2.1-darwin-amd64](https://static2.fnnas.com/fnpack/fnpack-1.2.1-darwin-amd64) - macOS M: [fnpack-1.2.1-darwin-arm64](https://static2.fnnas.com/fnpack/fnpack-1.2.1-darwin-arm64) fnpack 已预置在 fnOS 系统中。 ### 创建项目 ```bash # Native 应用（默认模板） fnpack create myapp # Docker 应用 fnpack create myapp --template docker # 纯服务类型（无 UI） fnpack create myapp --without-ui true fnpack create myapp --template docker --without-ui true ``` ### 打包项目 ```bash cd myapp fnpack build # 或指定目录 fnpack build --directory /path/to/myapp ``` ### 打包校验规则 | 项目 | 规则 | |------|------| | manifest | 必须存在，必选字段完整 | | config/privilege | 必须存在，合法 JSON | | config/resource | 必须存在，合法 JSON | | ICON.PNG | 必须存在 | | ICON_256.PNG | 必须存在 | | app/ | 必须存在 | | cmd/ | 必须存在 | | wizard/ | 必须存在 | | app/{desktop_uidir}/ | 若 manifest 定义了 desktop_uidir 则必须存在 | ## 批量构建脚本（RROrg 模式） RROrg 项目使用统一的 `build.sh` / `build.bat` 批量构建仓库下所有应用。支持： - 自动下载 fnpack 二进制（指定版本，如 1.0.4） - 遍历仓库下所有包含 `manifest` 的目录 - 支持跳过 `norelease` 标记的应用 - 支持每个应用独立的 `build.sh` 构建脚本 - 打包时自动解 appname/version/platform 重命名 fpk 文件 - 支持指定单个或多个应用构建 ### Linux 构建脚本（build.sh） ```bash #!/bin/bash curl -kL https://static2.fnnas.com/fnpack/fnpack-1.0.4-linux-amd64 -o fnpack sudo chmod +x fnpack [ -n "$*" ] && APPS="$*" || APPS=$(find "${PWD}" -maxdepth 1 -type d | sort) for APP in ${APPS}; do [ -f "${APP}/norelease" ] && continue [ -f "${APP}/manifest" ] || continue APPNAME=$(grep -w '^appname' "${APP}/manifest" | awk -F= '{print $2}' | xargs) VERSION=$(grep -w '^version' "${APP}/manifest" | awk -F= '{print $2}' | xargs) PLATFORM=$(grep -w '^platform' "${APP}/manifest" | awk -F= '{print $2}' | xargs) echo "Building ${APP} ..." if [ -f "${APP}/build.sh" ]; then chmod +x "$(realpath "${APP}")/build.sh" "$(realpath "${APP}")/build.sh" [ $? -ne 0 ] && echo "Build script failed for ${APP}" && exit 1 else ./fnpack build --directory ${APP} mv -f "${APPNAME}.fpk" "${APPNAME}_${PLATFORM}_v${VERSION}.fpk" fi done ``` ### Windows 构建脚本（build.bat） ```batch @echo off setlocal enabledelayedexpansion curl -kL https://static2.fnnas.com/fnpack/fnpack-1.0.4-windows-amd64 -o fnpack.exe if not "%~1"=="" ( set "APPS=%*" ) else ( set "APPS=" for /f "delims=" %%D in ('dir /b /ad "%CD%" ^| sort') do ( set "APPS=!APPS! "%%~fD"" ) ) for %%A in (%APPS%) do ( if exist "%%A\norelease" ( REM skip ) else if exist "%%A\manifest" ( REM 解析 appname 和 version for /f "tokens=2 delims==" %%i in ('findstr /i /r "^appname *=.*" "%%A\manifest"') do ( if not defined APPNAME set "APPNAME=%%i" ) for /f "tokens=2 delims==" %%i in ('findstr /i /r "^version *=.*" "%%A\manifest"') do ( if not defined VERSION set "VERSION=%%i" ) for /f "tokens=2 delims==" %%i in ('findstr /i /r "^platform *=.*" "%%A\manifest"') do ( if not defined PLATFORM set "PLATFORM=%%i" ) for /f "tokens=* delims= " %%i in ("!APPNAME!") do set "APPNAME=%%i" for /f "tokens=* delims= " %%i in ("!VERSION!") do set "VERSION=%%i" for /f "tokens=* delims= " %%i in ("!PLATFORM!") do set "PLATFORM=%%i" if exist "%%A\build.bat" ( call "%%A\build.bat" ) else ( .\fnpack.exe build --directory %%A if defined APPNAME if defined VERSION if exist "!APPNAME!.fpk" ( move /y "!APPNAME!.fpk" "!APPNAME!_!PLATFORM!_v!VERSION!.fpk" >nul ) ) ) ) ``` > **`norelease` 文件**：在应用目录下创建空文件 `norelease`，构建脚本会跳过该应用。用于开发中或已废弃的应用。 ## 开发工作流 1. **初始化**：`fnpack create <appname>` 创建项目骨架 2. **配置 manifest**：填写应用基本信息、依赖 3. **配置权限和资源**：编辑 `config/privilege` 和 `config/resource` 4. **编写生命周期脚本**：编辑 `cmd/main` 和其他 cmd 脚本 5. **配置 UI 入口**：编辑 `app/ui/config` 6. **配置向导**：编辑 `wizard/install` 等（可选） 7. **复制编译产物**：放入 `app/` 目录 8. **打包**：`fnpack build` 生成 `.fpk` 文件 9. **测试**：在 fnOS 上安装 fpk 测试 10. **发布**：通过开发者后台提交（目前需联系飞牛团队） ### 集成构建（推荐） 在 CI/编译脚本中添加 fnpack build，每次编译自动生成 fpk： ```bash # 以 Node.js 为例 npm run build fnpack build -d fnnas.notepad ``` --- ## 实战踩坑记录（持续更新） 以下是在实际 FPK 项目开发中遇到的问题和解决方案。 ### 1. cmd 脚本必须全部补齐 **症状**：应用中心安装报 [REDACTED_SECRET]，应用中心日志显示类似： ``` checkPackage /vol3/appcenter-downloads/musicplayer-1.0.0-tpk/cmd/install_init is not exist ``` **原因**：fnOS V1.1.31+ 的应用中心会校验所有 8 个 cmd 脚本是否存在。路径验证时是按文件名列表逐个检查的，缺任何一个都会直接失败。 **解决**：确保 `cmd/` 目录下包含以下全部 9 个文件（即使内容只有 `exit 0`）： - `main`（必须实现 start/stop/status 三个分支） - `install_init`、`install_callback` - `uninstall_init`、`uninstall_callback` - `upgrade_init`、`upgrade_callback` - `config_init`、`config_callback` ### 2. CRLF 换行符导致脚本执行失败 **症状**：应用中心报 `config_init` / `upgrade_init` 等脚本错误，错误码 15001，无详细消息。 **原因**：在 Windows 环境下创建/编辑脚本文件时，换行符是 CRLF（`\r\n`）。Linux 执行 `#!/bin/bash\r` 时，`\r` 被当作命令名的一部分，导致 shebang 失效，脚本执行失败。 **解决**：所有 `cmd/` 脚本文件必须使用 **LF** 换行符。 ```bash # 在 Linux 上检查换行符 file cmd/main # 应该显示 "Bourne-Again shell script, ASCII text executable" # 如果有 CRLF 会显示 "with CRLF line terminators" # 转换 CRLF 为 LF sed -i 's/\r$//' cmd/* # 或用 dos2unix dos2unix cmd/* ``` > **注意**：在 Node.js 中 `str.replace(/\r\n/g,'\n')` 在 Windows 上保存时可能又被系统加回 CRLF。建议用二进制方式直接删除 `\r` 字节： > ```javascript > let b = fs.readFileSync(fp); > b = Buffer.from(b.filter(x => x !== 13)); > fs.writeFileSync(fp, b); > ``` ### 3. manifest 格式兼容性 **症状**：本地 `fnpack build` 成功，但上传到应用中心安装时报"应用包不符合系统要求"。 **原因**：不同版本的 fnOS 对 manifest 的支持有差异。 **经验**： - **`platform` vs `arch`**：V1.1.8+ 推荐用 `platform=x86|arm|all`，但老版本不认识。兼容方案：使用 `arch=x86_64`（旧格式），等号两边加空格： ``` arch = x86_64 ``` - **`os_min_version`**：V1.1.3104 这种版本号（4段）可能不会被正确解析。设为较低版本（如 `0.5.0`）或 `1.1.0`。 - **`install_dep_apps`**：依赖应用名必须与商店中的名称完全一致，如 `nodejs_v22`。 - **等号两边空格不影响解析**：`appname=musicplayer` 和 `appname = musicplayer` 都有效。 - **每个字段独占一行**，没有续行符。 - **不需要的字段去掉**：如 `disable_authorization_path` 等低版本不认识的字段会导致校验失败。 ### 4. ICON.PNG 必须是有效 64x64 PNG **症状**：`fnpack build` 成功但安装时提示"应用包不符合系统要求"。 **原因**：`fnpack` 不太校验图标内容，但应用中心安装时会解析 PNG 头信息验证尺寸。尺寸不对或文件损坏都会失败。 **解决**：生成后用工具验证： ```bash # 用 Python 检查 python3 -c "import struct; h=open('ICON.PNG','rb').read(24); print(struct.unpack('>II',h[16:24]))" # 输出应为 (64, 64) ``` ### 5. 统一网关 (gatewaySocket) 与端口方案的选择 **症状**：用 `gatewaySocket` 配置时，通过应用中心点击打开无响应或页面空白。 **经验**： - **gatewaySocket 要求 fnOS V1.1.31+**，且 socket 文件必须写到 `TRIM_PKGDEST` 目录（即应用安装目录，非 `var/`）。 - **gatewaySocket 在 `@appcenter` 路径下可能不可用**（沙箱限制），具体看系统版本。 - **如果 fnOS HTTP/HTTPS 端口不是标准 80/443**，`gatewaySocket` 可能不会正常工作。 - **可靠方案**：用端口方式（`type: "url"` + `port`），把认证放在应用层做（网页登录密码），不依赖系统网关。 - **入口配置示例（端口方案）**： ```json { ".url": { "myapp.main": { "title": "我的应用", "icon": "images/icon-{0}.png", "type": "url", "protocol": "http", "port": "8399", "url": "/", "allUsers": true } } } ``` - **`type: "iframe"` 需要谨慎**：HTTPS 页面上 iframe 加载 HTTP 内容会被浏览器拦截为混合内容。如果系统使用 HTTPS，入口必须用统一网关或应用层也跑 HTTPS。 ### 6. 应用权限（文件系统访问） **症状**：应用启动正常，但无法读取 NAS 共享文件夹中的音乐/文件。 **原因**：应用以专用用户（如 `musicplayer`）运行，默认不在 `Users` 组中，没有访问其他用户目录的权限。 **解决**： - 将应用用户加入 `Users` 组：`sudo usermod -a -G Users <appname>` - 或在应用设置 → 编辑 → 文件权限中授权文件夹（但可能触发 `config_init` 回调报错） - 最简单：直接加组后重启应用中心：`sudo systemctl restart trim_app_center.service` ### 7. 应用设置保存失败（config_init） **症状**：在应用中心编辑应用设置 → 保存时提示"执行配置初始化脚本失败"。 **原因**：应用设置→文件权限授权后，系统会调用 `config_init` 脚本。如果脚本有 CRLF 换行符，或者脚本执行返回非零退出码，就会报这个错。 **解决**：确保 `cmd/config_init` 和 `cmd/config_callback`: - 使用 LF 换行符 - 内容至少包含 `exit 0` - 有可执行权限 > **备选方案**：如果应用不需要响应设置变更，可以让脚本快速成功返回： > ```bash > #!/bin/bash > exit 0 > ``` ### 8. 调试方法论 - **查看应用中心错误日志**： ```bash sudo cat /var/log/trim_app_center/error.log | tail -30 ``` - **查看应用启动日志**： ```bash sudo cat /vol3/@appdata/<appname>/info.log ``` - **查看系统事件**： ```bash sudo journalctl -u trim_app_center.service --no-pager -n 30 ``` 关注 [REDACTED_SECRET]、[REDACTED_SECRET] 等事件。 - **手动测试 API**： ```bash curl -s http://127.0.0.1:<port>/api/stats curl -s -X PUT -H 'Content-Type: application/json' -d '{"musicPaths":["/vol3/music"]}' http://127.0.0.1:<port>/api/config ``` - **检查监听状态**： ```bash sudo ss -tlnp | grep <port> ``` ### 9. Node.js 应用常见坑 - **PATH 问题**：`which node` 在系统 PATH 中找不到，实际 Node.js 可能在 `/vol3/@appcenter/nodejs_v22/bin/node`。cmd/main 中需要先找到正确路径再 export PATH。 - **依赖安装**：`npm install` 需要 `node` 在 PATH 中才能执行。cd 到 `server/` 目录后要设置好 PATH 再调用 npm。 - **IPv6 监听**：`server.listen(PORT, '::')` 在 Node.js 中同时监听 IPv4 和 IPv6（默认 `'0.0.0.0'` 只监听 IPv4），如果 NAS 通过 IPv6 访问需要这个。 - **端口冲突**：`checkport=true` 时系统会先检查端口占用。如果旧进程没清理干净，启动失败。cmd/main 的 `start` 分支必须 `kill_old`。 ### 10. fpk 包构建内部原理 `fnpack build` 实际上做了两件事： 1. **打包 `app.tgz`**：把 `app/` 目录下的所有文件打包（但不保留 `app/` 这一层目录） 2. **打包最终 fpk**：把 `manifest`、`ICON.PNG`、`ICON_256.PNG`、`cmd/`、`config/`、`wizard/` 和 `app.tgz` 一起打包 高级用户也可以用**纯 `tar` 命令手动构建 fpk**（不依赖 `fnpack`）： ```bash # 1. 构建 app.tgz，--transform 去掉 app/ 前缀层 tar -czf app.tgz --transform='s,app/,,g' app/docker app/www app/ui app/server config # 2. 打包 fpk（排除原始 app/ 目录，用 app.tgz 替代） tar -czf myapp.fpk --exclude='app' * ``` > `--transform='s,app/,,g'` 的作用：`app/ui/config` 变成 `ui/config`，`app/server/server.js` 变成 `server/server.js`。 > 这是因为安装后，`app/` 的内容会被解压到 `target/`（`TRIM_APPDEST`），而 fpk 根目录的其他文件保持不变。 ### 10.5 CGI 代理模式的常见陷阱 #### 陷阱一：直接写 Python CGI 不可靠 **症状**：`app/ui/index.cgi` 用 `#!/usr/bin/env python3` 直接写 Python CGI 代码，在浏览器中打开时 `trim_http_cgi` 返回 `bogus header line` 或 `no headers` 错误。 **原因**： - Python stdout 默认是缓冲的，CGI 输出可能不是第一时间到达 - `trim_http_cgi` 对 CGI 协议要求严格：第一行必须是 `Content-Type: xxx`，任何先输出的内容都会被当作 HTTP 头解析 - Python 3 不支持 `os.fdopen(fd, 'w', 0)`（unbuffered text I/O 被禁止） **解决方案**： 1. 用 **bash CGI 脚本** 做反向代理（方案三），Python/Flask 只处理 localhost 端口请求 2. 或者在 Python CGI 脚本顶层立刻 `sys.stdout = open(sys.stdout.fileno(), 'w', buffering=1)`（行缓冲） 3. 确保 `print()` 输出的第一行是 `Content-Type: text/html` #### 陷阱二：不能直接用 PATH_INFO 做路由 **症状**：`/cgi/ThirdParty/app/index.cgi/api/status` 等子路径请求，`trim_http_cgi` 始终返回同样响应。 **原因**：`trim_http_cgi` 不转发 PATH_INFO。调用 CGI 脚本时所有子路径请求走同一入口，脚本内部只能通过 `REQUEST_URI` 环境变量区分路径。 **解决方案**：用 proxy.cgi 方案，解析 `REQUEST_URI` 提取路径转发到本地服务。 #### 陷阱三：curl `--include` 产生不合法的 CGI 响应头 **症状**：proxy.cgi 用 `curl --include` 转发响应时，`trim_http_cgi` 返回 `bogus header line: HTTP/1.1 200 OK`。 **原因**：`curl --include` 输出的第一行是 HTTP 状态行（如 `HTTP/1.1 200 OK`），这不是 `trim_http_cgi` 期望的 `Key: Value` 头格式。 **解决方案**：用 sed 去掉第一行：`curl ... | sed -e '1{/^HTTP\//d}'` #### 陷阱四：前端 API 路径用绝对路径绕过代理 **症状**：前端 JS 用 `fetch('/api/status')` 发请求，浏览器实际访问 `http://nas:5666/api/status`（404），而不是通过 proxy.cgi 代理。 **原因**：绝对路径 `/api/status` 相对于当前域名 root，不会经过 `/cgi/ThirdParty/app/proxy.cgi/` 路由。 **解决方案**：前端必须用**相对路径** `fetch('api/status')`，让浏览器根据当前 iframe URL 自动补全为 `proxy.cgi/api/status`。 ### 11. 入口配置实战 经过多个项目验证，最可靠的入口配置方式： **方案一：端口直连（推荐，最通用）** ```json { ".url": { "myapp.main": { "title": "我的应用", "icon": "images/icon_{0}.png", "type": "url", "protocol": "http", "port": "8399", "url": "/", "allUsers": true } } } ``` **方案二：CGI 网关（Native 应用通过 fnOS 反向代理）** ```json { ".url": { "myapp.main": { "title": "我的应用", "icon": "images/icon_{0}.png", "type": "iframe", "protocol": "", "port": "", "url": "/cgi/ThirdParty/myapp/proxy.cgi/", "allUsers": true, "control": { "accessPerm": "readonly", "fullUrlPerm": "readonly" } } } } ``` > **经验**：如果 fnOS 的 HTTP/HTTPS 是非标端口（如 5666/5667），统一网关方案可能不工作。此时用方案一 + 应用层密码认证最可靠。 > `port: ""` 空字符串表示不暴露独立端口，完全走系统的反向代理。 ### 方案三：CGI 同源反向代理（推荐，btrfs-cleaner 实战方案） **背景**：如果 app 需要 Web UI 但想保持同源（使用 fnOS 鉴权、同一域名下 iframe），可以用 CGI bash 脚本做反向代理。 **原理**：fnOS 的 `trim_http_cgi` 网关在调用 CGI 脚本时设置了 `REQUEST_URI` 环境变量（包含完整请求路径）。用 bash 脚本解析 `REQUEST_URI`，将 `proxy.cgi` 后面的路径**原样转发**到本地端口（如 `localhost:5100`）。 **工作流程**： ``` 浏览器 → /cgi/ThirdParty/app/proxy.cgi/api/status ↓ (trim_http_cgi 执行 bash proxy.cgi，传入 REQUEST_URI) bash proxy.cgi 解析 REQUEST_URI，提取 /api/status ↓ (curl 转发) http://localhost:5100/api/status ← Flask app ↓ (curl 透传响应) bash proxy.cgi 回传 → trim_http_cgi → 浏览器 ``` **proxy.cgi 模板**： 保存到 `app/ui/proxy.cgi`（基于 [飞牛论坛攻略](https://club.fnnas.com/forum.php?mod=viewthread&tid=59220)）： ```bash #!/bin/bash # CGI 反向代理 - 将 CGI 同源请求转发到本地端口服务 # REQUEST_URI 环境变量由 trim_http_cgi 设置 cgi_name="proxy.cgi" target_url="http://localhost:5100" # 解析 REQUEST_URI，提取 proxy.cgi 后面的路径 if [[ "$REQUEST_URI" == *"$cgi_name"* ]]; then after_proxy="${REQUEST_URI#*$cgi_name}" if [[ "$after_proxy" == *"?"* ]]; then target_path=$(echo "$after_proxy" | cut -d'?' -f1) target_query=$(echo "$after_proxy" | cut -d'?' -f2-) else target_path="$after_proxy" target_query="" fi else target_path="" target_query="$QUERY_STRING" fi [ -z "$target_path" ] && target_path="/" target_url="$target_url$target_path" [ -n "$target_query" ] && target_url="$target_url?$target_query" # 构建 curl 参数并执行 curl_args=(-s --include -X "$REQUEST_METHOD") [ -n "$HTTP_COOKIE" ] && curl_args+=(-H "Cookie: $HTTP_COOKIE") [ -n "$CONTENT_TYPE" ] && curl_args+=(-H "Content-Type: $CONTENT_TYPE") curl_args+=("$target_url") # 去掉 curl --include 输出的 HTTP 状态行（如 HTTP/1.1 200 OK） # trim_http_cgi 只接受 "Key: Value" 格式的头部，拒绝 "HTTP/1.1 ..." 格式 if [ "$REQUEST_METHOD" = "POST" ] || [ "$REQUEST_METHOD" = "PUT" ]; then exec cat | curl "${curl_args[@]}" --data-binary @- | sed -e '1{/^HTTP\//d}' -e '/^HTTP\/1.1 100/,/^\r\?$/d' else exec curl "${curl_args[@]}" | sed -e '1{/^HTTP\//d}' -e '/^HTTP\/1.1 100/,/^\r\?$/d' fi ``` **前端 API 路径**：前端 JS 必须用**相对路径**（不带前导 `/`），让浏览器通过 proxy.cgi 发起请求： ```javascript // ✅ 正确 — 相对路径，浏览器解析为 proxy.cgi/api/status fetch('api/status', { credentials: 'same-origin' }) // ❌ 错误 — 绝对路径，会绕过 proxy.cgi 直接请求根路径 fetch('/api/status') ``` **入口配置**： ```json { ".url": { "myapp.main": { "title": "我的应用", "icon": "images/icon_{0}.png", "type": "iframe", "protocol": "http", "port": "", "url": "/cgi/ThirdParty/myapp/proxy.cgi/", "allUsers": true, "control": { "accessPerm": "readonly", "fullUrlPerm": "readonly" } } } } ``` **manifest 配置**：CGI 代理模式下 `checkport=false` 避免端口检查阻塞： ``` service_port = 5100 checkport = false ctl_stop = true disable_authorization_path = true ``` ### 12. 安全建议 #### 12.1 CGI 同源代理模式的安全防护 本地服务通过 proxy.cgi 间接暴露时，应用端口应**只在 localhost 监听**，拒绝外部直接访问： **Python Flask 示例**： ```python from flask import request, abort @app.before_request def check_local_only(): remote = request.remote_addr if remote not in ('127.0.0.1', '::1', '::ffff:127.0.0.1'): abort(403) ``` > **关键**：Flask 绑定 `::`（IPv6 双栈）时，IPv4 localhost 请求的 `remote_addr` 是 `::ffff:127.0.0.1`，必须加入白名单。 验证：外部 IP 直连应返回 403，`127.0.0.1` 和 `::1` 应返回 200。 #### 12.2 端口直连模式的安全防护 如果应用暴露端口到外网（如 8399），建议在应用层加密码保护： - **Web 前端加登录页**：访问 `http://nas:8399/` 时先显示登录页，输入密码后才能进入 - **密码存储**：存到 `TRIM_PKGVAR` 目录下的隐藏文件（如 `.webpass`），默认密码可设为 `admin` - **Session Token**：登录成功后生成 token 保存到 cookie，设置过期时间（如 24 小时） - **后端认证中间件**：对 `/api/` 请求验证 token 或 X-Trim-Uid 头（从统一网关来的自动通过） --- ## 附录：RROrg/fn-apps 项目结构速查 参考项目：https://github.com/RROrg/fn-apps ### 应用一览 | 应用 | 类型 | manifest 关键字段 | |------|------|------------------| | fn-chromium | Docker | `reloadui=yes`，`platform=all` | | fn-codeserver | Docker | `reloadui=yes`，`platform=all` | | fn-fail2ban | Native | `install_type=root`，`platform=all` | | fn-monitor | Native | `install_type=root`，`platform=all` | | fn-kodi | Docker | `reloadui=yes` | | fn-terminal | Docker | `reloadui=yes` | ### Docker 应用目录结构 ``` fn-chromium/ ├── app/ │ ├── docker/ │ │ ├── docker-compose.yaml # 标准 Compose，使用 trim-default 网络 │ │ └── endpoint.sh # 占位入口（#!/bin/sh） │ └── ui/ │ ├── config # 桌面入口 JSON │ └── images/ ├── manifest # 含 reloadui=yes ├── cmd/ │ ├── main # Docker 状态检查 │ ├── install_init # 检查兼容性冲突 │ └── ... # 其余占位 exit 0 ├── config/ │ ├── privilege # run-as: package, 使用 docker-{appname} 用户 │ └── resource # docker-project + data-share ├── ICON.PNG └── ICON_256.PNG ``` ### CGI 代理型 Native 应用目录结构 ``` fn-fail2ban/ 或者 xinZip/ ├── app/ │ ├── server/ │ │ └── api.js # Node.js 后端 API │ ├── www/ │ │ ├── index.html # Web 前端 │ │ ├── css/ │ │ └── js/ │ ├── vendor/ │ │ └── 7zz # 捆绑的第三方二进制（可选） │ └── ui/ │ ├── config # 入口 JSON（CGI 路径 + fileTypes） │ ├── images/ │ └── api.cgi # CGI 代理入口（exec 后端进程） ├── manifest # arch=x86_64, install_dep_apps=nodejs_v22 ├── cmd/ │ ├── main # 全部 exit 0（不守护进程） │ └── ... ├── config/ │ ├── privilege # run-as: package │ └── resource # data-share（可选） ├── ICON.PNG └── ICON_256.PNG ``` ### 系统级 Native 应用目录结构（fail2ban 模式） ``` fn-fail2ban/ ├── app/ │ ├── server/ │ │ └── .gitkeep # 后端服务代码（可选目录） │ ├── www/ │ │ ├── index.html # Web 前端 │ │ ├── app.js │ │ ├── style.css │ │ └── api.cgi # CGI API 后端 │ └── ui/ │ ├── config # 入口 JSON（CGI 路径） │ ├── images/ │ └── index.cgi # CGI 代理入口 ├── manifest # install_type=root, platform=all ├── cmd/ │ ├── main # systemctl 管理服务 │ ├── install_init # apt install 依赖 │ ├── install_callback # chmod +x *.cgi │ └── ... # 其余占位 exit 0 ├── config/ │ ├── privilege # run-as: root │ └── resource # {}（空 JSON） ├── ICON.PNG └── ICON_256.PNG ```


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)