# HomeDash — 自托管内网导航门户 将 **Dashy + SearXNG + 浏览器历史同步 + 管理后台**整合在单个 Docker 容器中,并附带**完整的内网服务发布方案**(带统一认证的反向代理 + webui4frpc 内网穿透),让外网通过域名安全访问内网服务。 ## 架构总览(生产全链路) ``` 公网用户 │ ▼ 阿里云 VPS (frps 服务端, :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. 拉取仓库(把 换成你自己的 Git 托管地址) git clone 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://: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= \ -e NAVI_ADMIN_TOKEN= \ -v $PWD/data:/app/data \ jianf-navi ``` ### 首次配置导航项 容器启动后按需编辑 `data/conf.yml`(sections 列表),或直接用管理后台添加: ```bash # 先让 conf.yml 从模板生成 docker compose restart # 或用管理 WebUI 在浏览器添加:http://: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` 给 `` 打上 `data-navi-ext`。不会在其他站点上运行。 > Chrome / Edge **不允许**从网站直接安装非商店扩展,所以这一步只能下载后手动加载; > 若你已把扩展发布到商店,把链接写进 `.env` 或 data 卷的 `extension-store.json`, > 安装页就会出现真正的「从商店一键安装」按钮: > > ```json > { "chrome": "https://chrome.google.com/webstore/detail/", > "firefox": "https://addons.mozilla.org/firefox/addon//" } > ``` > > 放到 `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-.png`,缓存命中后 conf.yml 里写的是本地文件名,与手工图标走同一套解析。 - 抓取顺序:`/favicon.ico` → 首页 `` - `.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://: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 " http://:3003/admin/api/sections # 添加 curl -H "Authorization: Bearer " -X POST -H 'Content-Type: application/json' \ -d '{"sectionIndex":0,"item":{"title":"New","url":"https://example.com"}}' \ http://:3003/admin/api/item # 删除 curl -H "Authorization: Bearer " -X DELETE -H 'Content-Type: application/json' \ -d '{"sectionIndex":0,"itemIndex":2}' \ http://: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= CK= 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) | 必须挂载:`:/app/data`(conf.yml、history.json、gateway.yml、图标、壁纸、portal-auth.json)、 `:/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 不影响登录。想自己设口令:后台「统一登录账号」。 ### 门户页服务卡片(可配置,不写死域名) 门户页的卡片区带 `` / `` 标记,两种改法: 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` 也不再预置域名:受保护路由要求配置认证与登录地址, 留空会在生成配置时报错并说明原因(不会静默生成一个跳不出去的登录页)。 ### 统一登录账号 管理后台「统一登录账号」写入数据卷 `/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 '