Files
HomeDash/README.md
JianFeeeee 270445a516 feat(navi): Enter 唤起搜索框 + Space 激活焦点卡片;监听器幂等守卫
- bindSearchKeys:Enter 展开搜索并聚焦(搜索框已展开/焦点在可编辑元素时放行),
  Space 激活焦点所在的 a[href]/[role=button](preventDefault 防滚动)
- bindSearchKeys/enablePeekGuard/mountViewSwitch 加幂等守卫:
  DOM 抖动场景下 HEAD 版泄漏 914 个 keydown 监听器,修复后 0
- MutationObserver 自触发加 selfWrite 计数 + 100ms 节流
- showWorkspaceBlockedModal 的 Escape 从 document 改挂 overlay
- 验收脚本:verify-search-key.js(真实按键,页面内计数断言)、
  verify-swipe.js(CDP 注入真实触摸,断言分组真的切换)
- README/AGENTS:快捷键表、手势验收方法、改样式前先确认设计基准(§9)
2026-10-02 11:24:55 +08:00

1129 lines
55 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.

# HomeDash — 自托管内网导航门户
> **两种读者,两个入口**
>
> | 你是 | 看哪个 | 内容 |
> | --- | --- | --- |
> | **AI agent / 自动化脚本** | **[AGENTS.md](AGENTS.md)** | 可机械执行的步骤、每步**期望输出**、验收清单、失败处置、脱敏自检、回滚 —— 照做即可,无需理解原理 |
> | **人类**(本文) | 本文件 | 架构与设计取舍、为什么这么选、配置项含义、运维注意事项 |
>
> 两者不冲突:AGENTS.md 是"怎么做且怎么确认做对了",README 是"为什么"。
把 **反向代理 + 统一登录 + Dashy + SearXNG + 浏览器历史同步 + 管理后台 + 服务转发路由管理**
全部装进**同一个 Docker 容器**:容器自己就是统一入口(对外 3080/8443,3081 做 http→https 跳转),
按域名把请求分流到内网各服务,并对**逐条路由**决定是否需要登录。配合 webui4frpc 内网穿透,
外网即可用域名安全访问内网服务。
> 早期形态是「portal-nginx + portal-auth + navi」三个容器;现已合并为单容器(见下文「一体化模式」)。
> 那三个旧组件中,`portal-nginx` / `portal-auth` 已停用,仅作为回滚保留。
## 架构总览(生产全链路)
```
公网用户
│
▼
阿里云 VPS (frps 服务端, <vps-public-ip>:7000/8080/443)
│ frps: vhost 端口 + tcp 隧道
▼
frpc (由 webui4frpc 在内网主机上管理, WebUI :7500)
│ configs/*.json → 定义各隧道(把公网 443 转到容器入口端口)
▼
navi 单容器(--network host,既是应用也是统一入口)
│ nginx 监听 3080(TLS)/8443(TLS)/3081(→https 跳转)
│ ├─ 按 $host 查路由表(gateway.yml 生成,后台可维护)
│ ├─ 需要登录的路由 → auth_request ↗ 容器内置登录服务(:3005)
│ ├─ 未配置的域名 → 自定义 404(不会被兜底到某个服务)
│ └─ 上游故障 → 自定义 500(错误页本身免认证,否则会显示成 401)
▼
Dashy / SearXNG(→Clash 代理出网) / 历史 API / 管理 UI / 各内网服务
```
### navi 容器内部
```
┌─────────────────────────────────────────────────────┐
│ nginx (:18080) — 容器内统一入口(对外 3080/8443) │
│ ├─ / → Dashy 静态仪表盘 │
│ ├─ /search → SearXNG 搜索 (:8888) │
│ ├─ /static/ → SearXNG 静态资源 (:8888) │
│ ├─ /api/history/ → 历史同步 API (:3004) │
│ ├─ /admin/ → 管理后台 WebUI (:3005) │
│ ├─ /auth/verify → 统一登录校验(auth_request) │
│ └─ gateway/*.conf → 按域名生成的服务路由(可热更新)│
│ ├─ /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:18080 # 容器内 nginx 端口已由 8080 改为 18080 \
-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: false # 统一入口下探测会命中登录 302,结论不可信,默认关闭
```
### 图标
放入 `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 在本地过滤,不上传
- 「常用服务」= **所有被点击过**的导航项(按 `nav-visits.json` 的点击次数降序,**不设条数上限**;
0 次点击的不算"常用",不会塞进来);「历史访问」= 浏览器同步来的**全部**浏览记录(同样不限条数,
**包含站内服务**——它是"最近访问",会与其它分组有重叠)。两者数据源互相独立:**常用服务不看浏览器历史**
- 历史条目的图标由服务端自动抓取并缓存(见下)
### 历史条目的站点图标
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 | 获取历史(按频次排序,**返回全部**,响应含 `total`) |
| `/api/history/` | DELETE | 清空全部历史 |
| `/api/history/stats` | GET | 统计信息 |
| `/api/history/nav-visit` | POST | 导航项点击计数(「常用服务」的唯一排序依据) |
| `/api/history/item?url=` | DELETE | 删除单条历史(需 admin token) |
> `visit_count` 的语义:浏览器 `history.search` 的 `visitCount` 是**该 URL 的绝对累计值**,
> 不是增量。服务端按设备取绝对值再求和(同一设备重复上报由 max 吸收),
> 所以扩展每 30s 重报不会把计数放大。**若改成 `+=` 累加,n 次真实访问会记成 n(n+1)/2。**
```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`、`NAVI_COOKIE_DOMAIN`、`NAVI_HOME_URL`、`NAVI_CHECK_HOST`、`NAVI_ROTATE_PORTAL_PASSWORD`。
### 一个必须知道的坑:单文件 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 模式,接受数秒中断)
> 机械步骤与期望输出见 **[AGENTS.md](AGENTS.md)** §3(含升级前必须确认的 cookie 域)。
```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
```
流程:本地构建 → 校验(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`。
后台入口:**站点信息 → 门户页服务卡片**(与 `/api/portal-cards` 是同一份数据,界面能改的 agent 都能改)。仓库内置的卡片是通用示例,不含任何真实域名。
### 跨子域单点登录需要的两个环境变量
登录 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,结论不可信,
因此不显示而不是显示错的
### 键盘快捷键
| 键 | 行为 | 位置 |
| --- | --- | --- |
| `Enter` | 展开底部搜索栏,**焦点直接落进输入框** | 三个视图(`/`、`/minimal`、`/workspace`) |
| `Enter` | 在搜索框内**提交**(新标签打开 `/search?q=…`) | 搜索框展开时 |
| `Space` | 打开**焦点所在**的导航项 | 焦点在卡片上时 |
| `Escape` | 收起搜索栏并清空 | 搜索框展开时 |
| `←` / `→` | 切换极简页分组 | 仅 `/minimal` |
实现见 `web/navi.js` 的 `bindSearchKeys`(挂 `document`,用 `searchCtl` 与搜索框通信)。
**为什么需要 `Space` 补一刀**(实测,真实 Dashy V4.4.4 + 真实按键):
焦点落在卡片 `<a>` 上时,原生 `Enter` 会被 Dashy 接管并打开该站点,
但**原生 `Space` 完全无反应** —— 链接的默认 Space→激活语义被 Dashy 的
点击接管吃掉了,所以这里显式补一次激活。
守卫(缺一个就会出事):
- 搜索栏**已展开**时不接管 `Enter` —— 否则搜索框里的 Enter 提交会被抢走
- 焦点在可编辑元素(`input`/`textarea`/`select`/`contenteditable`)时不处理,
否则打不出带空格的搜索词
- `Space` 必须 `preventDefault()`:默认行为会滚动页面
- 带 `Ctrl`/`Meta`/`Alt` 时一律放过,不抢浏览器快捷键
验收脚本:`deploy/verify-search-key.js`(连 CDP 跑,覆盖两个视图,
断言焦点真的进了输入框、真的打开了焦点所在的那一张)。
### 极简页分组切换(点击 / 滑动 / 键盘)
`/minimal` 的分组(页签)除点击外,还支持左右滑动切换。四种输入:
| 输入 | 操作 | 实现 |
| --- | --- | --- |
| 触屏 | 在内容区左右滑动 | `pointerdown/move/up` |
| 触控板 | 双指横滑 | `wheel` 的 `deltaX` 累积(每次手势只切一次) |
| 鼠标 | 按住左键横向拖动 | `pointerdown/move/up` |
| 键盘 | `←` / `→` | 不在输入框内、无弹窗时生效 |
- **判定**:横移 ≥ 56px 且 `|dx| > 1.5|dy|` 才切换;先判出 `|dy| > |dx|` 则直接让给竖向滚动
- **边界**:到第一个/最后一个分组即停(clamp),只给一次轻微回弹提示
- **动画**:只对**页框内的元素**(`.minimal-section-inner.selected > *`)淡出淡入;
页签行与框体本身不动。`prefers-reduced-motion` 下不做动画。用 keyframes 而非 transition,
避免覆盖卡片自带的 `transition: all`
- **页签行跟随**:切换后把选中页签滚进可视区。页签行本身可横向滚动
(实测 8 个分组宽 1306px > 可视 828px),不跟随的话选中项会跑到视口外,看起来像"没切换"
- **防误触**:横拖超过 20px 后吞掉紧随的一次 click,避免误开卡片链接;
手势起点若落在可横向滚动的容器或输入框内,直接放行不接管
- **实现契约**:接的是 Dashy 的 DOM —— `.minimal-headings-row > .minimal-section-heading.headings`,
当前项带 `.selected`,切换 = 对该元素派发一次真实 click。
**Dashy 升级若改类名会静默失效**;加载时匹配不到标签会在 `<html>` 上写
`data-navi-swipe="unavailable"`(可编程检查,不刷控制台)
验收脚本:`deploy/verify-swipe.js`。它用 CDP **注入真实触摸序列**
(`Emulation.setTouchEmulationEnabled` + `Input.dispatchTouchEvent`),
断言的是**分组真的切了**(选中页签下标变化 + 面板卡片数变化),
而不是"事件到了 / 监听器挂了" —— 后者在本项目曾出现过
“事件全到达、但自建拦截器把自己的切换 click 吞了”的假绿。
```bash
NODE_PATH=/root/node_modules PORTAL_BASE=http://<host>:18080 node deploy/verify-swipe.js
```
覆盖:触摸左滑切换、面板内容跟随、反向滑回、**竖滑不误触发**、无 `pageerror`。
### 回滚
- 容器:`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>' # 应为配置的标题或默认值
```
## 从零部署(干净机器)
> 只想照步骤做、并按期望输出核对成败:直接看 **[AGENTS.md](AGENTS.md)** §2。
前面的「快速开始」是最小试用形态(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_COOKIE_DOMAIN`(zd-host.sh) | 必填 | 跨子域单点登录所需,如 `.example.com`;**旧容器没有该项且未传时脚本拒绝升级**(避免登录 cookie 退化为 host-only) |
| `NAVI_HOME_URL`(zd-host.sh) | `/` | 登录成功后跳回的地址,如 `https://example.com/` |
| `NAVI_CHECK_HOST`(zd-host.sh) | 自动 | 入口自检探测的域名;默认取数据卷 `gateway.yml` 路由表首条,脚本内**不写死任何域名** |
| `NAVI_ROTATE_PORTAL_PASSWORD`(zd-host.sh) | `0` | `1` 时把门户口令重置为随机值并打印一次。**默认 0**:只清理容器 env 里的明文口令,不改登录口令(生效凭据本是数据卷里的加盐哈希) |
| `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/history/nav-visit` `{"url":"https://…"}`(导航页点击时由前端自动上报;「常用服务」据此排序) |
| 图标上传 | `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"
```
> **注意 `常用服务` 与 `历史访问` 是系统管理的分组**:前者收录所有被点击过的导航项(按次数降序,不置顶、不限条数),
> 后者按浏览器同步的浏览记录重建(含站内服务,不限条数)。新增服务要放进用户分组(如 `AI & 工具`);`navEnabled` 的 `navSection`
> 若指向 `常用服务`,条目会在下次重建时被挤掉。
**示例:让 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 抓取、**导航项点击计数**(`/api/history/nav-visit`)、重建 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/pages.go` | 认证页/门户页文案渲染(读 `pageInfo`,留空回落内置默认)+ 门户卡片覆盖(数据卷 `portal/cards.html`)+ `/api/portal-cards` |
| `admin-server/schema.go` | `/api/schema`:全部可配置项与其写入口清单(供 agent/脚本按图配置) |
| `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:18080; # 容器内 nginx(旧文里的 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 '无敏感文件'
```