验证过程中实际踩到的四点,写进 README 便于 agent 与人复用:
1. 管理接口前缀是 /admin/api;打根 /api/ 会拿到 200 + Dashy HTML 的假成功,
判断是否命中要看返回体类型而非状态码
2. 路由表是整表替换,必须提交完整 {routes:[...]};清空非空表需 force:true
3. 改完必须 apply(内部 nginx -t 校验,失败保留旧配置并回显 nginx 报错)
4. apply 是异步 reload:立刻探测会得到旧结果(表现为'apply 了却 404'),
给出轮询等待入口就绪的示例脚本
1019 lines
46 KiB
Markdown
1019 lines
46 KiB
Markdown
# HomeDash — 自托管内网导航门户
|
||
|
||
将 **Dashy + SearXNG + 浏览器历史同步 + 管理后台**整合在单个 Docker 容器中,并附带**完整的内网服务发布方案**(带统一认证的反向代理 + webui4frpc 内网穿透),让外网通过域名安全访问内网服务。
|
||
|
||
## 架构总览(生产全链路)
|
||
|
||
```
|
||
公网用户
|
||
│
|
||
▼
|
||
阿里云 VPS (frps 服务端, <vps-public-ip>:7000/8080/443)
|
||
│ frps: vhost 端口 + tcp 隧道
|
||
▼
|
||
frpc (由 webui4frpc 在内网主机上管理, WebUI :7500)
|
||
│ configs/*.json → 定义各隧道
|
||
▼
|
||
portal-nginx (统一认证反向代理, 容器)
|
||
│ auth_request → portal-auth(:4001) 统一登录
|
||
│ map $host → 按子域名分流
|
||
▼
|
||
navi 单容器 (Dashy+SearXNG+历史+管理, :3003)
|
||
│ 内层 nginx :8080 按路径分流
|
||
▼
|
||
Dashy / SearXNG(→Clash 代理出网) / 历史 API / 管理 UI
|
||
```
|
||
|
||
### navi 容器内部
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────┐
|
||
│ nginx (:8080) — 统一入口,同域名同端口 │
|
||
│ ├─ / → Dashy 静态仪表盘 │
|
||
│ ├─ /search → SearXNG 搜索 (:8888) │
|
||
│ ├─ /static/ → SearXNG 静态资源 (:8888) │
|
||
│ ├─ /api/history/ → 历史同步 API (:3004) │
|
||
│ ├─ /admin/ → 管理后台 WebUI (:3005) │
|
||
│ ├─ /conf.yml → 运行时导航配置(数据卷) │
|
||
│ └─ /item-icons/ → 自定义图标 (数据卷) │
|
||
└─────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**SearXNG 配置优先级**:容器启动时如果发现数据卷里有
|
||
`data/searxng-settings.yml`,就用它;否则用镜像内置的
|
||
`/etc/searxng/settings.yml`。所以代理这类**部署相关**配置应放在数据卷:
|
||
改完只需重启容器,**不需要重建镜像,重建容器也不会丢**。
|
||
|
||
**技术栈**:Go(历史 API + 管理 API)· SearXNG(Python) · Dashy/Vue · Nginx · Supervisord · frp/webui4frpc
|
||
|
||
## 快速开始
|
||
|
||
### 前置条件
|
||
|
||
- Docker 20+ 与 Docker Compose v2
|
||
- 可选:公网域名 + 反向代理,用于外网访问(见下文「生产部署」)
|
||
|
||
### 方式 A:从本仓库拉取源码构建(推荐)
|
||
|
||
```bash
|
||
# 1. 拉取仓库(把 <your-gitea> 换成你自己的 Git 托管地址)
|
||
git clone <repo-url> HomeDash
|
||
cd HomeDash
|
||
|
||
# 2. 配置环境变量
|
||
cp .env.example .env
|
||
# 编辑 .env:
|
||
# - NAVI_SYNC_TOKEN / NAVI_ADMIN_TOKEN:鉴权令牌,openssl rand -hex 24 生成
|
||
# - SEARXNG_BASE_URL:对外访问地址(有域名填 https://nav.example.com)
|
||
# - NAVI_PORT:对外端口(默认 3003)
|
||
|
||
# 3. 首次启动(会拉取 golang/dashy/searxng 基础镜像并编译)
|
||
docker compose up -d --build
|
||
|
||
# 4. 访问
|
||
echo "打开 http://<ip>:3003"
|
||
```
|
||
|
||
### 方式 B:直接构建镜像运行
|
||
|
||
```bash
|
||
docker build -t jianf-navi .
|
||
docker run -d --name jianf-navi -p 3003:8080 \
|
||
-e SEARXNG_BASE_URL=https://nav.example.com \
|
||
-e NAVI_SYNC_TOKEN=<random> \
|
||
-e NAVI_ADMIN_TOKEN=<random> \
|
||
-v $PWD/data:/app/data \
|
||
jianf-navi
|
||
```
|
||
|
||
### 首次配置导航项
|
||
|
||
容器启动后按需编辑 `data/conf.yml`(sections 列表),或直接用管理后台添加:
|
||
|
||
```bash
|
||
# 先让 conf.yml 从模板生成
|
||
docker compose restart
|
||
# 或用管理 WebUI 在浏览器添加:http://<ip>:3003/admin/
|
||
```
|
||
|
||
> 首次没有 data/ 目录时,容器会用内置 `conf.example.yml` 生成一份最小配置。
|
||
|
||
## 配置说明
|
||
|
||
### conf.yml 核心字段
|
||
|
||
```yaml
|
||
appConfig:
|
||
theme: default
|
||
webSearch:
|
||
searchEngine: custom
|
||
customSearchEngine: /search?q= # 同域名反代到 SearXNG
|
||
openingMethod: newtab
|
||
|
||
sections:
|
||
- name: 常用服务 # 按访问频次自动排序
|
||
icon: fas fa-star
|
||
collapsed: false
|
||
items:
|
||
- title: Gitea
|
||
icon: fas fa-code-branch
|
||
url: http://192.168.1.100:3000
|
||
statusCheck: true
|
||
```
|
||
|
||
### 图标
|
||
|
||
放入 `data/item-icons/`,conf.yml 引用文件名:
|
||
|
||
```yaml
|
||
icon: my-icon.png # 本地图标
|
||
icon: fas fa-star # Font Awesome
|
||
```
|
||
|
||
### 环境变量
|
||
|
||
| 变量 | 说明 | 默认 |
|
||
|------|------|------|
|
||
| `NAVI_SYNC_TOKEN` | 浏览器扩展同步历史令牌 | 空(不鉴权) |
|
||
| `NAVI_ADMIN_TOKEN` | 管理后台/API 写操作令牌 | 空(不鉴权) |
|
||
| `SEARXNG_BASE_URL` | 对外访问地址,影响搜索结果链接 | `http://localhost:3003` |
|
||
| `NAVI_PORT` | compose 对外端口 | `3003` |
|
||
| `NAVI_FAVICON_PROXY` | 抓历史图标用的代理;留空自动复用 `searxng-settings.yml` 的 `outgoing.proxies` | 空 |
|
||
|
||
## 浏览器扩展
|
||
|
||
### 安装(两种方式)
|
||
|
||
**方式 1:导航页内一键引导(推荐)**
|
||
|
||
导航页会自动检测插件是否已安装:
|
||
|
||
- 未安装时,「历史访问」**第一条**固定显示「安装浏览历史插件」入口;
|
||
- 点击后打开 `/extension/install.html`,**自动识别浏览器**并给出对应安装步骤与安装包;
|
||
页面同时展示**服务地址**与**同步令牌**(各带「复制」按钮),不用再去翻 `.env`。
|
||
令牌由 `admin-server` 的 `/admin/api/sync-info` 提供 —— `/admin/` 在门户登录墙之内,
|
||
而插件上报用的 `/api/history/sync` 是被门户放行的,所以令牌绝不能挂在 `/api/` 下。
|
||
- 已安装时,`/navi.js` 读到插件写入的 `data-navi-ext` 标记,把该入口自动隐藏。
|
||
|
||
检测原理:插件设置里填了服务地址后,会向**该站点的域名**动态注册一个 content script
|
||
(Chrome 用 `chrome.scripting.registerContentScripts`,Firefox 用 `browser.contentScripts.register`),
|
||
在 `document_start` 给 `<html>` 打上 `data-navi-ext`。不会在其他站点上运行。
|
||
|
||
> Chrome / Edge **不允许**从网站直接安装非商店扩展,所以这一步只能下载后手动加载;
|
||
> 若你已把扩展发布到商店,把链接写进 `.env` 或 data 卷的 `extension-store.json`,
|
||
> 安装页就会出现真正的「从商店一键安装」按钮:
|
||
>
|
||
> ```json
|
||
> { "chrome": "https://chrome.google.com/webstore/detail/<id>",
|
||
> "firefox": "https://addons.mozilla.org/firefox/addon/<slug>/" }
|
||
> ```
|
||
>
|
||
> 放到 `data/extension-store.json` 即可,无需重建镜像。
|
||
|
||
**方式 2:手动加载源码目录**
|
||
|
||
**Chrome / Edge**:`chrome://extensions/` → 开发者模式 → 加载 `extension/chrome/`
|
||
|
||
**Firefox**:`about:debugging#/runtime/this-firefox` → 临时加载 `extension/firefox/manifest.json`
|
||
|
||
> 扩展的「同步令牌」需填服务端 `.env` 里 `NAVI_SYNC_TOKEN` 的**同一个值**,否则同步被拒(401)。
|
||
|
||
### 配置
|
||
|
||
| 字段 | 示例 |
|
||
|------|------|
|
||
| 服务地址 | `https://nav.example.com` |
|
||
| 同步令牌 | `.env` 中的 `NAVI_SYNC_TOKEN` |
|
||
| 设备名称 | `desktop-chrome` |
|
||
|
||
### 行为
|
||
|
||
- 每 30 秒检查本地队列,**仅在有新数据时 POST**
|
||
- 搜索引擎 URL 在本地过滤,不上传
|
||
- 「常用服务」和「历史访问」按总访问频次自动排序
|
||
- 历史条目的图标由服务端自动抓取并缓存(见下)
|
||
|
||
### 历史条目的站点图标
|
||
|
||
history 里的站点不可能像手工导航项那样预先配好图标,所以服务端会**后台抓取每个
|
||
host 的 favicon** 并缓存到 `data/item-icons/favicon-<host>.png`,缓存命中后
|
||
conf.yml 里写的是本地文件名,与手工图标走同一套解析。
|
||
|
||
- 抓取顺序:`<host>/favicon.ico` → 首页 `<link rel="icon">`
|
||
- `.ico` 会先尝试抽出内嵌 PNG;若是传统 BMP/DIB 编码,则服务端解码并转成 PNG
|
||
(Dashy 的 `isImage()` 只认 `svg/png/jpg`,直接写 `.ico` 是渲染不出来的)
|
||
- 内网地址(含端口,如 `192.168.1.10:3000`)只走直连;外网站点先直连,
|
||
失败时自动复用 SearXNG `outgoing.proxies` 的代理(也可用 `NAVI_FAVICON_PROXY` 单独指定)
|
||
- 抓不到就保持 `fas fa-globe`,6 小时后再试;缓存文件持久化在 data 卷
|
||
|
||
## 管理后台
|
||
|
||
访问 `http://<your-ip>:3003/admin/` 可视化管理导航项。
|
||
|
||
页面分卡片:站点信息(标题/副标题/顶部链接/备案号)、**导航分组**、添加服务、
|
||
历史访问、外观(壁纸)、接口令牌。窄屏(手机)已适配:字段竖排、分组行可换行、
|
||
输入框整行占满、字号 ≥16px 防 iOS 聚焦缩放。
|
||
|
||
### 导航分组可以自由增删
|
||
|
||
分组就是导航页上的栏目,初始为空(`conf.example.yml` 的 `sections: []`),
|
||
按需创建即可。要点:
|
||
|
||
- 删除**非空**分组需要二次确认(接口侧要求 `?force=1`),避免误点连带删掉一组服务
|
||
- **「历史访问」分组由 history-server 依据浏览记录自动维护**:改名/删除/手工新建
|
||
都会被拒绝(409)并在界面上锁住——否则下次同步就把它覆写回来
|
||
- 若配置里完全没有该分组,history-server 会补建一个(但**完全空配置时不补**,
|
||
保持"初始无分组")
|
||
|
||
### API(需 `NAVI_ADMIN_TOKEN`)
|
||
|
||
| 端点 | 方法 | 说明 |
|
||
|---|---|---|
|
||
| `/admin/api/sections` | GET | 列出分组与服务 |
|
||
| `/admin/api/section` | POST | 新建分组 `{name,icon}`;重名/空名/占用「历史访问」→ 4xx |
|
||
| `/admin/api/section` | DELETE | 删除分组 `{index}`;非空需 `?force=1` |
|
||
| `/admin/api/section/update` | POST | 改名/换图标 `{index,name,icon}` |
|
||
| `/admin/api/item` | POST | 向分组添加服务 |
|
||
| `/admin/api/item/delete` | DELETE | 删除服务 `{sectionIndex,itemIndex}` |
|
||
| `/admin/api/pageinfo` | GET/POST | 站点信息与备案号 |
|
||
| `/admin/api/icon` | POST | 上传图标(`.ico` 前端转 PNG) |
|
||
| `/admin/api/wallpaper` | GET/POST | 壁纸与遮罩强度 |
|
||
| `/admin/api/history` | GET/DELETE | 浏览历史列表 / 清空全部 |
|
||
| `/admin/api/history/item` | DELETE | 删除单条历史 `?url=` |
|
||
| `/admin/api/frameable` | GET | 目标地址能否被 iframe 内嵌(三态,见下) |
|
||
| `/admin/api/sync-info` | GET | 插件安装页用的同步令牌 |
|
||
|
||
```bash
|
||
|
||
```bash
|
||
# 查看
|
||
curl -H "Authorization: Bearer <token>" http://<ip>:3003/admin/api/sections
|
||
|
||
# 添加
|
||
curl -H "Authorization: Bearer <token>" -X POST -H 'Content-Type: application/json' \
|
||
-d '{"sectionIndex":0,"item":{"title":"New","url":"https://example.com"}}' \
|
||
http://<ip>:3003/admin/api/item
|
||
|
||
# 删除
|
||
curl -H "Authorization: Bearer <token>" -X DELETE -H 'Content-Type: application/json' \
|
||
-d '{"sectionIndex":0,"itemIndex":2}' \
|
||
http://<ip>:3003/admin/api/item/delete
|
||
```
|
||
|
||
## 历史同步 API
|
||
|
||
| 端点 | 方法 | 说明 |
|
||
|------|------|------|
|
||
| `/api/history/sync` | POST | 批量同步浏览记录 |
|
||
| `/api/history/` | GET | 获取历史(按频次排序) |
|
||
| `/api/history/` | DELETE | 清空全部历史 |
|
||
| `/api/history/stats` | GET | 统计信息 |
|
||
|
||
```json
|
||
// POST /api/history/sync
|
||
{
|
||
"items": [{
|
||
"url": "https://example.com/page",
|
||
"title": "Page Title",
|
||
"device": "desktop-chrome",
|
||
"timestamp": 1725800000,
|
||
"visit_count": 3
|
||
}]
|
||
}
|
||
```
|
||
|
||
## 零停机部署(合并前形态)
|
||
|
||
> 适用前提:入口是独立容器(`portal-nginx`)。
|
||
> **合并成单容器之后请改用 `deploy/zd-host.sh`**(见下文「一体化模式」):
|
||
> 合并后入口就是 navi 容器本身,双端口轮换换的正是入口端口,不再成立。
|
||
|
||
|
||
不要直接 `docker stop/rename/run`:那样有十几秒完全不可用,而且新容器里
|
||
**nginx 先就绪、SearXNG 还在启动**,这期间 `/search` 会是 502(本项目实际发生过)。
|
||
|
||
用仓库里的脚本,它会先在对侧端口把新容器验证到底、再瞬时切换上游:
|
||
|
||
```bash
|
||
deploy/zd.sh v29 # 版本号只用于镜像 tag
|
||
```
|
||
|
||
流程(任一步失败自动回滚):
|
||
|
||
1. 本地构建镜像 → `docker save` → scp
|
||
2. 远端在**另一侧端口**(3003/3004 自动对调)起 `navi-next`
|
||
3. 在该端口逐个验证 `/conf.yml`、`/admin`、`/navi.js`、**`/search?q=linux`**
|
||
(后者既确认 SearXNG 可用,也把它预热,避免用户撞上冷启动 502)
|
||
4. 改门户 nginx 上游那一行 → 容器侧确认看到新值 → `nginx -t` → `nginx -s reload`
|
||
5. **切后哨兵**:经门户访问不得是 502/503/504(未登录正常是 302)
|
||
6. 停旧容器并保留为回滚点 `navi-prev-<时间戳>`,新容器改名为 `navi`
|
||
|
||
可用环境变量:`NAVI_HOST`、`NAVI_SSH_PASS`、`NAVI_UPSTREAM_HOST`、`NAVI_PORTAL_CONF`、
|
||
`NAVI_DATA_DIR`、`NAVI_BASE_IMAGE`。
|
||
|
||
### 一个必须知道的坑:单文件 bind mount 不能用 `sed -i`
|
||
|
||
门户 nginx 配置文件是以**单文件 bind mount**(且只读)挂进 `portal-nginx` 容器的。
|
||
`sed -i` / `mv` 会写临时文件再 rename,**换了 inode**,而容器在启动时就绑定了当时那个
|
||
inode —— 于是宿主机文件改好了,容器里看到的还是旧的,`nginx -s reload` 读的也是旧的。
|
||
一旦上游被切走而新端口无人应答,就是**全站 502**。
|
||
|
||
所以:**同 inode 覆写**(`cat new > file`),改完先用
|
||
`docker exec portal-nginx grep -q "<新端口>" /etc/nginx/nginx.conf` **断言容器真的看到了**,
|
||
看不到就 `docker restart portal-nginx`(唯一出路),然后再 `nginx -t` + `reload`。
|
||
`deploy/zd.sh` 已按这个顺序实现。
|
||
|
||
### 部署后验收
|
||
|
||
```bash
|
||
# 起一个 headless chromium 指向 CDP 端口,然后:
|
||
PORT=<cdp端口> CK=<portal_session> VPS=<对外IP> node deploy/verify-ui.js
|
||
```
|
||
|
||
覆盖:渲染开销(`backdrop-filter` 元素数)、后台窄屏是否横向溢出、工作空间收边与
|
||
"第一次交互只滑出"、不支持内嵌应用的弹层、分组增删改与「历史访问」保护。
|
||
**任何运行时异常都会判失败**(promise 里抛出的错误很容易被忽略,本项目因此漏掉过
|
||
一个 `ReferenceError` 五个版本)。
|
||
|
||
## 一体化模式:单容器同时充当统一入口(已在本项目启用)
|
||
|
||
> 本节描述**当前生产形态**:HomeDash 一个容器同时承担「反向代理 + 统一登录 + 导航 + 搜索 + 历史/管理」,
|
||
> 原先独立的 `portal-nginx`(TLS 与按域名分流)与 `portal-auth`(登录服务)**已停用但保留**,随时可回退。
|
||
|
||
### 为什么合并
|
||
|
||
- 一份数据:**服务转发(域名→后端、是否统一认证、是否加入导航)**都在管理后台维护,不再手写 nginx
|
||
- 一个容器:TLS 终止、登录校验、导航页、搜索、历史与管理后台同进程组,运维面缩小
|
||
- 代价(必须知情):**入口即应用容器**,因此升级镜像会有数秒中断(host 网络下端口就是入口,无法双端口轮换)
|
||
|
||
### 端口与挂载
|
||
|
||
| 用途 | 端口 | 说明 |
|
||
| --- | --- | --- |
|
||
| 内部入口 | `18080` | 容器内 nginx,Dashy/搜索/历史/管理都从这里出 |
|
||
| 对外 HTTPS | `3080`、`8443` | gateway 生成,对外主入口(frp/雷池回源指向 3080) |
|
||
| HTTP→HTTPS | `3081` | 302 跳转到 `https://$host$request_uri` |
|
||
| 管理后台 | 经 `3080` 的 `/admin/` | 由同一 nginx 反代到 :3005 |
|
||
| 统一登录 | 经 `3080` 的 `/login` | 容器内 admin-server 自带(HMAC 签名会话 cookie) |
|
||
|
||
必须挂载:`<data>:/app/data`(conf.yml、history.json、gateway.yml、图标、壁纸、portal-auth.json)、
|
||
`<cert>:/etc/nginx/certs:ro`(fullchain.pem / privkey.pem)。
|
||
|
||
### 部署(host 模式,接受数秒中断)
|
||
|
||
```bash
|
||
NAVI_SSH_PASS='<密码>' bash deploy/zd-host.sh v50
|
||
```
|
||
|
||
流程:本地构建 → 校验(navi.js 语法、两个 Go 模块 gofmt+test)→ 上传 → **停旧容器** → 起新容器(`--network host`)
|
||
→ 自检(`/healthz`、`/conf.yml`、`/admin`、受保护域名应 302、未知域名应 404、3081 应 302)→ 改名完成。
|
||
|
||
失败自动回滚:删掉新容器,把 `navi-prev-<时间戳>` 改回并启动(回滚时会先删掉已占用 `navi` 名字的新容器)。
|
||
`deploy/zd.sh`(双端口轮换的零停机脚本)**仅适用于合并之前**:它换的正是入口端口本身,合并后会失效。
|
||
|
||
### 切换步骤(从「portal-nginx + portal-auth + navi」到单容器)
|
||
|
||
1. 先在旁路端口(如 18443)用隔离的数据副本验证全部域名
|
||
2. 停 `portal-nginx`,把 gateway 端口切到 3080/3081/8443(带证书)
|
||
3. 复核:受保护 302、公开 200、未知域名 404(自定义页)、HTTP 302 跳转
|
||
4. 停 `portal-auth`,把容器改名为 `navi`,保留旧容器与配置备份作为回滚点
|
||
|
||
### 服务转发(管理后台)
|
||
|
||
一条记录 = 一个域名,字段:`kind`(反代/门户静态页/登录服务)、`upstream`、`requireLogin`、
|
||
`websocket`、`streaming`、`insecureTls`、`bypassPaths`(免认证路径)、`injectBeian`,
|
||
以及导航属性 `navEnabled/navSection/navName/navDescription/navIcon/navAutoIcon/navUrl`。
|
||
|
||
- **是否走统一认证**就是每条记录上的开关(认证按路由生效,不是全局)
|
||
- 勾选「加入导航」后,域名与导航项**同一份数据**:同 host 是接管而非重复,取消勾选会清理,
|
||
无关手工项与系统「历史访问」分组不受影响
|
||
- 「从现有 nginx 配置导入」可把旧 portal-nginx 的配置整段贴进来,解析出全部域名→后端初稿
|
||
(含认证状态、免认证路径、SSE、备案注入、自签证书、auth/portal 类型);**解析只读,确认后才保存**
|
||
|
||
### 升级已有部署前必须确认的两个变量
|
||
|
||
`deploy/zd-host.sh` 会在切换前检查:旧容器 env 里没有 `PORTAL_COOKIE_DOMAIN` 且你没传
|
||
`NAVI_COOKIE_DOMAIN` 时**直接拒绝升级**(退出码 2),因为新版本已移除代码里的写死默认值,
|
||
直接升级会让会话 cookie 退化为 host-only —— 表现为"在登录页登录成功,跳到别的子域却又是未登录"。
|
||
|
||
```bash
|
||
NAVI_COOKIE_DOMAIN=.example.com NAVI_HOME_URL=https://example.com/ \
|
||
NAVI_HOST=user@host NAVI_SSH_PASS='<密码>' bash deploy/zd-host.sh v54
|
||
```
|
||
|
||
同一脚本还会**丢弃旧容器 env 里的明文 `PORTAL_PASSWORD`**(`docker inspect` 可见,属泄露面),
|
||
并在升级后写入一个强随机门户口令、在部署日志里**只打印一次**;实际生效的本来就是数据卷里的
|
||
加盐哈希,因此丢掉 env 不影响登录。想自己设口令:后台「统一登录账号」。
|
||
|
||
### 门户页服务卡片(可配置,不写死域名)
|
||
|
||
门户页的卡片区带 `<!--NAVI_CARDS_START-->` / `<!--NAVI_CARDS_END-->` 标记,两种改法:
|
||
|
||
1. **数据卷片段**(推荐,改完刷新即生效,无需重建镜像):把卡片 HTML 写到
|
||
`<数据卷>/portal/cards.html`,内容会替换标记之间的部分;清空该文件即回到内置卡片。
|
||
2. **接口**:`GET/POST /api/portal-cards`,body `{"html":"…"}`(POST 需后台令牌,空串表示重置)。
|
||
|
||
整页替换(含版式)仍可用 `<数据卷>/portal/index.html`。仓库内置的卡片是通用示例,不含任何真实域名。
|
||
|
||
### 跨子域单点登录需要的两个环境变量
|
||
|
||
登录 cookie 不再内置任何域名,**默认下发 host-only cookie**(登录站点自身可用)。
|
||
要让 `login.example.com` 登录后能访问 `dashy.example.com` 等子域,必须设置:
|
||
|
||
| 变量 | 值 | 说明 |
|
||
| --- | --- | --- |
|
||
| `PORTAL_COOKIE_DOMAIN` | `.example.com` | cookie 作用域(前导点表示含所有子域) |
|
||
| `PORTAL_HOME_URL` | `https://example.com/` | 登录成功后跳回的门户地址(默认 `/`,相对跳转) |
|
||
|
||
首装脚本在给了 `NAVI_DOMAIN` 时会自动带上这两个变量;**升级已有部署时请确认容器 env 里已设置**
|
||
(早期版本靠代码里的写死默认值工作,现在已移除,不设会导致跨子域登录失效)。
|
||
|
||
同理,`gateway.yml` 的 `loginUrl` 也不再预置域名:受保护路由要求配置认证与登录地址,
|
||
留空会在生成配置时报错并说明原因(不会静默生成一个跳不出去的登录页)。
|
||
|
||
### 统一登录账号
|
||
|
||
管理后台「统一登录账号」写入数据卷 `<data>/portal-auth.json`(**只存加盐哈希,不存明文**)。
|
||
优先级:数据卷设置 > 环境变量 `PORTAL_USERNAME/PORTAL_PASSWORD`。改完立即生效,无需重新部署;
|
||
会话 cookie 为 HMAC 签名,改账号后旧会话自动失效。
|
||
|
||
### 主题与显示
|
||
|
||
- **深浅色**:导航页、`/minimal`、`/workspace`、登录页、后台设置页均跟随系统 `prefers-color-scheme`;
|
||
手动选择写入 `navi-theme`,三处共用。深色实现是**整套覆盖 Dashy 主题变量**(`html.navi-dark`),
|
||
不改写 `data-theme`(那是 Dashy 的主题作用域)
|
||
- **壁纸**:深色下由服务端写入同图 + 深色遮罩(`applyWallpaperBlock`),不要用 `background` 简写覆盖,
|
||
否则会把 `background-image` 清成 `none`(本项目踩过)
|
||
- **可达性状态点已关闭**(`statusCheck: false`):统一入口下探测会命中登录 302,结论不可信,
|
||
因此不显示而不是显示错的
|
||
|
||
### 回滚
|
||
|
||
- 容器:`docker start portal-nginx portal-auth` + 把 `navi` 换回 bridge 版本(镜像 `homedash:ui-*` 均在)
|
||
- 配置:`portal/nginx.conf.bak-<时间戳>`;`conf.yml` 的改动另有 `conf.yml.bak-*`
|
||
- 数据:`conf.yml`、`history.json`、`gateway.yml`、`portal-auth.json` 都在数据卷,容器重建不丢
|
||
|
||
### 认证页与门户页文案(可配置,不写死)
|
||
|
||
原先登录页与门户页的标题/副标题/徽标/页脚都硬编码在代码与镜像里。现在统一从 `conf.yml`
|
||
的 `pageInfo` 读取,**留空即回落内置默认值**(不配置时行为与之前一致):
|
||
|
||
| 字段 | 作用 | 默认 |
|
||
| --- | --- | --- |
|
||
| `authTitle` / `authSubtitle` / `authLogo` | 登录页标题、副标题、徽标文字 | 统一门户 / 登录后访问受保护的内部服务 / JF |
|
||
| `portalTitle` | 门户页浏览器标题 | JianF Git - 个人开发环境 |
|
||
| `heroTitle` / `heroSubtitle` | 门户页主标题、副标题 | 代码、博客与灵感之站 / … |
|
||
| `icp` / `gongan` | 两页页脚备案号(与导航页共用) | — |
|
||
|
||
入口:管理后台 →「站点信息」→「认证页与门户页文案」。
|
||
|
||
**整页完全自定义**:把 `index.html` 放进数据卷的 `portal/` 目录即生效(优先于上述字段,
|
||
用于改版式而非只改文案);`portal/` 下的 `portal.css`、favicon 等静态资源同样可覆盖。
|
||
门户首页由 admin-server 渲染(`/portal-page`),静态资源仍由 nginx 提供。
|
||
|
||
自检(发布前确认占位符已被替换、页面无残留标记):
|
||
|
||
```bash
|
||
curl -s https://<域名>/ | grep -c '<!--HERO' || echo '门户页占位符已替换'
|
||
curl -s https://<域名>/login | grep -o '<h1>[^<]*</h1>' # 应为配置的标题或默认值
|
||
```
|
||
|
||
## 从零部署(干净机器)
|
||
|
||
前面的「快速开始」是最小试用形态(bridge 网络 + 端口映射)。要装成**统一入口**(一个容器同时
|
||
当反向代理 + 统一登录 + 导航 + 搜索),用 `deploy/install-host.sh`。
|
||
|
||
### 两条路径的分工(别混用)
|
||
|
||
| 场景 | 用什么 | 在哪执行 |
|
||
| --- | --- | --- |
|
||
| 干净机器首次安装 | `deploy/install-host.sh` | **目标机器上**(需要 docker) |
|
||
| 已有安装的升级/回滚 | `deploy/zd-host.sh <版本>` | 开发机(ssh 推送镜像,接受数秒中断) |
|
||
|
||
`zd-host.sh` 会拒绝在「没有 host 网络的 navi 容器」的机器上运行,这是刻意设计:它只做升级。
|
||
|
||
### 前置条件
|
||
|
||
| 项 | 要求 |
|
||
| --- | --- |
|
||
| 系统 | Linux + Docker(`docker info` 可用) |
|
||
| 端口 | 入口端口(默认 3080;TLS 时另加 8443)、跳转端口 3081 **必须独占** |
|
||
| 证书 | `NAVI_TLS=1`(默认)时,证书目录需有 `fullchain.pem` + `privkey.pem`;纯 HTTP 用 `NAVI_TLS=0` |
|
||
| 域名 | 各服务域名解析到本机入口端口(DNS 由你自行配置) |
|
||
| 可选依赖 | `openssl`(生成令牌;没有则用 /dev/urandom 兜底) |
|
||
| 远程执行 | 开发机需 `sshpass`(`zd-host.sh` 用;用密钥登录可自行改脚本) |
|
||
|
||
### 安装
|
||
|
||
```bash
|
||
# 在目标机器上(仓库目录内)
|
||
NAVI_DOMAIN=example.com \
|
||
NAVI_SYNC_TOKEN=<自定> NAVI_ADMIN_TOKEN=<自定> \
|
||
bash deploy/install-host.sh
|
||
|
||
# 远程执行
|
||
ssh root@<目标机> 'bash -s' < deploy/install-host.sh
|
||
```
|
||
|
||
脚本按 6 步执行并在任一步失败即停:前置检查 → 令牌 → 构建镜像 → 写初始 `gateway.yml` →
|
||
host 网络启动容器 → 应用配置 + 自检。**自检不全绿就退出**(容器保留,便于看日志)。
|
||
|
||
结束时打印一次令牌与后续步骤;之后到后台「服务转发」逐个添加服务域名并勾选统一认证。
|
||
|
||
### 环境变量(install-host.sh 全部可覆盖)
|
||
|
||
| 变量 | 默认 | 说明 |
|
||
| --- | --- | --- |
|
||
| `NAVI_SRC` | 脚本上级目录 | 仓库位置(用 `ssh 'bash -s'` 管道执行时需显式指定或先把仓库拷过去) |
|
||
| `NAVI_DATA_DIR` | `/vol1/docker/navi-data` | 数据卷(`conf.yml`、`gateway.yml`、历史、后台账号) |
|
||
| `NAVI_CERT_DIR` | `/vol1/docker/cert` | 证书目录,只读挂载到 `/etc/nginx/certs` |
|
||
| `NAVI_TAG` | `homedash:ui-install` | 构建出的镜像名 |
|
||
| `NAVI_ENTRY_PORT` / `NAVI_TLS_PORT` / `NAVI_REDIRECT_PORT` | `3080` / `8443` / `3081` | 入口、额外 TLS、http→https 跳转 |
|
||
| `NAVI_TLS` | `1` | `0` = 纯 HTTP(无需证书,适合内网先跑通) |
|
||
| `NAVI_DOMAIN` / `NAVI_LOGIN_DOMAIN` | 空 | 填了就自动生成门户路由 + 统一登录路由(否则装完在后台加) |
|
||
| `NAVI_SYNC_TOKEN` / `NAVI_ADMIN_TOKEN` | 空→自动生成 | 历史同步令牌 / 后台 API 令牌 |
|
||
| `NAVI_BUILD` / `NAVI_TAR` | `1` / 空 | `0` 表示用 `docker load -i $NAVI_TAR` 导入现成镜像 |
|
||
| `NAVI_SSH_PASS`(zd-host.sh) | 空 | ssh 密码;用密钥则留空 |
|
||
| `NAVI_HOST`(zd-host.sh / zd.sh) | **必填、无默认** | 目标机 ssh 地址 `user@host`(仓库不预置任何真实地址);缺失会直接以退出码 2 报错 |
|
||
|
||
### 证书与初始网关配置
|
||
|
||
`gateway.yml`(数据卷内)里的 `certFile` / `keyFile` 指向**容器内**路径,默认
|
||
`/etc/nginx/certs/fullchain.pem`、`/etc/nginx/certs/privkey.pem`,对应宿主机的
|
||
`$NAVI_CERT_DIR/fullchain.pem`、`$NAVI_CERT_DIR/privkey.pem`。
|
||
证书文件名不同时:改 `gateway.yml` 的这两个字段,或把证书按上述名字放好。
|
||
|
||
生成配置后由后台「转发与应用」的 **应用** 动作执行 `nginx -t` 校验,不通过则保留上一份可用配置
|
||
并把 nginx 报错原文返回给界面——所以改错配置不会打断入口。
|
||
|
||
### 装完自检(与脚本末尾一致)
|
||
|
||
```bash
|
||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18080/healthz # 200
|
||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18080/admin # 200
|
||
curl -sk -o /dev/null -w '%{http_code}\n' --resolve dashy.example.com:3080:127.0.0.1 \
|
||
https://dashy.example.com:3080/conf.yml # 受保护 → 302
|
||
curl -sk -o /dev/null -w '%{http_code}\n' --resolve nobody.invalid:3080:127.0.0.1 \
|
||
https://nobody.invalid:3080/ # 未知域名 → 404
|
||
```
|
||
|
||
## 配置接口(agent 可直接改,无需读代码)
|
||
|
||
**所有可配置项集中在一个清单里**,agent/脚本按图配置即可,不必猜字段名或路径:
|
||
|
||
```bash
|
||
curl -s http://127.0.0.1:18080/api/schema | jq # 人读:items + resources
|
||
```
|
||
|
||
返回两部分:
|
||
|
||
- `items`:单值配置项(站点标题/描述、登录页标题/副标题/徽标、门户页标题/主标题/副标题、
|
||
ICP 与公安备案号、页脚 HTML、页脚导航数组…),每项含 `value`(当前值)、`type`、
|
||
`write.path` 与 `write.bodyExample`。**即使当前为空的键也会列出**,避免"存在但发现不了"。
|
||
- `resources`:结构化资源与它们真正的写入口——
|
||
导航分组与条目、服务转发路由表、门户页卡片、壁纸、统一登录账号、浏览器历史、图标上传、重载。
|
||
|
||
写操作需要 `Authorization: Bearer <NAVI_ADMIN_TOKEN>`(读与后台页面同源,可直接 GET)。
|
||
管理接口前缀是 **`/admin/api`**(经统一入口),容器内也可直连 `http://127.0.0.1:3005/api`。
|
||
提交路由表只影响路由表,**不会**改动入口端口/TLS/认证地址等其它配置。
|
||
|
||
| 想改什么 | 接口 |
|
||
| --- | --- |
|
||
| 站点信息 / 各页文案 / 备案号 | `POST /api/pageinfo`(字段见 `/api/schema`;空串=回落默认) |
|
||
| 门户页服务卡片 | `POST /api/portal-cards` `{"html":"…"}`(空串=恢复内置) |
|
||
| 服务转发路由 | `POST /api/gateway/routes`(**提交完整 `{"routes":[…]}`,替换整张表**;单个对象会被拒绝;清空需 `force:true`)后**必须** `POST /api/gateway/apply`(失败保留旧配置) |
|
||
| 导入现有 nginx 配置 | `POST /api/gateway/import` |
|
||
| 导航分组 / 条目 | `POST /api/section`、`/api/section/update`、`DELETE /api/section`、`POST /api/item`、`/api/item/delete` |
|
||
| 壁纸 | `POST /api/wallpaper` |
|
||
| 门户口令 | `POST /api/portal-auth` |
|
||
| 历史记录 | `DELETE /api/history/item?url=…`、`DELETE /api/history` |
|
||
| 图标上传 | `POST /api/icon`(multipart,字段 `file`) |
|
||
| 重建导航分区 | `POST /api/reload` |
|
||
|
||
### 接口注意事项(agent 最容易踩的四点)
|
||
|
||
1. **前缀是 `/admin/api`**(经统一入口);容器内也可直连 `http://127.0.0.1:3005/api`。
|
||
根路径 `/api/` 不是管理接口:打到那里会得到 **200 + Dashy 的 HTML**,看着像成功其实什么都没改。
|
||
判断是否命中看返回体类型(JSON vs `<!DOCTYPE html>`),别只看状态码。
|
||
2. **路由表是整表替换**:必须提交完整 `{"routes":[…]}`。只给单个路由对象会被 400 拒绝并提示;
|
||
清空非空表要显式 `force:true`。该接口只改路由表,**不会**动入口端口/TLS/认证地址等其它字段。
|
||
3. **改完必须 apply**:`POST /admin/api/gateway/apply` 先做 `nginx -t` 校验,不通过则保留上一份
|
||
可用配置,并把 nginx 报错原文返回给你(不会把入口改坏)。
|
||
4. **apply 之后是异步 reload**,不是同步生效:立刻探测可能拿到旧结果(表现为"明明 apply 了却 404")。
|
||
正确做法是轮询等入口就绪:
|
||
|
||
```bash
|
||
HOST=app.example.com
|
||
for i in $(seq 1 20); do
|
||
code=$(curl -s -o /dev/null -w '%{http_code}' -m 3 --resolve "$HOST:3080:127.0.0.1" "https://$HOST:3080/")
|
||
[ "$code" != "000" ] && break
|
||
sleep 0.5
|
||
done
|
||
echo "$code"
|
||
```
|
||
|
||
**示例:让 agent 一次性把站点改成你要的样子**
|
||
|
||
```bash
|
||
TOK="$NAVI_ADMIN_TOKEN"; A=http://127.0.0.1:18080
|
||
curl -s -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
|
||
-d '{"title":"我的导航","heroTitle":"欢迎回家","icp":"","authSubtitle":""}' "$A/api/pageinfo"
|
||
curl -s -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
|
||
-d '{"html":"<a class=\"card\" href=\"https://example.com\">我的服务</a>"}' "$A/api/portal-cards"
|
||
curl -s -X POST -H "Authorization: Bearer $TOK" "$A/api/gateway/apply"
|
||
```
|
||
|
||
后台页面用同一批接口,所以"界面能改的,agent 都能改"。
|
||
|
||
## 部署需要的全部自定义文件
|
||
|
||
仓库即完整部署物料,没有额外依赖仓库外的文件:
|
||
|
||
| 文件 | 作用 |
|
||
|---|---|
|
||
| `Dockerfile` | 镜像定义(构建期用 `web/patch-index.py` 往 Dashy 的 index.html 注入资源) |
|
||
| `nginx.conf` | 容器内 nginx:Dashy 静态 + SearXNG + history/admin API 的统一切入口 |
|
||
| `supervisord.conf` | 容器内三服务(nginx / SearXNG / 两个 Go 二进制)的进程管理 |
|
||
| `searxng-settings.yml` | SearXNG 引擎与出网代理由此注入(可被数据卷同名文件覆盖) |
|
||
| `conf.example.yml` | 首次启动生成 `conf.yml` 的模板(**sections 为空**,分组由后台创建) |
|
||
| `admin/index.html` | 管理后台单页(站点信息 / 分组 / 服务 / 图标 / 壁纸 / 历史) |
|
||
| `admin-server/` | Go:站点信息、分组与服务增删改、图标与壁纸上传、历史转发、可内嵌性探测 |
|
||
| `history-server/` | Go:浏览历史接收与排序、favicon 抓取、按访问频次重建 Dashy 分组 |
|
||
| `web/navi.js` / `web/navi.css` | 门户表现层:搜索球、视图切换、管理入口、收边、工作空间内嵌、页脚 |
|
||
| `web/patch-index.py` | 幂等地把 `navi.css` / `navi.js` 注入 Dashy 的 index.html |
|
||
| `web/extension/install.html` | 插件安装引导页(含同步令牌展示) |
|
||
| `extension/chrome/` `extension/firefox/` | 浏览器扩展(MV3 / MV2) |
|
||
| `deploy/verify-matrix.js` | 生产验收矩阵:6 尺寸 × 3 视图 × 深浅色,断言滚动/文字亮度/深色生效/零异常(需 `PORTAL_COOKIE`) |
|
||
| `deploy/verify-workspace.js` | 工作空间深色与"无法内嵌"弹窗验收(触发 blocked 态并读 computed style) |
|
||
| `deploy/verify-scroll.js` | 多视口纵向滚动复现/回归检查(含矮窗口) |
|
||
| `deploy/install-host.sh` | 干净机器首次安装(host 模式统一入口) |
|
||
| `deploy/zd-host.sh` | **合并后**的部署脚本(host 模式,接受数秒中断,失败自动回滚) |
|
||
| `portal/`(index.html、404.html、500.html、portal.css、favicon) | 门户静态页与自定义错误页,进镜像的 `/app/portal` |
|
||
| `admin-server/auth.go` | 容器内置统一登录(HMAC 会话 cookie、恒定时间比较、跳转白名单) |
|
||
| `admin-server/gateway.go` / `gateway_nav.go` / `gateway_import.go` / `portal_auth.go` | 路由表生成、导航同步、现有 nginx 配置导入、登录账号设置 |
|
||
| `deploy/zd.sh` | 零停机部署脚本 |
|
||
| `deploy/verify-ui.js` | 部署后浏览器验收脚本 |
|
||
| `docker-compose.yml` / `.env.example` | 单机 compose 启动与变量样例 |
|
||
|
||
## 生产部署:外层统一认证反向代理(可选方案)
|
||
|
||
> 本项目的当前形态已把这一层合并进单容器(见下文「一体化模式」)。
|
||
> 本节保留为**可选方案**:当入口需要独立于应用容器(例如希望应用升级不留任何中断)时使用。
|
||
|
||
|
||
> 内网门户通常不直接暴露 3003 端口,而是放在一个**带统一登录认证的反向代理**后面,
|
||
> 所有子域名共用一个登录入口。下面是本项目参考的 portal-nginx 方案。
|
||
|
||
### portal-nginx(统一入口容器)
|
||
|
||
结构:一个 nginx 容器负责所有 `*.example.com` 的 TLS 终止 + 按域名分流 + 统一认证。
|
||
|
||
```nginx
|
||
# 按域名映射到内网后端
|
||
map $host $backend {
|
||
hostnames;
|
||
default "";
|
||
navi.example.com http://127.0.0.1:3003; # 本导航容器
|
||
gitea.example.com http://127.0.0.1:3000;
|
||
blog.example.com http://127.0.0.1:8080;
|
||
}
|
||
|
||
# 需登录的域名统一走 auth_request
|
||
server {
|
||
listen 443 ssl;
|
||
http2 on;
|
||
server_name navi.example.com gitea.example.com blog.example.com;
|
||
|
||
auth_request /_auth; # 先问认证服务
|
||
error_page 401 = @login; # 未登录 → 跳登录页
|
||
|
||
location /_auth {
|
||
internal;
|
||
proxy_pass http://127.0.0.1:4001; # portal-auth 认证服务
|
||
proxy_pass_request_body off;
|
||
proxy_set_header Content-Length "";
|
||
proxy_set_header Cookie $http_cookie;
|
||
}
|
||
location @login {
|
||
return 302 https://login.example.com/login?redirect=$scheme://$host$request_uri;
|
||
}
|
||
location / {
|
||
if ($backend = "") { return 404; }
|
||
proxy_pass $backend;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Upgrade $http_upgrade;
|
||
proxy_set_header Connection "upgrade";
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
}
|
||
}
|
||
```
|
||
|
||
### portal-auth(统一认证服务)
|
||
|
||
一个极简 HTTP 认证服务(:4001),只做两件事:
|
||
|
||
1. `GET/POST /login` — 校验用户名密码,签发 `portal_session` cookie
|
||
2. `/_auth` 被 nginx `auth_request` 调用 — 校验 cookie 是否有效(200=通过,401=拒绝)
|
||
|
||
```python
|
||
# app.py(示意)
|
||
USERNAME = os.environ['PORTAL_USERNAME'] # admin
|
||
PASSWORD = os.environ['PORTAL_PASSWORD'] # 强密码
|
||
sessions = {} # token -> 过期时间
|
||
|
||
# /login 成功 → uuid token 写入 sessions + Set-Cookie
|
||
# /_auth 读 Cookie → 校验存在且未过期 → 200/401
|
||
```
|
||
|
||
部署:
|
||
|
||
```bash
|
||
docker run -d --name portal-auth \
|
||
-p 4001:4001 \
|
||
-e PORTAL_USERNAME=admin \
|
||
-e PORTAL_PASSWORD=change-me \
|
||
-v /path/auth/app.py:/app/app.py:ro \
|
||
python:3.11-slim
|
||
```
|
||
|
||
### 让搜索/管理子路径免登录(可选)
|
||
|
||
Dashy 域名整体在认证后,登录用户能正常搜索。若希望搜索页**免登录公开访问**,在对应 server 块加:
|
||
|
||
```nginx
|
||
location /search { auth_request off; proxy_pass $backend; }
|
||
```
|
||
|
||
> ⚠️ 管理后台 `/admin/` 与历史 API `/api/` **不应**免认证——
|
||
> 它们能修改导航与数据,建议保留登录墙,或改用 **Bearer Token**(见下文鉴权章节)。
|
||
|
||
## 内网穿透:webui4frpc + frp
|
||
|
||
> [webui4frpc](https://gitcode.com/JianFeeeee/webui4frpc) — 基于 Go 与 Web 的可视化 frpc 配置管理工具,
|
||
> 提供 WebUI 创建/启停 frpc 隧道,无需手写 frpc.toml。
|
||
> 也可不用它,直接用原生 frpc(见文末「原生 frpc 方式」)。
|
||
|
||
### 为什么要穿透
|
||
|
||
本导航容器跑在**内网**(如 192.168.x.x:3003)。要让**外网通过域名访问**,需要:
|
||
|
||
```
|
||
外网用户
|
||
→ 公网 VPS(有公网IP) 上的 frps [frp 服务端,监听控制端口 + 域名端口]
|
||
→ frpc(内网运行,webui4frpc 管理或原生)
|
||
→ 你内网的 portal-nginx / navi [把域名流量转发进来]
|
||
```
|
||
|
||
### 第 1 步:公网 VPS 部署 frps
|
||
|
||
下载 frp 服务端二进制并写配置:
|
||
|
||
```toml
|
||
# frps.toml
|
||
bindPort = 7000 # frpc 连接 frps 的控制端口
|
||
vhostHTTPPort = 8080 # HTTP 类型代理的入口端口(雷池/WAF 也走它)
|
||
|
||
auth.method = "token"
|
||
auth.token = "<长随机token>" # frpc 必须带相同 token 才能连
|
||
|
||
tls.enable = true # frpc↔frps 加密
|
||
|
||
# 用 systemd 启动,或直接:
|
||
# ./frps -c frps.toml
|
||
```
|
||
|
||
VPS 安全组/防火墙需放行:`7000`(frpc连接)、`8080`(http域名)、`443/80`(若 https 由 frps 终结)。
|
||
|
||
### 第 2 步:内网主机部署 webui4frpc(可视化)
|
||
|
||
从 [webui4frpc](https://gitcode.com/JianFeeeee/webui4frpc) 下载二进制,启动:
|
||
|
||
```bash
|
||
/opt/webui4frpc/webui4frpc \
|
||
-addr 0.0.0.0:7500 \
|
||
-user admin -password '<强密码>' \
|
||
-workdir /opt/webui4frpc/data
|
||
# 打开 http://<内网IP>:7500 进入 WebUI
|
||
```
|
||
|
||
### 第 3 步:在 WebUI 建「portal」隧道
|
||
|
||
在 WebUI 里新建配置,指向公网 frps,并添加代理。等价配置文件存于 `configs/*.json`:
|
||
|
||
```json
|
||
{
|
||
"serverAddr": "<vps-public-ip>",
|
||
"serverPort": 7000,
|
||
"auth": { "method": "token", "token": "<frp-token>" },
|
||
"proxies": [
|
||
{
|
||
"name": "portal_https",
|
||
"type": "https",
|
||
"localIP": "<内网portal-nginx主机IP>",
|
||
"localPort": 3080,
|
||
"customDomains": ["*.nav.example.com", "nav.example.com"]
|
||
},
|
||
{
|
||
"name": "portal_http",
|
||
"type": "tcp",
|
||
"localIP": "<内网portal-nginx主机IP>",
|
||
"localPort": 3081,
|
||
"remotePort": 8080
|
||
},
|
||
{
|
||
"name": "navi_ssh",
|
||
"type": "tcp",
|
||
"localIP": "<内网目标主机IP>",
|
||
"localPort": 22,
|
||
"remotePort": 220
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
webui4frpc 会自动下载并启动 `frpc`(存 `data/bin/`),配置变更即热加载。
|
||
|
||
### 第 4 步:DNS 解析
|
||
|
||
把 `nav.example.com`、`*.nav.example.com` 的 DNS 指向**公网 VPS IP**。
|
||
|
||
- `type: https` 代理:frps 把域名 443 流量转发内网 portal-nginx:3080,**TLS 证书由你内网管理**
|
||
- `type: tcp`(portal_http):把外网 8080 → 内网 3081(HTTP→HTTPS 跳转或备用入口)
|
||
|
||
### 原生 frpc 方式(不用 webui4frpc)
|
||
|
||
```toml
|
||
# frpc.toml
|
||
serverAddr = "<vps-public-ip>"
|
||
serverPort = 7000
|
||
|
||
auth.method = "token"
|
||
auth.token = "<frp-token>"
|
||
|
||
[[proxies]]
|
||
name = "portal_https"
|
||
type = "https"
|
||
localIP = "192.168.1.100"
|
||
localPort = 3080
|
||
customDomains = ["nav.example.com", "*.nav.example.com"]
|
||
|
||
[[proxies]]
|
||
name = "portal_http"
|
||
type = "tcp"
|
||
localIP = "192.168.1.100"
|
||
localPort = 3081
|
||
remotePort = 8080
|
||
```
|
||
|
||
```bash
|
||
./frpc -c frpc.toml
|
||
```
|
||
|
||
### 隧道规划对照
|
||
|
||
| 代理名 | type | 本地 | 远端 | 用途 |
|
||
|--------|------|------|------|------|
|
||
| portal_https | https | 内网:3080 | 443域名 | 统一反代 HTTPS 入口 |
|
||
| portal_http | tcp | 内网:3081 | 8080 | HTTP 跳 HTTPS / 备用 |
|
||
| gitea_ssh | tcp | 内网:22 | 220 | Git SSH |
|
||
|
||
## 鉴权(历史同步 + 管理 API)
|
||
|
||
导航门户的 `/api/` 和 `/admin/api/` 能读写数据,**不应裸奔**。推荐两层:
|
||
|
||
1. **外层**:portal-nginx 保留登录墙(`/admin/` 页面需登录)
|
||
2. **内层**:Go 服务校验 `Authorization: Bearer <token>`
|
||
|
||
部署时通过环境变量注入令牌:
|
||
|
||
```bash
|
||
docker run -d ... \
|
||
-e NAVI_SYNC_TOKEN=<长随机串> \ # 浏览器扩展同步用
|
||
-e NAVI_ADMIN_TOKEN=<长随机串> \ # 管理 API 写操作用
|
||
jianf-navi:latest
|
||
```
|
||
|
||
浏览器扩展在设置里填同一个 `NAVI_SYNC_TOKEN`,每次同步请求带 `Authorization: Bearer <token>`。
|
||
|
||
```bash
|
||
# 管理 API 示例(写操作必须带 admin token)
|
||
curl -H "Authorization: Bearer <NAVI_ADMIN_TOKEN>" \
|
||
-X POST -H 'Content-Type: application/json' \
|
||
-d '{"sectionIndex":0,"item":{"title":"New","url":"https://example.com"}}' \
|
||
http://<ip>:3003/admin/api/item
|
||
```
|
||
|
||
> 实现参考:`history-server` 的 `/sync` 校验 `NAVI_SYNC_TOKEN`,
|
||
> `admin-server` 的写操作校验 `NAVI_ADMIN_TOKEN`。
|
||
|
||
## 数据备份
|
||
|
||
```
|
||
data/
|
||
├── conf.yml # 导航配置
|
||
├── history.json # 浏览历史
|
||
├── item-icons/ # 自定义图标 + 历史站点 favicon 缓存
|
||
└── searxng-settings.yml # (可选)覆盖内置 SearXNG 配置,如代理
|
||
```
|
||
|
||
```bash
|
||
# 自动备份
|
||
0 2 * * * docker exec navi tar czf /tmp/bak.tar.gz -C /app/data . && \
|
||
docker cp navi:/tmp/bak.tar.gz /backup/navi-$(date +\%Y\%m\%d).tar.gz
|
||
```
|
||
|
||
## 项目结构
|
||
|
||
```
|
||
├── history-server/ # Go 历史同步 API (:3004)
|
||
├── admin-server/ # Go 管理后台 API (:3005)
|
||
├── admin/ # 管理后台前端
|
||
├── web/ # Dashy 注入脚本 + 扩展安装引导页
|
||
├── extension/ # 浏览器扩展 (Chrome/Firefox)
|
||
├── nginx.conf # 容器内统一反代
|
||
├── supervisord.conf # 进程管理
|
||
├── conf.example.yml # 脱敏示例配置
|
||
├── searxng-settings.yml
|
||
├── docker-compose.yml
|
||
├── Dockerfile
|
||
└── .env.example
|
||
```
|
||
|
||
## 工作空间视图
|
||
|
||
点左侧分组 → 右侧**在 iframe 里打开该应用**。不是所有应用都允许被嵌:
|
||
|
||
- 实测本仓库作者环境 24 个应用里 **19 个可以**,Gitea(含内网 IP)、docker.com 为
|
||
`X-Frame-Options: SAMEORIGIN`,Dify 为 `DENY`
|
||
- 是否可嵌由 `GET /admin/api/frameable` **服务端探测**决定(浏览器侧无法区分"被拒绝"
|
||
与"加载中",`onload` 两种情况都会触发)。探测是**三态**:确定可嵌 / 确定被拒 /
|
||
无法确认(被 WAF 拦成 403、或跳到了别的主机)。第三态照常尝试内嵌,但在工具条里
|
||
说明原因并保留"在新标签页打开"的退路
|
||
- 探测**会转发浏览器自己的 Cookie**:门户后面的应用对未登录请求会 302 到登录页,
|
||
而登录页没有 X-Frame-Options,不转发会话就会把"跳登录页"误判成"可内嵌"
|
||
- 被拒时:右侧面板给出说明,并**弹出提示卡片**(应用不支持在工作区显示 + 原因 +
|
||
在新标签页打开),同时清空 iframe 的 `src`,不留一个拒绝渲染的空白框
|
||
|
||
### 悬浮控件会"收边",第一次交互只滑出
|
||
|
||
工作空间中间是别人的应用,所以搜索球/视图切换/管理入口/备案号默认收在屏幕边缘
|
||
(各露 14px,备案号 13px)。**第一次点击/触摸只把它滑出来,第二次才真正生效**——
|
||
否则贴边的按钮会被直接点中(想看搜索框,结果把管理后台打开了)。判据是"元素是否真的
|
||
露在视口内",所以鼠标悬停已展开时一次点击即生效。6 秒无操作或 Esc 自动收回。
|
||
|
||
## 常见问题
|
||
|
||
**搜索无结果**:确认容器能访问搜索引擎。
|
||
|
||
- 国内服务器通常需要代理才能出网:SearXNG 不读 `http_proxy` 环境变量,
|
||
必须在 `outgoing.proxies` 里配置(见文件内注释)。
|
||
推荐把配置放到**数据卷**:`cp searxng-settings.yml data/searxng-settings.yml`
|
||
改完后编辑 `data/searxng-settings.yml` 并重启容器即可,无需重建镜像。
|
||
- **Bing 结果异常少**:`www.bing.com` 会 302 跳到 `cn.bing.com`,而 SearXNG
|
||
引擎请求不跟随重定向,只会解析跳转页得到 0 条且不报错。已在默认配置里把
|
||
bing 的 `base_url` 指向 `https://cn.bing.com` 绕开该重定向。
|
||
- **引擎大面积超时**:`outgoing.max_request_timeout` 会覆盖每个引擎自己的
|
||
`timeout`。默认配置已放宽到 `request_timeout: 8.0` / `max_request_timeout: 15.0`,
|
||
经代理访问时不要再调回 3/4 秒。
|
||
- 百度与搜狗会返回 302 验证页(并触发引擎 1 小时封禁),默认已关闭。
|
||
|
||
**管理后台 401**:设置了 `NAVI_ADMIN_TOKEN` 时需携带 Bearer token。
|
||
|
||
**历史不显示**:检查扩展配置中的服务地址和同步令牌。
|
||
|
||
**「安装浏览历史插件」入口不消失**:该入口由插件写入的 `data-navi-ext` 标记驱动隐藏。
|
||
|
||
- 确认扩展已启用,且设置里的服务地址**主机名**与当前访问地址一致
|
||
(例如填 `https://nav.example.com`、却用 `http://192.168.1.100:3003` 访问,就检测不到)。
|
||
- 改完设置请刷新页面。Chrome 若因新增 `scripting` 权限把扩展置为待确认,
|
||
需在 `chrome://extensions` 重新启用。
|
||
- 检测失败只会多显示一个入口,不影响同步功能。
|
||
|
||
**内存占用**:正常约 100MB。
|
||
|
||
## 许可
|
||
|
||
MIT
|
||
|
||
## 绝不提交的内容(脱敏清单)
|
||
|
||
| 类别 | 具体 | 原因 |
|
||
| --- | --- | --- |
|
||
| 导航配置 | `conf.yml`、`conf.yml.bak-*` | 含内网地址与服务清单(已 `.gitignore`) |
|
||
| 凭据 | `NAVI_SYNC_TOKEN`、`NAVI_ADMIN_TOKEN`、`PORTAL_PASSWORD`、`PORTAL_SESSION_SECRET` | 只在 `.env` / 容器环境变量里 |
|
||
| 登录账号 | `<data>/portal-auth.json` | 加盐哈希也是凭据等价物 |
|
||
| 证书 | `fullchain.pem`、`privkey.pem` | 仅以只读卷挂载,不进仓库 |
|
||
| 部署档案 | `/tmp/navi-*.env`、`portal/nginx.conf.bak-*` | 含运行时环境与内部拓扑 |
|
||
|
||
仓库内只应出现占位符:`.env.example` 里的空 token、`conf.example.yml` 里 `sections: []`、
|
||
文档中的 `10.0.0.10`/`*.example.com` 示例地址。发布前自检:
|
||
|
||
```bash
|
||
# 只匹配"看起来是真值"的赋值:排除 <占位>、${VAR:-}、change-me 之类
|
||
git grep -nE '(NAVI_(SYNC|ADMIN)_TOKEN|PORTAL_PASSWORD|PORTAL_SESSION_SECRET)=[^<${]' -- . || echo '无凭据'
|
||
git ls-files | grep -E '(^|/)conf\.yml$|\.env$|portal-auth\.json$|(fullchain|privkey)\.pem$' || echo '无敏感文件'
|
||
```
|