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

+10 -1
View File
@@ -10,12 +10,21 @@ ENV=development
LOG_LEVEL=debug LOG_LEVEL=debug
TZ=Asia/Shanghai TZ=Asia/Shanghai
# ---- 数据目录(未设置时按 OS 惯例解析) ---- # ---- 数据目录(裁决 12.4.2/15.4.1,design/13 §2) ----
# 两种取法都实现:默认 ./data;显式写 platform(保留字哨兵)则走平台标准数据目录
# (Windows %APPDATA%\Cyrene、macOS ~/Library/Application Support/Cyrene、Linux ~/.local/share/cyrene)。
DATA_DIR=./data DATA_DIR=./data
# ---- 服务器(网关,唯一对外端口) ---- # ---- 服务器(网关,唯一对外端口) ----
# HOST 默认 0.0.0.0,暴露面由 .env 决定(本机自托管要收紧就改 127.0.0.1)。
HOST=0.0.0.0 HOST=0.0.0.0
PORT=8080 PORT=8080
# 管理面板的安全入口路径(裁决 1.6/16.4.7,design/07 §0.2):
# 管理面板前端挂在这个路径下,默认 admin(即 /admin);想藏起来就自定义,例如
# ADMIN_PATH=my-secret/admin → 面板在 /my-secret/admin,猜 /admin 直接 404。
# 它只影响面板前端路由;REST 仍是 /api/v1/admin/*(API 不藏,安全靠 JWT + 角色门),
# 且该路径串不会出现在用户端页面里。
ADMIN_PATH=admin
# ---- 认证 / 管理员 ---- # ---- 认证 / 管理员 ----
# JWT_SECRET 必填,长度 >= 32;缺失或过短会在启动时 panic。 # JWT_SECRET 必填,长度 >= 32;缺失或过短会在启动时 panic。
+17 -9
View File
@@ -1,10 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { TopBar } from "./components/TopBar"; import { TopBar } from "./components/TopBar";
import { Sidebar, MobileNav } from "./components/Nav"; import { Sidebar, MobileNav } from "./components/Nav";
import { ModeRouter } from "./modes/ModeRouter"; import { ModeRouter } from "./modes/ModeRouter";
import { AdminApp } from "./admin/AdminApp"; import { AdminApp } from "./admin/AdminApp";
import { Login } from "./components/Login"; import { Login } from "./components/Login";
import { parseAdminHash } from "./admin/router"; import { parseAdminRoute, readAdminEntry, subscribeAdminRoute } from "./admin/router";
import { connectWs, disconnectWs } from "./lib/ws"; import { connectWs, disconnectWs } from "./lib/ws";
import { markSessionOpenedRead } from "./lib/sessionRead"; import { markSessionOpenedRead } from "./lib/sessionRead";
import { initTheme } from "./lib/theme"; import { initTheme } from "./lib/theme";
@@ -21,7 +21,15 @@ import { personaMoodSeed } from "./store/personaSelectors";
export default function App(): React.ReactNode { export default function App(): React.ReactNode {
// Apply the persisted (dark-first) theme and open the realtime channel only // Apply the persisted (dark-first) theme and open the realtime channel only
// when we hold a token — otherwise the gateway rejects the handshake with 401. // when we hold a token — otherwise the gateway rejects the handshake with 401.
const [route, setRoute] = useState(() => parseAdminHash(window.location.hash)); //
// 管理面板是**安全入口**(裁决 1.6 / 16.4.7,design/07 §0.2):入口前缀由后端
// 按 .env 的 ADMIN_PATH 注入到面板那一份 index.html
// (`window.__CYRENE_ADMIN_ENTRY__`,见 internal/services/gateway/routes.go)。
// 用户端页面没有这个全局量,因此这里的 route.admin 恒为 false —— 用户端
// 既不含入口链接、也解析不出面板路由。面板模块切换走 history(不再读 hash,
// hash 只留给渠道页的子页)。
const entry = useMemo(readAdminEntry, []);
const [route, setRoute] = useState(() => parseAdminRoute(window.location.pathname, entry));
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const setPersonas = useAppStore((s) => s.setPersonas); const setPersonas = useAppStore((s) => s.setPersonas);
const seedMoods = useAppStore((s) => s.seedMoods); const seedMoods = useAppStore((s) => s.seedMoods);
@@ -29,13 +37,13 @@ export default function App(): React.ReactNode {
useEffect(() => { useEffect(() => {
initTheme(); initTheme();
if (getAuthToken()) connectWs(); if (getAuthToken()) connectWs();
const onHash = () => setRoute(parseAdminHash(window.location.hash)); const onRoute = () => setRoute(parseAdminRoute(window.location.pathname, entry));
window.addEventListener("hashchange", onHash); const unsubscribe = subscribeAdminRoute(onRoute);
return () => { return () => {
disconnectWs(); disconnectWs();
window.removeEventListener("hashchange", onHash); unsubscribe();
}; };
}, []); }, [entry]);
// Load the role catalog (design/22 §4.6). The rendered role is no longer one // Load the role catalog (design/22 §4.6). The rendered role is no longer one
// global value: it is derived per active session from this catalog plus that // global value: it is derived per active session from this catalog plus that
@@ -136,7 +144,7 @@ export default function App(): React.ReactNode {
}, [activeSessionId, activeSessionKnown, setMessages]); }, [activeSessionId, activeSessionKnown, setMessages]);
// The M9 modal UI and the admin subsystem (design/10) are two separate shells. // The M9 modal UI and the admin subsystem (design/10) are two separate shells.
// `/admin` is reached via `#/admin`; every admin module is a hash segment. // 面板由安全入口路径进入(见上方 route 注释),每个模块是前缀下的一段路径。
if (route.admin) { if (route.admin) {
return <AdminApp section={route.section} />; return <AdminApp section={route.section} />;
} }
@@ -144,7 +152,7 @@ export default function App(): React.ReactNode {
// No credential: never render the chat shell (it would silently show an // No credential: never render the chat shell (it would silently show an
// "未登录" shell with no way in). Show the user-side login card instead; its // "未登录" shell with no way in). Show the user-side login card instead; its
// onSuccess reloads the page so connectWs + session/persona effects re-run // onSuccess reloads the page so connectWs + session/persona effects re-run
// with the fresh token. Admin shell keeps its own LoginGate (#/admin). // with the fresh token. Admin shell keeps its own LoginGate.
if (!getAuthToken()) { if (!getAuthToken()) {
return <Login onSuccess={() => window.location.reload()} />; return <Login onSuccess={() => window.location.reload()} />;
} }
+76 -19
View File
@@ -1,9 +1,14 @@
/** /**
* Hash-based admin routing. The M9 SPA uses a state store (useModeStore) instead * 管理面板的**安全入口**路由(路径式,裁决 1.6 / 16.4.7,design/07 §0.2)。
* of react-router, so the admin subsystem stays consistent: it is entered via *
* `#/admin` and each module is a hash segment (`#/admin/personas`, ...). Keeps * 面板挂在 `.env` 的 `ADMIN_PATH` 前缀下(默认 `/admin`,可自定义成
* the build dependency-free and works behind the single-binary gateway serving * `/xxx/admin`),深链是 `/<前缀>/<section>`。这个前缀**绝不硬编进用户端
* `base: "./"`. * bundle**:后端只在把**面板入口页**送达时注入 `window.__CYRENE_ADMIN_ENTRY__`
* (见 internal/services/gateway/routes.go 的 adminEntryHatch)。用户端页面
* 没有这个全局量,因此用户端既没有管理入口链接、也解析不出面板路由。
*
* 面板内部的模块切换走 history(pushState + popstate);hash 只留给渠道页
* 自己的子页(admin/modules/channels/useAdapterNav.ts)。
*/ */
export type AdminSectionId = export type AdminSectionId =
@@ -30,6 +35,17 @@ export interface AdminRoute {
section: AdminSectionId; section: AdminSectionId;
} }
/** 后端注入的面板入口信息:只有面板入口那一份 index.html 带它。 */
export interface AdminEntry {
prefix: string;
}
declare global {
interface Window {
__CYRENE_ADMIN_ENTRY__?: AdminEntry;
}
}
const VALID_SECTIONS: ReadonlySet<string> = new Set([ const VALID_SECTIONS: ReadonlySet<string> = new Set([
"dashboard", "dashboard",
"personas", "personas",
@@ -50,26 +66,67 @@ const VALID_SECTIONS: ReadonlySet<string> = new Set([
"users", "users",
]); ]);
/** Parse a `window.location.hash` into an admin route descriptor. */ /** 面板入口的路径前缀(去掉尾斜杠);无入口信息时返回空串。 */
export function parseAdminHash(hash: string): AdminRoute { function entryBase(entry: AdminEntry | null): string {
const raw = hash.replace(/^#/, "").replace(/^\/+/, "").split("/"); return (entry?.prefix ?? "").replace(/\/+$/, "");
if (raw[0] !== "admin") return { admin: false, section: "dashboard" }; }
const section = raw[1];
/** 读取后端注入的入口信息;用户端(无注入)返回 null。 */
export function readAdminEntry(): AdminEntry | null {
if (typeof window === "undefined") return null;
const entry = window.__CYRENE_ADMIN_ENTRY__;
return entry && typeof entry.prefix === "string" ? entry : null;
}
/**
* 把 pathname 解析成面板路由:前缀不匹配(或压根没有入口信息,即用户端)
* 一律 `admin: false`,因此用户端永远不会被路径名带进面板;未知的 section
* 段回落到 dashboard。
*/
export function parseAdminRoute(pathname: string, entry: AdminEntry | null): AdminRoute {
const base = entryBase(entry);
if (!base) return { admin: false, section: "dashboard" };
if (pathname !== base && !pathname.startsWith(`${base}/`)) {
return { admin: false, section: "dashboard" };
}
const rest = pathname.slice(base.length).replace(/^\/+/, "").split("/")[0] ?? "";
return { return {
admin: true, admin: true,
section: VALID_SECTIONS.has(section) ? (section as AdminSectionId) : "dashboard", section: VALID_SECTIONS.has(rest) ? (rest as AdminSectionId) : "dashboard",
}; };
} }
/** The hash that selects the admin root (dashboard). */ /** 某个 section 的绝对路径(面板根 = 前缀本身)。 */
export const ADMIN_ROOT_HASH = "#/admin"; export function adminSectionPath(section: AdminSectionId, entry: AdminEntry | null): string {
const base = entryBase(entry);
/** Build a hash for a given admin section. */ if (!base) return "/";
export function adminSectionHash(section: AdminSectionId): string { return section === "dashboard" ? base : `${base}/${section}`;
return section === "dashboard" ? ADMIN_ROOT_HASH : `#/admin/${section}`;
} }
/** Navigate to an admin section by setting the location hash. */ const listeners = new Set<() => void>();
/** 订阅面板路由变化(浏览器前进/后退 + 本模块的 pushState)。 */
export function subscribeAdminRoute(onChange: () => void): () => void {
listeners.add(onChange);
window.addEventListener("popstate", onChange);
return () => {
listeners.delete(onChange);
window.removeEventListener("popstate", onChange);
};
}
/** 用 history 跳转并通知订阅者(不刷新页面、不靠 hash)。 */
function navigateTo(path: string): void {
if (window.location.pathname !== path) window.history.pushState({}, "", path);
for (const fn of listeners) fn();
}
/** 跳转到面板某模块(面板侧栏/仪表盘下钻用)。 */
export function navigateAdmin(section: AdminSectionId): void { export function navigateAdmin(section: AdminSectionId): void {
window.location.hash = adminSectionHash(section); navigateTo(adminSectionPath(section, readAdminEntry()));
}
/** 离开面板回到用户端根路径(面板外壳的「返回用户端」用它)。 */
export function navigateUserShell(): void {
navigateTo("/");
} }
+62 -3
View File
@@ -12,13 +12,27 @@ import (
"reflect" "reflect"
"strings" "strings"
"time" "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. // Config is the root configuration tree.
type Config struct { type Config struct {
Env string `env:"ENV" default:"development"` Env string `env:"ENV" default:"development"`
LogLevel string `env:"LOG_LEVEL" default:"debug"` LogLevel string `env:"LOG_LEVEL" default:"debug"`
Timezone string `env:"TZ" default:"Asia/Shanghai"` 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"` DataDir string `env:"DATA_DIR" default:"./data"`
AllowedOrigins []string `env:"ALLOWED_ORIGINS" default:"http://localhost:5173,http://localhost:3000"` AllowedOrigins []string `env:"ALLOWED_ORIGINS" default:"http://localhost:5173,http://localhost:3000"`
@@ -43,6 +57,35 @@ type Config struct {
type ServerConfig struct { type ServerConfig struct {
Host string `env:"HOST" default:"0.0.0.0"` Host string `env:"HOST" default:"0.0.0.0"`
Port int `env:"PORT" default:"8080"` 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. // 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 { if err := decodeValue(reflect.ValueOf(cfg), fromEnv); err != nil {
return nil, err return nil, err
} }
cfg.DataDir = resolveConfiguredDataDir(cfg.DataDir)
if errs := validateStruct(reflect.ValueOf(cfg).Elem()); len(errs) > 0 { if errs := validateStruct(reflect.ValueOf(cfg).Elem()); len(errs) > 0 {
parts := make([]string, 0, len(errs)) parts := make([]string, 0, len(errs))
@@ -424,3 +468,18 @@ func MustLoad(envPath string) *Config {
} }
return cfg 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 package gateway
import ( import (
"bytes"
"encoding/json"
"io/fs"
"net/http" "net/http"
"path"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
) )
// 本文件拥有 HTTP 表面:路由表(REST /api/v1、实时 // 本文件拥有 HTTP 表面:路由表(REST /api/v1、实时
@@ -252,15 +258,115 @@ func (s *Server) apiKeyTable() auth.KeyTable {
// withMiddleware 把已路由的 handler 包进外层,最外层在前: // withMiddleware 把已路由的 handler 包进外层,最外层在前:
// 上传能力 URL 防护、SPA 回退(当配置了前端时)、 // 上传能力 URL 防护、SPA 回退(当配置了前端时)、
// panic recover、CORS 与请求日志。 // 安全入口(管理面板前缀)、panic recover、CORS 与请求日志。
func (s *Server) withMiddleware(mux http.Handler) http.Handler { func (s *Server) withMiddleware(mux http.Handler) http.Handler {
var h http.Handler = mux var h http.Handler = mux
h = guardUploadPaths(h) h = guardUploadPaths(h)
if s.frontend != nil { if s.frontend != nil {
h = s.spa(h) 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.recover(h)
h = s.cors(h) h = s.cors(h)
h = s.logging(h) h = s.logging(h)
return 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
}