飞牛NAS (fnOS) FPK 应用打包开发技能。使用此技能开发和打包飞牛NAS第三方应用(.fpk),包括:Native 应用(Node.js/Python/Java等)和 Docker 应用。涵盖整个开发周期:项目创建、manifest配置、权限/资源配置、生命周期脚本编写(cmd/main)、用户入口配置...
---
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/` 目录下是否有全部脚本文件,缺少任何一个都会导致安装失败(`APP_INSTALL_FAILED_PKG_EXCEPTION`)。
必选文件:`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}
- PASSWORD=${wizard_password:-admin}
- 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 脚本必须全部补齐
**症状**:应用中心安装报 `APP_INSTALL_FAILED_PKG_EXCEPTION`,应用中心日志显示类似:
```
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
```
关注 `APP_INSTALL_FAILED_PKG_EXCEPTION`、`APP_START_FAILED_LOCAL_APP_RUN_EXCEPTION` 等事件。
- **手动测试 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
```don't have the plugin yet? install it then click "run inline in claude" again.