Files
HomeDash/README.md
JianFeeeee e7ea091e19 fix(history): give history entries real site favicons
历史条目的图标原先写死 fas fa-globe,整栏都是地球图标。

- 新增 history-server/favicon.go:后台抓取每个 host 的 favicon 并缓存到
  data/item-icons/,命中后 conf.yml 写本地文件名,与手工图标同一套解析。
  抓取顺序 <host>/favicon.ico → 首页 <link rel=icon>;3 个 worker,
  失败 6 小时后再试;完成后合并触发一次 conf.yml 重建。
- 只缓存 Dashy 能渲染的格式:其 isImage() 白名单是 [svg,png,jpg],
  写 .ico 进去完全渲染不出来。因此 .ico 先抽内嵌 PNG,若是传统 BMP/DIB
  编码则解码后转 PNG(Google 这类站点只给老式 .ico)。
- 内网地址用 host:port 区分(不同端口的服务不再共用一个图标),且只直连;
  外网站点直连失败时自动复用 SearXNG outgoing.proxies 的代理,
  也可用 NAVI_FAVICON_PROXY 覆盖。
- 修掉一个真实缺陷:导航项都没有访问数据时,原来会提前 return 并把
  「历史访问」清成只剩安装入口 —— 只浏览站外站点时真实历史会整个消失。
  现在只冻结「常用服务」,历史照常生成。
- Dockerfile 去掉 # syntax=docker/dockerfile:1:该指令会让 BuildKit 去 pull
  docker/dockerfile 前端镜像,无外网环境直接构建失败;本文件未用 BuildKit 语法。

实测:6/6 站点(含 google.com 的 DIB 型 ico、github.com、docker.com、
news.ycombinator.com 与两个内网服务)均得到真实图标,页面 naturalWidth>0。
2026-09-11 10:55:00 +08:00

556 lines
18 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 — 自托管内网导航门户
将 **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/ → 自定义图标 (数据卷) │
└─────────────────────────────────────────────────────┘
```
**技术栈**: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` |
## 浏览器扩展
### 安装(两种方式)
**方式 1:导航页内一键引导(推荐)**
导航页会自动检测插件是否已安装:
- 未安装时,「历史访问」**第一条**固定显示「安装浏览历史插件」入口;
- 点击后打开 `/extension/install.html`,**自动识别浏览器**并给出对应安装步骤与安装包;
- 已安装时,`/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/` 可视化管理导航项。
API(需 `NAVI_ADMIN_TOKEN`):
```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
}]
}
```
## 生产部署:外层统一认证反向代理
> 内网门户通常不直接暴露 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/ # 自定义图标
```
```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
```
## 常见问题
**搜索无结果**:确认容器能访问搜索引擎。
- 国内服务器通常需要代理才能出网:SearXNG 不读 `http_proxy` 环境变量,
必须在 `searxng-settings.yml` 的 `outgoing.proxies` 里配置(见文件内注释)。
- **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