← 返回 Skills 市场
dalingo81

fn-fpk

作者 Thomas · GitHub ↗ · v1.0.0 · MIT-0
cross-platform ⚠ suspicious
54
总下载
0
收藏
0
当前安装
1
版本数
在 OpenClaw 中安装
/install fn-fpk
功能描述
飞牛NAS (fnOS) FPK 应用打包开发技能。使用此技能开发和打包飞牛NAS第三方应用(.fpk),包括:Native 应用(Node.js/Python/Java等)和 Docker 应用。涵盖整个开发周期:项目创建、manifest配置、权限/资源配置、生命周期脚本编写(cmd/main)、用户入口配置...
使用说明 (SKILL.md)

fn-fpk - 飞牛NAS FPK 应用开发

应用类型

飞牛 fnOS 支持两种应用类型:

类型 用途 模板命令
Native 应用 直接运行在 fnOS 上的应用(Node.js/Python/Java/Go/shell 等) fnpack create \x3Cappname>
Docker 应用 基于 Docker Compose 容器编排运行 fnpack create \x3Cappname> --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 = `\x3Cdiv>\x3Cstrong>分卷解压\x3C/strong>\x3Cbr/>专为 fnOS 文件管理器右键场景设计\x3Cbr/>\x3Cbr/>\x3Cstrong>支持格式\x3C/strong>\x3Cbr/>• 7Z 分卷:\x3Ccode>.001\x3C/code>\x3Cbr/>• ZIP 分卷:\x3Ccode>.zip\x3C/code> + \x3Ccode>.z01\x3C/code>\x3Cbr/>• RAR 分卷:\x3Ccode>.part1.rar\x3C/code>\x3Cbr/>\x3Cbr/>\x3Cstrong>核心功能\x3C/strong>\x3Cbr/>• 自动识别首卷并校验分卷顺序\x3Cbr/>• 支持输入密码的压缩包解压\x3C/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 应用推荐)

{
  "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"}\
' "$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
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+)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 用户 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":"只能包含字母数字下划线"}

安装向导示例

[
  {
    "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 \x3C\x3CEOF >/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 声明 默认连接
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 源文件:下载

应用创建与打包(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\
orelease" (
    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 \x3Cappname> 创建项目骨架
  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\ )。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\ /g,'\ ') 在 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 \x3Cappname>
  • 或在应用设置 → 编辑 → 文件权限中授权文件夹(但可能触发 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/\x3Cappname>/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:\x3Cport>/api/stats
    curl -s -X PUT -H 'Content-Type: application/json' -d '{"musicPaths":["/vol3/music"]}' http://127.0.0.1:\x3Cport>/api/config
    
  • 检查监听状态
    sudo ss -tlnp | grep \x3Cport>
    

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-chromium Docker reloadui=yesplatform=all
fn-codeserver Docker reloadui=yesplatform=all
fn-fail2ban Native install_type=rootplatform=all
fn-monitor Native install_type=rootplatform=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
安全使用建议
Install only if you are comfortable using a skill that helps create NAS apps with root-capable lifecycle scripts, Docker services, CGI proxies, and package installation steps. Before applying its examples, prefer scoped FPK file permissions over adding app users to broad host groups, and manually review any generated shell scripts before running or packaging them.
能力评估
Purpose & Capability
The skill’s stated purpose is fnOS FPK app development, and most examples for manifests, lifecycle scripts, Docker apps, CGI proxies, resources, and packaging fit that purpose.
Instruction Scope
The skill includes user-directed sudo/systemctl/apt/Docker examples, and one troubleshooting section recommends adding an app account to the broad host Users group as the easiest fix for file access instead of using narrower app authorization controls.
Install Mechanism
The artifact contains only SKILL.md, with no executable installer, package dependencies, background worker, or automatic runtime behavior in the skill itself.
Credentials
Privileged NAS administration commands are expected for FPK system-app development, but the blanket group-membership workaround can expand an app’s access to user files beyond least privilege.
Persistence & Privilege
The skill describes persistent generated apps, PID files, Docker services, root run modes, and systemd management; these are disclosed and purpose-aligned, but require careful review before applying.
如何使用
  1. 确保已安装 OpenClaw(本地或 Docker 部署)
  2. 在对话框中输入安装命令:/install fn-fpk
  3. 安装完成后,直接呼叫该 Skill 的名称或使用 /fn-fpk 触发
  4. 根据 Skill 的参数说明提供必要输入,即可获得结构化输出
版本历史
v1.0.0
飞牛NAS FPK应用打包开发技能:支持Native和Docker应用的全生命周期开发
元数据
Slug fn-fpk
版本 1.0.0
许可证 MIT-0
累计安装 0
当前安装数 0
历史版本数 1
常见问题

fn-fpk 是什么?

飞牛NAS (fnOS) FPK 应用打包开发技能。使用此技能开发和打包飞牛NAS第三方应用(.fpk),包括:Native 应用(Node.js/Python/Java等)和 Docker 应用。涵盖整个开发周期:项目创建、manifest配置、权限/资源配置、生命周期脚本编写(cmd/main)、用户入口配置... 它是一个面向 Claude Code / OpenClaw 的 AI Agent Skill 插件,目前累计下载 54 次。

如何安装 fn-fpk?

在 OpenClaw 或 Claude Code 对话框中运行命令「/install fn-fpk」即可一键安装,无需额外配置。

fn-fpk 是免费的吗?

是的,fn-fpk 完全免费,采用 MIT-0 许可证,可自由下载、安装和使用。

fn-fpk 支持哪些平台?

fn-fpk 跨平台运行,可在任意部署了 OpenClaw / Claude Code 的环境中使用(cross-platform)。

谁开发了 fn-fpk?

由 Thomas(@dalingo81)开发并维护,当前版本 v1.0.0。

💬 留言讨论