fn-fpk

Other

飞牛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"、"飞牛应用"等关键词时触发。

Install

openclaw skills install fn-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_uidirUI 组件目录路径(相对应用根目录,默认 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官方
reloaduiDocker 应用容器重启后刷新 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 应用推荐)

{
  "defaults": {
    "run-as": "package"
  },
  "username": "docker-fn-chromium"
}
  • run-aspackage(应用用户,默认)或 root
  • username:指定运行用户(常用于 Docker 应用,如 docker-{appname}
  • root 权限仅限飞牛官方合作企业开发者使用,但部分第三方应用会使用

Root 权限(Native 系统服务)

{
  "defaults": {
    "run-as": "root"
  }
}
  • 适用于 install_type=root 的 Native 应用(如 fail2ban 需要 systemctl 管理系统服务)

外部文件访问

用户可在应用设置中授权目录,支持:读写权限、只读权限、禁止访问。也可通过 config/resourcedata-share 设置默认共享目录。

应用资源(config/resource)

JSON 格式,声明应用的扩展能力。

数据共享(data-share)

共享目录在文件管理器的"应用文件"中可见:

{
  "data-share": {
    "shares": [
      {
        "name": "config",
        "permission": { "rw": ["docker-fn-chromium"] }
      }
    ]
  }
}
  • rw:读写权限 | ro:只读权限
  • 应用可通过 $TRIM_DATA_SHARE_PATHS 环境变量访问共享目录路径

Docker 项目(docker-project)

Docker 应用必须声明此块:

{
  "docker-project": {
    "projects": [
      {
        "name": "fn-chromium",
        "path": "docker"
      }
    ]
  }
}
  • name:Docker Compose 项目名
  • path:docker-compose.yaml 所在子目录(相对于 app/

系统集成(usr-local-linker)

启动时自动创建软链接到系统目录:

{
  "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.cgiindex.cgi 脚本,由 fnOS 系统通过 HTTP 请求调用,进而转发到后端进程(Node.js/Python/Go 等)。

CGI 代理脚本示例(Node.js)

app/ui/api.cgi 负责接收用户请求并代理到 Node.js 后端:

#!/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)

#!/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 或直接引用:

export PATH=$PATH:${TRIM_APPDEST}/vendor
7zz x /path/to/archive.7z

应用入口(app/ui/config)

定义应用的访问入口,JSON 格式。

文件右键菜单入口(Native 应用)

Native 应用通过 CGI 网关提供功能,用户通过 fnOS 文件管理器的右键菜单触发:

{
    ".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 应用:端口直连)

{
  ".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 网关代理)

{
  ".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.cgifullUrlPerm: "readonly" 防止用户修改 URL。

统一的桌面入口配置

{
  ".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/,应用中心打开入口时页面空白。

文件右键入口

{
  ".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
fullUrlPermURL 编辑权限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+)porturl 字段可使用 ${wizard_xxx} 语法动态获取向导配置。

统一网关注册(进阶)

需要 fnOS V1.1.31+。接入后无需新增端口监听,用户通过系统地址+路径访问。

{
  ".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用户 UID1000
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":"只能包含字母数字下划线"}

安装向导示例

[
  {
    "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} 语法。

卸载向导示例

[
  {
    "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 检查:

#!/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:

#!/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(进程管理模式)

#!/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 占位,或检查兼容性冲突。

#!/bin/bash
# 检查与官方商店是否冲突
if [ -d "/var/apps/docker-chromium" ]; then
  echo "该应用不能与官方商店的浏览器共存,请先卸载官方商店的浏览器后再安装此应用。" >$TRIM_TEMP_LOGFILE
  exit 1
fi
exit 0

Native 应用:安装依赖包、配置默认设置。

#!/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 — 安装完成后

设置文件权限、初始化配置:

#!/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 个文件存在性):

#!/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

echo "配置文件不存在,应用启动失败!" > "${TRIM_TEMP_LOGFILE}"
exit 1

不写入环境变量直接 exit 1 时,系统展示:执行XX脚本出错且原因未知

Docker Compose 配置示例

docker-compose.yaml 放在 app/docker/ 目录下,支持常见的 fnOS 模板变量:

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

#!/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

# 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 标志:

${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(旧版本兼容)。

# 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

# 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 声明默认连接
Redisredis127.0.0.1:6379
MinIOminio127.0.0.1:9000
RabbitMQrabbitmq127.0.0.1:5672 (guest/guest)

应用依赖管理

  • 安装/启用:自动安装和启用依赖应用
  • 停用/卸载:检查是否有其他应用依赖当前应用
  • 嵌套依赖:不递归检查,需平铺声明所有直接和间接依赖
  • 顺序:从后往前安装(install_dep_apps=dep2:dep1 先装 dep1)

图标规范

  • ICON.PNG:64x64 像素,应用中心列表显示
  • ICON_256.PNG:256x256 像素,应用详情页显示
  • 入口图标(images/):64x64 和 256x256,文件名含 {0} 占位符
  • 圆角矩形背景图标 PSD 源文件:下载

应用创建与打包(fnpack CLI)

安装 fnpack

下载对应平台的二进制并加入 PATH:

fnpack 已预置在 fnOS 系统中。

创建项目

# 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

打包项目

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)

#!/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)

@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/privilegeconfig/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:

# 以 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_initinstall_callback
  • uninstall_inituninstall_callback
  • upgrade_initupgrade_callback
  • config_initconfig_callback

2. CRLF 换行符导致脚本执行失败

症状:应用中心报 config_init / upgrade_init 等脚本错误,错误码 15001,无详细消息。

原因:在 Windows 环境下创建/编辑脚本文件时,换行符是 CRLF(\r\n)。Linux 执行 #!/bin/bash\r 时,\r 被当作命令名的一部分,导致 shebang 失效,脚本执行失败。

解决:所有 cmd/ 脚本文件必须使用 LF 换行符。

# 在 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 字节:

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=musicplayerappname = musicplayer 都有效。
  • 每个字段独占一行,没有续行符。
  • 不需要的字段去掉:如 disable_authorization_path 等低版本不认识的字段会导致校验失败。

4. ICON.PNG 必须是有效 64x64 PNG

症状fnpack build 成功但安装时提示"应用包不符合系统要求"。

原因fnpack 不太校验图标内容,但应用中心安装时会解析 PNG 头信息验证尺寸。尺寸不对或文件损坏都会失败。

解决:生成后用工具验证:

# 用 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/443gatewaySocket 可能不会正常工作。
  • 可靠方案:用端口方式(type: "url" + port),把认证放在应用层做(网页登录密码),不依赖系统网关。
  • 入口配置示例(端口方案)
    {
      ".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_initcmd/config_callback:

  • 使用 LF 换行符
  • 内容至少包含 exit 0
  • 有可执行权限

备选方案:如果应用不需要响应设置变更,可以让脚本快速成功返回:

#!/bin/bash
exit 0

8. 调试方法论

  • 查看应用中心错误日志
    sudo cat /var/log/trim_app_center/error.log | tail -30
    
  • 查看应用启动日志
    sudo cat /vol3/@appdata/<appname>/info.log
    
  • 查看系统事件
    sudo journalctl -u trim_app_center.service --no-pager -n 30
    
    关注 APP_INSTALL_FAILED_PKG_EXCEPTIONAPP_START_FAILED_LOCAL_APP_RUN_EXCEPTION 等事件。
  • 手动测试 API
    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
    
  • 检查监听状态
    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:把 manifestICON.PNGICON_256.PNGcmd/config/wizard/app.tgz 一起打包

高级用户也可以用tar 命令手动构建 fpk(不依赖 fnpack):

# 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/configapp/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 lineno 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. 入口配置实战

经过多个项目验证,最可靠的入口配置方式:

方案一:端口直连(推荐,最通用)

{
  ".url": {
    "myapp.main": {
      "title": "我的应用",
      "icon": "images/icon_{0}.png",
      "type": "url",
      "protocol": "http",
      "port": "8399",
      "url": "/",
      "allUsers": true
    }
  }
}

方案二:CGI 网关(Native 应用通过 fnOS 反向代理)

{
  ".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(基于 飞牛论坛攻略):

#!/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 发起请求:

// ✅ 正确 — 相对路径,浏览器解析为 proxy.cgi/api/status
fetch('api/status', { credentials: 'same-origin' })

// ❌ 错误 — 绝对路径,会绕过 proxy.cgi 直接请求根路径
fetch('/api/status')

入口配置

{
  ".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 示例

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-chromiumDockerreloadui=yesplatform=all
fn-codeserverDockerreloadui=yesplatform=all
fn-fail2banNativeinstall_type=rootplatform=all
fn-monitorNativeinstall_type=rootplatform=all
fn-kodiDockerreloadui=yes
fn-terminalDockerreloadui=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