Skip to content

1Panel 转 fnOS 转换规约 ​

本规约是 FNOS-001 需求 与 PLAN-FNOS-001 计划 的执行细则,配合 1Panel 应用转换清单 使用:清单回答「哪个应用属于哪一级、要改什么」,本文回答「具体怎么改」。

上游 1Panel-dev/appstore(工作区检出的 appstore/)只读参考,任何情况下都不修改、不复制其文件内容。文中的字段语义以飞牛应用开放平台文档为准,不自行发明字段。

使用方式 ​

text
1. 在清单里查到目标应用的分级与改造点
2. 按「命名规约」确定 appname、目录名、容器名和运行用户
3. 按「字段映射表」把上游变量逐个翻译成 fnOS 字段
4. 按「compose 改造模式」改写 app/docker/docker-compose.yaml
5. 按「配置文件模板」补齐 manifest、config/、wizard/、cmd/、app/ui/config
6. 按「自检清单」逐项确认后再进入构建与实机验收

分级口径见清单的「盘点口径」,本文不重复。级别与主要改造的对应关系:

级别主要改造
T0命名、网络、入口;无状态落盘改造
T1相对挂载改 TRIM_PKGVAR;补卸载数据保留向导
T2联动字段拆成显式连接字段;多端口拆入口;多容器确认服务互通
T3 / T4不在本规约的批量改造范围内,先做能力与权限决策

命名规约 ​

同一应用在六个位置必须使用同一个 <app> 词干,且全部小写、连字符分隔。

位置规则示例(上游 it-tools)
应用目录apps/fn-<app>/apps/fn-it-tools/
manifest.appnamefn-<app>fn-it-tools
容器名container_name 固定为 fn-<app>fn-it-tools
compose 项目名生命周期脚本统一用 -p "$TRIM_APPNAME"-p fn-it-tools
config/privilegeusername / groupname 使用 docker-fn-<app>docker-fn-it-tools
config/resourcedocker-project.projects[].name 与共享目录顶级名用 fn-<app>fn-it-tools
桌面入口 ID<appname>.Application,与 desktop_applaunchname 一致;总长必须 ≤ 32 字符,超限时改用 <appname>.mainfn-it-tools.Application(23)
应用文档页docs/apps/fn-<app>.mddocs/apps/fn-it-tools.md

派生规则:

  • 下划线归一:上游应用键含下划线时改连字符,例如 act_runner → fn-act-runner、obsidian_livesync → fn-obsidian-livesync(上游仅这两个)。仓库既有的 fn-mysql_v8 是历史命名,不据此推广。
  • 容器名与项目名一致:container_name 必须等于 manifest.appname,也等于 -p $TRIM_APPNAME 传入的项目名。三处不一致会导致 cmd/main status 检查不到容器。
  • 不要用 ${CONTAINER_NAME}:上游 263 个应用使用该变量,全部改写为字面量 fn-<app>。
  • 共享目录命名:data-share 顶级目录用 fn-<app>,需要用户可见的子目录用 fn-<app>/<用途>(参考 fn-halo 的 fn-halo/halo2)。不要在版本之间改共享目录名,否则升级会让用户看不到老数据。

上游 → fnOS 字段映射表 ​

端口 ​

上游fnOS约束
PANEL_APP_PORT_HTTP向导字段 APP_PORT(text + 数字校验)默认值取上游该字段的 default;${APP_PORT}:<容器端口>;同时写入 app/ui/config 的 port
PANEL_APP_PORT_HTTPS独立向导字段 + 独立入口证书由用户自备;默认只提供 HTTP 入口,HTTPS 入口不进 desktop_applaunchname
PANEL_APP_PORT_SSH、PANEL_APP_PORT_* 等非 HTTP 端口各自独立向导字段非 HTTP 端口不建桌面入口,只在向导和 compose 中体现
同一应用多个 HTTP 端口每个端口一个字段、一个入口入口标题区分用途,例如「管理台」「Web 客户端」

端口字段统一校验:必填;范围 1—65535 用单条严格正则完整覆盖,不依赖 min/max(其语义在官方文档中未明确定义,跨字段类型解释不一致):

text
^(?:[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$

该正则已全量验证:1—65535 全部通过(无遗漏);0、65536、70000、-1、1.5、abc、空串、040116(前导零)、前后空格、+80、0x50 全部拒绝。不要改用 ^[0-9]+$:它不挡越界,与「范围 1—65535」并列会自相矛盾。

上游 default 是唯一默认值来源,不要自行改端口。

时区 ​

上游fnOS约束
TZ、TIME_ZONE向导字段 TIME_ZONE,默认 Asia/Shanghai;compose 内写 TZ=${TIME_ZONE}上游两种变量名都收敛到同一个字段
/etc/timezone:/etc/timezone:ro、/etc/localtime:/etc/localtime:ro 绑定挂载删除挂载,改用 TZ 环境变量上游 22 个应用使用该写法;宿主 /etc/timezone 在 fnOS 上的可用性未确认,不依赖它

数据库与联动字段 ​

上游 type: apps 字段(配 child.type: service)是 1Panel 的服务选择器:用户在 1Panel 里选一个已装数据库,平台自动填 PANEL_DB_HOST。fnOS 没有等价的服务选择器,必须改成用户显式填写的连接字段。

上游fnOS 向导字段说明
PANEL_DB_TYPE(type: apps)DB_TYPE(select)选项取自上游 values,例如 mysql / postgres / mariadb
PANEL_DB_HOST(child,由平台注入)DB_HOST(text)用户手填,格式 地址:端口/数据库名,参考 fn-halo 的校验正则
PANEL_DB_PORT合并进 DB_HOST,或单独 DB_PORT同一应用内只选一种写法,不要两处都填
PANEL_DB_NAMEDB_NAME(text)上游 random: true 只在 1Panel 内生效,fnOS 侧给出建议值即可
PANEL_DB_USERDB_USERNAME(text)—
PANEL_DB_USER_PASSWORDDB_PASSWORD(password)不回显;不写进仓库、日志和示例
PANEL_DB_ROOT_USER、PANEL_DB_ROOT_PASSWORD、PANEL_DB_PASSWORD默认删除只有应用确实需要 root 凭据时才保留为独立字段,并在计划中记录理由
type: service 子字段本身无对应物它由平台自动填充,不是用户输入项
DBHUB_DB_*各字段对应 DB_TYPE/DB_NAME/DB_USER/DB_USER_PASSWORD/DB_ADDRESS/DB_PORT与 PANEL_DB_* 同义,同样代表需要外部数据库,不要因为变量名不同就漏判

判定「是否依赖外部数据库」的口径(决定应用归 T2 还是 T1):只有 compose 使用 PANEL_DB_* 或 DBHUB_DB_* 变量(即需要用户提供外部数据库实例)才算外部数据库依赖。容器内自带的 SQLite(DB_DATABASE=...sqlite、*_DATABASE_DRIVER: sqlite、GOTIFY_DATABASE_DIALECT=sqlite3)与镜像内置数据库(MYSQL_INTERNAL=true)不算,这类应用按状态落盘处理(T1),转换重点是卷路径而非连接字段。反面教训:按「env 里出现 DATABASE 字样」判断,会把 2fauth、ai-gateway、gotify、zentao 这类自带数据库的应用误判为 T2;只看 PANEL_DB_* 又会漏掉用 DBHUB_DB_* 的 dbhub。

helpText 必须给出连接串格式示例并说明连接失败的表现。上游靠平台保证连通性,fnOS 靠用户输入,这是转换后最大的行为差异。

Redis 与其他中间件 ​

上游fnOS约束
PANEL_REDIS_HOST、PANEL_REDIS_PORT、PANEL_REDIS_ROOT_PASSWORDmanifest 声明 install_dep_apps=redis,向导字段按应用实际消费方式给依赖应用由宿主安装,不在包内固定地址。单串消费型给 REDIS_CONN_STRING(redis://host:port,如 fn-new-api);分字段消费型给 REDIS_HOST/REDIS_PORT/REDIS_PASSWORD(如 cloudreve 的 CR_CONF_Redis.Server 只收 host、密码另用 PANEL_REDIS_ROOT_PASSWORD)。把连接串硬塞给分字段消费的应用会得到 redis://host:port:6379 这种错值
PANEL_VALKEY_ROOT_PASSWORD、PANEL_KEYDB_ROOT_PASSWORD同 Redis:install_dep_apps + 连接串字段以上游实际中间件类型为准,不要一律写成 redis
PANEL_MINIO_ROOT_USER、PANEL_MINIO_ROOT_PASSWORDinstall_dep_apps=minio + 端点与凭据字段见飞牛「中间件服务」文档的约定

install_dep_apps 只能声明应用名和最低版本(>),无法声明「PHP 8.2 + 指定扩展集」这类组合能力。因此运行时类应用(T3)不适用本节的映射,需要先做宿主依赖契约决策。

存储与挂载 ​

上游fnOS约束
./data、./conf 等相对挂载${TRIM_PKGVAR}/<子目录>(目标 NAS 路径 /var/apps/fn-<app>/var/)禁止把运行数据写进 FPK 安装目录(TRIM_APPDEST 只放只读资源)。上游按「行首 - 后接可选引号再接 ./」口径计为 223 个应用,其中真实 ./ volume 挂载 222 个,差额 1 个是仅出现在 env_file: 段的 zitadel(该口径不影响分级);复算方式见转换清单的「盘点口径」
用户需要直接看到/导入/导出的数据(媒体库、下载目录、书签文件)config/resource 声明 data-share,路径从 TRIM_DATA_SHARE_PATHS 取声明后卸载向导必须能区分「删应用」与「删用户数据」
命名卷(volumes: 下的具名卷,不论是否 external: true)改为 ${TRIM_PKGVAR} 绑定挂载,并删除顶层 volumes: 段两种都要改:外部卷(external: true)在 fnOS 上不会被自动创建;托管卷(无 external、无 driver)虽会自动创建,但数据落在 /var/lib/docker/volumes/,不在应用数据目录、不在 uninstall_callback 的删除范围 → 卸载后残留泄漏。已命中:synapse(外部卷)、discuz(托管卷 discuz_data)
挂载源是宿主绝对路径(/etc/1panel、/showdoc_data 等)删除该行,其余相对挂载照常改造fnOS 上不存在这些路径,保留会导致挂载失败。判据是挂载源是否为宿主绝对路径,与是否 1Panel 专属无关。已命中:ai-gateway(/etc/1panel)、showdoc(/showdoc_data:/showdoc_data_old,版本迁移遗留)
PANEL_WEBSITE_DIR需要用户授权目录时用 TRIM_DATA_ACCESSIBLE_PATHS / data-share上游 16 处,集中在 T3 Web 服务器类,需随依赖契约一并决策

网络、标签与其他 ​

上游fnOS约束
networks: - 1panel-network 与顶层 networks: 1panel-network: external: true整体删除fnOS 由 compose 项目网络接管;上游 257 个应用命中
labels: createdBy: "Apps"删除1Panel 应用商店自有标记,fnOS 无对应概念
镜像标签为可变引用沿用上游标签(用户裁决 2026-10-15)先选定固定版本再进批次。口径已改:用户明确「1panel 用什么就用什么」——可变标签不再阻塞转换。代价是构建不可复现、上游更新会漂移,属用户已知并接受的取舍
镜像标签含 ${...} 变量插值先解析为真实存在的标签再写死(此条仍有效)与上一行的区别:插值变量在 fnOS 侧没有任何来源(如 casdoor/forgejo 的 .env 由上游 init.sh 生成),不解析就是空值;而 :latest 是能解析到的具体标签,只是会漂移
镜像来自 ${IMAGE_NAME} 等向导变量同上标签不由 compose 固定,无法保证可复现

可变标签的判定必须用「标签串包含可变词」,不能用「标签等于可变值」:

text
❌ 错误:标签等于 latest/master/main/dev/edge/stable
✅ 正确:标签串中包含这些词即可

zabbix/zabbix-java-gateway:alpine-latest      ← 含 latest,可变
zabbix/zabbix-server-mysql:alpine-7.4.5      ← 固定
ghcr.io/umami-software/umami:${PANEL_DB_TYPE}-v2.20.2  ← 变量插值,不可复现

这个坑已踩三次(漏 :master、漏 alpine-latest、漏变量插值)。扫描时用宽判据:标签串包含 latest/master/main/dev/edge/stable/nightly,或含 ${。

关于可变标签的最新口径(2026-10-15 用户裁决):「1panel 用什么就用什么」——可变标签不再阻塞转换,直接沿用上游标签即可,不需要联网查 tag、不需要在文档里写「因可变标签收敛版本」。

但变量插值仍然必须解析(两条不可混为一谈):

情形处理原因
:latest、:master、alpine-latest 等沿用能解析到具体镜像,只是会随上游漂移
${IMAGE}、${PANEL_DB_TYPE} 等插值必须解析为真实标签变量在 fnOS 侧没有来源(上游 init.sh 生成),不解析就是空值 → 启动失败

已解析的插值案例(保持有效):zabbix-java-gateway 钉 alpine-7.4.5;umami 钉 3.4.0;forgejo 钉 16.0.5(这个值取自 1Panel 自己的 init.sh,因此与「1panel 用什么就用什么」一致,不需改回)。 | labels: skipStatusCheck: "true" | 删除 | 1Panel 状态检查专用;多容器迁移任务在 fnOS 侧改为 cmd/main 的实际探测逻辑 | | ${CONTAINER_NAME} | 字面量 fn-<app> | — | | env_file | 展开成 compose 内的 environment | fnOS 侧不保证 env_file 路径存在;上游 4 个应用命中,属 T4 待确认项 | | build: | 不使用 | 包内不含构建上下文,必须换用可拉取的镜像 | | 镜像标签 | 保持上游标签(含 :latest 等可变标签) | 上游 12 处 :latest 直接沿用(用户裁决 2026-10-15);只处理 ${...} 变量插值——那类在 fnOS 侧无来源,不解析会得到空值 | | restart、healthcheck、depends_on、command、entrypoint | 保留 | 只删除 1Panel 专有语义,不动应用自身编排逻辑 |

表单多语言 ​

上游 data.yml 的 formFields 用 label 对象承载十几种语言(labelZh、labelEn、label 的 en/ja/ko 等)。fnOS 向导只保留中文:

上游fnOS约束
labelZh / label.zh向导字段的 label取中文文案;没有中文时用 labelEn 翻译,不要直接留英文
labelEn 及其他语言丢弃仓库文档、向导 label 和 helpText 一律用中文
label 里没有对应语言回退到 labelEn回退后仍需通顺,必要时改写
default向导字段的 initValue默认值语义与上游保持一致
required、rule: paramPortrules 的 required 与端口正则paramPort 翻译为上一节的严格正则,不要照抄上游规则名
random: true丢弃,改给建议值随机生成只在 1Panel 内生效,fnOS 侧给可运行的默认值
type: number / password / select / texttext(配数字正则)/ password / select / text控件类型按飞牛「用户向导」文档的字段类型映射
应用展示信息 name、shortDescZhmanifest.display_name、manifest.desc见「配置文件模板」;recommend、tags 等 1Panel 排序字段不写入 manifest

compose 改造模式 ​

三个例子都是上游真实片段,改造后可直接作为同类应用的模板。

模式 A:T0 直接套模板(it-tools) ​

上游 appstore/apps/it-tools/2024.10.22-7ca5933/docker-compose.yml:

yaml
services:
  it-tools:
    image: corentinth/it-tools:2024.10.22-7ca5933
    container_name: ${CONTAINER_NAME}
    restart: always
    networks:
      - 1panel-network
    ports:
      - ${PANEL_APP_PORT_HTTP}:80
    labels:
      createdBy: "Apps"
networks:
  1panel-network:
    external: true

改造后 apps/fn-it-tools/app/docker/docker-compose.yaml:

yaml
services:
  it-tools:
    image: corentinth/it-tools:2024.10.22-7ca5933
    container_name: fn-it-tools
    restart: always
    ports:
      - "${APP_PORT}:80"

改动点:容器名写死;删 networks 与顶层 networks 块;删 labels;端口变量换成向导字段 APP_PORT(默认值取上游 default,即 40116)。应用无状态,不需要 volumes。

模式 B:T1 状态落盘改造(n8n) ​

上游 appstore/apps/n8n/2.42.3/docker-compose.yml:

yaml
services:
  n8n:
    image: n8nio/n8n:2.42.3
    container_name: ${CONTAINER_NAME}
    restart: always
    networks:
      - 1panel-network
    ports:
      - ${PANEL_APP_PORT_HTTP}:5678
    volumes:
      - ./data:/home/node/.n8n
    environment:
      N8N_SECURE_COOKIE: false
    labels:
      createdBy: "Apps"
networks:
  1panel-network:
    external: true

改造后 apps/fn-n8n/app/docker/docker-compose.yaml:

yaml
services:
  n8n:
    image: n8nio/n8n:2.42.3
    container_name: fn-n8n
    restart: always
    ports:
      - "${APP_PORT}:5678"
    volumes:
      # 目标 NAS 运行时数据目录(TRIM_PKGVAR 由 fnOS 注入),不是开发机路径
      - "${TRIM_PKGVAR}/data:/home/node/.n8n"
    environment:
      - N8N_SECURE_COOKIE=false
      - TZ=${TIME_ZONE}

改动点:./data 相对挂载改为 ${TRIM_PKGVAR}/data;补 TZ(上游未标注时区,仍按统一字段补齐);environment 从映射写法统一为列表写法以匹配仓库现有风格;容器名、网络、标签同上。

上例中的 /home/node/.n8n 是 n8n 镜像内部的运行目录,不是开发机路径;${TRIM_PKGVAR} 才是指向目标 NAS 的路径,由 fnOS 在生命周期中注入。

n8n 的 wizard/uninstall 必须提供「保留数据 / 删除数据」二选一,cmd/uninstall_callback 依据 wizard_data_action 处理 ${TRIM_PKGVAR}。

模式 B 的三个补充裁决(T1 批次实测沉淀) ​

以下三项在 T1 第 1 组施工中由实际应用暴露,均已裁决并有真机取证。T1 后续批次直接照用,不要重新发明。

1. PUID/PGID 必须改用 ${TRIM_UID}/${TRIM_GID} ​

linuxserver 系镜像(linuxserver/code-server、linuxserver/freshrss 等)硬编码 PUID=1000/PGID=1000。在 fnOS 上这是错的,真机取证:

fnOS 包用户 UID 是动态分配的,不是 1000:
  docker-fn-it-tools  UID 928      docker-fn-cyberchef UID 927
  docker-fn-halo      UID 969      docker-fn-reader    UID 975
uid 1000 在本机是普通登录用户 tnnevol

数据目录权限:
  /vol4/@appdata/fn-halo    owner=docker-fn-halo(969)  mode=770
  /vol4/@appdata/fn-reader  owner=docker-fn-reader(975) mode=770

目录属主是包用户、权限 770(组与其他用户无权),因此容器以 uid 1000 写入必然失败。

yaml
# 错误(上游原样)
- PUID=1000
- PGID=1000

# 正确
- PUID=${TRIM_UID}
- PGID=${TRIM_GID}

TRIM_UID(应用用户 ID)与 TRIM_GID(应用用户组 ID)见飞牛《环境变量》文档「用户和权限上下文」章节。

依据强度说明(重要,勿高估):官方《环境变量》文档列出了这两个变量,但没有任何 compose 示例使用它们——官方 Docker 案例只出现 ${TRIM_APPDEST} 与 ${TRIM_SERVICE_PORT},本仓库既有 33 个应用在 compose 中使用 ${TRIM_PKGVAR} 亦为旁证。这些只能证明「fnOS 会把部分 TRIM_* 注入 compose 环境」,不能证明 TRIM_UID/TRIM_GID 也在其中。

待真机验证(优先级最高):若这两个变量未被注入 compose,compose 不会报错,而是静默传入空值:

text
warning: The "TRIM_UID" variable is not set. Defaulting to a blank string.
PUID: ""        ← PUID 变为空字符串
exit=0          ← 不失败

属静默失败,构建与解析环节都拦不住,只能靠真机看容器日志里 PUID 是否为 9xx、数据能否写入。使用这两个变量的应用必须在真机验收时优先验证;若确认未注入,需改为其他权限方案并重新裁决。

2. 向导字段按「安装时必须由用户决定」取舍,不照搬上游全量 ​

上游把整个应用配置文件暴露成向导字段的情况存在(gotify 有 29 个 envKey、其中 18 个 required,涵盖 SSL/CORS/流配置)。全部搬进安装向导不可取——这些是部署后可按需调整的高级配置,不是安装时的必经决策。

取舍原则:

类别处理
安装时必须决定的(端口、时区、初始管理员账号、访问令牌)搬进向导
部署后可改的高级配置(SSL、CORS、外发 SMTP、插件路径)省略,用镜像默认值
平台专属凭据(PANEL_DB_ROOT_* 等)默认删除,见「数据库与联动字段」

省略字段必须留出口:在 docs/apps/fn-<app>.md 增「高级配置」小节,列出被省略的字段名与修改方式。减功能不能让用户找不到路。

3. 上游数据落盘缺陷要修正,不要照搬 ​

2fauth 上游 compose 存在真实缺陷:

yaml
volumes:
  - ./data:/2fauth
environment:
  - DB_DATABASE="/srv/database/database.sqlite"   # /srv/database 不在 /2fauth 挂载内

数据库落在容器可写层,容器重建即丢——而 T1 的全部意义就是修这个问题,照搬等于没做 T1。

做法:改到挂载目录内(DB_DATABASE=/2fauth/database/sqlite.db),并在 cmd/install_callback 预创建目录(mkdir -p "${TRIM_PKGVAR}/data/database")。

同类问题:若某个上游应用的持久化路径落在挂载点之外,按此处理并在遗留风险里单列,因为「镜像是否接受任意路径」需要真机确认。

另注意 2fauth 的 APP_KEY=SomeRandomStringOf32CharsExactly 是示例占位值——固定密钥意味着所有安装共享同一加密密钥,属安全风险,必须改为向导字段(password、required,helpText 说明「32 位随机字符串,安装后不要更改」)。

通用原则:上游配置不总是正确的。发现数据落盘路径不在挂载内、示例占位凭据、平台专属路径等问题时,修正并登记,而不是忠实照搬。

模式 B-2:D 组用户可见数据(共享目录挂载) ​

T1 应用的数据默认落在 ${TRIM_PKGVAR}(私有目录,文件管理器不可见)。但媒体库、网盘、书库类应用的数据是用户要直接管理的文件——用户必须能把媒体放进去、应用也要能读到。这类归 D 组,落盘约定与普通 T1 不同。

踩过的坑(务必先读):仓库里曾有 10 个应用(fn-halo、fn-moviepilot、fn-reader 等)在 config/resource 声明了 data-share,但没有一处真的使用它——grep TRIM_DATA_SHARE_PATHS apps/ 命中 0,且 /vol4/@appshare/ 为空。实际数据仍全部落在 ${TRIM_PKGVAR}。结果是「声明了共享目录,但用户看到的目录永远是空的、应用也不读它」——形同虚设。照抄这个先例会把已知问题复制一遍。

正确做法:

  1. 恰好声明 1 个共享目录:

    json
    {
      "data-share": {
        "shares": [{ "name": "fn-<app>" }]
      }
    }

    只能 1 个——TRIM_DATA_SHARE_PATHS 是 : 分隔的多值,不能直接作为单个绑定挂载源。

  2. 不需要 permission——官方文档:「一般情况下不需要写 permission。只有需要授权其他应用访问该目录时,再声明额外的访问权限」。系统会自动为应用运行用户授予 ACL。

  3. 数据挂载源用 ${TRIM_APPDEST_VOL}/@appshare/fn-<app>/<子目录>,不再用 ${TRIM_PKGVAR};子目录在 cmd/install_callback 预创建。

    卷号必须用变量,不能写死。 共享目录落在 /vol<N>/@appshare/,而 <N> 是安装时用户选择的存储卷(我们安装时用的是 volume-id=4)。写死 /vol4/ 会在用户选了别的卷时直接失效。

    yaml
    volumes:
      - ${TRIM_APPDEST_VOL}/@appshare/fn-<app>/<用途子目录>:/<容器内目标>

    为什么不用 TRIM_DATA_SHARE_PATHS:它是 : 分隔的多值,无法作为单个绑定挂载源;且其能否在 compose 中展开未经真机确认(TRIM_PKGVAR 已实证可用,但两者机制不同,不能类推)。

    为什么 TRIM_APPDEST_VOL 的依据成立(2026-10-16 复核):官方 fnnas-docs 中该变量只有 1 处定义、未列在 compose 变量表、@appshare 出现 0 次——但官方文档的描述与平台实际实现存在落差。真实依据是用户侧的使用中证据:

    • apps/fn-xiaoya-only/(用户既有应用,正在使用该写法)的 compose 引用该变量 4 处;
    • 其应用文档写明实际路径:「例如应用安装在存储 4,实际目录为 /vol4/@appshare/fn-xiaoya-only/alist/data/」——这是用户侧的实际路径说明,强于官方文档的描述;
    • 真机观测:/var/apps/fn-halo/shares/fn-halo → /vol4/@appshare/fn-halo(实际目录名是 shares 复数,官方文档写作 share 单数,以实际为准)。

    教训(值得记住):官方文档没写 ≠ 平台不支持。「正在使用中的用户应用 + 其文档 + 真机观测」是比官方文档更强的证据;反过来,官方文档里列出的变量(如 TRIM_DATA_SHARE_PATHS)也不等于它就能在 compose 里当挂载源用(多值问题实实在在存在)。

    同一 share 下按用途分子目录:audiobookshelf 的多个共享用途写成

    yaml
    - ${TRIM_APPDEST_VOL}/@appshare/fn-audiobookshelf/audiobooks:/audiobooks
    - ${TRIM_APPDEST_VOL}/@appshare/fn-audiobookshelf/podcasts:/podcasts

    这样声明仍只有 1 个 data-share,而用户打开该共享目录即可看到全部内容。install_callback 需 mkdir -p 这些子目录,否则 Docker 会以 root 建出属主错误的目录。

  4. 文档「运行要求」写明:共享目录位于文件管理器的 <存储卷>/@appshare/fn-<app>/,用户把媒体文件放在那里。

关于 TRIM_APPDEST_VOL 的展开(2026-10-16 用户裁决:按方案 C 实施,本轮不做真机测试)

已知事实:该变量在官方文档中仅 1 处定义、未列入 compose 变量表;但用户既有应用 fn-xiaoya-only 正在使用它,且其文档给出了实际路径(/vol4/@appshare/...)。

残留风险(如实登记,不隐瞒):其在 compose 中的展开仍未经真机确认。若展开为空,${TRIM_APPDEST_VOL}/@appshare/... 会退化成 /@appshare/...——容器仍能启动、应用仍能运行,但数据落到错误位置,属静默错误、不会报错。

将来验证方法:真机执行下面的命令,看 Mounts[].Source 是真实路径还是 /@appshare/...:

bash
docker inspect <container> --format '{{json .Mounts}}'

本轮决策依据:用户裁决「先按方案 C 做,不用测试」。

注意:data-share 与「用户授权既有目录」(TRIM_DATA_ACCESSIBLE_PATHS / pickSharedFile)是两套不同机制,不要混淆——前者是应用声明并由 fnOS 自动创建 + 自动 ACL,后者是管理员授权已存在的任意目录。

真机实测参考:/var/apps/fn-halo/shares/fn-halo → /vol4/@appshare/fn-halo(共享目录),/var/apps/fn-halo/var → /vol4/@appdata/fn-halo(私有数据目录)。两者是不同位置。(注:实际目录名是 shares 复数,官方文档写作 share,以实际为准。)

模式 B-3:单文件挂载与最小种子配置 ​

上游形如 ./data/conf.yml:/app/config.yml 的单文件挂载,源是文件而非目录。两个要点:

1. 保持文件形态。 ${TRIM_PKGVAR}/data/conf.yml:/app/config.yml——去掉 conf.yml 会挂成目录,应用起不来。

2. 父目录与文件必须预创建。 Docker 不会为单文件挂载自动创建源文件。cmd/install_callback 需 mkdir -p 父目录并准备该文件;TRIM_PKGVAR 为空时写日志并 exit 1(不要让 mkdir -p "" 静默失败)。

3. 空文件不一定是可用的种子——这是实测踩过的坑。 上游的 data/ 目录里自带真实初始内容,touch 出的空文件会导致:

应用空文件的后果
focalboard空串不是合法 JSON → 配置解析失败
obsidian_livesyncrequire_valid_user = true 丢失 → 未认证请求可访问公开端点(安全弱化)
dashy上游自带示例仪表盘 → 面板空白
glance无 pages → 什么都不渲染
phpmyadmin上传限制与隐藏系统库配置失效(功能退化,不报错)

做法:自建 app/seed/ 存放自己写的最小可用配置,由 install_callback 在文件不存在时拷入 ${TRIM_PKGVAR}(用户已改过的不覆盖)。

为什么不直接搬上游的种子文件:禁止项 4 的立法目的是「不搬运上游的表达」(YAML 注释、README 措辞、表单文案)。「让应用能正常启动的最小配置」是配置事实而非文案,但上游 glance.yml/conf.yml 里含具体 RSS 源与示例仪表盘——那是示例数据,本就不该搬。自己写最小配置同时满足两边:不碰上游内容,又能修掉上述实质缺陷。

最小配置至少要满足:focalboard 给出合法 JSON 对象;obsidian_livesync 必须含 require_valid_user = true;glance 至少一个 pages 条目;dashy 为可加载的最小 YAML。

校验判据要升级:断言「挂载源存在」不够,还要断言「文件非空且格式合法」(至少对 JSON 做解析断言)。

模式 C:T2 资源集成改造(gitea) ​

上游 appstore/apps/gitea/27.3.4/docker-compose.yml:

yaml
services:
  gitea:
    image: commitgo/gitea-ee:27.3.4
    container_name: ${CONTAINER_NAME}
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - GITEA__database__DB_TYPE=${PANEL_DB_TYPE}
      - GITEA__database__HOST=${PANEL_DB_HOST}:${PANEL_DB_PORT}
      - GITEA__database__NAME=${PANEL_DB_NAME}
      - GITEA__database__USER=${PANEL_DB_USER}
      - GITEA__database__PASSWD=${PANEL_DB_USER_PASSWORD}
    restart: always
    networks:
      - 1panel-network
    volumes:
      - ./data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "${PANEL_APP_PORT_HTTP}:3000"
      - "${PANEL_APP_PORT_SSH}:22"
    labels:
      createdBy: "Apps"
networks:
  1panel-network:
    external: true

改造后 apps/fn-gitea/app/docker/docker-compose.yaml:

yaml
services:
  gitea:
    image: commitgo/gitea-ee:27.3.4
    container_name: fn-gitea
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - TZ=${TIME_ZONE}
      # 上游由 1Panel 服务选择器注入,fnOS 改为向导显式填写
      - GITEA__database__DB_TYPE=${DB_TYPE}
      - GITEA__database__HOST=${DB_HOST}
      - GITEA__database__NAME=${DB_NAME}
      - GITEA__database__USER=${DB_USERNAME}
      - GITEA__database__PASSWD=${DB_PASSWORD}
    restart: always
    volumes:
      - "${TRIM_PKGVAR}/data:/data"
    ports:
      - "${APP_PORT}:3000"
      - "${APP_SSH_PORT}:22"

改动点:

  • 联动字段拆成显式连接字段;PANEL_DB_HOST:${PANEL_DB_PORT} 合并为一个 DB_HOST,helpText 写明 地址:端口 格式。
  • 删两个时区绑定挂载,改用 TZ=${TIME_ZONE}。
  • 两个端口分别建向导字段;APP_SSH_PORT 是 SSH 端口,只进向导不进桌面入口。
  • 数据库初始化(建库、建用户)由用户在向导里填已有实例,不要在 cmd/install_callback 里尝试连接并建库;连接失败时应用能启动但功能页报错,向导 helpText 必须说明这一点。

模式 D:配置变更后靠 cmd/main 的 up -d 生效(所有 Docker 应用) ​

用户在「应用设置 → 运行设置」改完保存后,变化必须进容器。此前本节给出的方案是 错的,这里按真机取证更正。

生命周期脚本的执行身份不同,这是选型的关键(2026-11-02 真机取证,fnOS 1.2.0800):

脚本执行身份能否执行 docker能否拿到新向导值
cmd/config_callback包用户(实测 uid=813,不在 docker 组)否,一律 permission denied能
cmd/mainroot(实测 uid=0)能能

因此不能在 config_callback 里跑 docker 命令。两个原因:

  1. 包用户没有 /var/run/docker.sock 权限(该 socket 属 root:docker);
  2. 平台会检查回调退出码,失败会让「保存并重启」整体返回 {"code":10000,"msg":"15002:exit code: 1"} —— 等于把配置保存功能弄坏。

正确做法是改 cmd/main:去掉「容器已存在就提前 return」的判断,让 docker compose up -d 每次状态检查都执行。

bash
ensure_containers_created() {
    cd "$COMPOSE_DIR" || return 1
    # 不要先判断「容器已存在」就 return:那样 up -d 永远不会被执行,
    # 用户改的端口、数据库地址等就进不了容器。
    docker compose -p "$TRIM_APPNAME" up -d 2>&1 || return 1
}

为什么这样就够了 —— up -d 自身会比对期望状态并只重建有差异的容器:

  • 配置未变:输出 Container xxx Running,容器 ID 不变,幂等, 可安全用于平台每 30 秒一次的状态轮询;
  • 配置变更:实测把 APP_PORT 从 8080 改成 8092 后,同一条命令输出 Recreate / Recreated,新端口随即生效(8092 -> 302、8080 -> 000)。 不需要 --force-recreate。

也不要担心它会把用户停用的容器误启动:经应用中心「停用」后,平台不再调用 status(实测日志行数 100 秒内零增长,容器保持 Exited)。 注意这与手动 docker stop 不同 —— 后者之后平台仍会轮询并把它拉起来。

config_callback 保持空操作即可(只留注释说明为何不在此处重建):

bash
#!/bin/bash

### This script is called after the user change environment variables in application setting page.

# 本脚本以包用户身份运行,没有 Docker 权限;配置变更生效由 cmd/main 负责。
exit 0

真机验证方式:改一次配置并保存,容器 ID 变化且新配置在入口上生效,才算通过。

bash
# 保存前记下容器 ID
docker inspect <容器名> --format '{{.Id}}'
# 保存后(等一次状态轮询)再取一次:ID 变了说明按新配置重建过
docker inspect <容器名> --format '{{.Id}}'
docker port <容器名>          # 新端口是否已映射

注意:双花括号形式(Go 模板的 format 串)只能写在围栏代码块里。 放进行内反引号会被 VitePress 当作 Vue 插值解析,导致 pnpm run build -- --docs 构建失败。

配置文件模板 ​

manifest ​

ini
appname               = fn-<app>
version               = <版本号>
display_name          = <上游 data.yml 的 name>
desc                  = <上游 shortDescZh,去掉营销词>
platform              = x86
source                = thirdparty
maintainer            = <上游项目方>
maintainer_url        = <上游官网>
distributor           = tnnevol
distributor_url       = https://github.com/tnnevol/fn-os-apps
os_min_version        = 1.1.8
desktop_uidir         = ui
desktop_applaunchname = fn-<app>.Application
disable_authorization_path = true

规则:

  • desc 用中文,取上游 additionalProperties.shortDescZh;display_name 取 additionalProperties.name。上游 recommend 只用于排批次,不写入 manifest。
  • platform 按清单的架构信息填写:上游只声明 amd64 的应用写 x86,同时声明 arm64 的应用仍按实际构建产物填写,不凭镜像支持列表推断。
  • os_min_version 填已在设备上验证过的版本,不照抄开发文档的最低版本要求。
  • install_dep_apps 只在确实依赖宿主提供的中间件应用时声明,多个依赖用 : 分隔。
  • service_port 只在需要宿主做端口检查时声明;使用向导动态端口时不要写死。

字段语义以飞牛官方 Manifest 文档和仓库 Manifest 配置 为准。

config/privilege ​

json
{
  "defaults": {
    "run-as": "package"
  },
  "username": "docker-fn-<app>",
  "groupname": "docker-fn-<app>"
}

默认 run-as: package,不使用 root,不使用 join-groups 提权。需要访问共享目录时由 data-share 的 permission 授权,见 权限与入口。

config/resource ​

json
{
  "docker-project": {
    "projects": [
      {
        "name": "fn-<app>",
        "path": "docker"
      }
    ]
  },
  "data-share": {
    "shares": [
      {
        "name": "fn-<app>",
        "permission": {
          "rw": ["docker-fn-<app>"]
        }
      }
    ]
  }
}

data-share 按需声明:只有用户需要看到或管理的数据才声明,纯内部状态放 ${TRIM_PKGVAR} 即可,不必声明共享。

wizard ​

文件内容
wizard/install端口、时区、数据库/Redis 连接、初始化账号;字段与 wizard/config 保持一致
wizard/config与 wizard/install 同名字段,供安装后修改
wizard/uninstallradio 字段 wizard_data_action,选项 keep(默认)/delete

固定契约名(不要改):端口 APP_PORT、时区 TIME_ZONE、卸载动作 wizard_data_action。其余自定义字段参考现成实现的命名,不要使用 TRIM_ 前缀。字段语义与控件类型见飞牛「用户向导」文档和仓库 用户向导。

端口字段必须使用上面给出的 1—65535 严格正则。不要依赖 min/max 表达范围。

cmd 与入口 ​

  • cmd/main 用 docker compose -p "$TRIM_APPNAME" 检查与重建容器,容器缺失时 up -d,失败返回非 0;参考 fn-zentao 的 ensure_containers_created。

  • cmd/uninstall_callback 不能保留模板原样。fnpack 模板给的是空实现(仅一段注释加 exit 0),必须按规约实现数据保留分支:wizard_data_action=delete 时删除数据,否则保留。删除必须用 ${VAR:?} 防误删(变量为空时 :? 让命令直接失败,而不是让 rm -rf 拿到空路径):

    bash
    #!/bin/bash
    
    ### This script is called after the user uninstalls the application.
    
    if [ "$wizard_data_action" = "delete" ]; then
        echo "Deleting application data..."
        # ${TRIM_PKGVAR:?} / ${TRIM_PKGHOME:?} 的 :? 防止变量为空时误删根目录
        rm -rf "${TRIM_PKGVAR:?}"
        rm -rf "${TRIM_PKGHOME:?}"
        echo "Application data deleted."
    else
        echo "Keeping application data for future reinstallation."
    fi
    
    exit 0

    完整示例见 apps/fn-zentao/cmd/uninstall_callback 与 apps/fn-it-tools/cmd/uninstall_callback。

  • 模板文件缺末尾换行,必须补齐。fnpack 模板 cmd/ 下的 9 个文件(main 与 8 个回调)末尾都没有换行符,会触发仓库的 shfmt 规则(shell-format/format)报 lint error。生成骨架后先给这 9 个文件各补一个 \n,再按规约改内容。补齐后,7 个保持模板语义的文件(install_init、install_callback、config_init、config_callback、upgrade_init、upgrade_callback、uninstall_init)与 apps/fn-zentao/cmd/* 逐字节一致;另外 2 个按规约必须重写(main 改 docker compose -p "$TRIM_APPNAME" 探测逻辑,uninstall_callback 实现数据保留分支),因此不与模板一致。补换行属于「修改模板文件内容」,不违反禁止项第 1 条(该条禁止的是删除模板文件)——避免用删除文件的方式绕开 lint。

  • cmd/install_init、cmd/config_init、cmd/upgrade_init 等模板自带的空脚本保留并返回 0,不删除(补换行即可,不要加入额外逻辑)。

  • 入口 ID 长度上限 32 字符。fnpack 对 app/ui/config 的入口键做硬校验,超过 32 字符直接拒绝打包:

    text
    Packing failed. The entry ID "fn-wechatmarkdowneditor.Application" exceeds the 32-character limit.

    fn- + <app> + .Application 超过 32 字符,等价于 <app> 长度 ≥ 18 字符时必然超限。此时把后缀换成 .main(官方《应用入口》文档示例即用 myapp.main;仓库先例 fn-memos.main、fn-xiaoya-only.main),仍以 appname 为前缀、满足前缀校验。实测边界:ID 长度 32 通过、33 拒绝。

    注意:fnpack 在此情况下仍返回退出码 0,只有依赖产物存在性判定(见下)才能发现;且 manifest 的 desktop_applaunchname 必须与 app/ui/config 的键逐字符一致。

    另一个相邻的硬校验:入口键必须以 manifest.appname 为前缀,否则报 should start with <appname>。

  • app/ui/config 的入口端口必须与向导字段同名引用:"port": "${APP_PORT}"。默认 type: "url";需要宿主内嵌时用 iframe,并在真机验证静态资源、API 与 SSE 不被网关重写。

禁止项 ​

  1. 不得删除 fnpack 模板自带文件。fnpack create <app> --template docker 生成的结构(cmd/ 各回调、config/、wizard/、ICON*.PNG)只能在基础模板上修改内容或新增文件;不需要的回调保持安全、可重复执行并返回成功。

    例外:.DS_Store 属于 macOS 系统垃圾文件,不算「模板自带文件」,可以删除。 fnpack 模板会带入 5 个 .DS_Store(app/、app/ui/、app/ui/images/、cmd/、config/ 各一个),其中 cmd/.DS_Store 与 config/.DS_Store 会被打进 FPK 顶层,其余随 app/ 进入 app.tgz。它们不是 fnpack 模板的结构性文件,保留只会让垃圾文件随包发给用户;本仓库既有 14 个应用也均无此类文件。批量转换时在自检清单确认 .DS_Store 计数为 0。

  2. 不得把运行数据写进 FPK 安装目录。TRIM_APPDEST 只放只读资源,可写状态一律落 ${TRIM_PKGVAR} 或声明的共享目录。

  3. 不得用 root 运行。默认 run-as: package + docker-fn-<app>;确需特权时在计划中记录理由,不能靠放宽权限绕过路径或端口配置错误。

    运行身份的统一裁决(2026-10-15)——「优先使用非 root,即默认用户」:

    上游情形处理
    有 PUID/PGID/UID/GID 变量改为 ${TRIM_UID}/${TRIM_GID}
    写了 user: root改为 user: "${TRIM_UID}:${TRIM_GID}"
    镜像层默认 root、无可注入变量加 user: "${TRIM_UID}:${TRIM_GID}"
    镜像层默认 root,但镜像内有自己的 uid 变量(如 Jupyter 的 NB_UID/NB_GID)改变量,不加 user:
    镜像初始化强依赖 root(s6-overlay、supervisord 等)例外,保持原样——加 user: 会直接起不来。须在应用文档写明「镜像默认以 root 运行」让用户知情

    例外条款按「现象」而非「实现」判定(2026-10-16 修正):原条款写的是「s6-overlay 类镜像(Entrypoint: ["/init"])」,那是按实现写的。实测 neko 用 supervisord(非 s6)但机制完全相同——supervisord.conf 里 user=root 启动、各服务按 %(ENV_USER)s 降权,且 chown=root:neko 要求 root 改 socket 属主。因此例外的正确判据是「镜像初始化强依赖 root」这一现象,s6 只是其中一种实现。

    两个易错点:

    • 「镜像层有 USER 环境变量」不等于「有可注入的 uid 机制」。neko 的镜像层 Env 是 USER=neko,但那是普通环境变量,%(ENV_USER)s 取它只决定降权目标,改它不改变进程归属;而 calibre-web 的 PUID/PGID、jupyter 的 NB_UID/NB_GID 是镜像设计给你改的,性质不同。
    • 判据是 entrypoint 源码,不是镜像名或标签。对照案例:fn-wordpress 的条件式 chown(仅在属主为 root:root 时触发、且带 \|\| true)不算硬依赖;fn-roundcube 的 chown 因脚本内有 ${VAR:=default} 默认值而每次必执行,算。

    判断「镜像层默认 root」必须实拉 registry 的 config blob 查 config.User,不能看 compose——compose 不写 user: 不等于容器不以 root 运行(USER 未设置时以镜像默认用户运行,而镜像默认常常就是 root)。已实证案例:rustdesk/sentinel-dashboard 的 config.User 为空即 root,而 rustfs/rocketmq 是有名用户。

    「镜像内脚本主动降权」与「镜像层直接非 root」要分清:

    形态判定处理
    镜像内脚本以 root 启动、做 chown 后降权到自己的 uid 变量(jupyter-notebook 的 start-notebook.sh + CHOWN_HOME=yes)有降权机制改变量,不加 user:——加 user: 会让非 root 无权执行 CHOWN_HOME 的 chown,直接冲突
    镜像内脚本以 root 启动、由服务自身降权(dvwa/emlog 的 Apache dropped privileges)有降权机制加 user: 可行,pid/日志目录风险登记真机项
    镜像层 config.User 是固定名或 uid(nexus、redis-commander=1000)已非 root不改;与包用户 uid 不匹配的登记属主清单
    镜像层 config.User 为空且无降权逻辑默认 root加 user:

    「镜像内脚本有 root 硬依赖」的判定要看 entrypoint 源码,不能只看镜像名(2026-10-16 补,含 6 个已交付应用的实测结论):

    应用镜像 USERentrypoint 的 root 依赖结论
    fn-sentinel-dashboard空(root)无(纯 java -jar)✅ 加 user:
    fn-wordpress空(root)chown 仅在属主为 root:root 时触发,且带 || true✅ 加 user:
    fn-phpmyadmin显式 root写 /etc/phpmyadmin/config.secret.inc.php + chgrp www-data❌ 例外(硬依赖)
    fn-roundcube空(root)chown www-data ${ROUNDCUBEMAIL_TEMP_DIR},该变量脚本内有默认值 \/tmp/roundcube-temp,每次启动必执行❌ 例外(硬依赖)
    fn-typecho空(root)chown -Rf www-data /app、写 /usr/local/etc/php/conf.d/、写 /etc/localtime、su -p www-data❌ 例外(硬依赖)
    fn-rustdesk空(root)s6-overlay(Entrypoint: ["/init"])❌ 例外

    判定方法:拉取镜像层、读出 entrypoint 脚本、逐行找 chown/chgrp/chmod/写 /etc/ 与 /usr/local/etc//su/useradd。有则归例外并在应用文档「运行身份」节写明原因(本轮已为 4 个例外补写)。

    注意两个易错点:① wordpress 那种条件式 chown + \|\| true 不算硬依赖;② roundcube 那种变量虽未由我们设置、但脚本内有 := 默认值的,仍会执行——只看 compose 里有没有设该变量会误判。

    「Apache/PHP 系镜像必须 root 才能绑 80 端口」不成立:Docker ≥ 20.3 起容器内默认 net.ipv4.ip_unprivileged_port_start=0,非 root 可以绑定 80。这类镜像的真正风险在 pid / 日志目录写入,属可验证的运行时风险(与 s6 的结构性依赖不同),因此按加 user: 处理并登记为真机验证项,不要写成「已解决」。

  4. 不得复制上游文件内容。上游 LICENSE 是 GPL-3.0,本仓库是 Apache-2.0。只提取配置事实(镜像标签、端口、卷路径、环境变量名和默认值),不搬运 YAML 注释、README、脚本或表单文案。

  5. 不得修改上游 appstore/,也不向上游提交补丁。它是只读的契约与版本来源。

  6. 不得把同一应用的多份 compose 塞进一个 FPK。上游 40 个应用有多版本,每个应用只选定一个版本,多版本差异通过升级而非并行入口体现。

  7. 不得覆盖仓库已有的同名应用。同步或批量转换前,先核对 apps/ 下是否已有该应用——无论是原生(app/server、cmd/main 直接拉起进程)还是 Docker(app/docker/)形态,都视为已存在,不做替换、不覆盖、不并行出两份。

    判据用归一化后的名字比对(fn-mysql_v8 的下划线与文档里的 fn-mysql-v8 连字符视为同一个),否则会漏判。转换开始时仓库已有 14 个应用,其中 6 个与上游同名:bitwarden、halo、hermes-agent、memos、new-api、zentao——这 6 个不再转换,清单在「去掉同名后」列里已扣除。

    确实需要纳入上游的新版本时,走升级而不是重建:保留 manifest.appname 不变,只更新 app/docker/ 与 wizard/,因为 appname 是已安装用户的升级主键,改名会断掉升级路径。

  8. 不得修改用户既有的应用。831b7d3(v6.0.0 发布点)之前已存在的应用,视为用户既有资产,任何情况下不修改、不重构、不"顺带优化"。

    该时点的 14 个应用:fn-bitwarden、fn-halo、fn-hermes-agent、fn-memos、fn-moviepilot、fn-mysql_v8、fn-new-api、fn-nvm、fn-ohmyzsh、fn-quark-auto-save、fn-reader、fn-uv、fn-xiaoya-only、fn-zentao。实测这些应用在 831b7d3 之后改动次数为 0,规则已然遵守,此处固化为条款。

    判据:git ls-tree 831b7d3 apps/ 在列即属既有应用。注意 fn-xiaoya-only 是 TRIM_APPDEST_VOL 的既有先例——引用它作为设计依据可以,但不要改它。

  9. 不得在仓库内写入开发机绝对路径。脚本内定位同目录文件时基于脚本位置推导;fnOS 设备端路径(TRIM_*、/var/apps/...)可以出现,但要注明它指向目标 NAS。完整规则见 SDD 规格驱动维护规范 的「路径约束」。

  10. 不得在文档、向导 helpText、示例和日志里出现真实密码或 Token。

自检清单 ​

单个应用提交前逐项确认:

  • [ ] apps/fn-<app>/ 目录名、manifest.appname、container_name、-p $TRIM_APPNAME、docker-fn-<app> 五者词干一致。
  • [ ] compose 无 1panel-network、无 createdBy 标签、无 ${CONTAINER_NAME}、无 env_file、无 build:。
  • [ ] 所有 ./ 相对挂载已改为 ${TRIM_PKGVAR} 或声明的共享目录;用户可见数据已声明 data-share。
  • [ ] 端口、时区字段在 wizard/install 与 wizard/config 中同名且默认值一致;端口字段使用 1—65535 严格正则。
  • [ ] wizard/uninstall 提供保留/删除二选一,cmd/uninstall_callback 已实现 wizard_data_action 分支(不是模板原样的空 exit 0),删除分支使用 rm -rf "${TRIM_PKGVAR:?}"。
  • [ ] cmd/ 下 9 个模板文件已补末尾换行,pnpm run lint 不被 shfmt 规则判错。
  • [ ] .DS_Store 计数为 0:tar tzf <app>.fpk | grep -c DS_Store 输出 0(模板会带入 5 个,须按禁止项 1 的例外删除)。
  • [ ] app/ui/config 的 port 与向导字段同名(${APP_PORT},不写死);非 HTTP 端口没有桌面入口(app/ui/config 写 {} 且 manifest 不含 desktop_* 两行)。
  • [ ] 入口 ID ≤ 32 字符(<app> ≥ 18 字符时用 <appname>.main),且入口键以 appname 为前缀、与 manifest.desktop_applaunchname 逐字符一致。
  • [ ] 镜像标签不是 latest,也没有未解析的变量插值。
  • [ ] 文档内没有开发机绝对路径,设备端路径已注明指向目标 NAS。
  • [ ] bash -n apps/fn-<app>/cmd/* 通过;pnpm run check -- --all、pnpm run lint、git diff --check 通过。

卸载数据删除的三组实测(2026-10-20 批量验收期间) ​

实验做法结果推论
① 平台是否原生删除把某应用 cmd/uninstall_callback 改成空操作(不删任何东西)再卸载数据目录仍在;但卸载回调确实被调用平台不删数据,删除责任在应用侧;回调是正确入口,只是写法有问题
② 只删内容能否删干净回调改为只 rm -rf "$DIR"/*(不做 unlink),以会写入真实数据的应用测试删不干净:容器以 root 写入的文件(实测 storage/app/.gitignore)报 Permission denied包用户能删自己拥有的目录项,但删不动 root 属主的文件
③ 包用户能否自行提权回调内 runuser -u "$TRIM_USERNAME" -- rm -rf ...runuser: may not be used by non-root users包用户无法自行提权,只能靠平台给的特权位

补充事实(同一轮实测):存储卷为 btrfs + trimacl;/vol2/@appdata、/vol2/@apphome 属 root:root 755; uid ≥ 1000 的普通账号在该卷上可以 unlink 条目,而 uid 9xx 的服务账号(包用户)不行—— 即平台对「人类账号」与「服务账号」的权限并不对等。

据此分层的处置方案(不是「全仓改 Root 模式」):

类别判据数据文件属主需要的改动
A 类:镜像允许非 root 运行镜像默认 USER 可降权包用户回调只删内容 + 如实上报残留;并补 user: "${TRIM_UID}:${TRIM_GID}"(落实 2026-10-15「运行身份优先非 root」裁决)。不需要 Root 模式
B 类:镜像初始化强依赖 roots6-overlay / supervisord / 显式 USER=root、启动必执行 chownroot只有这类需要 Root 模式:生命周期脚本提权删数据,服务进程用 runuser 切回包用户(官方权限文档允许「访问包用户无法处理的文件」)。规约已登记的硬依赖例外见「裁决 B 例外表」

已定案规则(2026-10-21,用户裁定 + 真机验证) ​

应用可以删除自己创建的数据,但不允许删除环境变量对应的目录。

由此确定卸载回调的写法(已应用到本仓库全部转换应用):

项规则
删什么只删 TRIM_PKGVAR / TRIM_PKGHOME 目录内的内容(含隐藏文件)
不删什么不删这两个目录本身——它们由平台管理,应用无权也不允许 unlink(父目录 /vol2/@appdata 属 root:root 755)
残留怎么办删不干净就如实上报(「Some application data in ... could not be removed」),禁止再打印「Application data deleted.」
要不要 Root 模式不要。删不掉的原因是「文件不是包用户自己创建的」(容器以其它 uid 写入),解法是让容器以包用户身份写数据,而不是给回调提权
覆盖范围本仓库 179 个转换应用已统一为新写法;831b7d3 之前的 14 个用户既有应用按禁止项 8 不修改

新回调模板(cmd/uninstall_callback):

bash
delete_contents() {
    local d="$1"
    [ -n "$d" ] || return 0
    [ -d "$d" ] || return 0
    rm -rf "$d"/* "$d"/.[!.]* 2>/dev/null
    [ -z "$(ls -A "$d" 2>/dev/null)" ]
}

if [ "$wizard_data_action" = "delete" ]; then
    echo "Deleting application data..."
    for d in "${TRIM_PKGVAR:-}" "${TRIM_PKGHOME:-}"; do
        [ -n "$d" ] || continue
        [ -d "$d" ] || continue
        if delete_contents "$d"; then
            echo "Application data deleted: $d"
        else
            echo "Some application data in $d could not be removed; the platform keeps this directory."
        fi
    done
else
    echo "Keeping application data for future reinstallation."
fi

真机验证(2026-10-21):fn-it-tools 装好 → 入口 200 → 卸载(选删除数据)→ 日志逐目录如实输出 Application data deleted: /vol2/@appdata/fn-it-tools 与 .../fn-it-tools;核对设备:两个平台目录仍在且 内容条目数为 0(平台保留目录、应用只删内容),与规则完全一致。

由此得到的一条独立结论:「应用只能删自己创建的数据」——所以要真正删干净,得让容器以包用户身份写数据 (user: "${TRIM_UID}:${TRIM_GID}",即 2026-10-15「运行身份优先非 root」裁决),而不是给回调提权。 对镜像固定 uid 的应用(见「属主待验证清单」),需在 install_callback 里用 ACL 授权镜像 uid, 并用默认 ACL(setfacl -d -m u:<包用户>:rwx)让容器后续创建的子目录也带包用户权限,否则仍删不掉。

必须先做、与权限无关的一项:把「删除失败却打印 Application data deleted.」改成如实上报—— 在这之前不得宣称「删除所有数据」已实现。

待确认 ​

以下事实尚未确认,转换时遇到对应场景应先暂停并上报,不要按猜测实现:

事项影响现状
fnOS 是否提供与 1Panel service 选择器等价的自动填地址能力清单登记的 74 处联动字段只能手填,用户路径需试点验证计划阶段 3 试点确认
install_dep_apps 能否表达运行时版本与扩展集(PHP 8.2 + 扩展)决定 T3 运行环境类是否上架需求中登记为待确认
env_file、devices、cap_add、privileged、host 网络在 fnOS 的可用性决定 T4 33 个应用能否转换需求中登记为待确认
/etc/timezone、/etc/localtime 在 fnOS 宿主上的存在性与权限上游 22 个应用;本规约统一改为 TZ 环境变量,需真机确认时区生效待真机验证
1Panel 宿主路径(如 /etc/1panel)挂载的替代方案影响 ai-gateway 等应用的数据目录设计待确认
既有 14 个已发布应用的端口校验是否统一为严格正则既有应用普遍用 ^[0-9]+$(不挡越界),与本规约的 1—65535 严格正则不一致;本次不回溯,如需统一须另开需求已登记,由计划维护者决定是否单独排期
卸载「删除所有数据」实际删不掉,且日志谎报成功见下方「卸载数据删除的三组实测」与「已定案规则」已定案并已实施(2026-10-21):应用只删自己创建的数据内容、保留平台目录、如实上报;不引入 Root 模式
镜像默认 USER 与 TRIM_PKGVAR 目录属主不匹配——受影响应用清单install_callback 建的目录属主是包用户、mode 770;镜像若以固定非 root 身份运行则无写权限。已从 registry 实拉镜像 config 与 /etc/passwd 逐个核实(非推测):
应用镜像 USER实测 uid:gid
fn-2fauth1000:10001000:1000
fn-casdoor10001000
fn-dashy1000:10001000:1000
fn-filebrowseruser1000:1000
fn-focalboardnobody65534:65534
fn-langflowuser1000:0
fn-nexusnexus200:200
fn-nodebbnodebb1001:1001
fn-openldap10011001
fn-pgadmin450505050
fn-sonarqubesonarqube未解析(2026-10-15 新增)
fn-redis-commander10001000:1000(2026-10-16 新增)
fn-ezbookkeeping1000:10001000:1000(2026-10-16 新增)
fn-neko镜像层 USER=neko1000:1000(2026-10-16 新增)
fn-rustfsrustfs10001:10001
fn-sftpgo1000:10001000:1000
fn-tomcat10011001
fn-verdaccio1000110001
fn-ollama-webui0:0实为 root(性质不同,单列)
fn-firefly-iii/fn-mattermost/fn-n8n/fn-openclaw/fn-stalwart-mail有 USER 名uid 未解析(/etc/passwd 不在扫描的最小 10 层内)

对照修法:仓库已有 4 个应用(fn-node-red/fn-komari/fn-tinyrdm 等)用 user: "${TRIM_UID}:${TRIM_GID}" 强制对齐,但对依赖自身 home 属主的镜像(pgadmin4/nexus/mattermost)强加可能反而起不来。是否必然失败仍属推断——fnOS 若对数据目录授予 ACL、或 TRIM_UID 恰为 1000/1001 则可能侥幸可用。真机验证第一优先级 | | 镜像默认 USER 与 TRIM_PKGVAR 目录属主可能不匹配 | install_callback 创建的数据目录属主是包用户、mode 770;若镜像 config.User 是固定非 root 名字或固定 uid(如 nexus、n8n(node)、rustfs(10001)、pgadmin4(5050)、mattermost、focalboard(nobody) 等),容器进程 uid ≠ 目录属主 uid,而 770 对「既非 owner 又不在 group」的进程无写权限。命中约 20 个应用(其中多数已交付)。尚未断言必然失败——fnOS 是否对数据目录授予 ACL、TRIM_UID 是否会巧合等于镜像 uid 均未验证。修法需逐个判断:仓库已有 4 个应用(fn-node-red/fn-komari/fn-tinyrdm 等)用 user: "${TRIM_UID}:${TRIM_GID}" 强制对齐,但对 pgadmin4/nexus/mattermost 这类依赖自身 home 目录属主的镜像,强加可能反而起不来。本轮不批量修改,列为真机专项验证的第一优先级 | 待真机验证;转换时凡使用 TRIM_PKGVAR 绑定挂载的应用都要核对镜像默认 USER,不匹配必须在应用文档登记为待验证项 | | .fpk 字节级不可复现 | fnpack 会向包内 manifest 注入一行随构建时刻变化的 checksum,tar 成员 mtime 也随之变化。实测:同一目录同一秒内连续构建结果一致,跨秒则 .fpk 大小与哈希均不同。.fpk 大小/哈希不能作为验收判据,CI 也不应做产物哈希断言 | 已知事实;要校验产物请比对解包内容:app.tgz 内层解包后 diff -r 无输出,顶层 manifest 仅 checksum 一行不同 |

变更记录 ​

日期变更
2026-10-09建立转换规约。
2026-10-09端口校验改为单条严格正则完整覆盖 1—65535(原表述 ^[0-9]+$ 与「范围 1—65535」并列自相矛盾),不依赖 min/max。
2026-10-09沉淀 fnpack 模板两处坑:cmd/ 下 9 个文件缺末尾换行会触发 shfmt 规则;cmd/uninstall_callback 模板为空实现,必须实现 wizard_data_action 数据保留分支。
2026-10-09禁止项 1 增加例外:.DS_Store 属系统垃圾文件可删除(模板会带入 5 个,其中 2 个会进 FPK 顶层);自检清单同步增加 .DS_Store 计数为 0 的检查项。
2026-10-09登记两项已知事实:既有 14 应用的端口正则不回溯统一(须另开需求);.fpk 字节级不可复现,不作为验收或 CI 哈希判据。
2026-10-09移除已过期的「清单分级与复算结果的 6 处偏差」待确认项——该偏差已在清单订正中收敛为 0 项。
2026-10-10补入口 ID 长度上限(≤32 字符)与 appname 前缀硬校验:fn-wechatmarkdowneditor.Application(35 字符)实测打包失败,改用 .main(28 字符)通过。实测边界 32 通过 / 33 拒绝。同步补自检清单的无入口应用形态(app/ui/config 为 {})。
2026-10-10新增「模式 B 的三个补充裁决」(T1 第 1 组实测):① PUID/PGID 改用 ${TRIM_UID}/${TRIM_GID}(真机取证 fnOS 包用户 UID 是动态值、数据目录 mode 770);② 向导字段按「安装时必须由用户决定」取舍,gotify 29 字段收敛为核心 5 个并要求文档留出口;③ 上游数据落盘缺陷(2fauth 的 DB_DATABASE 在挂载外、APP_KEY 为占位示例值)必须修正而非照搬。
2026-10-10订正 ${TRIM_UID}/${TRIM_GID} 的依据强度:原表述称「fnOS 在 compose 中会展开 TRIM_*(官方 Docker 案例使用 …)」并引用了官方案例,但该案例实际只含 ${TRIM_APPDEST}/${TRIM_SERVICE_PORT},未出现 TRIM_UID/TRIM_GID,属引用不实。改为「文档列出变量但无 compose 示例」,并补充未注入时的静默空值实测输出(exit=0、PUID: "")。
2026-10-10补两处泛化:① 宿主路径挂载条款判据从「/etc/1panel 等 1Panel 路径」改为「挂载源是否为宿主绝对路径」,并补录 showdoc 的 /showdoc_data 案例;② 新增可变镜像标签禁令(:latest/:master/:main 等一律不转换),补录 6 个暂缓应用。
2026-10-14新增「模式 B-2 D 组用户可见数据」与「模式 B-3 单文件挂载与最小种子配置」两节。D 组记录既有 10 个 data-share 先例「声明了但未使用」的坑(TRIM_DATA_SHARE_PATHS 命中 0、@appshare 为空),确立「数据挂共享目录而非 TRIM_PKGVAR、恰好声明 1 个、不需 permission」,并标注 TRIM_DATA_SHARE_PATHS 在 compose 的展开能力待真机验证。B-3 记录 touch 空文件导致 focalboard JSON 解析失败与 obsidian_livesync 认证弱化等实质缺陷,确立自写最小配置而非搬运上游。
2026-10-14订正 D 组规则的卷号缺陷:原写「挂载源改为共享目录绝对路径(/vol<N>/@appshare/…)」,但 <N> 是安装时用户选择的存储卷,写死会在选错卷时失效。改为沿用既有先例 fn-xiaoya-only 的 ${TRIM_APPDEST_VOL}/@appshare/fn-<app>/… 写法,并说明为何不用 TRIM_DATA_SHARE_PATHS(多值分隔,无法作单个挂载源且展开能力未验证)。
2026-10-14补可变标签判据的宽匹配规则与三类命中场景(:latest 类、:master 类、alpine-latest 类、变量插值类)。此前的「标签等于可变值」写法连续漏判三次(:master、alpine-latest、${PANEL_DB_TYPE} 插值),改为「标签串包含可变词或含 ${ 即命中」,并要求选定目标版本前联网确认 tag 存在。
2026-10-14订正两条映射:① Redis 字段改为「按应用实际消费方式给」——分字段消费型(如 cloudreve 的 CR_CONF_Redis.Server 只收 host)用 REDIS_HOST/REDIS_PORT/REDIS_PASSWORD,硬塞连接串会得到 redis://host:port:6379 错值;② 命名卷条款从「仅 external: true」扩到全部具名卷,托管卷虽自动创建但数据落在 /var/lib/docker/volumes/,不在 uninstall_callback 删除范围,卸载后残留泄漏(discuz 即此类)。
2026-10-14补「待确认」条目:镜像默认 USER 与数据目录属主可能不匹配(约 20 个应用)。install_callback 建目录的属主是包用户、mode 770;镜像若以固定非 root 身份运行则无写权限。已用 fn-node-red 的 user: "${TRIM_UID}:${TRIM_GID}" 先例对齐,但对依赖自身 home 属主的镜像强加可能致其起不来,故不批量改,列为真机验证第一优先级。
2026-10-15新增禁止项「不得覆盖仓库已有的同名应用」:同步或批量转换前先核对 apps/ 是否已有该应用(原生或 Docker 形态都算),按归一化名字比对(fn-mysql_v8 与 fn-mysql-v8 视为同一个)。确需纳入上游新版本时走升级、保留 appname,不重建。
2026-10-15属主待验证清单补入 fn-sonarqube(镜像 USER=sonarqube,非 root,且有 3 个由install_callback 创建的挂载点),系 T2 第 4 组新增命中项。
2026-10-15口径变更(用户裁决):镜像标签改为「1panel 用什么就用什么」——可变标签(:latest/:master/alpine-latest 等)不再阻塞转换,直接沿用上游标签,不再要求联网查 tag 或收敛版本。但 ${...} 变量插值仍必须解析——两者性质不同:插值变量在 fnOS 侧没有来源(上游 init.sh 生成),不解析就是空值;:latest 能解析到具体镜像,只是会漂移。另新增裁决:运行身份优先非 root(默认用户)——镜像层默认 root 且无可注入变量时,加 user: "${TRIM_UID}:${TRIM_GID}";唯一例外是 s6-overlay 类镜像(Entrypoint: ["/init"]),其初始化强依赖 root,加 user: 会起不来。
2026-10-16属主待验证清单补入 fn-redis-commander(镜像层 USER 1000,与包用户 uid 9xx 不匹配)。该应用无我们创建的挂载需要写入,故按裁决 B「已是非 root」不改,但属主匹配仍待真机确认。
2026-10-16裁决 B 补子类:镜像内自带 uid 变量且依赖 root 做 chown 的(jupyter-notebook 的 NB_UID/NB_GID + CHOWN_HOME=yes)→ 改变量、不加 user:。加 user: 与 CHOWN_HOME 直接冲突(非 root 无权 chown)。同时补入「镜像内降权 / 镜像层非 root / 镜像层默认 root」四类判定表。另修正一处 Lead 的统计 bug:归一化名比对时只归一化一侧等于没归一化,导致把已交付的 obsidian_livesync 误报为待转换(详见经验教训 1.4)。
2026-10-16D 组解除阻塞(用户裁决):TRIM_APPDEST_VOL 在 compose 的展开未经真机确认,但用户决定先按方案 C 实施、本轮不做真机测试。已把已知事实与残留风险(变量为空时会退化为 /@appshare/...,容器仍能起、属静默错误)如实登记,并写明将来的验证方法(docker inspect 看 Mounts[].Source)。同时新增禁止项 8:不得修改 831b7d3 之前用户既有的 14 个应用。
2026-10-16D 组方案订正 C→B:原推荐 ${TRIM_APPDEST_VOL}/@appshare/fn-<app>/(依据 fn-xiaoya-only 先例)依据不足。查证官方文档:TRIM_APPDEST_VOL 全文仅 1 次(定义句),不在「Compose 可以使用」的变量表中,无任何用法示例;而 TRIM_DATA_SHARE_PATHS 出现 6 次、明确列在该表中、且有官方示例 ${TRIM_DATA_SHARE_PATHS%%:*};@appshare 在官方文档 0 次(属平台实现细节,非文档化契约)。改用 ${TRIM_DATA_SHARE_PATHS}。同时采纳官方 resource.md:34 建议:声明 1 个顶级 share(fn-<app>),各用途子目录在容器内挂载目标区分。install_callback 加 mkdir -p "${TRIM_DATA_SHARE_PATHS%%:*}" 兜底。
2026-10-16D 组方案订正 B→C(回归):同日曾据官方文档把方案改为 ${TRIM_DATA_SHARE_PATHS},随即复核后撤回——该变量是 : 分隔多值,无法作单个挂载源(此点在更早的变更记录里已有记载,是我推翻了自己写下的条款)。恢复 ${TRIM_APPDEST_VOL}/@appshare/fn-<app>/<子目录>,并补记其真实依据:用户既有应用 fn-xiaoya-only 正在使用该写法(compose 引用 4 处),其文档给出实际路径 /vol4/@appshare/fn-xiaoya-only/alist/data/。教训:官方文档没写 ≠ 平台不支持;"使用中的用户应用 + 其文档 + 真机观测"强于官方文档描述。反之官方变量表中的变量也不等于能在 compose 当挂载源用。
2026-10-16属主待验证清单补入 fn-ezbookkeeping(镜像层 USER 1000:1000,与包用户 uid 9xx 不匹配)。它有三个挂载需写入(storage 共享 + data/log 私有),属 D 组(task-25)新增命中项。
2026-10-20批量真机验收(FNOS-001)沉淀:① 走 Midscene 驱动应用中心 Web UI 的批量通道(设备端 appcenter-cli 因 rpcbroker 权限不可用),完整流程与判定分级见 批量真机验收规程;② 向导密钥必须按 helpText 写明的位数生成——2fauth 的 APP_KEY 要求 32 位随机串,按 17 位生成会被应用中心拒收(错误码 10236);③ 卸载数据删除实际失败且日志谎报成功(详见「待确认」表新增条目),影响全部使用同一写法的应用;④ 入口探测必须在卸载之前做,且应用中心报 running 不等于容器已监听,探针需重试;⑤ 外部依赖类必填字段(DB_HOST/REDIS_HOST 等)可用本地默认填充以验证转换本身,失败归类为「需外部依赖」而非转换缺陷;⑥ 文档与脚本一律不记录设备地址与凭据。
2026-10-16追溯整改(task-22):按裁决 B 复核 6 个已交付应用。2 个加 user:(fn-wordpress、fn-sentinel-dashboard);4 个归例外并在文档补「运行身份」节——fn-phpmyadmin(显式 USER=root + 写 /etc/phpmyadmin/)、fn-roundcube(chown ${TEMP_DIR} 且该变量脚本内有默认值、必执行)、fn-typecho(chown -R /app + 写 /etc/ + su)、fn-rustdesk(s6)。判定方法写入规约:读 entrypoint 源码逐行找 root 操作,不能只看镜像名或 compose 是否设了变量。
2026-10-16裁决 B 例外条款按现象改写:原写「s6-overlay 类镜像(Entrypoint: ["/init"])」是按实现判定;实测 neko 用 supervisord 但机制相同(user=root 启动 + %(ENV_USER)s 降权 + chown=root:neko),故改为「镜像初始化强依赖 root」这一现象。另补两个易错点:① 镜像层 USER 环境变量 ≠ 可注入的 uid 机制(neko 的 USER=neko 只是降权目标);② 判据是 entrypoint 源码而非镜像名(wordpress 的条件式 chown + || true 不算,roundcube 因 := 默认值必执行则算)。
2026-10-16属主待验证清单补入 fn-neko(镜像内 neko 用户固定 uid 1000,与包用户 uid 9xx 不匹配)。该应用归「初始化强依赖 root」例外(supervisord),故运行身份保持原样;其 /home/neko(0700)的写入能力待真机确认。
2026-10-16task-23(openlist/gsmanager/neko)交付。neko 的 IMAGE_NAME 选项由 17 删至 13(vncviewer/arm-* 实测 tag 不存在);openlist 镜像写死 v4.2.6(取自上游 init.sh)、删 PRE_INSTALLED 死字段、user: "0:0" 改包用户;gsmanager 加 user: 并改 container_name。另删除 apps/docker-ui-application-sample/ —— 那是 fnpack 内置示例模板的残留(git 未跟踪、无任何引用),它的存在使全量 lint 失败 10 项。

基于 VitePress 构建