feat(config,gateway,web): 路径式安全入口 ADMIN_PATH + 数据目录 platform 哨兵(裁决 1.6/16.4.7/12.4.2 + 1.5/12.4.3 核对)
- 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:
1 parent
9253fef92a
commit
a398dc9b92
7 files changed
+481
-33
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user