HomeDash Bot 700775866a fix(deploy): 修掉远端引号嵌套导致切换中途崩溃 + 补最终名字断言
真实事故:探测域名提取用了 `tr -d '"'"'"'` 这种引号嵌套,远端 bash 报
"unexpected EOF while looking for matching `'`",脚本在"停旧容器→起新容器→
改名"的改名之前中断,容器留在 navi-next(服务正常但名字不对,
且验收里 docker inspect navi 全为空,看起来像数据丢失)。

修复:
- 改用无引号的字符类提取域名(grep -oE '[A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,}'),
  彻底避开引号嵌套
- 收尾加断言:docker inspect navi 必须成功;若发现仍在 navi-next,
  明确提示可直接改名,而不是静默"成功"

现场已手动补齐:navi-next 完整核验(healthz/conf.yml/admin/schema 200、
入口 302/404/200/200/200、22 条路由、端口 3080/3081、门户标题与备案号完整、
env COOKIE_DOMAIN=1 明文 PASSWORD=0、数据卷凭据文件在)后改名为 navi,
回滚点 navi-prev-20260912104324(ui-v53) 保留。
2026-09-12 10:46:09 +08:00

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:从本仓库拉取源码构建(推荐)

# 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:直接构建镜像运行

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 列表),或直接用管理后台添加:

# 先让 conf.yml 从模板生成
docker compose restart
# 或用管理 WebUI 在浏览器添加:http://<ip>:3003/admin/

首次没有 data/ 目录时,容器会用内置 conf.example.yml 生成一份最小配置。

配置说明

conf.yml 核心字段

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 引用文件名:

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, 安装页就会出现真正的「从商店一键安装」按钮:

{ "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
# 查看
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 统计信息
// 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(本项目实际发生过)。

用仓库里的脚本,它会先在对侧端口把新容器验证到底、再瞬时切换上游:

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 已按这个顺序实现。

部署后验收

# 起一个 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 模式,接受数秒中断)

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 —— 表现为"在登录页登录成功,跳到别的子域却又是未登录"。

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 提供。

自检(发布前确认占位符已被替换、页面无残留标记):

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 用;用密钥登录可自行改脚本)

安装

# 在目标机器上(仓库目录内)
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 报错原文返回给界面——所以改错配置不会打断入口。

装完自检(与脚本末尾一致)

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/脚本按图配置即可,不必猜字段名或路径:

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")。 正确做法是轮询等入口就绪:

    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 一次性把站点改成你要的样子

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 终止 + 按域名分流 + 统一认证。

# 按域名映射到内网后端
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=拒绝)
# 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

部署:

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 块加:

location /search { auth_request off; proxy_pass $backend; }

⚠️ 管理后台 /admin/ 与历史 API /api/ 不应免认证—— 它们能修改导航与数据,建议保留登录墙,或改用 Bearer Token(见下文鉴权章节)。

内网穿透:webui4frpc + frp

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 服务端二进制并写配置:

# 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 下载二进制,启动:

/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:

{
  "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)

# 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
./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>

部署时通过环境变量注入令牌:

docker run -d ... \
  -e NAVI_SYNC_TOKEN=<长随机串> \    # 浏览器扩展同步用
  -e NAVI_ADMIN_TOKEN=<长随机串> \    # 管理 API 写操作用
  jianf-navi:latest

浏览器扩展在设置里填同一个 NAVI_SYNC_TOKEN,每次同步请求带 Authorization: Bearer <token>。

# 管理 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 配置,如代理
# 自动备份
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 示例地址。发布前自检:

# 只匹配"看起来是真值"的赋值:排除 <占位>、${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 '无敏感文件'
Description
HomeDash - unified self-hosted navigation portal (Dashy+SearXNG+history sync+admin WebUI), with portal-nginx auth reverse proxy & webui4frpc tunnel guide. Sanitized.
Readme 5.3 MiB
Languages
Go 40.7%
CSS 17.9%
JavaScript 15.8%
HTML 13%
Shell 11.1%
Other 1.5%