Files
Havline/docs/fnos-unified-gateway-plan.md
T
mrlee a14d92ccbb
CI / verify (push) Canceled after 0s
docs: add fnOS unified gateway plan
2026-10-02 14:38:00 +08:00

891 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Havline 飞牛统一网关应用改造方案
> 日期:2026-10-02
> 适用范围:`fnos/havline` 飞牛应用包与 Havline 主程序 1.9.x
> 官方依据:飞牛应用开放平台「统一网关」「应用入口」「应用资源」「环境变量」「Docker 应用案例」
## 一、结论先行
统一网关不是 Docker 应用模板的替代品,而是飞牛应用的一种**访问模型**。
官方文档明确说明:Docker 应用也可以使用统一网关。做法是把
`${TRIM_APPDEST}` 挂载到容器内,让容器中的服务在安装目录下创建
Unix Socket,再由 fnOS 网关转发请求。
因此 Havline 的正确改造方向是:
```text
Docker 运行形态(保留)
+ 统一网关访问模型(新增)
+ 端口服务回退(保留)
```
而不是把现有 Docker 应用整体推翻重做。
推荐首版边界:
```text
管理界面:
通过 /app/havline 和 Unix Socket 暴露
公网反代、DDNS、证书、FRP、Cloudflare Tunnel:
仍由 Havline 自己管理
端口 18080/9443:
作为公网反代功能端口和回退入口保留
```
## 二、统一网关是什么
飞牛统一网关为应用提供飞牛 fnOS 访问域名下的稳定系统 URL。
请求路径:
```text
/app/{appname}
/app/{appname}/{customPath}
```
工作流程:
1. 用户在飞牛桌面打开应用。
2. fnOS 先校验用户会话。
3. fnOS 将请求转发到应用声明的 Unix Socket。
4. 应用读取网关注入的用户 Header。
5. 应用继续执行自己的业务权限校验。
统一网关与端口服务的对比:
| 能力 | 端口服务 | 统一网关 |
| --- | --- | --- |
| 访问地址 | 独立端口 | `/app/{appname}` |
| NAS 登录态 | 不接入 | 转发前校验 |
| 用户上下文 | 无 | `X-Trim-*` Header |
| WebSocket | 应用自行处理 | 支持 |
| 常驻服务 | 支持 | 支持 |
| 部署形态 | 任意 | 任意,Docker 也支持 |
| 核心适配 | 监听端口 | 监听 Unix Socket |
## 三、官方字段与约束
### 3.1 应用入口
统一网关入口写在 `app/ui/config`:
```json
{
".url": {
"havline.main": {
"title": "Havline",
"icon": "images/icon_{0}.png",
"type": "iframe",
"protocol": "",
"gatewayPrefix": "/app/havline",
"gatewaySocket": "app.sock",
"url": "/app/havline",
"allUsers": false,
"control": {
"accessPerm": "readonly"
}
}
}
}
```
字段规则:
```text
gatewayPrefix
必须使用 /app/{appname} 或 /app/{appname}/{customPath}
发布后应保持稳定
gatewaySocket
只填写 Socket 文件名,例如 app.sock
实际路径为 ${TRIM_APPDEST}/app.sock
protocol / port
在统一网关入口中被忽略,不参与路由
```
### 3.2 Docker 应用接入
官方 Docker 案例给出的结构:
```yaml
services:
web:
image: my-app:latest
container_name: hello-docker-web
restart: unless-stopped
volumes:
- "${TRIM_APPDEST}:/app/target:rw"
environment:
GATEWAY_SOCKET: /app/target/app.sock
```
容器内服务监听:
```text
/app/target/app.sock
```
宿主机对应路径:
```text
/var/apps/{appname}/target/app.sock
```
### 3.3 用户 Header
网关校验会话后注入:
| Header | 含义 | 示例 |
| --- | --- | --- |
| `X-Trim-Userid` | 当前用户 UID | `1000` |
| `X-Trim-Isadmin` | 是否管理员 | `true` / `false` |
| `X-Trim-Username` | 当前用户名 | `admin` |
这些 Header 只能作为网关可信上下文使用。应用仍然需要校验:
```text
接口权限
资源归属
文件路径
高风险操作
```
不能信任客户端自己传入的用户 ID。
## 四、当前 Havline 的差距
### 4.1 当前监听方式
```text
HAVLINE_LISTEN=:6893
```
主程序使用 TCP 端口监听:
```text
internal/config/config.go
ListenAddr = HAVLINE_LISTEN,默认 :6893
internal/app/app.go
http.Server{Addr: cfg.ListenAddr}
server.ListenAndServe()
```
当前没有 Unix Socket 监听能力。
### 4.2 当前飞牛入口
```json
{
".url": {
"havline.main": {
"title": "Havline",
"icon": "images/icon_{0}.png",
"type": "iframe",
"protocol": "http",
"port": "${wizard_service_port}",
"url": "/",
"allUsers": false
}
}
}
```
这是端口服务入口,不是统一网关入口。
### 4.3 当前前端路径假设
```text
Vue Router:
createWebHistory(),无 base
API 客户端:
直接使用 /api/... 绝对路径
SSE / EventSource:
/api/proxies/{id}/logs/stream
静态资源:
由 Vite 默认根路径构建
```
统一网关路径是 `/app/havline`,这些绝对路径不能直接复用。
### 4.4 当前容器权限
Dockerfile 没有显式 `USER`,容器默认以 root 运行。
这对创建 Socket 文件是有利的:
```text
/app/target/app.sock
```
但如果后续改为非 root 用户运行,需要额外处理 Socket 的属主和权限。
### 4.5 当前端口映射
```yaml
ports:
- "${wizard_service_port}:6893"
- "${wizard_nginx_http_port}:80"
- "${wizard_nginx_https_port}:443"
```
统一网关只替代管理界面的入口,不会替代 Havline 的反向代理端口。
## 五、目标架构
### 5.1 访问分层
```text
飞牛桌面
-> https://fnOS域名/app/havline
-> 飞牛统一网关校验登录态
-> /var/apps/havline/target/app.sock
-> Havline 网关监听器
-> Havline API / Web UI
Havline 公网反代功能
-> 18080/9443 端口
-> Havline 内置 Nginx
-> 用户配置的 NAS 服务
```
### 5.2 包结构目标
```text
fnos/havline/
├── manifest
├── app/
│ ├── docker/
│ │ └── docker-compose.yaml
│ └── ui/
│ └── config
├── config/
│ ├── privilege
│ └── resource
├── cmd/
│ ├── main
│ ├── install_callback
│ ├── upgrade_callback
│ ├── config_callback
│ └── uninstall_callback
└── wizard/
├── install
├── config
└── uninstall
```
### 5.3 Docker Compose 目标
```yaml
services:
havline:
image: jayhub.top/mrlee/havline:${TRIM_APPVER}-amd64-docker
container_name: havline
restart: unless-stopped
ports:
- "${wizard_nginx_http_port}:80"
- "${wizard_nginx_https_port}:443"
volumes:
- "${TRIM_PKGVAR}:/data"
- "${TRIM_APPDEST}:/app/target:rw"
environment:
HAVLINE_DATA_DIR: /data
HAVLINE_LISTEN: ":6893"
HAVLINE_GATEWAY_SOCKET: "/app/target/app.sock"
HAVLINE_GATEWAY_PREFIX: "/app/havline"
HAVLINE_TRUST_GATEWAY_HEADERS: "false"
HAVLINE_NGINX_HTTP_PORT: "80"
HAVLINE_NGINX_HTTPS_PORT: "443"
HAVLINE_UPDATER_SOCKET: ""
HAVLINE_INITIAL_ADMIN_PASSWORD: "${wizard_initial_admin_password}"
TZ: "${wizard_timezone}"
```
说明:
```text
HAVLINE_LISTEN 保留,用于端口回退和内部健康检查
HAVLINE_GATEWAY_SOCKET 指向统一网关 Socket
HAVLINE_GATEWAY_PREFIX 用于前端 base path 和路由
HAVLINE_TRUST_GATEWAY_HEADERS 第一阶段关闭
wizard_service_port 可以从端口映射中移除
```
### 5.4 manifest 目标
首版建议:
```ini
appname = havline
version = <版本>
display_name = Havline
desc = NAS 公网访问管理:反向代理、DDNS、HTTPS 证书、FRP、Cloudflare Tunnel 与一键升级
source = thirdparty
platform = all
maintainer = Havline
maintainer_url = https://jayhub.top/mrlee/Havline
distributor = Havline
desktop_uidir = ui
desktop_applaunchname = havline.main
checkport = false
ctl_stop = true
service_port = 6893
```
`service_port` 是否保留需要实测:
```text
如果只注册统一网关入口:
可以不依赖 service_port
如果同时保留端口回退入口:
保留 service_port 便于调试
```
## 六、三种实现路线
### 方案 A:Go 主程序原生监听 Unix Socket
实现:
```text
internal/config
增加 GatewaySocket、GatewayPrefix 配置
internal/app
同时创建 TCP listener 和 Unix listener
TCP 用于端口回退
Unix 用于统一网关
```
伪代码:
```go
tcpServer := &http.Server{Addr: cfg.ListenAddr, Handler: handler}
unixServer := &http.Server{Handler: handler}
if cfg.GatewaySocket != "" {
_ = os.Remove(cfg.GatewaySocket)
listener, err := net.Listen("unix", cfg.GatewaySocket)
if err != nil {
return err
}
if err := os.Chmod(cfg.GatewaySocket, 0o660); err != nil {
return err
}
go unixServer.Serve(listener)
}
go tcpServer.ListenAndServe()
```
优点:
```text
无额外进程
可以精确处理 base path
可以按来源区分 TCP 与网关请求
可以安全启用 X-Trim-* SSO
网络性能更好
```
缺点:
```text
需要修改 Go 主程序
需要补充 Unix Socket 生命周期测试
```
推荐度:高。
### 方案 B:容器内 Nginx 监听 Unix Socket
实现:
```text
容器内 Nginx 监听 /app/target/app.sock
反向代理到 127.0.0.1:6893
```
优点:
```text
不改 Go 主程序
可以快速做统一网关 PoC
```
缺点:
```text
多一层代理
base path 和 Header 处理复杂
错误排查链路更长
SSO 头部的可信边界不清晰
```
推荐度:中,适合过渡验证,不建议作为长期方案。
### 方案 C:单独 Sidecar 容器
实现:
```text
新增 sidecar 容器监听 Unix Socket
代理到 havline 容器
```
优点:
```text
主容器不增加进程
```
缺点:
```text
Compose 复杂度高
生命周期和升级耦合
收益不明显
```
推荐度:低。
## 七、前端 base path 改造
统一网关路径固定为:
```text
/app/havline
```
前端需要支持可配置 base path。
### 7.1 Vite
目标:
```ts
export default defineConfig({
base: process.env.VITE_BASE_PATH || '/',
// ...
})
```
构建飞牛统一网关版本时:
```text
VITE_BASE_PATH=/app/havline/
```
### 7.2 Vue Router
当前:
```ts
createWebHistory()
```
目标:
```ts
createWebHistory(import.meta.env.BASE_URL)
```
或者由运行时 `HAVLINE_GATEWAY_PREFIX` 注入。
### 7.3 API 客户端
当前:
```ts
fetch('/api/...')
```
目标:
```ts
const API_BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
fetch(`${API_BASE}/api/...`)
```
或者由前端运行时配置读取:
```text
window.__HAVLINE_BASE_PATH__
```
### 7.4 SSE / EventSource
当前:
```ts
new EventSource('/api/proxies/{id}/logs/stream')
```
目标:
```ts
new EventSource(`${API_BASE}/api/proxies/{id}/logs/stream`)
```
### 7.5 WebSocket
目标路径:
```text
/app/havline/ws
```
前端必须使用当前页面主机和协议:
```ts
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${wsProtocol}//${location.host}/app/havline/ws`
```
### 7.6 Cookie
需要确认 Havline 的会话 Cookie 是否设置了 Path。
建议:
```text
端口模式:Path=/
网关模式:Path=/app/havline
```
或者统一使用 `Path=/`,减少改造量。
### 7.7 CSRF / Origin
如果 Havline 有 Origin 或 CSRF 校验,需要把以下来源加入白名单:
```text
https://<fnOS域名>
https://<fnOS域名>/app/havline
```
## 八、认证与权限设计
### 8.1 第一阶段:双登录,低风险
```text
飞牛网关校验 fnOS 登录态
Havline 仍然显示自己的登录页
用户登录 Havline 后正常使用
```
优点:
```text
改造最小
不影响端口模式
不引入 SSO 安全问题
```
缺点:
```text
用户需要登录两次
```
建议首版采用。
### 8.2 第二阶段:网关 SSO
启用条件:
```text
HAVLINE_TRUST_GATEWAY_HEADERS=true
只有 Unix Socket 请求才信任 X-Trim-* Header
```
映射规则建议:
```text
X-Trim-Isadmin=true
-> 映射到 Havline admin
X-Trim-Isadmin=false
-> 默认拒绝管理界面
-> 或后续映射为只读用户
缺少 X-Trim-Userid
-> 视为非网关请求,回退到本地登录
```
安全要求:
```text
Unix Socket 文件权限限制为网关和 Havline 进程可访问
TCP 端口请求绝对不能信任 X-Trim-* Header
网关 Header 只用于身份上下文,不替代 Havline 业务权限
```
## 九、Havline 功能边界
统一网关只解决飞牛系统域名下的应用入口,不替代 Havline 的核心功能。
| Havline 功能 | 统一网关是否替代 | 说明 |
| --- | --- | --- |
| 管理界面入口 | 是 | 改为 `/app/havline` |
| fnOS 登录态 | 部分 | 网关校验登录,Havline 仍需业务鉴权 |
| 内置 Nginx 反向代理 | 否 | 仍由 Havline 管理 |
| DDNS | 否 | 仍由 Havline 管理 |
| HTTPS 证书申请 | 否 | 仍由 Havline 管理 |
| FRP 内网穿透 | 否 | 仍由 Havline 管理 |
| Cloudflare Tunnel | 否 | 仍由 Havline 管理 |
| 公网域名反代 | 否 | 仍由 Havline 管理 |
| 端口 18080/9443 | 否 | 作为 Havline 功能端口保留 |
不要把 Havline 的公网反代、证书和隧道功能迁入飞牛统一网关,否则会丢失
Havline 作为独立公网访问管理工具的能力。
## 十、生命周期与打包变更
### 10.1 安装回调
需要确保:
```text
${TRIM_APPDEST} 存在
${TRIM_APPDEST} 权限允许容器写入 app.sock
${TRIM_PKGVAR} 数据目录权限正确
```
### 10.2 启动
容器启动时:
```text
删除 ${TRIM_APPDEST}/app.sock 残留文件
创建新的 Unix Socket
设置 Socket 权限
```
### 10.3 升级
升级时:
```text
gatewayPrefix 保持不变
gatewaySocket 文件名保持不变
数据目录保持不变
Socket 文件重新创建
```
### 10.4 卸载
卸载时:
```text
删除 ${TRIM_APPDEST}/app.sock
默认保留 ${TRIM_PKGVAR}
```
## 十一、分阶段实施计划
### 阶段 0:官方行为 PoC
目标:用最小 Docker 示例验证统一网关。
必须验证:
```text
[ ] ${TRIM_APPDEST} 挂载路径
[ ] Socket 文件权限
[ ] /app/havline 请求是否保留前缀
[ ] Header 是否原样注入
[ ] WebSocket 是否可用
[ ] 应用重启后 Socket 是否自动重建
```
产出:
```text
docs/fnos-unified-gateway-poc.md
```
### 阶段 1:Havline 支持 Unix Socket
目标:主程序可以同时监听 TCP 和 Unix Socket。
改造:
```text
internal/config
internal/app
启动日志
停止清理
```
验收:
```text
[ ] TCP 6893 正常
[ ] Unix Socket 正常
[ ] Socket 权限正确
[ ] 容器重启后 Socket 重建
```
### 阶段 2:前端 base path
目标:Havline 可以在 `/app/havline` 下完整运行。
改造:
```text
Vite base
Vue Router base
API client base
EventSource base
WebSocket base
Cookie Path
CSRF / Origin
```
验收:
```text
[ ] 页面打开
[ ] 静态资源加载
[ ] 登录
[ ] API
[ ] SSE
[ ] WebSocket
[ ] 刷新深层路由
```
### 阶段 3:飞牛包切换统一网关入口
目标:桌面入口走 `/app/havline`。
改造:
```text
app/ui/config
docker-compose.yaml
```
保留:
```text
18080/9443 端口
端口回退入口
```
验收:
```text
[ ] 飞牛桌面入口打开正确
[ ] 不需要端口入口即可访问管理界面
[ ] 18080/9443 功能不受影响
```
### 阶段 4:网关 SSO
目标:使用 `X-Trim-*` 自动识别飞牛管理员。
改造:
```text
HAVLINE_TRUST_GATEWAY_HEADERS=true
只对 Unix Socket 请求生效
管理员映射
非管理员处理策略
```
验收:
```text
[ ] 管理员免登录
[ ] 非管理员被正确拒绝或只读
[ ] TCP 请求伪造 Header 无效
```
### 阶段 5:体验收敛
目标:统一网关成为默认入口,端口仅保留给 Havline 自身功能。
验收:
```text
[ ] 飞牛桌面入口只显示统一网关入口
[ ] 端口入口不再出现在应用卡片
[ ] 升级、卸载、重装正常
```
## 十二、验收清单
```text
[ ] gatewayPrefix 稳定为 /app/havline
[ ] gatewaySocket 稳定为 app.sock
[ ] ${TRIM_APPDEST} 正确挂载
[ ] Havline 监听 Unix Socket
[ ] Socket 启动前清理残留文件
[ ] Socket 权限满足网关访问
[ ] 前端使用 /app/havline base
[ ] API、SSE、WebSocket 路径正确
[ ] Cookie 和 CSRF 正常
[ ] 端口回退仍可用
[ ] 飞牛管理员可通过网关访问
[ ] 非管理员访问策略明确
[ ] 升级后数据不丢
[ ] 卸载默认保留数据
[ ] amd64 与 arm64 镜像可启动
```
## 十三、风险与未决问题
| 风险 | 影响 | 处理 |
| --- | --- | --- |
| 网关是否剥离 `/app/havline` 前缀未知 | 前端 base path 可能不一致 | 阶段 0 PoC 实测 |
| Socket 属主和权限未知 | 网关无法访问 Socket | PoC 验证并在启动时 chmod |
| 前端绝对路径较多 | 网关下页面或 API 404 | 统一引入 base path |
| SSE / WebSocket 路径 | 实时日志和状态页不可用 | 阶段 2 专项测试 |
| 双登录体验 | 用户需要登录两次 | 阶段 4 再做 SSO |
| 伪造 `X-Trim-*` Header | 越权风险 | 只信任 Unix Socket 请求 |
| 端口与网关并存 | 入口混乱 | 桌面只注册网关入口 |
| Havline 公网反代功能边界 | 用户误解为被替代 | 文档明确说明 |
| 飞牛审核要求 | 上架被拒 | 提交前复核官方审核项 |
| 镜像多架构兼容 | 飞牛 Docker 28.5.2 对 OCI index 兼容性有限 | 使用 Docker schema2 兼容格式 |
## 十四、推荐落地顺序
```text
1. 做最小统一网关 Docker PoC
2. 确认前缀、Socket 权限、Header、WebSocket
3. 主程序增加 Unix Socket 监听
4. 前端增加可配置 base path
5. 飞牛包注册统一网关入口
6. 保留端口回退
7. 实测通过后再做网关 SSO
8. 最后收敛桌面入口
```
## 十五、结论
Havline 不需要从 Docker 应用模板重写成另一种应用,而应在现有 Docker
应用上叠加统一网关访问模型。
推荐的最终形态:
```text
运行形态:飞牛 Docker 应用
访问模型:统一网关 /app/havline
服务监听:TCP 6893 + Unix Socket app.sock
用户体系:首版保留 Havline 登录,后续再做网关 SSO
功能边界:Havline 继续管理公网反代、DDNS、证书、FRP 和 Cloudflare Tunnel
回退能力:保留 18080/9443 端口
```
这样既能获得飞牛系统统一的 HTTPS 域名、登录态和桌面入口体验,又不会
破坏 Havline 作为独立 NAS 公网访问管理工具的核心能力。