适用对象:想在飞牛 NAS (fnOS) 上开发第三方应用、并复用「NAS 账号登录态」的开发者
核心能力:统一网关鉴权 + 用户身份 Header + 文件授权 + 页面路由
版本要求:fnOS ≥1.2.0401,飞牛 App ≥1.34.0
官方文档:https://developer.fnnas.com/docs/guide/ · https://developer.fnnas.com/api/overview/
目录
- 架构总览
- 三种接入模式对比
- 统一网关:复用 NAS 登录态(核心)
- 应用包配置(manifest / privilege / resource / ui)
- 后端监听 Unix Socket
- 前端 JS SDK (@trimjs/web-app)
- 文件授权体系
- 后端 API 清单与调用方式
- 页面路由能力
- 错误码速查表
- 安全红线
- 完整接入清单 Checklist
- 参考资源
- ★ 实战逆向:飞牛音乐/影视的登录机制分析(含完整 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 工作流程
- 应用在
app/ui/config里通过gatewayPrefix注册公开路径(如/app/myapp)。 - 应用服务监听
gatewaySocket声明的 Unix Socket(如app.sock,位于应用 target 目录下)。 - 用户访问
/app/myapp。 - fnOS 校验用户会话 —— 未登录用户在这里被拦截,跳转登录页。
- 校验通过后,fnOS 把请求转发到
/var/apps/myapp/target/app.sock。 - 应用从请求 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. 安全红线
- 绝不信任客户端传的 userId —— 身份唯一来源是网关注入的
X-Trim-*Header。 - 网关只保证「已登录」,业务权限必须自己做:
- 用户只能访问自己的数据;
- 管理接口校验X-Trim-Isadmin === 'true';
- 高危操作显式二次确认。 - 文件操作前必做三件事:
- 路径标准化(拒绝..穿越、解析符号链接);
- 确认路径在授权范围内;
- 调trim.file.checkUserAcl验证当前用户权限(拿到应用 ACL ≠ 可以绕过用户权限)。 - token 管理:
TRIM_API_TOKEN每次调用从进程环境变量现读;不落库、不写配置文件、不出现在前端代码。 - WebSocket:握手时绑定
X-Trim-Userid,之后忽略客户端自报的身份。 - 公开回调路径(如 OAuth 回调):保持窄而明确,未鉴权路径只开必要的方法和数据。
12. 完整接入清单 Checklist
开发准备:
- ☐ fnOS 测试机 ≥
1.2.0401,有管理员账号 - ☐ 安装
fnpack(打包)、appcenter-cli(设备端装包)
应用包:
- ☐
manifest:micro_app=true、os_min_version≥1.2.0401、必要时ctl_stop=true - ☐
config/privilege:run-as=package专用用户 - ☐
config/resource:按需声明api-scope(不多不少) - ☐
app/ui/config:gatewayPrefix=/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 如何在自己的应用里继承这套机制
按你能获得的接入权限分三档:
情况一:你是飞牛官方/合作开发者(可获得系统级路由)
完全照抄飞牛音乐架构:
- 向系统注册专属路径前缀(如
/yourapp/),挂在系统 nginx 下; - 应用自建账号表 + 自签发 JWT/Token;
- 实现
password-login+auth-login(OAuth code 换 token)双通道; - 在 fnOS OAuth 服务注册你的
client_id,配置 redirect_uri; - 公开接口白名单(login、config、init),其余一律校验 token。
情况二:普通第三方开发者(推荐 ⭐)
用「统一网关 + 自建账号」模拟同样的体验:
- 通过统一网关
/app/yourapp接入(获得 NAS 会话校验 + X-Trim-Username Header); - 后端拿到
X-Trim-Userid/Username后,映射到你应用内的用户档案表(首次登录自动建档,相当于把 NAS 用户当作 OAuth 提供方); - 签发你自己业务的 token 给前端,后续业务请求不再依赖网关 Header(这样 App/TV 客户端也能用同一套 token 逻辑);
- 若需要支持「非 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)
还没有评论,来抢沙发吧~