feat(config,gateway,web): 路径式安全入口 ADMIN_PATH + 数据目录 platform 哨兵(裁决 1.6/16.4.7/12.4.2 + 1.5/12.4.3 核对)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 1.6 + 16.4.7:安全入口=路径式 `/<ADMIN_PATH>`(env `ADMIN_PATH`,默认 `admin`;`xxx/admin` 亦可,
  非法值回退 `/admin`)。命中前缀 → 送注入了 `<base href="/">` + `window.__CYRENE_ADMIN_ENTRY__` 的面板入口页
  (深链直达);**形如面板入口但前缀不符 → 404**(不回落用户端聊天壳);`/api/*`、`/ws`、webhook 先放行
  ⇒ **API 不藏**。前端路由改**路径式**(`router.ts` 只读注入,无硬编兜底;移除 hash 入口)
- 1.5 / 12.4.3 核对:暴露面默认 `0.0.0.0` 与裁决一致,无需改代码(仅补注释)
- 12.4.2:`DATA_DIR` 支持 `platform` 哨兵(大小写不敏感)→ 平台标准目录;默认 `./data` 与显式空值行为不变
  (显式空值保持原兜底,避免正在运行实例突然换库)
- 复核:build/vet 绿 + config/gateway 包测试绿 + linux/darwin 交叉编译绿;新增 7 条用例,
  并用真实构建产物(`webui.Assets()`)跑通「浏览器直开 /admin 能启动面板」(`<base>` 必须在资源引用之前)
- 变异 2 条(关掉错前缀 404 / 前端默认前缀兜底)已还原
- ⚠️ 运行时序:前端已生效而后端 hatch 未生效期间**面板打不开** ⇒ 需紧接一次「build 到位 + restart」
This commit is contained in:
AskaEth committed 2026-10-04 14:00:25 +08:00
1 parent 9253fef92a
commit a398dc9b92
7 files changed
+481 -33

No files matched your search

+62 -3
View File
@@ -12,13 +12,27 @@ import (
"reflect"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
)
// DataDirPlatform 是 DATA_DIR 的保留字哨兵:显式配置它就走平台标准数据目录
// (design/13 §2,裁决 12.4.2 + 15.4.1);未配置时仍是标签里的默认 `./data`。
const DataDirPlatform = "platform"
// DefaultAdminPath 是 ADMIN_PATH 的默认值:面板挂在 /admin
// (design/07 §0.2,裁决 1.6 / 16.4.7)。
const DefaultAdminPath = "admin"
// Config is the root configuration tree.
type Config struct {
Env string `env:"ENV" default:"development"`
LogLevel string `env:"LOG_LEVEL" default:"debug"`
Timezone string `env:"TZ" default:"Asia/Shanghai"`
Env string `env:"ENV" default:"development"`
LogLevel string `env:"LOG_LEVEL" default:"debug"`
Timezone string `env:"TZ" default:"Asia/Shanghai"`
// DataDir 是数据目录。两种取法都实现(design/13 §2,裁决 12.4.2/15.4.1):
// 未配置 → `./data`(标签默认,也是历史默认);显式配置保留字哨兵
// DataDirPlatform(`DATA_DIR=platform`)→ 平台标准数据目录,见
// resolveConfiguredDataDir。
DataDir string `env:"DATA_DIR" default:"./data"`
AllowedOrigins []string `env:"ALLOWED_ORIGINS" default:"http://localhost:5173,http://localhost:3000"`
@@ -43,6 +57,35 @@ type Config struct {
type ServerConfig struct {
Host string `env:"HOST" default:"0.0.0.0"`
Port int `env:"PORT" default:"8080"`
// AdminPath 是管理面板前端路由的**安全入口**路径前缀(裁决 1.6 / 16.4.7,
// design/07 §0.2):形如 `admin`(默认,面板挂在 `/admin`)或用户自定义的
// `xxx/admin`(面板挂在 `/xxx/admin`)。网关按它挂载面板入口,猜错的前缀
// 直接 404;该值只注入到面板入口页,用户端 bundle 不含它。
// REST 路径**不随它变化**——`/api/v1/admin/*` 照旧,安全靠 JWT + 角色门
// (design/07 §0.1「API 不藏」)。
AdminPath string `env:"ADMIN_PATH" default:"admin"`
}
// AdminPathPrefix 返回管理面板前端路由的绝对路径前缀(默认 `/admin`)。
// 容忍配置里的首尾斜杠与空白;值非法(空、含 `.`/`..` 段,或含 `?`/`#`
// 这类会截断路径的字符)时回退到默认前缀,宁可挂回默认也不挂到不可预测的
// 路径上。
func (s ServerConfig) AdminPathPrefix() string {
raw := strings.Trim(strings.TrimSpace(s.AdminPath), "/")
if raw == "" {
return "/" + DefaultAdminPath
}
segs := strings.Split(raw, "/")
clean := make([]string, 0, len(segs))
for _, seg := range segs {
seg = strings.TrimSpace(seg)
if seg == "" || seg == "." || seg == ".." || strings.ContainsAny(seg, "?#") {
return "/" + DefaultAdminPath
}
clean = append(clean, seg)
}
return "/" + strings.Join(clean, "/")
}
// AuthConfig holds authentication and admin credential settings.
@@ -403,6 +446,7 @@ func Load(envPath string) (*Config, error) {
if err := decodeValue(reflect.ValueOf(cfg), fromEnv); err != nil {
return nil, err
}
cfg.DataDir = resolveConfiguredDataDir(cfg.DataDir)
if errs := validateStruct(reflect.ValueOf(cfg).Elem()); len(errs) > 0 {
parts := make([]string, 0, len(errs))
@@ -424,3 +468,18 @@ func MustLoad(envPath string) *Config {
}
return cfg
}
// resolveConfiguredDataDir 在读取阶段把 DATA_DIR 的哨兵值换成平台标准数据
// 目录(design/13 §2,裁决 12.4.2 + 15.4.1),使「显式 `./data`」与「平台
// 标准路径」两条路都真实可达:
// - 未配置 → 标签默认 `./data`(调用方拿到的就是它,行为与历史一致);
// - `DATA_DIR=platform`(大小写不敏感)→ platform.DefaultDataDir();
// - 其余值 → 原样保留。
//
// 空串保持原样:它由组合根的兜底分支处理,语义不变。
func resolveConfiguredDataDir(raw string) string {
if strings.EqualFold(strings.TrimSpace(raw), DataDirPlatform) {
return platform.DefaultDataDir()
}
return raw
}
@@ -0,0 +1,72 @@
package config
import (
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
)
// TestLoad_DataDirPlatformSentinel 覆盖数据目录的两种取法(design/13 §2,
// 裁决 12.4.2 + 15.4.1):未配置走默认 `./data`;显式哨兵 `platform` 才走
// 平台标准数据目录;显式路径原样保留。
func TestLoad_DataDirPlatformSentinel(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
unsetEnvForTest(t, "DATA_DIR")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.DataDir != "./data" {
t.Errorf("DataDir = %q, want ./data (unset keeps the historic default)", cfg.DataDir)
}
t.Setenv("DATA_DIR", "platform")
cfg, err = Load("")
if err != nil {
t.Fatalf("Load() with sentinel error: %v", err)
}
if want := platform.DefaultDataDir(); cfg.DataDir != want {
t.Errorf("DataDir = %q, want the platform standard dir %q", cfg.DataDir, want)
}
// 显式路径(含显式 ./data)不被改写。
for _, p := range []string{"./data", "/var/lib/cyrene"} {
t.Setenv("DATA_DIR", p)
cfg, err = Load("")
if err != nil {
t.Fatalf("Load() with DATA_DIR=%q error: %v", p, err)
}
if cfg.DataDir != p {
t.Errorf("DataDir = %q, want %q verbatim", cfg.DataDir, p)
}
}
}
// TestAdminPathPrefix 覆盖安全入口前缀的归一化(裁决 1.6 / 16.4.7,
// design/07 §0.2):默认 `admin` → `/admin`,自定义 `xxx/admin` →
// `/xxx/admin`(即 `host/xxx/admin` 形态),非法值一律挂回默认。
func TestAdminPathPrefix(t *testing.T) {
want := map[string]string{
"": "/admin",
"admin": "/admin",
"/admin": "/admin",
"/admin/": "/admin",
"xxx/admin": "/xxx/admin",
"/xxx/admin/": "/xxx/admin",
" a / b ": "/a/b",
"../etc": "/admin",
"a/../b": "/admin",
"a//b": "/admin",
"a?b": "/admin",
"a#b": "/admin",
" ": "/admin",
}
for in, exp := range want {
cfg := ServerConfig{AdminPath: in}
if got := cfg.AdminPathPrefix(); got != exp {
t.Errorf("AdminPath %q → %q, want %q", in, got, exp)
}
}
}
@@ -0,0 +1,137 @@
package gateway
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
)
// adminEntryTestIndex 是最小的 index.html:注入点 `<head>` 与
// 站点根锚点都由 handler 追加,因此这里只保证两者都存在。
const adminEntryTestIndex = `<html><head><meta charset="utf-8"></head><body><div id="root"></div></body></html>`
// adminEntryEnv 构造一个挂了前端、并按 adminPath 配置安全入口的网关。
func adminEntryEnv(t *testing.T, adminPath string) *httptest.Server {
t.Helper()
cfg := cfgDefault()
cfg.Server.AdminPath = adminPath
front := fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte(adminEntryTestIndex)},
"assets/app.js": &fstest.MapFile{Data: []byte("var x = 1;")},
}
_, ts, _ := buildEnv(t, cfg, Deps{Frontend: front})
return ts
}
// TestAdminEntry_DefaultPrefix 守住默认前缀下的安全入口(裁决 1.6/16.4.7):
// `/admin` 与深链直达面板入口页(带注入),不再回落用户端聊天壳;用户端
// 路径与真实静态资源不受影响。
func TestAdminEntry_DefaultPrefix(t *testing.T) {
t.Parallel()
ts := adminEntryEnv(t, "")
resp, body := doReq(t, http.MethodGet, ts.URL+"/admin", nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /admin status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if !strings.Contains(string(body), `window.__CYRENE_ADMIN_ENTRY__={"prefix":"/admin"}`) {
t.Fatalf("GET /admin missing injected entry info: %s", body)
}
if !strings.Contains(string(body), `<base href="/">`) {
t.Fatalf("GET /admin missing root base href: %s", body)
}
if got := resp.Header.Get("Cache-Control"); got != "no-store" {
t.Fatalf("GET /admin Cache-Control = %q, want no-store", got)
}
resp, body = doReq(t, http.MethodGet, ts.URL+"/admin/personas", nil, "")
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"prefix":"/admin"`) {
t.Fatalf("GET /admin/personas = %d body=%s, want the injected admin entry", resp.StatusCode, body)
}
// 真实静态资源仍按文件送出(不注入)。
resp, body = doReq(t, http.MethodGet, ts.URL+"/assets/app.js", nil, "")
if resp.StatusCode != http.StatusOK || string(body) != "var x = 1;" {
t.Fatalf("GET /assets/app.js = %d body=%q, want the raw asset", resp.StatusCode, body)
}
// 用户端回落路径不带入口信息(用户端 bundle 与页面都不该出现面板前缀)。
resp, body = doReq(t, http.MethodGet, ts.URL+"/chat/somewhere", nil, "")
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "root") {
t.Fatalf("GET /chat/somewhere = %d body=%s, want the user shell", resp.StatusCode, body)
}
if strings.Contains(string(body), "__CYRENE_ADMIN_ENTRY__") {
t.Fatalf("user shell leaked the admin entry info: %s", body)
}
}
// TestAdminEntry_CustomPrefix 守住自定义前缀(`host/xxx/admin` 形态):正确
// 前缀直达,猜错前缀(含默认 /admin)一律 404,绝不回落用户端聊天壳。
func TestAdminEntry_CustomPrefix(t *testing.T) {
t.Parallel()
ts := adminEntryEnv(t, "xxx/admin")
resp, body := doReq(t, http.MethodGet, ts.URL+"/xxx/admin", nil, "")
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"prefix":"/xxx/admin"`) {
t.Fatalf("GET /xxx/admin = %d body=%s, want the injected custom prefix", resp.StatusCode, body)
}
for _, p := range []string{"/admin", "/admin/personas", "/other/admin", "/admin/"} {
resp, body = doReq(t, http.MethodGet, ts.URL+p, nil, "")
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("GET %s = %d body=%s, want 404 (wrong prefix must not fall back)", p, resp.StatusCode, body)
}
if strings.Contains(string(body), "__CYRENE_ADMIN_ENTRY__") {
t.Fatalf("GET %s leaked the admin entry info", p)
}
}
// 用户端路径依旧正常。
resp, body = doReq(t, http.MethodGet, ts.URL+"/chat", nil, "")
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "root") {
t.Fatalf("GET /chat = %d body=%s, want the user shell", resp.StatusCode, body)
}
}
// TestAdminEntry_APIRouteUntouched 守住「API 不藏」:前缀即便被配成含 api 段
// 的值,也不得截住 REST 路由(design/07 §0.1)。
func TestAdminEntry_APIRouteUntouched(t *testing.T) {
t.Parallel()
ts := adminEntryEnv(t, "api/v1/admin")
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/health", nil, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /api/v1/health = %d body=%s, want the health payload", resp.StatusCode, body)
}
if strings.Contains(string(body), "__CYRENE_ADMIN_ENTRY__") {
t.Fatalf("API route was served the admin entry page: %s", body)
}
}
// TestAdminEntry_PrefixOverAssetDir 守住前缀撞上真实资源目录的退化:把前缀
// 配成 assets 时,真实资源必须仍由文件服务器送出,不能被面板入口页顶掉。
func TestAdminEntry_PrefixOverAssetDir(t *testing.T) {
t.Parallel()
ts := adminEntryEnv(t, "assets")
resp, body := doReq(t, http.MethodGet, ts.URL+"/assets/app.js", nil, "")
if resp.StatusCode != http.StatusOK || string(body) != "var x = 1;" {
t.Fatalf("GET /assets/app.js = %d body=%q, want the real asset (not the shell)", resp.StatusCode, body)
}
}
// TestAdminEntryHTML_NoHead 守住注入的两个回退点:没有 `<head>` 时注入到
// 文档最前(仍先于任何资源引用),且 `<base>` 与入口信息一并出现。
func TestAdminEntryHTML_NoHead(t *testing.T) {
t.Parallel()
out := adminEntryHTML([]byte("<html><body>x</body></html>"), "/admin")
s := string(out)
if !strings.HasPrefix(s, `<base href="/">`) {
t.Fatalf("injection without <head> = %q, want it prepended", s)
}
if !strings.Contains(s, `"prefix":"/admin"`) {
t.Fatalf("injection without <head> lost the entry info: %s", s)
}
}
+107 -1
View File
@@ -1,9 +1,15 @@
package gateway
import (
"bytes"
"encoding/json"
"io/fs"
"net/http"
"path"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// 本文件拥有 HTTP 表面:路由表(REST /api/v1、实时
@@ -252,15 +258,115 @@ func (s *Server) apiKeyTable() auth.KeyTable {
// withMiddleware 把已路由的 handler 包进外层,最外层在前:
// 上传能力 URL 防护、SPA 回退(当配置了前端时)、
// panic recover、CORS 与请求日志。
// 安全入口(管理面板前缀)、panic recover、CORS 与请求日志。
func (s *Server) withMiddleware(mux http.Handler) http.Handler {
var h http.Handler = mux
h = guardUploadPaths(h)
if s.frontend != nil {
h = s.spa(h)
// 安全入口(裁决 1.6 / 16.4.7,design/07 §0.2):管理面板前端按 .env 的
// ADMIN_PATH 挂载(默认 /admin),猜错的前缀 404 而不回落用户端聊天壳。
h = s.adminEntryHatch(h, s.cfg.Server.AdminPathPrefix())
}
h = s.recover(h)
h = s.cors(h)
h = s.logging(h)
return h
}
// --- 安全入口(裁决 1.6 / 16.4.7,design/07 §0.2) ---------------------------
// adminEntryGlobal 是注入面板入口页的全局变量名:SPA 据此判定「本页由安全
// 入口送达」并取得面板前缀。它是前缀抵达前端的唯一通道,因此该串不硬编进
// 用户端 bundle(见 frontend/web/src/admin/router.ts)。
const adminEntryGlobal = "__CYRENE_ADMIN_ENTRY__"
// adminEntryHatch 把管理面板挂到配置的 ADMIN_PATH 前缀下:
// - 前缀自身与其下的深链(/<前缀>/<section>)送出注入了入口信息的
// index.html,使真实路径直达面板而不回落用户端聊天壳;
// - 形如面板入口但前缀不对的探测(例如前缀被改后仍被访问的 /admin)
// 直接 404,既不回落聊天壳也不泄露正确前缀;
// - 其余路径原样交给常规 SPA / API 处理。
func (s *Server) adminEntryHatch(next http.Handler, prefix string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
if isAPIRoute(p) || s.isWebhookRoute(p) {
next.ServeHTTP(w, r)
return
}
switch {
case p == prefix || strings.HasPrefix(p, prefix+"/"):
if s.adminEntryRealFile(p) || !s.serveAdminEntry(w, prefix) {
next.ServeHTTP(w, r)
}
case isAdminShapedPath(p):
http.NotFound(w, r)
default:
next.ServeHTTP(w, r)
}
})
}
// adminEntryRealFile 报告 path 是否命中 dist 里的真实静态文件:前缀若恰好
// 撞上资源目录/文件名(例如把 ADMIN_PATH 配成 assets),文件服务器照常送
// 文件,绝不让面板入口页顶掉 SPA 的资源。
func (s *Server) adminEntryRealFile(p string) bool {
rel := strings.TrimPrefix(path.Clean(p), "/")
if rel == "" {
return false
}
info, err := fs.Stat(s.frontend, rel)
return err == nil && !info.IsDir()
}
// serveAdminEntry 送出管理面板入口页。返回 false 表示 index.html 读不出来
// (例如外置 dist 尚未构建),交由常规 SPA 处理。
func (s *Server) serveAdminEntry(w http.ResponseWriter, prefix string) bool {
raw, err := fs.ReadFile(s.frontend, "index.html")
if err != nil {
return false
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// 入口页按请求注入(前缀 / 站点根锚点),不能缓存。
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(adminEntryHTML(raw, prefix))
return true
}
// adminEntryHTML 在 `<head>` 之后注入入口信息与 `<base href="/">`:
// - 入口信息是内联经典脚本,必先于 Vite 的 deferred module 执行,前端启动
// 时即可读到面板前缀;
// - `<base href="/">` 把相对资源锚回站点根:面板入口可能是多段路径
// (/xxx/admin)或深链(/admin/personas),否则 Vite 产出的 `./assets/...`
// 会解析到错误目录(单二进制始终挂在站点根,design/13 §0)。
//
// 找不到 `<head>` 时注入到文档最前,同样先于任何资源引用。
func adminEntryHTML(indexHTML []byte, prefix string) []byte {
payload, err := json.Marshal(map[string]string{"prefix": prefix})
if err != nil {
// 字符串映射不会让 json.Marshal 失败;真出错时只保住 <base>。
payload = []byte(`{}`)
}
snippet := []byte(`<base href="/"><script>window.` + adminEntryGlobal + "=" + string(payload) + `;</script>`)
out := make([]byte, 0, len(indexHTML)+len(snippet))
if i := bytes.Index(indexHTML, []byte("<head>")); i >= 0 {
at := i + len("<head>")
out = append(out, indexHTML[:at]...)
out = append(out, snippet...)
return append(out, indexHTML[at:]...)
}
out = append(out, snippet...)
return append(out, indexHTML...)
}
// isAdminShapedPath 报告 path 是否形如一次管理面板入口尝试(路径里出现
// `admin` 段,如 /admin、/admin/x、/foo/admin):前缀被自定义后,这类猜测
// 一律 404,避免回落用户端聊天壳把探测结果变成「面板不存在」以外的信息。
func isAdminShapedPath(p string) bool {
for _, seg := range strings.Split(p, "/") {
if seg == config.DefaultAdminPath {
return true
}
}
return false
}