飞牛 NAS (fnOS) 应用登录认证 & 开放 API 接入手册

目录

适用对象:想在飞牛 NAS (fnOS) 上开发第三方应用、并复用「NAS 账号登录态」的开发者
核心能力:统一网关鉴权 + 用户身份 Header + 文件授权 + 页面路由
版本要求:fnOS ≥ 1.2.0401,飞牛 App ≥ 1.34.0
官方文档https://developer.fnnas.com/docs/guide/ · https://developer.fnnas.com/api/overview/


目录

  1. 架构总览
  2. 三种接入模式对比
  3. 统一网关:复用 NAS 登录态(核心)
  4. 应用包配置(manifest / privilege / resource / ui)
  5. 后端监听 Unix Socket
  6. 前端 JS SDK (@trimjs/web-app)
  7. 文件授权体系
  8. 后端 API 清单与调用方式
  9. 页面路由能力
  10. 错误码速查表
  11. 安全红线
  12. 完整接入清单 Checklist
  13. 参考资源
  14. ★ 实战逆向:飞牛音乐/影视的登录机制分析(含完整 API)

1. 架构总览

┌─────────────────────────────────────────────────────────────────┐
│                     用户浏览器 / 飞牛 App / TV                    │
└──────────────────────────┬──────────────────────────────────────┘
                           │  HTTPS(fnOS 域名,已带登录 Cookie/会话)
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│                      飞牛 fnOS 统一网关                           │
│   ✅ 校验用户登录态(未登录直接拦截,不会到达你的服务)                │
│   ✅ 注入可信身份 Header:X-Trim-Userid / X-Trim-Username / ...   │
│   ✅ 将请求转发到应用的 Unix Socket                               │
└──────────────────────────┬──────────────────────────────────────┘
                           │  Unix Socket: /var/apps/{app}/target/app.sock
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│                        你的应用后端服务                            │
│   🔐 从 X-Trim-* Header 读取当前登录用户(可信身份)                 │
│   🔧 通过 /var/run/trim_open_gateway_apiscope.socket             │
│      调用系统开放 API(查授权、查权限、转路径)                       │
└─────────────────────────────────────────────────────────────────┘

一句话理解​:你不需要自己实现「登录」,fnOS 网关已经替你完成了登录校验;你的应用只要从网关注入的 Header 里读出「当前是谁」,再做自己的业务权限即可。


2. 三种接入模式对比

能力 index.cgi 统一网关 端口服务
简单静态页面 ✅ 适合 ✅ 支持 ⚠️ 杀鸡用牛刀
常驻后台服务 ❌ 不推荐 ✅ 适合 ✅ 适合
WebSocket ❌ 不支持 ✅ 支持 ✅ 支持
NAS 登录态 调用 CGI 前校验 转发前校验 + 注入用户 Header ❌ 与登录态无关
性能 每请求起 CGI 进程 转发到常驻服务 直连端口
访问路径 /cgi/ThirdParty/{appname}/index.cgi/ /app/{appname} http://nas-ip:{port}

🎯 要做「NAS 账号登录」的应用,请选统一网关。


3. 统一网关:复用 NAS 登录态(核心)

3.1 工作流程

  1. 应用在 app/ui/config 里通过 gatewayPrefix 注册公开路径(如 /app/myapp)。
  2. 应用服务监听 gatewaySocket 声明的 Unix Socket(如 app.sock,位于应用 target 目录下)。
  3. 用户访问 /app/myapp
  4. fnOS 校验用户会话 —— 未登录用户在这里被拦截,跳转登录页。
  5. 校验通过后,fnOS 把请求转发到 /var/apps/myapp/target/app.sock
  6. 应用从请求 Header 中读取网关注入的用户信息。

3.2 可信身份 Header

Header 说明 示例值
X-Trim-Userid 当前用户 UID 1000
X-Trim-Username 当前用户名 admin
X-Trim-Isadmin 是否管理员 true / false

转发示例:

GET /app/myapp/api/list HTTP/1.1
Host: nas.example.com
X-Trim-Userid: 1000
X-Trim-Isadmin: false
X-Trim-Username: alice

3.3 入口配置

app/ui/config

{
  ".url": {
    "myapp.main": {
      "title": "我的应用",
      "icon": "images/icon_{0}.png",
      "type": "iframe",
      "protocol": "",
      "port": "",
      "gatewayPrefix": "/app/myapp",
      "gatewaySocket": "app.sock",
      "url": "/app/myapp",
      "allUsers": true
    }
  }
}

字段规则:

  • gatewayPrefix:必须是 /app/{appname}/app/{appname}/{customPath};路径在版本间保持稳定;避免用点号。
  • gatewaySocket:只填 Socket 文件名(如 app.sock);Socket 文件必须放在应用安装后的 target 目录下(脚本里用 ${TRIM_APPDEST} 定位)。
  • protocol / port:统一网关模式下被忽略,留空即可。
  • Docker 应用也能用统一网关:把 ${TRIM_APPDEST} 挂载进容器,让容器内服务在该目录创建 Socket。

3.4 WebSocket

WebSocket 复用同一个前缀和 Socket,建议放稳定子路径:

// 前端
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${wsProtocol}//${location.host}/app/myapp/ws`);

连接建立时同样会带上 X-Trim-* Header —— 在 onopen/握手阶段把连接绑定到 X-Trim-Userid,之后不要信任消息体里客户端自报的 userId。


4. 应用包配置

4.1 manifest

appname="myapp"
version="1.0.0"
display_name="我的应用"
desc="接入 NAS 登录的示例应用"
source="thirdparty"
platform="all"
maintainer="Your Name"
maintainer_url="https://example.com"
os_min_version="1.2.0401"
desktop_uidir=ui
desktop_applaunchname=myapp.main
micro_app=true          # ★ 必须声明,否则 JS SDK 无法初始化
ctl_stop=true           # 有后台服务时显示启停控制
service_port=           # 统一网关模式不需要固定端口,可省略
checkport=false

4.2 config/privilege

{
  "defaults": { "run-as": "package" },
  "username": "myapp_user",
  "groupname": "myapp_group"
}

用专用包用户运行(最小权限)。需要 GPU/硬件转码时可加 "join-groups": ["video", "render"]

4.3 config/resource(声明 API Scope)

{
  "api-scope": [
    "trim.file.sharedAccess",
    "trim.file.userAccess",
    "trim.file.userAcl",
    "trim.file.path",
    "trim.system.getPlatformConfig"
  ]
}

Scope 与能力对应表:

Scope 能力
trim.file.sharedAccess 管理员为应用授权共享目录(查询/删除)
trim.file.userAccess 当前用户授权个人目录/文件(查询/删除)
trim.file.userAcl 检查某用户对某路径的读/写/删权限
trim.file.path 内部路径 → 用户可见路径转换
trim.system.getPlatformConfig 后端读取系统语言和版本

只声明实际用到的 Scope,别无脑写满。

4.4 关键环境变量(启动脚本/服务可用)

变量 含义
TRIM_APPDEST 应用安装目录(target),Socket 放这里
TRIM_PKGETC / TRIM_PKGVAR / TRIM_PKGHOME 配置 / 数据 / 用户数据目录
TRIM_SERVICE_PORT manifest.service_port
TRIM_API_TOKEN ★ 调用开放 API 的 Bearer Token(每次从环境变量现读,勿持久化)
TRIM_APPNAME / TRIM_APPVER 应用名 / 版本

5. 后端监听 Unix Socket

5.1 cmd/main 启动脚本

#!/bin/bash
case "$1" in
  start)
    cd "$TRIM_APPDEST" || exit 1
    exec node server.js >> "$TRIM_TEMP_LOGFILE" 2>&1 &
    ;;
  stop)
    pkill -f "node server.js" || true
    ;;
  status)
    pgrep -f "node server.js" >/dev/null && exit 0 || exit 3
    ;;
  *)
    echo "Unknown command: $1" > "$TRIM_TEMP_LOGFILE"
    exit 1
    ;;
esac

5.2 Node.js 示例(HTTP + WebSocket 同端口)

const http = require('http');
const fs = require('fs');
const path = require('path');

const sockPath = path.join(process.env.TRIM_APPDEST, 'app.sock');
try { fs.unlinkSync(sockPath); } catch (_) {}

const server = http.createServer((req, res) => {
  // ★ 网关保证能到达这里的请求都已通过 NAS 登录校验
  const user = {
    uid: req.headers['x-trim-userid'],
    username: req.headers['x-trim-username'],
    isAdmin: req.headers['x-trim-isadmin'] === 'true',
  };

  if (!user.uid) {
    res.writeHead(401, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ code: 401, msg: 'Unauthorized' }));
  }

  // TODO: 业务逻辑 —— 记住:Header 是身份,业务权限仍需自己做
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ code: 0, data: { user } }));
});

server.listen(sockPath, () => {
  fs.chmodSync(sockPath, 0o666); // 允许网关读写
  console.log('listening on', sockPath);
});

5.3 Python 示例

import os, socket, json

sock_path = os.path.join(os.environ["TRIM_APPDEST"], "app.sock")
try: os.unlink(sock_path)
except FileNotFoundError: pass

srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
srv.bind(sock_path)
os.chmod(sock_path, 0o666)
srv.listen(16)

def handle(conn):
    # 生产环境建议用 gunicorn+uvicorn workers 监听 unix socket,
    # 或直接用 uvicorn --fd / uWSGI --socket。此处仅示意读取 Header。
    data = conn.recv(65536).decode(errors="ignore")
    headers = {}
    for line in data.split("\r\n")[1:]:
        if ":" in line:
            k, v = line.split(":", 1)
            headers[k.strip().lower()] = v.strip()
    user = {
        "uid": headers.get("x-trim-userid"),
        "username": headers.get("x-trim-username"),
        "is_admin": headers.get("x-trim-isadmin") == "true",
    }
    body = json.dumps({"code": 0, "data": {"user": user}}).encode()
    conn.sendall(
        b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
        + f"Content-Length: {len(body)}\r\n\r\n".encode() + body
    )
    conn.close()

while True:
    conn, _ = srv.accept()
    handle(conn)

5.4 Go / Rust 提示

  • Go:net.Listen("unix", sockPath) 配合 http.Serve(ln, mux) 即可。
  • Rust:tokio::net::UnixListener + hyper serve_incoming
  • 别忘了 os.Chmod(sockPath, 0666) / std::fs::set_permissions

6. 前端 JS SDK (@trimjs/web-app)

6.1 安装与初始化

npm install @trimjs/web-app
import { TrimApp } from '@trimjs/web-app';
const sdk = new TrimApp();

6.2 运行环境判断

属性 含义
sdk.isWeb 是否 Web 宿主(移动 App 内嵌通常为false
sdk.isStandaloneWeb 是否独立浏览器页面(脱离宿主打开)

分支策略:

if (sdk.isStandaloneWeb) {
  // 独立浏览器 → 授权走 openAppAuth 回调路由
} else {
  // 宿主内 → 直接调 pickSharedFile / pickUserFile
}

6.3 读取平台配置

const config = await sdk.getPlatformConfig();
/*
{
  theme: 'dark' | 'light',
  language: 'zh-CN',
  systemLanguage?: string,
  appVersion?: string,
  systemVersion: '1.2.0401',
  format: { date?: 'YYYY-MM-DD', time?: '24h' }
}
*/
applyTheme(config.theme);
applyI18n(config.language);

6.4 监听主题 / 语言变化

仅 Web 宿主支持(isWeb === true && isStandaloneWeb === false)。

if (sdk.isWeb && !sdk.isStandaloneWeb) {
  sdk.$on('themechange', (theme) => applyTheme(theme));
  sdk.$on('languagechange', (lang) => applyI18n(lang));
}

7. 文件授权体系

7.1 背景

应用进程以「应用用户」(如 myapp_user)运行,不是当前登录用户,因此默认无法读写用户的 NAS 文件。必须先走授权流程,由系统把目标路径的 ACL 授给应用用户。

两种授权模式:

模式 操作人 适用场景
应用共享授权 管理员 所有用户共用同一目录(媒体库、公共素材)
用户个人授权 当前用户本人 每个用户各自的目录(私人备份、个人歌单扫描)

7.2 宿主环境直调(推荐入口)

// 用户个人授权(选目录)
const result = await sdk.pickUserFile({ directory: true });
// result.code === 0 且 result.data.paths 为授权成功的路径数组

// 应用共享授权(需管理员)
const shared = await sdk.pickSharedFile({ directory: true });

7.3 独立浏览器回调授权(openAppAuth)

发起授权:

const authState = crypto.randomUUID();   // 自行生成并暂存,防 CSRF

await sdk.openAppAuth('pickUserFile', {
  appName: 'myapp',
  directory: true,
  redirectUri: '/app/myapp/auth-callback.html',  // ★ 必须同域(统一网关天然满足)
  state: authState,
}, {
  target: '_blank',                       // 新窗口不打断原页面
  features: 'width=750,height=630',
});

回调页 auth-callback.html

<script type="module">
import { TrimApp } from '@trimjs/web-app';
const sdk = new TrimApp();

const result = sdk.parseAppAuthCallback(window.location.href);

// ① 校验 state,确认结果来自本次请求
// if (result.state !== savedState) { window.close(); }

// ② postMessage 通知原页面(同源校验)
if (window.opener && !window.opener.closed) {
  window.opener.postMessage(
    { type: 'myapp:auth-result', result },
    window.location.origin
  );
}
window.close();
</script>

原页面接收:

window.addEventListener('message', (e) => {
  if (e.origin !== window.location.origin) return;
  if (e.data?.type !== 'myapp:auth-result') return;
  refreshAccessiblePaths();
});

// 兜底:部分浏览器 _blank 行为不稳定,保留手动刷新按钮
document.querySelector('#refresh-auth')
  .addEventListener('click', refreshAccessiblePaths);

7.4 服务重启后的授权同步

选择器只返回当次结果;重启 / 刷新页面后要用后端 API 重新拉取(见 §8)。


8. 后端 API 清单与调用方式

8.1 通用调用规范

  • 协议POST /api/v1/trimapp
  • 传输层:Unix Socket /var/run/trim_open_gateway_apiscope.socket(仅限应用服务端本机调用)
  • 认证头Authorization: Bearer ${process.env.TRIM_API_TOKEN}
  • 禁止:前端浏览器直调后端 API;token 写死到代码/配置/前端。

请求结构:

{
  "reqId": "string(可选,响应原样带回)",
  "req": "trim.system.getPlatformConfig",
  "appName": "myapp",
  "data": {}
}

响应结构:

{ "reqId": "...", "code": 0, "msg": "", "data": {} }

code === 0 成功;非 0 见错误码表。

8.2 Node.js 通用封装(undici)

const { request } = require('undici');

const SOCKET = '/var/run/trim_open_gateway_apiscope.socket';

async function trimApi(reqName, data = {}, appName = process.env.TRIM_APPNAME) {
  const body = JSON.stringify({
    reqId: String(Date.now()),
    req: reqName,
    appName,
    data,
  });

  const resp = await request({
    method: 'POST',
    pathname: '/api/v1/trimapp',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${process.env.TRIM_API_TOKEN}`,
      'Content-Length': Buffer.byteLength(body),
    },
    body,
    socketPath: SOCKET,
  });

  const json = JSON.parse(await resp.body.text());
  if (json.code !== 0) throw new Error(`[${json.code}] ${json.msg}`);
  return json.data;
}

module.exports = { trimApi };

curl 快速验证:

curl --unix-socket /var/run/trim_open_gateway_apiscope.socket \
  -X POST http://localhost/api/v1/trimapp \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TRIM_API_TOKEN" \
  -d '{"reqId":"1","req":"trim.system.getPlatformConfig","appName":"myapp","data":{}}'

8.3 API 一览

① 平台配置

const cfg = await trimApi('trim.system.getPlatformConfig');
// → { systemLanguage: 'zh-CN', systemVersion: '1.2.0401' }

② 查询用户个人授权路径

const r = await trimApi('trim.file.listUserAccess', {
  // uid: '1000'   // 可选;不传则默认按当前网关用户上下文
});
// → { paths: ['/vol1/1000/Music', '/vol2/Photos'] }

③ 查询应用共享授权路径

const r = await trimApi('trim.file.listSharedAccess');
// → { paths: ['/vol1/MediaLibrary'] }

④ 删除授权路径

await trimApi('trim.file.removeUserAccess',   { path: '/vol1/1000/Music' });
await trimApi('trim.file.removeSharedAccess', { path: '/vol1/MediaLibrary' });

⑤ 文件权限检查(★ 业务操作前必调)

const r = await trimApi('trim.file.checkUserAcl', {
  path: '/vol1/1000/Music/song.flac',
  action: 'read',            // 'read' | 'write' | 'delete'
});
// → { allowed: true }

⑥ 路径转换(内部 → 展示)

const r = await trimApi('trim.file.convertPath', { path: '/vol1/1000/Music' });
// → { path: '/我的文件/Music' }

9. 页面路由能力

前端 JS SDK 方法(无需额外 Scope):

方法 签名 说明
openFile (path) => Promise 用系统默认方式打开文件
showFileDetails (paths[], options?) 打开文件详情页(元数据/权限调整)
openFileManager (path) 打开文件管理器并定位
openAppSetting () 打开本应用设置页
openURL (url, target?, features?) 外部链接(Web 宿主=window.open;App 内嵌=系统浏览器)
await sdk.openFile('/vol1/1000/Music/song.flac');
await sdk.openFileManager('/vol1/1000/Music');
await sdk.openAppSetting();
await sdk.showFileDetails(['/vol1/1000/photo.jpg']);
await sdk.openURL('https://example.com/help', '_blank');

⚠️ 打开文件/管理器前确保目标路径来自授权范围,并按需先做 checkUserAcl。路由只负责「打开」,不替代授权与权限判断。


10. 错误码速查表

JSSDK 错误码

code 含义 处理
0 成功
1000000 服务或内部异常 刷新状态重试
1000001 登录/认证失败 引导重新登录 fnOS
1000002 管理员权限或 Scope 不足 核对config/resource 的 api-scope
1000030 请求不合法/路径不支持 检查参数、路径类型、能力状态
1000300 找不到已安装应用 确认应用已装且在运行
1000701 路径不存在 让用户重新选择存在的路径
1003103 应用权限校验失败 尝试重新安装应用
1003201 管理员关闭了普通用户授权 提示仅管理员可操作

补充:普通用户误触共享授权入口可能返回 code:1 / "仅管理员可进行此操作",或回调返回 status:"error" / error:"access_denied" —— 都表示无权执行该授权。

后端 API 错误码

HTTP code msg 原因 / 处理
200/400 200001 Invalid Params JSON 格式或参数类型不对
401 200004 Unauthorized token 无效 → 现读TRIM_API_TOKEN
403 200003 Forbidden api-scope 未声明,或 token 不含该 scope
404 200005 Not Found req 拼错 / 接口未注册 / 系统版本过低
200/500 200006 Internal Error 系统内部错误,收集 reqId 反馈

11. 安全红线

  1. 绝不信任客户端传的 userId —— 身份唯一来源是网关注入的 X-Trim-* Header。
  2. 网关只保证「已登录」,业务权限必须自己做:
    - 用户只能访问自己的数据;
    - 管理接口校验 X-Trim-Isadmin === 'true'
    - 高危操作显式二次确认。
  3. 文件操作前必做三件事
    - 路径标准化(拒绝 .. 穿越、解析符号链接);
    - 确认路径在授权范围内;
    - 调 trim.file.checkUserAcl 验证当前用户权限(拿到应用 ACL ≠ 可以绕过用户权限)。
  4. token 管理TRIM_API_TOKEN 每次调用从进程环境变量现读;不落库、不写配置文件、不出现在前端代码。
  5. WebSocket:握手时绑定 X-Trim-Userid,之后忽略客户端自报的身份。
  6. 公开回调路径(如 OAuth 回调):保持窄而明确,未鉴权路径只开必要的方法和数据。

12. 完整接入清单 Checklist

开发准备:

  • fnOS 测试机 ≥ 1.2.0401,有管理员账号
  • 安装 fnpack(打包)、appcenter-cli(设备端装包)

应用包:

  • manifestmicro_app=trueos_min_version≥1.2.0401、必要时 ctl_stop=true
  • config/privilegerun-as=package 专用用户
  • config/resource:按需声明 api-scope(不多不少)
  • app/ui/configgatewayPrefix=/app/{appname} + gatewaySocket=app.sock
  • 图标 ICON.PNG / ICON_256.PNG、UI 目录图标齐备

后端:

  • 服务监听 ${TRIM_APPDEST}/app.sock,并 chmod 0666
  • 解析 X-Trim-Userid / X-Trim-Username / X-Trim-Isadmin
  • 封装 trimApi():unix-socket + Bearer ${TRIM_API_TOKEN}(现读现用)
  • 业务写好:按 uid 隔离数据、管理接口校验 isadmin
  • 文件读写前:范围校验 + checkUserAcl

前端:

  • npm i @trimjs/web-app,初始化 new TrimApp()
  • isStandaloneWeb 分流授权入口
  • 回调页:parseAppAuthCallback + 校验 state + postMessage + close
  • 主页面:监听 message + 提供「刷新授权状态」兜底按钮
  • 启动/刷新时调用 listUserAccess / listSharedAccess 同步授权

测试:

  • 管理员账号全流程 OK
  • 普通用户(非管理员)流程 OK
  • 未登录直接访问 /app/myapp → 应被网关拦截跳登录
  • 授权后服务重启 → 授权状态仍能同步回来
  • 卸载应用 → 授权清理、Socket 清理正常

13. 参考资源

资源 地址
官方开发文档 https://developer.fnnas.com/docs/guide/
开放 API 文档 https://developer.fnnas.com/api/overview/
统一网关章节 https://developer.fnnas.com/docs/core-concepts/gateway-registration/
调用方式章节 https://developer.fnnas.com/api/calling/
JS SDK npm 包 https://www.npmjs.com/package/@trimjs/web-app
文档镜像仓库(每日同步) https://github.com/ckcoding/fnnas-docs
更新日志 https://developer.fnnas.com/docs/update-log/
开发者先锋群 飞牛官网 → 右上角关注 → 微信群扫码 → 联系社区主理人

14. ★ 实战逆向:飞牛音乐/影视的登录机制分析(含完整 API)

分析样本:https://nas.demo.com:5001/music/login(飞牛音乐 v0.9.24 / mediasrv v0.8.41)
方法:直接抓取线上页面 HTML + 前端 JS bundle 逆向 + 实测 API 端点

14.1 先回答两个疑问

❓ 为何「游客」不登录 NAS 也能访问登录页?

因为飞牛音乐根本不走统一网关的「NAS 会话校验」,它有自己独立的应用级账号体系 + 应用级 Token

统一网关模式(第三方应用) 飞牛音乐/影视模式(官方应用)
接入层 /app/{appname} → fnOS 校验会话后才转发 /music/* 直接挂在系统 nginx 上,nginx 只反代、不校验登录
账号体系 复用 NAS 系统用户(X-Trim-* Header) 应用内自建账号(管理员在应用里为家人创建,与 NAS 用户无关)
登录态 无独立登录页,靠 NAS Cookie 有自己的/login 页面 + 自己签发的 userToken
游客可达性 未登录被网关拦截跳 NAS 登录页 登录页、公开接口(sys/config、initialization/state)游客可直接访问

所以「游客能打开登录页」不是漏洞,而是设计如此:认证责任从网关层下沉到了应用层。实测证据:

# 公开接口 —— 无需任何 token,游客可访问
GET /music/api/v1/sys/config
→ {"code":0,"msg":"","data":{"nasOAuth":{"clientId":"EDDLUH2WLY"},"serverGUID":"e5e6...","serverName":"HaoNas","serverVersion":"0.9.24","mediasrvVersion":"0.8.41"}}

GET /music/api/v1/initialization/state
→ {"code":0,"msg":"","data":{"initialized":true}}

# 受保护接口 —— 应用自己校验 token
GET /music/api/v1/user/me        (无 token)
→ {"code":99999,"msg":"INVALID TOKEN","data":null}

POST /music/api/v1/user/password-login  (错误凭据)
→ {"code":120001,"msg":"unauthorized, please login again","data":null}

❓ 为何端口是 NAS 的默认端口(5001)?

因为官方应用是系统内置组件,直接注册在 fnOS 的 Web 服务(nginx)上,与系统桌面共享同一域名+端口,通过路径前缀区分

https://nas.demo.com:5001/
├── /            → 飞牛 fnOS 桌面(<title>飞牛 fnOS</title>)
├── /v/          → 飞牛影视(<title>飞牛影视</title>)
├── /music/      → 飞牛音乐(<title>飞牛音乐</title>)
├── /signin      → fnOS 统一 OAuth 登录页
└── /app/{xxx}   → 第三方应用的统一网关入口

这是官方应用的特权路由——它们由飞牛官方打包进系统或深度集成,安装时向 nginx 注册自己的前缀。第三方应用拿不到这种特权,只能走:统一网关(/app/{appname})、index.cgi 或独立端口。但登录架构可以照抄(见 §14.5)。

14.2 双通道登录架构

飞牛音乐的登录有两个通道,前端按场景自动选择:

┌─────────────────────────────┐
                    │  打开 /music/login           │
                    └──────────┬──────────────────┘
                               │
              ┌────────────────┴────────────────┐
              ▼                                 ▼
   【通道A】从 fnOS 桌面/App 跳转过来      【通道B】游客直接访问
   (URL 带 OAuth code)                  (无 code)
              │                                 │
              ▼                                 ▼
   OAuth 免密登录                       应用内账号密码登录
   ① GET /api/v1/sys/config             POST /api/v1/user/password-login
      → 拿 nasOAuth.clientId/url           {username, password, deviceId}
   ② 跳 fnOS /signin?client_id=...         │
      &redirect_uri=/music/oauth/result    ▼
   ③ fnOS 校验 NAS 会话/完成登录        应用数据库校验
   ④ 带 code 重定向回回调页                │
   ⑤ POST /api/v1/user/auth-login         │
      {code, deviceId}                    │
      (后端用 code 向 fnOS 换身份)        │
              │                                 │
              └────────────────┬────────────────┘
                               ▼
                  音乐应用签发自己的 userToken
                  (后续请求全部携带此 token)

关键洞察

  • 通道 A 实现了「NAS 账号一键登录」——但本质是标准 OAuth 授权码模式,fnOS 是授权服务器,音乐应用是客户端;
  • 通道 B 实现了「独立账号体系」——家人账号由管理员在应用内创建,完全不碰 NAS 用户表;
  • 两个通道最终汇合到同一个终点:应用自己的 userToken

14.3 逆向出的完整 API 清单(/music/api/v1)

认证相关

方法 路径 说明 鉴权
GET /sys/config 系统配置:nasOAuth.clientId、serverGUID、serverName 等 🌐 公开
GET /initialization/state 初始化状态{initialized: bool} 🌐 公开
POST /initialization/prepare 初始化准备(首次部署向导) 🌐 公开
POST /initialization/confirm 初始化确认 🌐 公开
POST /user/password-login 应用内账号密码登录{username, password, deviceId} 🌐 公开
POST /user/auth-login OAuth code 换 token{code, deviceId}{userToken, user} 🌐 公开
GET /user/me 当前登录用户信息 🔒 userToken
POST /user/logout 退出登录 🔒 userToken
POST /user/passwd-change 修改密码 🔒 userToken
GET /user/list 用户列表(管理) 🔒 管理员
POST /user/create 创建用户(管理) 🔒 管理员
POST /user/delete 删除用户(管理) 🔒 管理员
POST /user/edit 编辑用户(管理) 🔒 管理员
POST /user/unbanned 解禁用户(管理) 🔒 管理员
GET /user/exists 检查是否已有用户(初始化判断) 🔒 -

业务功能(均需 userToken)

分组 路径示例
媒体库 /library/*(shared-library/create, list, scan, scan-all...)
曲目 /track/list, /track/stream, /track/transcode, /track/transcode/heartbeat
歌手/专辑/流派 /artist/*, /album/*, /genre/create,list,detail...
播放列表 /playlist/add-track, /playlist/purge-track-count ...
歌词 /lyric/list
播放历史 /play-history/list, /play-history/delete
搜索 /search/suggest, /search/track
设置 /settings/server/detail, /settings/user/detail
任务 /task/cancel, /task/retry, /task/list
静态资源 /static/cover/track, /static/cover/playlist

错误码(音乐应用自有体系,与 fnOS 开放API错误码无关)

code 含义
0 成功
99999 INVALID TOKEN — token 缺失/无效
120001 unauthorized, please login again — 凭据错误
100001 unknown error(如 OAuth code 无效)

14.4 OAuth 免密登录细节(可复用的设计)

前端 getNasOauthUrl() 的拼接逻辑(从 JS bundle 还原):

// 1. 从 sys/config 拿 OAuth 配置
const config = await get('/music/api/v1/sys/config');
const clientId = config.nasOAuth.clientId;    // 如 "EDDLUH2WLY"
const oauthBase = config.nasOAuth.url;        // fnOS 地址(同域名端口)

// 2. 拼 fnOS 统一登录页 URL
const redirectUri = encodeURIComponent(origin + '/music/oauth/result');
const url = `${oauthBase}/signin`
  + `?client_id=${encodeURIComponent(clientId)}`
  + `&redirect_uri=${redirectUri}`
  + `&app_name=${encodeURIComponent('brand.music')}`;
// window.location.href = url;

// 3. fnOS 登录成功后重定向回:
//    /music/oauth/result?code=XXXX

// 4. 回调页用 code 换应用 token
const r = await post('/music/api/v1/user/auth-login', {
  code: 'XXXX',
  deviceId: getOrCreateDeviceId(),   // 前端生成持久化的设备ID
});
// → { userToken, user }

初始化特殊逻辑(首次部署):若 initialization/state.initialized === false,auth-login 不直接发 token,而是走 prepare-initialization 建立 sessionId,引导创建管理员账号后再 confirm-initialization

14.5 如何在自己的应用里继承这套机制

按你能获得的接入权限分三档:

情况一:你是飞牛官方/合作开发者(可获得系统级路由)

完全照抄飞牛音乐架构:

  1. 向系统注册专属路径前缀(如 /yourapp/),挂在系统 nginx 下;
  2. 应用自建账号表 + 自签发 JWT/Token;
  3. 实现 password-login + auth-login(OAuth code 换 token)双通道;
  4. 在 fnOS OAuth 服务注册你的 client_id,配置 redirect_uri;
  5. 公开接口白名单(login、config、init),其余一律校验 token。

情况二:普通第三方开发者(推荐 ⭐)

用「统一网关 + 自建账号」模拟同样的体验:

  1. 通过统一网关 /app/yourapp 接入(获得 NAS 会话校验 + X-Trim-Username Header);
  2. 后端拿到 X-Trim-Userid/Username 后,映射到你应用内的用户档案表(首次登录自动建档,相当于把 NAS 用户当作 OAuth 提供方);
  3. 签发你自己业务的 token 给前端,后续业务请求不再依赖网关 Header(这样 App/TV 客户端也能用同一套 token 逻辑);
  4. 若需要支持「非 NAS 用户」(如家庭成员没有 NAS 账号),加一套应用内账号密码通道,与 NAS 映射账号并存。
// 统一网关模式下"继承"飞牛音乐的登录设计
app.post('/api/v1/auth/nas-login', (req, res) => {
  // 网关已校验 NAS 登录态,这里等价于"auth-login 用 code 换 token"
  const uid = req.headers['x-trim-userid'];
  const username = req.headers['x-trim-username'];

  // 首次登录自动建档(对应音乐应用的 initialization 逻辑)
  const user = upsertAppUser(uid, username);

  // 签发应用自己的 token(对应 userToken)
  const appToken = signJwt({ uid: user.id, role: user.role });
  res.json({ code: 0, data: { userToken: appToken, user } });
});

app.post('/api/v1/auth/password-login', (req, res) => {
  // 应用内账号密码通道(对应 password-login,供无NAS账号的家庭成员)
  const { username, password, deviceId } = req.body;
  const user = verifyAppPassword(username, password);
  if (!user) return res.json({ code: 120001, msg: 'unauthorized' });
  res.json({ code: 0, data: { userToken: signJwt({ uid: user.id }), user } });
});

情况三:纯独立部署(不在飞牛应用体系内)

把飞牛音乐的认证模块当参考实现直接移植:

  • 双通道登录(外部 IdP OAuth + 本地账号密码);
  • 公开接口白名单 + 其余全量 token 校验;
  • deviceId 维度做会话管理(多端登录、踢下线);
  • 初始化向导(prepare → confirm)保护首次部署。

这套模式本质上就是标准的「BFF + 自建 Identity + 外部 IdP 联合登录」,与具体 NAS 平台解耦。

14.6 一张图总结四种接入方式

fnOS Web 服务(nginx :5001)
                                │
     ┌──────────┬───────────────┼──────────────────┬─────────────┐
     ▼          ▼               ▼                  ▼         
  / 桌面     /music/ /v/     /app/{name}       /cgi/ThirdParty   独立端口
  (系统)     (官方应用特权)    (统一网关)          (index.cgi)     (第三方)
               │               │                  │         
          应用自管鉴权      网关校验NAS会话      网关校验NAS会话    与NAS无关
          应用级token      X-Trim-* Header     CGI前校验
          OAuth免密+本地账号  映射应用内用户       仅静态页面

文档整理日期:2026-08-23 · 基于官方 developer.fnnas.com 公开文档整理
§14 实战逆向基于线上飞牛音乐 v0.9.24 前端 bundle 分析与 API 实测,仅供学习研究

评论 (0)

还没有评论,来抢沙发吧~