HomeDash Bot 76a6a83e7d feat(web): 不支持内嵌时明确弹层;修掉被 promise 吞掉的 ReferenceError
1) 点不支持在工作区显示的应用 → 弹出提示卡片
   标题「该应用不支持在工作区显示」+ 域名 + 服务器返回的原因 +
   「在新标签页打开 / 知道了」两个按钮;面板里同时保留同样的说明。
   之前只在面板里显示一行字,用户点了之后缺少明确反馈。

2) 修 ReferenceError: openUrl is not defined(线上真实故障)
   setWorkspaceState 第 4 个参数早先从 openUrl 改名为 note 时,blocked
   分支漏改,于是每次被拒内嵌都在函数内部抛异常,异常沿 promise 链被吞,
   导致后面的弹层根本没有执行、面板提示也只渲染到一半(URL 行和按钮缺失)。
   教训:验收必须采集 Runtime.exceptionThrown,并且断言交互元素真的存在;
   只断言"状态=blocked + 提示文字前缀"会恰好掩盖这类错误。
2026-09-11 19:52:19 +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/ 可视化管理导航项。

API(需 NAVI_ADMIN_TOKEN):

# 查看
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
  }]
}

生产部署:外层统一认证反向代理

内网门户通常不直接暴露 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

常见问题

搜索无结果:确认容器能访问搜索引擎。

  • 国内服务器通常需要代理才能出网: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

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%