Commit Graph

38 Commits

Author SHA1 Message Date
cd108f8093 fix(admin): requireToken 去掉 fail-open 死分支,空 expected 一律拒绝
审计第 1 条的「另一半」。第 1 步把 history-server 的 requireToken 改成了
fail-closed,但 admin-server 的同类函数还留着原来的形状:

    if expected == "" {
        return true // 未配置 token 则不校验
    }

它今天打不到(唯一调用点 requireAdminToken 开头有 adminToken != "" 守卫),
但形状本身就是风险:任何人新写一行 requireToken(w, r, someOptionalToken)
就会把 fail-open 复活,且没有任何测试会报红 —— 第 1 步的静态护栏只钉了
history-server 一半仓库。

现在 admin-server 的 requireToken 与 history-server 完全对齐:
- expected 为空 → 401 + JSON {"error": "...set NAVI_ADMIN_TOKEN / NAVI_SYNC_TOKEN..."},
  绝不 return true;拒绝信息带修复指引(全站 401 时运维最需要的就是这句),
  且不回显任何 token 值。
- 「没有 token 也允许访问」的决策只允许出现在明确语义的函数里
  (requireAdminToken 的 allowUnauthenticated 分支),不再藏在通用校验函数里。

requireAdminToken 的三段判定顺序一行未动:
① 配了 adminToken → requireToken;② 未配 + allowUnauthenticated → 放行;
③ 其它 → 拒绝。security_test.go 的既有用例(TestAdminAPIFailsClosedByDefault、
TestAdminAPIRejectsWithoutTokenEvenIfFlagUnset 等)全部继续绿。

新增 admin-server/token_failclosed_test.go:
1. 直接调 requireToken(rec, req, "") 的四种请求形态 → 全部 false + 401 +
   JSON + 含修复指引 + 不回显 token;
2. 配了 token 时正常路径不变(无头/错 token/缺 Bearer 前缀/对 token),
   外加逐条断言 requireAdminToken 的三段顺序(含「配了 token 时不因
   allowUnauthenticated 而放行错误 token」这条最容易改坏的);
3. 静态护栏:admin-server 的 main.go / gateway.go / security_test.go 都不得再出现
   「未配置 token 则不校验」或 `expected == "" { return true`,并正向断言
   requireToken 函数体里有 StatusUnauthorized。

反向验证:把空 expected 分支改回 `return true` 后,
TestRequireTokenRejectsEmptyExpected(4 个子用例全红)与
TestNoFailOpenShortCircuitAcrossServers 报红;恢复后 admin-server 全集 ok。
2026-10-22 23:27:51 +08:00
5562d584d3 fix(gateway): https 上游不再无条件关闭证书校验
审计第 2 条:writeProxyLocation(主 location / bypass 段)与 writeRouteServer
的 favicon 段原本都是

    if r.InsecureTLS || strings.HasPrefix(strings.ToLower(upstream), "https://") {
        proxy_ssl_verify off;
    }

即「只要上游是 https 就关掉上游证书校验」,对所有 https 上游一律不验签 ——
中间人可冒充任意 https 后端,用户提交的凭据与 cookie 全部可截。

现在条件收敛为 `r.InsecureTLS && isHTTPSUpstream(upstream)`:
「上游是 https」不再是关闭校验的理由,只有**显式勾选 insecureTls** 的路由
才会输出 proxy_ssl_verify off。两处(writeProxyLocation 用 r.Upstream、
favicon 段用局部变量 up)都改到了,并抽出 isHTTPSUpstream 消除重复判断。

⚠️ 运维需知的行为变化(有意为之,不要偷偷改回去):
- 普通 https 上游现在走 nginx 默认:**校验上游证书**,使用系统 CA 库。
  公共 CA(Let's Encrypt 等)签发的上游不受影响,无需任何配置。
- 仓库内没有 proxy_ssl_trusted_certificate 的配置,因此**自签证书的上游现在会 502**。
  这是预期的 fail-closed 结果:要接自签上游,请在该路由上显式勾选 insecureTls
  (等价于确认「我知道这条链路不验签」)。
- http 上游行为不变(本来就不会有 TLS 握手);本次也顺带保证不会给它写这行噪声配置。

测试(admin-server/gateway_tls_verify_test.go,沿用 gateway_test.go 的
RenderGatewayConf + section 取值风格):
1. https 上游 + InsecureTLS=false → 主 location 不含 proxy_ssl_verify off;
2. https 上游 + InsecureTLS=true  → 含;
3. http 上游(两种情况)→ 都不含;
4. favicon 段(location = /favicon.ico 与 /favicon.svg)逐条断言 1/2 两种行为,
   防止只改主 location 漏掉 favicon 段;
5. 计数断言:整份配置里 proxy_ssl_verify off 的出现次数 == 勾了 insecureTls 的
   https 路由贡献的 location 数(主/bypass + 2 个 favicon),兜住别处同类短路;
6. 源码护栏:不得再出现两种旧写法,且注释必须保留「自签默认 502 是预期」的说明。

反向验证:把两处判断改回 `|| https://` 后,1/3/4/5/6 全部报红(详见
`go test -run 'TestHTTPSUpstream|TestHTTPUpstream|TestFaviconPassthrough|TestSSLVerifyOff|TestInsecureTLSBehaviour'`),
计数用例给出 13 次 vs 期望 7 次;恢复后全绿。
2026-10-22 23:14:32 +08:00
dea722f51c fix(security): 管理 API 默认拒绝 + 堵住渗透测试报告的 5 个攻击面
2026-10-22 渗透测试报告(report.md)实测确认:统一入口的管理 API 在公网
完全未鉴权,可匿名改路由表、拆登录墙、改门户口令、写门户 HTML、读全部
浏览历史、做内网 SSRF 探测。根因有两层,本次一并修复。

## 根因 1:requireAdminToken 默认放行(代码层)

    if !requireTokenForWrite { return true }

线上没有 NAVI_ADMIN_REQUIRE_TOKEN,于是所有"调了鉴权"的 handler 实际全部
放行——形同虚设。现在改为 fail-closed:

  - 配了 NAVI_ADMIN_TOKEN        → 校验 Bearer token
  - 未配且显式 NAVI_ADMIN_ALLOW_UNAUTHENTICATED=1 → 放行(仅本机调试)
  - 其它                          → 401 拒绝

## 根因 2:login 域没有任何 auth_request(部署层)

kind=auth 的 server 块全站 requireLogin=false(它本身就是登录服务,这点
合理),但 location / 把 /api/、/admin/ 原样反代到 127.0.0.1:3005。两层
都没管 → 管理 API 裸奔。

新增 Route.AdminGuard:开启后 server 级挂 auth_request,只白名单放行登录
流程(/_portal/login|logout|verify、/login、/portal-page、favicon),
其余 /api/、/admin/ 一律要求已登录。

## 同时修掉的 4 个具体问题

1. /api/sync-info 明文回显 syncToken → 不再回显,且要求鉴权。
   泄露该 token 等于浏览器历史的全读写权。
2. /api/history GET 无鉴权 → 与 DELETE 分支一致,要求管理员令牌。
3. /api/frameable 未鉴权 SSRF → 加鉴权 + 内网/回环黑名单。除 IP 字面量外
   还对域名解析结果做校验,防止 localtest.me 之类指向 127.0.0.1 的绕过。
4. dashy 域 bypassPaths 里的 "/api/" 过宽 → 收窄到 "/api/history/"
   (本意只放行浏览记录上报,实际把整个管理 API 暴露了)。

## 回归护栏

新增 admin-server/security_test.go(10 条)与 main_test_setup_test.go。
每条断言都是"改回旧行为就会失败",已用 3 次变异测试验证:
  - 恢复默认放行        → TestAdminAPIFailsClosedByDefault FAIL
  - sync-info 回显 token → TestSyncInfoDoesNotLeakToken FAIL
  - 移除内网黑名单      → TestFrameableBlocksInternalTargets FAIL
  - /api/ 豁免认证      → TestAuthKindWithAdminGuardProtectsAPI FAIL
  - 不给 server 级 auth → 同上 FAIL

测试 56 项全绿。

## 顺带

searxng-settings.yml:停用 360search / ddgw / wikidata / wikipedia
(各自的实测理由见文件内注释),request_timeout 8.0→4.0。
Dockerfile:给 duckduckgo_web 引擎硬编码的 timeout=2 打补丁(经代理必超时)。
2026-10-22 14:08:06 +08:00
681393c8ec feat(icon): 补齐 PWA / 错误页 / 登录页图标
漏掉的四处(都实测确认过):
- PWA manifest 的主图标是 /web-icons/dashy-logo.png(512x512),以及
  dashy-pwa_{48,72,96,144,192,512}.png —— 全部仍是上游 Dashy 品牌,
  浏览器「安装到桌面/主屏」时用的就是这批。由 favicon.svg 重新派生。
- portal/404.html、500.html 没有任何 icon 标签(浏览器只能退化去找
  /favicon.ico)。实测两条路径都能取到图标,但仍显式声明更稳妥。
- 登录页模板(admin-server/auth.go)同样缺 icon 标签。

所有派生 PNG 都逐个解码验证(颜色数 111~1603,非空非纯色)。
2026-10-03 08:45:47 +08:00
495e4ee20e fix(admin): 壁纸注入补测试;测试夹具真实备案号换占位值;后台历史条数展示 2026-10-02 11:24:55 +08:00
ca80ba2510 fix(gateway): auth 类路由重复写 /favicon.ico 导致 nginx -t 失败(v96 部署被回滚的真因)
v96 部署时脚本报"网关配置重新生成失败 → 回滚到 ui-v95"。根因:auth 类路由的 Upstream
是在 writeErrorPages 之后才赋值的,所以它先按"门户自己渲染"拿到了门户 favicon,
随后新的"favicon 直通上游"块又写了同一个 location → nginx 拒绝重复 location。

修法:直通块对 auth 类跳过(门户页/登录页继续用门户自己的图标)。
另加回归测试 TestNoDuplicateExactLocations:解析渲染结果,断言任一 server 块内
不得出现重复的 `location = X`;已用"临时移除守卫"做过反向对照,确认该测试真能抓到。
2026-09-13 08:58:41 +08:00
9c0d9f2bb5 fix(gateway): 被代理站点的 favicon 免登录直通上游(而非门户静态文件/登录墙)
承接上一提交:把门户 favicon 从代理块里去掉后暴露两个问题:
- requireLogin 的站点 /favicon.ico 变成 302(浏览器标签与图标抓取都取不到);
- 而 favicon 本来就不该要求登录。

改为:代理块显式写
    location = /favicon.ico  { auth_request off; proxy_pass <上游>; ... }
    location = /favicon.svg  { auth_request off; proxy_pass <上游>; ... }
(https/自签上游附带 proxy_ssl_verify off)。
效果:浏览器标签显示各站点自己的图标、无需登录;抓取端也因此能在未登录时取到真实图标。
门户页/登录页/默认 404 块仍提供门户自己的 favicon。测试断言同步改为"直通上游"语义。
2026-09-13 08:52:41 +08:00
3d1ed788f3 fix(gateway/favicon): 被代理域名的 /favicon.ico 被门户图标顶掉(图标全错的真因)
用户问"同时被路由的网站为什么图标不正确"。实测三处证据:
  ① gateway.conf 里**每个** server 块都有
       location = /favicon.ico { auth_request off; try_files $uri =404; }
     而 try_files 在门户自己的 root(/app/portal) 里找文件;
  ② 于是 https://pi|ittools|vikunja.jianfgit.xyz/favicon.ico 三者返回**同一个**
     门户图标(md5 80498a88…),而它们内部上游的真实图标各不相同
     (ittools dc36588f… / vikunja 6d9507b6… / pi cf3332ac…);
  ③ 导航图标抓取优先抓 /favicon.ico → pi/ittools/vikunja 存下了同一张 1091B 门户图标。

修法两处:
- admin-server/gateway.go:门户 favicon 的两个 location 只在"门户自己渲染"的
  server 块(kind=portal/auth、默认 404 块)里输出;代理到上游的块不再输出,
  让 /favicon.ico 正常走 location / 落到上游。新增断言测试。
- history-server/favicon.go:抓取改为**优先走内部上游**(读 gateway.yml 把对外域名
  映射到内部地址,带 Host 头、容忍自签证书),失败才回落对外地址 —— 这样受统一登录
  保护的站点也能拿到真实图标,且不再依赖门户的 favicon 行为。
2026-09-13 08:48:42 +08:00
3d18fb59ce fix(ui): 登录页页脚被当纯文本显示(html/template 转义)+ 三处页脚统一
用户发现登录页出现 `<div class="navi-footer">…` 这串标记文字。

① 根因:auth.go 把 beianFooter() 的字符串塞进 Go html/template(map[string]string),
   模板自动转义整段 HTML → 页面上直接显示标记源码。
   修法:改用 map[string]any + template.HTML(beianFooter 内部已对备案号做 html.EscapeString,
   注入合法)。
② 登录页与门户页此前**都无视**后台的「页脚」自定义字段(后台提示语写的是"填写后以这里为准")。
   新增统一入口 siteFooter(info):自定义 footer 优先,否则按 icp/gongan/座右铭生成;
   pages.go(门户页)与 auth.go(登录页)都改走它。
③ 页脚座右铭此前只由 navi.js 在 Dashy 页客户端注入 → 登录页/门户页永远没有。
   新增 pageInfo 字段 motto(换行或 | 分行,留空用内置默认),
   footerMarkup() 生成时一并输出 .navi-quote 两行;navi.js 已有幂等判断不会重复注入。
   后台「站点信息」新增座右铭输入框,并登记进 /api/schema。
④ 登录页 / 门户页补页脚样式:左列两行备案号、右列两行座右铭;窄屏退化为居中单列。
2026-09-13 07:56:38 +08:00
bb41b61ad6 fix(gateway): 路由表接口只替换路由,不再整份配置重置(agent 友好)
真实验证(干净环境练兵)抓到:POST /admin/api/gateway/routes 会把整个
GatewayConfig 反序列化为零值后保存,因此
- 只提交单个路由对象 → {"routes":0,"status":"saved"}:**静默清空整张路由表**
- 提交 {"routes":[…] } → 其余字段被重置为默认(实测入口端口 13080 → 8081,
  TLS/认证地址一并丢失),随后 apply 生成的配置与预期完全不符

对 agent 尤其危险:照文档提交一条路由就会改掉入口端口与所有其它路由。

修复:
- 只补路由表:以现有配置为基准,仅替换 Routes,其它字段原样保留
- 请求体必须是 {"routes":[...]};缺该键 → 400 并说明正确形状(不再静默变空表)
- 非空表要清空必须显式传 force:true,否则 400 拒绝
- 测试:单个对象被拒;数组提交后 listenPort/TLS/认证地址/证书路径全部保留;
  清空被拒、force 允许
- README:修正路由示例为完整数组形状,补"替换整张表"与 /admin/api 前缀说明
2026-09-12 10:28:03 +08:00
92fd32e7a9 feat(api): 全量配置项暴露为接口 + 后台增加门户卡片编辑框
用户要求:所有可配置项都应当是 API,方便 agent 直接把站点配成用户喜欢的样子。

- 新增 GET /api/schema:一次返回
  · items:全部单值配置项(站点信息、登录页/门户页文案、备案号、页脚…),
    含当前值、类型、写入口与请求体示例;**当前为空的键也会列出**,否则 agent 发现不了
  · resources:结构化资源与其真正的写入口(导航、路由表+apply、门户卡片、壁纸、
    门户口令、历史、图标上传、重载),并在 hint 里写明"路由表改完必须 apply"这类约束
- 后台「站点信息」新增「门户页服务卡片」编辑框:载入 / 保存 / 清空即回内置,
  与 /api/portal-cards 同一份数据(界面能改的 agent 都能改)
- README 新增「配置接口(agent 可直接改)」:schema 用法、写入口对照表、可直接复制的示例
- 测试:schema 必须列出关键项与写入口;卡片接口写入生效、清空返回 reset 并回落内置
2026-09-12 10:19:22 +08:00
7c2bed2f2d feat: 门户页卡片可配置 + 升级前域名校验 + 清理明文门户口令
按用户选择实施三件事:

1) 门户页服务卡片可配置(不再写死真实域名)
   - portal/index.html 卡片区加 <!--NAVI_CARDS_START/END--> 标记,内置卡片改为通用示例
   - renderPortalPage 支持数据卷 <data>/portal/cards.html 覆盖标记区间(清空即回落内置)
   - 新增 GET/POST /api/portal-cards 读写该片段
   - 测试:覆盖替换成功、空白片段回落内置
   - 仓库内真实域名由此清零

2) 升级前必须提供跨子域单点登录变量(防止"去掉写死默认值"造成 SSO 静默失效)
   - zd-host.sh 在切换前检查 PORTAL_COOKIE_DOMAIN:缺失且未传 NAVI_COOKIE_DOMAIN
     时直接拒绝升级(退出码 2)并给出正确命令;PORTAL_HOME_URL 可用 NAVI_HOME_URL 补
   - README 新增「升级已有部署前必须确认的两个变量」含失败表现与正确命令

3) 清理明文门户口令
   - 迁移旧容器 env 时丢弃 PORTAL_PASSWORD(docker inspect 可见 = 泄露面)
   - 升级后写入强随机口令并通过接口生效,部署日志只打印一次;失败不阻断升级并提示后台可改
2026-09-12 10:14:22 +08:00
b99319b38b chore(sanitize): 备案号与域名一律可配置,仓库不再预置真实域名/备案号/内网地址
用户要求:备案号与域名都应当可配置,仓库内容要脱敏。

功能(不只是替换字面量):
- 被代理页面注入的备案号改为读后台「站点信息」的 icp/gongan,**未配置就不注入**;
  原先写死具体备案号,等于每个部署者都会把别人的备案信息印到自己页面上
- 注入内容做 nginx 安全处理(去引号/反斜杠/换行/美元符),并有测试覆盖
- auth.go:PORTAL_COOKIE_DOMAIN / PORTAL_HOME_URL 不再预置域名,
  cookie 默认 host-only、首页默认相对路径 "/"
- gateway.go:默认 loginUrl 不再预置域名(受保护路由缺配置会明确报错,不静默生成跳不出去的登录页)
- 首装脚本在给了 NAVI_DOMAIN 时自动注入 PORTAL_COOKIE_DOMAIN/PORTAL_HOME_URL,
  避免"去掉写死默认值"导致跨子域单点登录失效

脱敏:内网 IP(10.0.0.10)与真实备案号清零;真实域名仅剩门户页内容 8 处(待定);
测试夹具/示例/错误页/脚本/后台占位提示全部通用化。

README:新增「跨子域单点登录需要的两个环境变量」;环境变量表改为 NAVI_HOST 必填。
测试:新增备案号注入三条(用配置、未配置不注入、危险字符清理)。
2026-09-12 10:10:52 +08:00
b01e49de49 fix(gateway): http→https 跳转是否带端口改为显式开关(避免生产回归)
练兵发现:跳转写成 return 302 https://$host$request_uri(不带端口),
非 443 端口的新装会跳到 443 打不开。

但直接用'端口 != 443 就带端口'推导会把生产弄坏:生产容器监听 3080,
对外经 frp 映射为 443,带端口会把公网用户导向 https://host:3080。

因此新增 gateway.yml 字段 redirectWithPort(默认 false,行为不变):
- 默认:跳 https://host(前置 frp/443 的部署,含现生产)
- 全新安装且对外端口=入口端口:install-host.sh 自动置 true
测试补两条:开关为真时带端口、默认(生产形态)不带端口。
2026-09-12 09:52:20 +08:00
158217103d fix(deploy): 首装路由表路径写错(静默零路由)+ apply 空表安全阀
真实练兵(干净数据目录跑 install-host.sh)抓到:
1. 路由表源文件被写到 <数据卷>/gateway/gateway.yml,容器实际读取 <数据卷>/gateway.yml
   → apply 报 routes: 0,用户自备/生成的初始路由被静默忽略
2. 自检在 apply 后立即探测,nginx 还没 reload 完 → 误判连接失败;
   code_of 失败时重复输出 000
3. 缺安全阀:路由表为空时 apply 会照常生成零路由配置并覆盖,
   等于静默清空入口路由表

修复:
- install-host.sh 改写 <数据卷>/gateway.yml(并注释说明两个目录的区别)
- 自检前轮询等待入口监听就绪(最多 10s),code_of 只在成功时输出状态码
- handleGatewayApply 增加安全阀:路由表为空但现有配置含 server_name → 400 拒绝覆盖
- 新增测试 TestApplyRefusesEmptyRoutesOverExisting
2026-09-12 09:40:36 +08:00
cb86557466 test(gateway): root 去重断言改为按 server 段计数
上一版断言用全局计数,而默认段与门户段各有一个 root(合法),因此必然失败;
真正要防的是同一段内重复。改为取门户段后再计数。
2026-09-12 09:20:35 +08:00
b4241741d6 fix(gateway): 门户段重复 root 导致 nginx -t 失败
v52 部署被 apply 的校验拦下("root directive is duplicate"):writeErrorPages 已写过
root /app/portal;,我又在 portal 分支写了一次。移除重复行,并加单测断言
该指令在生成结果里恰好出现一次。回滚机制按预期工作,服务未中断。
2026-09-12 09:20:18 +08:00
52164b6abe fix(gateway): 门户首页走 admin-server 渲染(上次改动未落盘)
上一轮声称改过 kind=portal 的生成逻辑,但 git log 显示 gateway.go 从未提交该改动
(我的修改没有真正落盘),导致:线上仍用内置模板,而新镜像的模板带 HTML 占位符
→ 门户页占位符裸露、标题为空、文案不可配置。

本次:portal 分支改为 location = / 反代到 :3005/portal-page(静态资源仍 root
/app/portal),并新增单测断言该引用存在——今后若再被改掉,测试会失败。
2026-09-12 09:17:34 +08:00
472d8d1cd0 feat(pages): 认证页与门户页文案可配置,不再写死
需求:登录页与门户页的内容应可自定义。

实现(配置存数据卷、后台可改、留空回落内置默认,不配置行为不变):
- pages.go:pageStr 读取 pageInfo;renderPortalPage 优先数据卷
  <data>/portal/index.html 整页覆盖,否则用内置模板替换占位符;
  authPageInfo 提供登录页字段
- auth.go:登录页标题/副标题/徽标/页脚改为模板数据(含备案号)
- gateway.go:kind=portal 的首页改由 admin-server /portal-page 渲染
  (静态资源仍由 nginx 提供),使文案可配置
- portal/index.html:加入 <!--PAGE_TITLE--> / <!--HERO_TITLE--> /
  <!--HERO_SUBTITLE--> / <!--FOOTER--> 占位符
- 后台「站点信息」新增六项文案输入(含回填与保存),并提示整页覆盖方式
- README 新增「认证页与门户页文案(可配置,不写死)」含字段表与自检命令

测试:默认回落、配置取值去空白、认证页字段读取、数据卷覆盖优先、空白覆盖忽略。
2026-09-12 09:12:13 +08:00
9798b68d07 feat(status): 按用户要求彻底关闭可达性状态显示
用户决定不显示服务状态,避免不准确的信号误导。因此:
- 移除导航同步里写入的 statusCheck / statusCheckUrl
- 移除网关为每条路由生成的 /.navi-ping(不再需要,且不应保留一个
  免认证旁路路径)
- 删除对应单测

生产 conf.yml 的 statusCheck 在同一次部署中关闭(appConfig 置 false、
条目级该行删除),并重新 apply 生成网关配置以清掉 /.navi-ping。
2026-09-12 08:32:03 +08:00
da7a56e7ec fix(status): 修掉 Dashy 可达性检查"全黄"——改用免认证探测路径
用户反馈可达性全黄。实测:appConfig.statusCheck 已开启,但等 6 秒仍无状态
节点渲染;上游直连全部 200。机制:Dashy 用 no-cors 探测条目 URL,而受保护域名
会 302 跳到另一个域(登录页),no-cors 下跨域重定向直接报错 → 检查永远停在
"进行中" → 全黄。

修法:
- gateway 为每条路由生成 location = /.navi-ping:auth_request off + 反代到
  真实上游(上游挂了返回 5xx → 红;正常 2xx → 绿),portal 类型直接 204
- 导航同步时设置 NavItem.StatusCheck = true 与 statusCheckUrl 指向该路径
- 注意:statusCheck 是 NavItem 的结构体字段,不能塞进 inline Extra(yaml 会
  panic: conflicts with struct field)——已改为设置字段本身

测试:新增断言——探测路径存在、免认证、反代到真实上游、portal 返回 204。
2026-09-12 08:23:33 +08:00
064f986a7f fix(theme): 深色遮罩交给服务端,避免双重压暗
实测发现深色下 body 的 background-image 仍是亮色的 70% 白遮罩(壁纸被洗白)。
遮罩由服务端 applyWallpaperBlock 写进 conf.yml,因此在那里补一条深色规则
(同一张壁纸 + rgba(6,12,24,dim+0.15) 遮罩),并移除 navi.css 里那层
body::before 遮罩,避免壁纸被双重压暗。加单测断言深浅两条规则共存、
且移除壁纸时一并清除。
2026-09-12 07:49:34 +08:00
c597fa1991 fix(auth): 登录页恢复自动深浅色(我在重写模板时造成的回归)
原 portal-auth 的登录页有 prefers-color-scheme 检测 + 手动切换 +
localStorage 记忆;我把它换成内置登录服务时只写了亮色,自动深浅色因此消失。

- 默认跟随系统(prefers-color-scheme),手动切换后写入 localStorage
- 深浅两套 CSS 变量(背景/卡片/边框/文字/输入框/错误提示/阴影)
- 右上角切换按钮,补 aria-label
- 加 color-scheme + prefers-reduced-motion
- 测试:断言主题检测、切换按钮、no-store;并断言 redirect 被转义(注入防护)
2026-09-11 23:14:45 +08:00
b941d8e8e3 fix(gateway): 错误页必须跳过 auth_request,否则上游故障会变成 401
实测:上游连接被拒(502)时 nginx 内部跳转 /500.html,而服务端的
auth_request 被这个内部请求继承 → 用户收到 nginx 默认 401 页,
自定义 404/500 永远显示不出来。
错误页及其静态资源一律 auth_request off,并加单测锁住。
2026-09-11 22:33:45 +08:00
1844586feb fix(gateway): TLS 段的 http2 必须写成 listen 参数
容器的 nginx 是 1.22,独立的 `http2 on;` 会报 unknown directive
(该指令 1.25.1 才引入;原 portal-nginx 用 nginx:latest 所以没暴露)。
改为 `listen PORT ssl http2;`,1.22 与 1.25+ 都可用,并加单测锁住。

本轮由旁路端口 TLS 验证发现;apply 的校验-保留机制正确拦下了这次坏配置。
2026-09-11 22:09:57 +08:00
53beff646a feat(admin): 现有配置导入 + 添加表单补齐认证/导航开关 + 统一登录账号设置 + 设置分组
针对反馈的五个问题:

1. 后台"没有映射":新增 /admin/api/gateway/import 与界面「从现有 nginx 配置导入」,
   把老统一入口的 nginx 配置贴进来即可解析出全部「域名 → 后端」初稿
   (含 requireLogin / 免认证路径 / SSE 流式 / 备案注入 / 自签证书 / auth 与 portal 类型)。
   解析只读不落盘,确认后才保存;导入默认不接管导航,避免一导入就改掉手工导航。
2. 添加转发时没有导航选项:添加表单补齐「同时加入 Dashy 导航 / 分组 / 名称 / 描述 /
   图标 / 自动抓取」。
3. 添加转发时没有 portal 保护选项:添加表单补齐「启用 portal 统一登录保护」,
   并补上 WebSocket 透传与上传上限。
4. 设置里无法配置 portal 账号:新增统一登录账号卡片,账号存数据卷
   (portal-auth.json,只存加盐哈希),优先级 数据卷 > 环境变量,
   改完立即生效、无需重新部署;会话校验按当前用户名,改账号后旧会话失效。
5. 设置项太乱:按用途分组(站点与外观 / 导航内容 / 历史记录 / 转发与统一登录 / 高级),
   外观卡片并入站点组。

另修两个本轮引入的缺陷:
- 容器假 unhealthy:内部端口改 18080 后,基础镜像的 HEALTHCHECK 仍指向 8080;
  部署脚本生成的 Dockerfile 现在显式覆盖为 :18080/healthz。
- kind=auth 不依赖 portal-auth 容器,指向内置登录服务;避免生成空 proxy_pass。

测试:nginx 配置解析(含兜底段忽略、多域名段、类型判定、默认不接管导航)、
垃圾输入报错、账号存储(不明文、可校验、改名后旧口令失效)、auth 类型生成。
2026-09-11 22:00:09 +08:00
994d93cc98 feat(gateway): 一体化统一入口——路由与 Dashy 导航同一份数据、内置登录、原自定义错误页
目标:一个 HomeDash 容器取代 navi + portal-nginx + portal-auth。

- Route 扩展为"反代 + 导航"一条记录:kind(proxy/portal/auth)、streaming、
  insecureTls、bypassPaths、injectBeian,以及 navEnabled/navSection/navName/
  navDescription/navUrl/navIcon/navAutoIcon
- 保存路由时**同时**写 gateway.yml 与 conf.yml 的导航项(gateway_nav.go):
  同 host 接管而不是重复、未勾选导航的会被清理、无关手工项与系统「历史访问」分组
  一律不动;两步任一步失败都回滚(避免后台显示与导航页各说各话)
- 未知域名与上游故障改用**内置自定义 404/500 页面**(portal/ 目录),
  不再是一行纯文本;HTTP→HTTPS 跳转段用 RedirectPort 复刻原 3081 设计
- 内置统一登录(auth.go):HMAC 签名 cookie、恒定时间比较、登录跳转白名单校验,
  portal-auth 容器可以退场
- 自动抓图标对托管导航项同样生效(history-server 识别 naviGatewayAutoIcon)
- 后台卡片补齐"加入导航/分组/名称/描述/图标/自动抓图标/服务类型"字段
- 内部入口端口 8080 → 18080:为一体化 host 模式让开宿主机 blog(:8080)
- 修掉上一轮编辑写坏的 deploy/zd.sh 一行(会让脚本直接失败)

测试:新增自定义错误页、portal 类型、导航字段校验、导航同步(接管/清理/保留)、
分组不存在必须报错等用例;admin-server 与 history-server 全绿。
2026-09-11 21:27:40 +08:00
5f6fca5c59 fix(gateway): 未知域名必须被兜底拒绝,不能落进某条路由的后端
实测发现:入口端口上若没有匹配 server_name 的请求,nginx 会交给该端口排序
第一的 server 块,于是一个打错的域名会访问到别的服务(若第一条恰好是开放
路由,等于把该服务暴露在错误域名下)。现在为 HTTP/HTTPS 各生成一个
default_server 返回 404,并在单测中断言其存在。
2026-09-11 20:34:20 +08:00
19d082e70a feat(gateway): 统一入口路由表(后端基础层)——"哪些服务走统一认证"变成一条数据
目标:不再手工维护外部 nginx 配置,把"域名→内网后端、是否走统一登录认证"
变成后台可编辑的数据,由本进程生成 nginx 配置并热加载。

本提交只做基础层(纯 Go、可测、零行为变更):

- gateway.go:Route/H gatewayConfig 数据模型 + ValidateRoute + RenderGatewayConf
  · 认证**按路由**决定:RequireLogin 为真才生成 auth_request 与登录跳转,
    也就是"哪些服务受统一认证保护"完全由路由表决定
  · 支持 websocket 透传、单域名上传上限、HTTPS 段(可同时生成旁路端口)
  · 路由表单独存 gateway.yml(不混进 Dashy 的 conf.yml),写坏了也不影响导航页
- API:GET/POST /admin/api/gateway/routes(保存)、POST /admin/api/gateway/apply
  · apply 的顺序是 生成 → nginx -t 校验 → 通过才 reload;**校验失败把上一份可用
    配置写回去并把 nginx 原始报错回给界面**,避免"点了应用整个入口挂掉"
- nginx.conf 加 include /app/data/gateway/*.conf(生成物在容器自己的卷里,
  不碰宿主机文件,天然避开"单文件 bind mount 不能用 sed -i"的坑)
- 生成物默认监听旁路端口(8081/8443):可以先与正式入口并存验证,再切 80/443

测试:受保护域名必须有 auth_request、未受保护域名必须没有;websocket/上传上限;
空域名/带协议域名/非 http 后端/域名重复/需认证却无认证地址/启用 HTTPS 却无证书
六种非法输入必须报错;缺文件时回落默认配置、保存后回读一致。
2026-09-11 20:17:57 +08:00
0d9619e446 feat(admin): 导航分组可增 / 可改名 / 可删;初始状态无分组
后台此前只能往已有分组里加服务,分组本身是写死在 conf.yml 里的,
用户无法增删。现在新增「导航分组」卡片:

- POST   /admin/api/section         新增(重名 409、空名 400)
- POST   /admin/api/section/update  改名/换图标
- DELETE /admin/api/section         删除;非空分组必须 force=1(二次确认),
                                    避免误点一下连带删掉一组服务

「历史访问」分组由 history-server 依据浏览记录重建,改名/删除/手工新建
都会被它覆写回来,所以一律 409 并说明原因 —— 否则用户以为删掉了,
下次同步又"长"回来。

初始状态无分组:conf.example.yml 的 sections 改为空数组,后台在两个位置
给空态提示(「还没有任何分组」/「先创建分组」,并把"添加服务"置灰)。

补上原本缺失的一环:rebuildDashySections() 原先只更新已存在的
「历史访问」分组、从不创建它,新装用户永远看不到历史分组;现在缺了就补建
(完全空配置时不补,保持"初始无分组")。

测试覆盖:新增/重名/改历史分组/删历史分组/删非空(无 force 拒绝)/force 删除,
以及删除后的分组名列表断言。
2026-09-11 19:52:19 +08:00
22c405d2a0 feat(admin): 新增可内嵌性三态探测 /admin/api/frameable
Dashy 的「工作空间」用 iframe 打开应用,被 X-Frame-Options /
CSP frame-ancestors 拒绝时浏览器只给一片空白,而且 JS 无法区分
「被拒绝」与「加载中」(onload 一样触发)。所以由服务端读响应头。

实测 24 个应用:19 个可内嵌;Gitea(含内网 IP) 与 docker.com 是
SAMEORIGIN,Dify 是 DENY。

三态而非布尔,因为探测本身会失真:
- conclusive=true  frameable=false → 有明确拒绝证据(XFO / frame-ancestors)
- conclusive=true  frameable=true  → 2xx 且无拒绝头
- conclusive=false                  → 探测被 WAF 拦成 403、或跳到了别的主机
  这类不给结论,前端照常尝试内嵌,但在工具条里说明原因并留出新标签页退路。

两个关键细节:
1) 转发浏览器自己的 Cookie。门户后面的应用对未登录请求会 302 到登录页
   (登录页没有 X-Frame-Options),不转发会话就会把「重定向到登录页」
   误判成「可内嵌」——这是线上真实发生过的误判。
2) 用普通浏览器 UA。自造的 "HomeDash embed check" UA 会被 WAF 当机器人拦成 403。

测试覆盖:无头/有 XFO/有 CSP frame-ancestors/WAF 403/带与不带 Cookie。
2026-09-11 19:04:38 +08:00
c04d00d4de feat(admin): 历史记录可在后台逐条删除/清空
此前后台**没有任何历史管理入口**,只有一条需要 admin token 的
DELETE /api/history/(清空全部),所以用户根本无法删除记录。

- history-server: 新增 DELETE /item?url=<url> 删除单条并重建分组;
  CORS 允许 Authorization 头
- admin-server: 新增 /admin/api/history(GET 列表 / DELETE 清空)与
  /admin/api/history/item(DELETE 单条),服务端带 token 转发给
  history-server —— 令牌只在容器内部使用,浏览器侧无需保存
- admin UI: 「历史访问」卡片,列出记录 + 逐条 ✕ + 清空全部(均二次确认)
  标题为空或本身是 URL 时显示简洁域名
2026-09-11 17:03:33 +08:00
e4967b99ee feat(admin): 备案号可编辑,页脚模板由服务端生成
备案号原来只能写死在 pageInfo.footer 的 HTML 里。现在 /admin/ 站点信息
拆出「ICP 备案号」「公安备案号」两个输入框,页脚 HTML 由服务端
beianFooter() 统一按模板生成(含 html.EscapeString 转义),
自定义页脚 HTML 降级为「高级」折叠项;旧 footer 会被解析回填到字段。

conf.example.yml 里重复的 .navi-search-bar 规则已由 web/navi.css 接管,删除。
2026-09-11 16:25:26 +08:00
de795de0fb feat(install): show the sync token on the install page
插件必须填 NAVI_SYNC_TOKEN,但页面上没有任何地方能看到它 —— 用户只能去翻
.env,实际就是配不起来。

- admin-server 新增 GET /admin/api/sync-info,返回 syncToken / hasToken。
  放在 /admin/ 下而不是 /api/ 下是必须的:插件上报用的 /api/history/sync
  被门户 auth_request 放行,若令牌挂在同一前缀下就等于公开。
  实测 /api/history/sync-info 返回 401,只有 /admin/api/sync-info 能取到。
- 安装引导页把「第一步」改为填写两项(服务地址 + 同步令牌),各自可复制;
  服务端未设令牌时提示留空即可;未登录时提示需先登录门户。
- README 补充对应说明。
2026-09-11 11:49:31 +08:00
e409f967a9 feat(admin): custom wallpaper for the nav background
- admin-server 新增 GET/POST /api/wallpaper:
  multipart 上传(png/jpg/webp/gif/svg,上限 8MB)或 JSON 直接给 URL,
  服务端把一段带标记的 CSS 写入 appConfig.customCss(标记块可反复替换,
  不碰主题其余部分),并记录 appConfig.wallpaper / wallpaperDim。
- 上传的图片落在 data 卷 wallpapers/,nginx 以 /wallpapers/ 提供
  (长缓存,文件名带时间戳)。
- 遮罩:壁纸上面铺一层可调强度的白色 linear-gradient,默认 0.45,
  否则玻璃卡片上的文字在照片上会读不清。
- 安全:URL 校验只允许 / 或 http(s):// 开头且不含引号/括号/换行,
  避免注入到 CSS;类型判定走正文特征而非文件名。
- admin UI 增加「外观」区块:上传/填 URL/遮罩滑块/应用/移除 + 预览。

实测:上传 → /wallpapers/wall-*.png,web 200 image/png,
body background-image 为遮罩+图片,移除后恢复默认渐变。
2026-09-11 11:46:53 +08:00
f8c4771828 fix(admin,theme): make admin actually work; glass theme targets real DOM
后台之前有两处让它完全不可用的问题(都属于「看着有、实际不工作」):
1. 写操作 401 —— admin UI 从未带 Authorization 头,而 admin-server 强制校验
   NAVI_ADMIN_TOKEN。于是添加/删除一直静默失败。
2. JSON 键名不匹配 —— Section/NavItem 只有 yaml tag,没有 json tag,
   接口返回 {"Name":..,"Items":[{"Title":..}]},而前端读 s.name / it.title,
   渲染直接抛异常(菜单区永远停在「加载中…」)。

修法:
- admin-server:token 改为**默认不校验**(/admin/ 已在外层门户登录墙内,
  用户确认无需叠加),仅当显式设 NAVI_ADMIN_REQUIRE_TOKEN=1 时才校验;
  历史 DELETE 不受影响(那条路径被门户放行,仍必须自带 token)。
- 补 json tag;Config/Section/NavItem 加 yaml ",inline" 保留未知键,
  避免保存 conf.yml 时把不认识的配置(layout/pages/item 的 target 等)丢掉。
- 新增 /api/pageinfo(标题/副标题/页脚/顶部导航可在后台编辑)与
  /api/icon(图标上传,限 png/svg/jpg,512KB)。
- 重写 admin UI:站点信息 + 顶部导航编辑、图标上传(.ico 用 canvas 转 PNG,
  因为 Dashy 的 isImage() 只认 svg/png/jpg)、可选令牌。

主题:旧 customCss 大部分是死代码 —— .tile / .item-group / .item-group-outer
在页面里根本不存在(querySelectorAll 计数为 0),卡片灰底其实来自 a.item,
而 .home 的不透明背景盖住了 body 渐变,所以玻璃效果一直看不出来。
现按真实类名(a.item / .item-wrapper / .collapsable / .collapsible-content /
.home / header)重写,并加入工作空间侧边栏的溢出修复。

安装页:.btn 自带 display 会盖掉浏览器默认的 [hidden]{display:none},
未配置商店时「一键安装」按钮仍然可见且 href 为空(点了没反应)。
现显式兜住 [hidden],并把浏览器识别补全(iOS/Android 单独提示为不可安装、
Edge/Brave/Vivaldi/Opera 给出各自的 extensions 地址、优先用 userAgentData)。
2026-09-11 11:43:44 +08:00
f0b318be45 feat: complete browser extensions + token auth for history/admin APIs
- Extensions: rewrite both Chrome MV3 & Firefox MV2 with reliable
  chrome.history.search incremental pull (not onVisited), local search-engine
  filtering, URL dedup, 30s alarm, retry queue, Bearer token header
- Add icons (16/48/96/128) for both browsers
- Manifest fixes: chrome action popup, firefox options_ui + browser shim
- history-server: require NAVI_SYNC_TOKEN for POST /sync & GET history,
  NAVI_ADMIN_TOKEN for DELETE; fix RWMutex deadlock (unlock before
  sortDashyFavorites) that stalled syncs with valid token
- admin-server: require NAVI_ADMIN_TOKEN for item add/delete writes
2026-09-08 22:35:54 +08:00
7e49d2b51c feat: HomeDash - unified self-hosted navigation portal
Single-container navigation service combining:
- Dashy dashboard + SearXNG metasearch + history sync API + admin WebUI
- Go services for history/admin APIs, nginx as unified entry
- Browser extensions (Chrome MV3 / Firefox MV2) with 30s batched sync
- Production guide: portal-nginx unified auth reverse proxy + webui4frpc/frp tunnel
- Fully sanitized (no internal IPs/domains/credentials)
2026-09-08 22:25:00 +08:00