Files
HomeDash/admin-server/main.go
JianFeeeee 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

1442 lines
46 KiB
Go
Raw Permalink 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.

package main
import (
"bytes"
"encoding/json"
"fmt"
"html"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"gopkg.in/yaml.v3"
)
var (
confFile = envOr("DASHY_CONF", "/app/data/conf.yml")
adminToken = os.Getenv("NAVI_ADMIN_TOKEN")
// requireTokenForWrite:是否需要 Bearer token 校验。
//
// 历史背景:早期假定 /admin/ 与 /api/ 整体位于外层统一认证之后,因此默认不校验,
// 只把 token 当成「直接暴露容器时」的附加开关。2026-10-22 的渗透测试证明这个假设
// 在线上不成立 —— login 域(kind=auth)的 server 块没有任何 auth_request,
// 管理 API 因此对公网完全开放,可匿名改路由表、拆登录墙、改门户口令。
//
// 现在的策略是「默认拒绝」:
// - 配了 NAVI_ADMIN_TOKEN → 校验 Bearer token(推荐,线上应始终如此)
// - 未配 token 且显式设 NAVI_ADMIN_ALLOW_UNAUTHENTICATED=1 → 放行(仅限本机调试)
// - 其它情况 → 拒绝
// 注意 NAVI_ADMIN_REQUIRE_TOKEN 仍被识别,但已不再是「总开关」:
// 即使没设它,只要有 token 就会校验,避免回到裸奔状态。
requireTokenForWrite = isTruthy(os.Getenv("NAVI_ADMIN_REQUIRE_TOKEN"))
// allowUnauthenticated:在既没有 token、也没有要求 token 时是否放行。
// 默认 false(拒绝)。只有明确设置 NAVI_ADMIN_ALLOW_UNAUTHENTICATED=1 才放行。
allowUnauthenticated = isTruthy(os.Getenv("NAVI_ADMIN_ALLOW_UNAUTHENTICATED"))
// allowLoopbackFrameable:是否允许 /api/frameable 探测回环/私网地址。
// 默认 false(防 SSRF)。仅测试需要打开(httptest 服务器就在 127.0.0.1)。
allowLoopbackFrameable = isTruthy(os.Getenv("NAVI_ALLOW_LOOPBACK_FRAMEABLE"))
// 浏览器插件同步用的令牌(供安装引导页展示,方便用户拷贝)
syncToken = os.Getenv("NAVI_SYNC_TOKEN")
// history-server 地址(管理后台转发历史记录管理请求用)
historyBaseURL = envOr("NAVI_HISTORY_URL", "http://127.0.0.1:3004")
// 本地上传的图标目录(Dashy 通过 /item-icons/ 读取)
iconDir = envOr("NAVI_ICON_DIR", filepath.Join(filepath.Dir(confFile), "item-icons"))
// 壁纸目录(nginx 通过 /wallpapers/ 提供)
wallpaperDir = envOr("NAVI_WALLPAPER_DIR", filepath.Join(filepath.Dir(confFile), "wallpapers"))
mu sync.Mutex
)
// ── 壁纸 ──
//
// Dashy 没有原生壁纸配置,所以由服务端把一段带标记的 CSS 写进
// appConfig.customCss。标记块可反复替换,不碰主题的其余部分。
const (
wallpaperBegin = "/* ==NAVI-WALLPAPER-BEGIN== */"
wallpaperEnd = "/* ==NAVI-WALLPAPER-END== */"
wallpaperMaxBytes = 8 << 20
)
// applyWallpaperBlock 把壁纸规则替换/插入 customCss;url 为空则只移除。
// dim 是铺在图片上的白色遮罩强度,保证玻璃卡片上的文字仍然可读。
func applyWallpaperBlock(css, url string, dim float64) string {
for {
i := strings.Index(css, wallpaperBegin)
if i < 0 {
break
}
rest := css[i:]
j := strings.Index(rest, wallpaperEnd)
if j < 0 {
css = css[:i]
break
}
css = css[:i] + rest[j+len(wallpaperEnd):]
}
css = strings.TrimRight(css, " \t\r\n")
if url == "" {
return css + "\n"
}
if dim < 0 {
dim = 0
}
if dim > 0.9 {
dim = 0.9
}
scrim := strconv.FormatFloat(dim, 'f', 2, 64)
// 深色下必须换一层深色遮罩:否则亮色的 70% 白遮罩会把壁纸洗白
// (实测:深色模式里 background-image 仍是 rgba(255,255,255,.7) 那层)。
// 用同一个 dim 作为深色遮罩强度,深浅两种模式观感一致。
dark := strconv.FormatFloat(dim+0.15, 'f', 2, 64)
if dim+0.15 > 0.9 {
dark = "0.90"
}
return css + "\n\n" + wallpaperBegin + "\n" +
"html[data-theme] body{\n" +
" background-image:\n" +
" linear-gradient(rgba(255,255,255," + scrim + "), rgba(255,255,255," + scrim + ")),\n" +
" url(\"" + url + "\") !important;\n" +
" background-size: cover !important;\n" +
" background-position: center center !important;\n" +
" background-attachment: fixed !important;\n" +
" background-repeat: no-repeat !important;\n" +
"}\n" +
// 深色:同一张壁纸 + 深色遮罩(选择器特异度更高,与注入顺序无关)
"html.navi-dark body.navi-dark,\n" +
"html[data-theme].navi-dark body{\n" +
" background-image:\n" +
" linear-gradient(rgba(6,12,24," + dark + "), rgba(6,12,24," + dark + ")),\n" +
" url(\"" + url + "\") !important;\n" +
" background-size: cover !important;\n" +
" background-position: center center !important;\n" +
" background-attachment: fixed !important;\n" +
" background-repeat: no-repeat !important;\n" +
"}\n" + wallpaperEnd + "\n"
}
// safeCSSURL 避免把引号/括号/换行注入到 CSS 里。
func safeCSSURL(u string) bool {
if u == "" {
return true
}
if len(u) > 512 {
return false
}
if strings.ContainsAny(u, "\"'()\\\n\r\t") {
return false
}
return strings.HasPrefix(u, "/") || strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://")
}
func readWallpaper(cfg *Config) map[string]any {
url, _ := cfg.AppConfig["wallpaper"].(string)
dim := 0.45
switch v := cfg.AppConfig["wallpaperDim"].(type) {
case float64:
dim = v
case int:
dim = float64(v)
}
return map[string]any{"url": url, "dim": dim}
}
// handleWallpaper 支持:
//
// GET 查看当前壁纸
// POST multipart 上传图片(字段 file)
// POST json 直接设置 URL 或关闭({"url":"","dim":0.45})
func handleWallpaper(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
cfg, err := loadConfig()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
if cfg.AppConfig == nil {
cfg.AppConfig = map[string]any{}
}
switch r.Method {
case http.MethodGet:
jsonResp(w, readWallpaper(cfg))
return
case http.MethodPost:
default:
jsonErr(w, 405, "method not allowed")
return
}
if !requireAdminToken(w, r) {
return
}
url := ""
dim := 0.45
if v, ok := cfg.AppConfig["wallpaperDim"].(float64); ok {
dim = v
}
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
r.Body = http.MaxBytesReader(w, r.Body, wallpaperMaxBytes+1<<20)
if err := r.ParseMultipartForm(4 << 20); err != nil {
jsonErr(w, 400, "解析上传失败: "+err.Error())
return
}
file, _, err := r.FormFile("file")
if err != nil {
jsonErr(w, 400, "缺少 file 字段")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, wallpaperMaxBytes+1))
if err != nil {
jsonErr(w, 400, err.Error())
return
}
if len(data) > wallpaperMaxBytes {
jsonErr(w, 400, "壁纸过大(上限 8MB)")
return
}
ext, ok := detectWallpaperExt(data)
if !ok {
jsonErr(w, 400, "壁纸只支持 png / jpg / webp / gif / svg")
return
}
name := fmt.Sprintf("wall-%d%s", time.Now().Unix(), ext)
if err := os.MkdirAll(wallpaperDir, 0755); err != nil {
jsonErr(w, 500, err.Error())
return
}
if err := os.WriteFile(filepath.Join(wallpaperDir, name), data, 0644); err != nil {
jsonErr(w, 500, err.Error())
return
}
url = "/wallpapers/" + name
} else {
var body struct {
URL *string `json:"url"`
Dim *float64 `json:"dim"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonErr(w, 400, err.Error())
return
}
if body.URL != nil {
url = strings.TrimSpace(*body.URL)
} else {
url, _ = cfg.AppConfig["wallpaper"].(string)
}
if body.Dim != nil {
dim = *body.Dim
}
}
if !safeCSSURL(url) {
jsonErr(w, 400, "壁纸地址不合法(需以 / 或 http(s):// 开头,且不含引号括号)")
return
}
css, _ := cfg.AppConfig["customCss"].(string)
cfg.AppConfig["customCss"] = applyWallpaperBlock(css, url, dim)
cfg.AppConfig["wallpaper"] = url
cfg.AppConfig["wallpaperDim"] = dim
if err := saveConfig(cfg); err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, map[string]any{"url": url, "dim": dim})
}
// detectWallpaperExt 以正文特征判定图片类型。
func detectWallpaperExt(data []byte) (string, bool) {
switch {
case bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")):
return ".png", true
case bytes.HasPrefix(data, []byte("\xff\xd8\xff")):
return ".jpg", true
case bytes.HasPrefix(data, []byte("GIF87a")), bytes.HasPrefix(data, []byte("GIF89a")):
return ".gif", true
case len(data) >= 12 && bytes.HasPrefix(data, []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")):
return ".webp", true
}
trim := bytes.TrimSpace(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}))
low := bytes.ToLower(trim)
if len(low) > 512 {
low = low[:512]
}
if bytes.HasPrefix(low, []byte("<svg")) ||
(bytes.HasPrefix(low, []byte("<?xml")) && bytes.Contains(low, []byte("<svg"))) {
return ".svg", true
}
return "", false
}
func isTruthy(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true
}
return false
}
// requireAdminToken 对需要管理权限的接口做鉴权。
//
// 默认拒绝(fail-closed):以前默认放行,前提是「外层一定有认证」,
// 2026-10-22 渗透测试证明线上 login 域没有 auth_request,管理 API 直接对公网裸奔。
//
// 判定顺序:
// 1. 配了 adminToken → 必须带对的 Bearer token(不管开关怎么设)
// 2. 没配 token 但显式允许匿名(NAVI_ADMIN_ALLOW_UNAUTHENTICATED=1)→ 放行
// 3. 其它 → 拒绝
func requireAdminToken(w http.ResponseWriter, r *http.Request) bool {
if adminToken != "" {
return requireToken(w, r, adminToken)
}
if allowUnauthenticated {
return true
}
jsonErr(w, http.StatusUnauthorized,
"management API is locked: set NAVI_ADMIN_TOKEN (and send Authorization: Bearer <token>), "+
"or set NAVI_ADMIN_ALLOW_UNAUTHENTICATED=1 for local-only use")
return false
}
// requireToken 校验 Bearer token(fail-closed)。
//
// 历史背景:早期实现在 expected 为空时直接 return true,等于「没配 token 就没有任何
// 鉴权」。今天它打不到(唯一调用点 requireAdminToken 有 adminToken != "" 守卫),
// 但那个空分支就是审计第 1 条点名的形状:任何人新写一行
// requireToken(w, r, someOptionalToken) 就会把 fail-open 复活。
//
// 现在与 history-server 的 requireToken 完全对齐:
// - 配了 token → 必须带对的 Bearer token
// - expected 为空 → **一律拒绝**(401 + JSON),绝不 return true
// 放行「没有 token 也允许访问」的决策只允许出现在明确语义的函数里
// (requireAdminToken 的 allowUnauthenticated 分支),不允许藏在通用校验函数里。
func requireToken(w http.ResponseWriter, r *http.Request, expected string) bool {
if expected == "" {
jsonErr(w, http.StatusUnauthorized,
"API is locked: this endpoint requires a configured token "+
"(set NAVI_ADMIN_TOKEN / NAVI_SYNC_TOKEN and send Authorization: Bearer <token>)")
return false
}
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") && strings.TrimPrefix(auth, "Bearer ") == expected {
return true
}
jsonErr(w, http.StatusUnauthorized, "invalid or missing token")
return false
}
type Config struct {
AppConfig map[string]any `yaml:"appConfig" json:"appConfig"`
PageInfo map[string]any `yaml:"pageInfo" json:"pageInfo"`
Sections []Section `yaml:"sections" json:"sections"`
// Extra 用 yaml 的 inline 机制原样保留本程序不认识的顶层键(如 layout、
// iconSize、pages 等)。admin 保存 conf.yml 时不能把没碰过的配置丢掉。
Extra map[string]any `yaml:",inline" json:"-"`
}
type Section struct {
Name string `yaml:"name" json:"name"`
Icon string `yaml:"icon,omitempty" json:"icon,omitempty"`
Collapsed *bool `yaml:"collapsed,omitempty" json:"collapsed,omitempty"`
Items []NavItem `yaml:"items" json:"items"`
Extra map[string]any `yaml:",inline" json:"-"`
}
type NavItem struct {
Title string `yaml:"title" json:"title"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
Icon string `yaml:"icon,omitempty" json:"icon,omitempty"`
URL string `yaml:"url" json:"url"`
StatusCheck *bool `yaml:"statusCheck,omitempty" json:"statusCheck,omitempty"`
// Dashy 的 item 还可能有 target / hotkey / color 等字段,一并保留
Extra map[string]any `yaml:",inline" json:"-"`
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func loadConfig() (*Config, error) {
data, err := os.ReadFile(confFile)
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func saveConfig(cfg *Config) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
tmp := confFile + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, confFile)
}
func cors(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(204)
return
}
next(w, r)
}
}
func jsonResp(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
// ── 历史记录管理 ──
//
// 门户对 /api/history/ 是放行的(不走登录),所以那条路径的 DELETE 必须自带 token。
// 管理后台改走 /admin/api/history*:门户登录已是关卡,admin token 只在容器内部使用,
// 不必让用户在浏览器里保存它。
func historyProxy(method, path, token string) (*http.Response, error) {
req, err := http.NewRequest(method, historyBaseURL+path, nil)
if err != nil {
return nil, err
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
client := &http.Client{Timeout: 10 * time.Second}
return client.Do(req)
}
func relayHistory(w http.ResponseWriter, res *http.Response) {
defer res.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(res.StatusCode)
_, _ = io.Copy(w, res.Body)
}
// handleHistoryList 列出历史记录(按访问次数降序)。
func handleHistoryList(w http.ResponseWriter, r *http.Request) {
// 2026-10-22 渗透测试:该 GET 原本无鉴权,匿名可导出全部浏览历史
// (含设备名、内网服务清单)。与 DELETE 分支保持一致,都要管理员令牌。
if !requireAdminToken(w, r) {
return
}
res, err := historyProxy(http.MethodGet, "/", syncToken)
if err != nil {
jsonErr(w, 502, "history-server unreachable: "+err.Error())
return
}
relayHistory(w, res)
}
// handleHistoryClear 清空全部历史。
func handleHistoryClear(w http.ResponseWriter, r *http.Request) {
if !requireAdminToken(w, r) {
return
}
res, err := historyProxy(http.MethodDelete, "/", adminToken)
if err != nil {
jsonErr(w, 502, "history-server unreachable: "+err.Error())
return
}
relayHistory(w, res)
}
// handleHistoryDeleteItem 删除单条历史。
func handleHistoryDeleteItem(w http.ResponseWriter, r *http.Request) {
if !requireAdminToken(w, r) {
return
}
raw := r.URL.Query().Get("url")
if raw == "" {
jsonErr(w, 400, "url query parameter is required")
return
}
res, err := historyProxy(http.MethodDelete, "/item?url="+url.QueryEscape(raw), adminToken)
if err != nil {
jsonErr(w, 502, "history-server unreachable: "+err.Error())
return
}
relayHistory(w, res)
}
func jsonErr(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
// ── 可内嵌性探测 ──
//
// Dashy 的「工作空间」用 iframe 打开应用。部分应用会带 X-Frame-Options 或
// CSP frame-ancestors 拒绝被嵌,此时浏览器只给一片空白,而且 JS 无法区分
// 「被拒绝」和「加载中」(onload 一样会触发)。所以由服务端先读响应头,
// 前端据此决定:能嵌就嵌,不能嵌就给出明确提示 + 新标签页按钮。
// frameBlockReason 判断响应头是否禁止被本站内嵌;允许时返回空串。
func frameBlockReason(h http.Header) string {
if xfo := strings.TrimSpace(h.Get("X-Frame-Options")); xfo != "" {
return "X-Frame-Options: " + xfo
}
for _, part := range strings.Split(h.Get("Content-Security-Policy"), ";") {
p := strings.TrimSpace(part)
if !strings.HasPrefix(strings.ToLower(p), "frame-ancestors") {
continue
}
if strings.Contains(p, "*") {
return ""
}
return p
}
return ""
}
// frameVerdict 是三态而不是布尔:
// 探测会被 WAF 拦成 403(响应里没有 X-Frame-Options),此时不能当成
// “可内嵌”——否则浏览器照样拒绝,用户只能看到一片空白。
// Conclusive=false 表示探测没拿到可信证据,前端会照常尝试内嵌,
// 但同时在工具条里说明原因并给出新标签页退路。
type frameVerdict struct {
Frameable bool `json:"frameable"`
Conclusive bool `json:"conclusive"`
Reason string `json:"reason"`
}
// browserUA 像普通浏览器:用自造 UA 会被 WAF 当机器人拦掉。
const browserUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
func frameableVerdict(target, cookie string) frameVerdict {
u, err := url.Parse(target)
if err != nil || u.Host == "" {
return frameVerdict{Frameable: false, Conclusive: true, Reason: "地址无效"}
}
if u.Scheme != "http" && u.Scheme != "https" {
// /extension/install.html 这类站内相对地址天然同源,可直接内嵌
return frameVerdict{Frameable: true, Conclusive: true}
}
// 2026-10-22 渗透测试:本接口原本可对任意地址发起请求并回显状态码/响应头,
// 构成未鉴权 SSRF(实测可探测 127.0.0.1:3005 / 3000 / 192.168.2.60:8081 等)。
// 这里对回环与私网地址直接拒绝:本功能的真实用途是判断**公网应用**能否被
// iframe 内嵌,内网探测没有正当场景。
if !allowLoopbackFrameable && isPrivateOrLoopbackURL(u) {
return frameVerdict{Frameable: false, Conclusive: false,
Reason: "不探测回环/内网地址"}
}
req, err := http.NewRequest(http.MethodGet, target, nil)
if err != nil {
return frameVerdict{Frameable: false, Conclusive: true, Reason: "地址无效"}
}
req.Header.Set("User-Agent", browserUA)
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
// 关键:带上浏览器自己的 Cookie。门户后面的应用对未登录请求会 302 到
// 登录页(无 X-Frame-Options),不转发会话就会把「重定向到登录页」
// 误判成「可内嵌」。
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
client := &http.Client{Timeout: 8 * time.Second}
res, err := client.Do(req)
if err != nil {
return frameVerdict{Frameable: true, Conclusive: false, Reason: "探测未能连通"}
}
defer res.Body.Close()
io.Copy(io.Discard, io.LimitReader(res.Body, 8<<10))
// 跳到了别的主机(例如登录页)→ 拿到的不是目标应用本身的响应头,不能当证据
if res.Request != nil && res.Request.URL != nil && !strings.EqualFold(res.Request.URL.Host, u.Host) {
return frameVerdict{Frameable: true, Conclusive: false,
Reason: "被重定向到 " + res.Request.URL.Host}
}
// 响应头里有明确的拒绝证据 → 确定不可内嵌
if reason := frameBlockReason(res.Header); reason != "" {
return frameVerdict{Frameable: false, Conclusive: true, Reason: reason}
}
if res.StatusCode < 200 || res.StatusCode > 299 {
return frameVerdict{Frameable: true, Conclusive: false, Reason: fmt.Sprintf("探测返回 HTTP %d", res.StatusCode)}
}
return frameVerdict{Frameable: true, Conclusive: true}
}
// isPrivateOrLoopbackURL 判断目标是否指向回环/链路本地/私网地址。
//
// 用于 SSRF 防护:/api/frameable 只应用来判定**公网应用**是否可被 iframe 内嵌,
// 没有探测内网的必要。除直接 IP 字面量外,也对解析后的地址做同样的检查,
// 防止用域名绕过(如 localtest.me 之类指向 127.0.0.1 的域名)。
func isPrivateOrLoopbackURL(u *url.URL) bool {
host := u.Hostname()
if host == "" {
return true
}
// 先看字面量(含 IPv6 字面量),命中即拒
if ip := net.ParseIP(host); ip != nil {
return isPrivateOrLoopbackIP(ip)
}
// 再把域名解析一遍:解析到私网地址同样拒绝
ips, err := net.LookupIP(host)
if err != nil || len(ips) == 0 {
return false // 解析不了就交给后续请求自己失败
}
for _, ip := range ips {
if isPrivateOrLoopbackIP(ip) {
return true
}
}
return false
}
func isPrivateOrLoopbackIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified()
}
func handleFrameable(w http.ResponseWriter, r *http.Request) {
// 该接口会由服务端主动发起请求,必须限管理员:否则就是公开 SSRF 探针。
if !requireAdminToken(w, r) {
return
}
target := r.URL.Query().Get("url")
if target == "" {
jsonErr(w, 400, "缺少 url 参数")
return
}
jsonResp(w, frameableVerdict(target, r.Header.Get("Cookie")))
}
func handleSections(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
cfg, err := loadConfig()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, cfg.Sections)
}
// ── 分组(section)增 / 改 / 删 ──
//
// 「历史访问」分组由 history-server 依据浏览记录重建,改名或删除都会被它
// 覆写回来;这里明确拒绝,避免用户以为删掉了、下次同步又“长”回来。
const historySectionName = "历史访问"
func isHistorySection(s Section) bool {
return strings.TrimSpace(s.Name) == historySectionName
}
func handleAddSection(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonErr(w, 405, "method not allowed")
return
}
if !requireAdminToken(w, r) {
return
}
mu.Lock()
defer mu.Unlock()
var req struct {
Name string `json:"name"`
Icon string `json:"icon"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "请求体解析失败")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
jsonErr(w, 400, "分组名不能为空")
return
}
if name == historySectionName {
jsonErr(w, 409, "「"+historySectionName+"」由浏览历史自动维护,不能手工创建")
return
}
cfg, err := loadConfig()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
for _, s := range cfg.Sections {
if strings.TrimSpace(s.Name) == name {
jsonErr(w, 409, "已存在同名分组")
return
}
}
icon := strings.TrimSpace(req.Icon)
if icon == "" {
icon = "fas fa-star"
}
cfg.Sections = append(cfg.Sections, Section{Name: name, Icon: icon})
if err := saveConfig(cfg); err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, cfg.Sections)
}
func handleUpdateSection(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonErr(w, 405, "method not allowed")
return
}
if !requireAdminToken(w, r) {
return
}
mu.Lock()
defer mu.Unlock()
var req struct {
Index int `json:"index"`
Name string `json:"name"`
Icon string `json:"icon"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "请求体解析失败")
return
}
cfg, err := loadConfig()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
if req.Index < 0 || req.Index >= len(cfg.Sections) {
jsonErr(w, 400, "分组不存在")
return
}
if isHistorySection(cfg.Sections[req.Index]) {
jsonErr(w, 409, "「"+historySectionName+"」由浏览历史自动维护,不能修改")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
jsonErr(w, 400, "分组名不能为空")
return
}
if name == historySectionName {
jsonErr(w, 409, "不能使用分组名「"+historySectionName+"」(它由浏览历史自动维护)")
return
}
for i, s := range cfg.Sections {
if i != req.Index && strings.TrimSpace(s.Name) == name {
jsonErr(w, 409, "已存在同名分组")
return
}
}
cfg.Sections[req.Index].Name = name
if icon := strings.TrimSpace(req.Icon); icon != "" {
cfg.Sections[req.Index].Icon = icon
}
if err := saveConfig(cfg); err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, cfg.Sections)
}
// handleDeleteSection 删除分组。默认拒绝删除非空分组(需 force=1),
// 避免误点一下就把一组服务连带删掉。
func handleDeleteSection(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
jsonErr(w, 405, "method not allowed")
return
}
if !requireAdminToken(w, r) {
return
}
mu.Lock()
defer mu.Unlock()
var req struct {
Index int `json:"index"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "请求体解析失败")
return
}
cfg, err := loadConfig()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
if req.Index < 0 || req.Index >= len(cfg.Sections) {
jsonErr(w, 400, "分组不存在")
return
}
if isHistorySection(cfg.Sections[req.Index]) {
jsonErr(w, 409, "「"+historySectionName+"」由浏览历史自动维护,不能删除")
return
}
if n := len(cfg.Sections[req.Index].Items); n > 0 && r.URL.Query().Get("force") != "1" {
jsonErr(w, 409, fmt.Sprintf("该分组下还有 %d 个服务,删除会一并移除;确认请加 force=1", n))
return
}
cfg.Sections = append(cfg.Sections[:req.Index], cfg.Sections[req.Index+1:]...)
if err := saveConfig(cfg); err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, cfg.Sections)
}
func handleAddItem(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonErr(w, 405, "method not allowed")
return
}
if !requireAdminToken(w, r) {
return
}
mu.Lock()
defer mu.Unlock()
var req struct {
SectionIndex int `json:"sectionIndex"`
Item NavItem `json:"item"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, err.Error())
return
}
if req.Item.Title == "" || req.Item.URL == "" {
jsonErr(w, 400, "title and url required")
return
}
// Block javascript: URLs
if strings.HasPrefix(strings.ToLower(req.Item.URL), "javascript:") {
jsonErr(w, 400, "javascript: URLs not allowed")
return
}
cfg, err := loadConfig()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
if req.SectionIndex < 0 || req.SectionIndex >= len(cfg.Sections) {
jsonErr(w, 400, "invalid section index")
return
}
cfg.Sections[req.SectionIndex].Items = append(cfg.Sections[req.SectionIndex].Items, req.Item)
if err := saveConfig(cfg); err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, map[string]string{"status": "ok"})
}
func handleDeleteItem(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
jsonErr(w, 405, "method not allowed")
return
}
if !requireAdminToken(w, r) {
return
}
mu.Lock()
defer mu.Unlock()
var req struct {
SectionIndex int `json:"sectionIndex"`
ItemIndex int `json:"itemIndex"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, err.Error())
return
}
cfg, err := loadConfig()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
if req.SectionIndex < 0 || req.SectionIndex >= len(cfg.Sections) {
jsonErr(w, 400, "invalid section index")
return
}
sec := &cfg.Sections[req.SectionIndex]
if req.ItemIndex < 0 || req.ItemIndex >= len(sec.Items) {
jsonErr(w, 400, "invalid item index")
return
}
sec.Items = append(sec.Items[:req.ItemIndex], sec.Items[req.ItemIndex+1:]...)
if err := saveConfig(cfg); err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, map[string]string{"status": "ok"})
}
// handleSyncInfo 把浏览器插件需要的接入信息给已登录用户。
//
// 2026-10-22 渗透测试:该接口在公网匿名可达,且**明文回显 syncToken**,
// 使任何人可伪造/读取全部浏览历史。现在:
// - 必须写权限鉴权(requireAdminToken);
// - 不再回显令牌本体,只告诉调用方「是否已配置」,需要时从环境变量自取。
func handleSyncInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonErr(w, 405, "method not allowed")
return
}
if !requireAdminToken(w, r) {
return
}
jsonResp(w, map[string]any{
// syncToken 本体不再回显:泄露后等同于浏览器历史的全读写权。
"hasToken": syncToken != "",
"requireAdminToken": adminToken != "",
"tokenHint": "sync token 不再回显;需要时从部署环境变量 NAVI_SYNC_TOKEN 读取",
})
}
// beianFooter 按固定模板生成页脚备案号 HTML。
//
// 备案号在 /admin/ 里就是两个普通输入框,模板统一由服务端生成,
// 用户不必写 HTML;类名与 web/navi.css 里的 .navi-footer 对应。
// footer 字段本身仍然保留:传了自定义 HTML 就以它为准(高级用法)。
// defaultMotto 是页脚座右铭的内置默认(每行一句)。
// 可用后台「站点信息 → 页脚座右铭」或 POST /admin/api/pageinfo {"motto":"…"} 覆盖(换行或 | 分行)。
const defaultMotto = "不念往昔之繁华\n无谓前路之沧桑"
// mottoLines 解析座右铭为多行;空值回落内置默认。
func mottoLines(info map[string]any) []string {
raw := strings.TrimSpace(mapString(info, "motto"))
if raw == "" {
raw = defaultMotto
}
parts := strings.FieldsFunc(raw, func(r rune) bool { return r == '\n' || r == '|' })
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
// beianFooter 保留旧签名(不含座右铭),供既有调用与测试使用。
func beianFooter(icp, gongan string) string {
return footerMarkup(icp, gongan, nil)
}
// footerMarkup 生成页脚 HTML:备案号两项 + 座右铭若干行(.navi-quote)。
// 结构契约:.navi-footer > (a 备案 ×2, span.navi-footer-sep ×2, span.navi-quote ×N)。
// 分隔符保留在标记里(Dashy/登录页靠 CSS 隐藏并排成两列),这样旧样式也不会串行。
func footerMarkup(icp, gongan string, motto []string) string {
icp = strings.TrimSpace(icp)
gongan = strings.TrimSpace(gongan)
parts := make([]string, 0, 2)
if icp != "" {
parts = append(parts, `<a href="https://beian.miit.gov.cn" target="_blank" rel="noopener noreferrer">`+html.EscapeString(icp)+`</a>`)
}
if gongan != "" {
parts = append(parts, `<a href="https://beian.mps.gov.cn" target="_blank" rel="noopener noreferrer">`+html.EscapeString(gongan)+`</a>`)
}
if len(parts) == 0 && len(motto) == 0 {
return ""
}
inner := strings.Join(parts, `<span class="navi-footer-sep">·</span>`)
for i, line := range motto {
cls := "navi-quote"
if i == 1 {
cls += " navi-quote-2"
}
inner += `<span class="` + cls + `">` + html.EscapeString(line) + `</span>`
}
return `<div class="navi-footer">` + inner + `</div>`
}
// siteFooter 是页脚的唯一入口:后台自定义 footer 优先,否则按备案号 + 座右铭生成。
// 登录页 / 门户页 / Dashy 三处都必须走它,避免"某一页漏掉座右铭、或整段被转义"。
func siteFooter(info map[string]any) string {
if s := strings.TrimSpace(mapString(info, "footer")); s != "" {
return s
}
return footerMarkup(mapString(info, "icp"), mapString(info, "gongan"), mottoLines(info))
}
// mapString 读取 map 里的字符串字段,缺失或类型不符时返回空串。
func mapString(m map[string]any, key string) string {
v, _ := m[key].(string)
return v
}
// ── 站点信息:标题 / 副标题 / 顶部导航 / 页脚 ──
// 这些东西原先只能在 conf.yml 里手改,现在可在 /admin/ 编辑。
func handlePageInfo(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
cfg, err := loadConfig()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
if cfg.PageInfo == nil {
cfg.PageInfo = map[string]any{}
}
switch r.Method {
case http.MethodGet:
jsonResp(w, cfg.PageInfo)
case http.MethodPost:
if !requireAdminToken(w, r) {
return
}
var body struct {
Title *string `json:"title"`
Description *string `json:"description"`
Footer *string `json:"footer"`
ICP *string `json:"icp"`
Gongan *string `json:"gongan"`
Motto *string `json:"motto"`
NavLinks *[]map[string]any `json:"navLinks"`
// 认证页与门户页的可配置文案(原先写死在代码/镜像里)
AuthTitle *string `json:"authTitle"`
AuthSubtitle *string `json:"authSubtitle"`
AuthLogo *string `json:"authLogo"`
PortalTitle *string `json:"portalTitle"`
HeroTitle *string `json:"heroTitle"`
HeroSubtitle *string `json:"heroSubtitle"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonErr(w, 400, err.Error())
return
}
if body.Title != nil {
cfg.PageInfo["title"] = *body.Title
}
if body.Description != nil {
cfg.PageInfo["description"] = *body.Description
}
if body.Footer != nil {
cfg.PageInfo["footer"] = *body.Footer
}
// 备案号单独存字段,便于在 /admin/ 里直接编辑,而不是让用户写 HTML。
if body.ICP != nil {
cfg.PageInfo["icp"] = *body.ICP
}
if body.Gongan != nil {
cfg.PageInfo["gongan"] = *body.Gongan
}
// 座右铭:留空回落内置默认;必须在下面"重新生成页脚"之前写入
if body.Motto != nil {
if strings.TrimSpace(*body.Motto) == "" {
delete(cfg.PageInfo, "motto")
} else {
cfg.PageInfo["motto"] = strings.TrimSpace(*body.Motto)
}
}
// 页脚为空(留空 = 交给模板)时,按备案号 + 座右铭重新生成。
if strings.TrimSpace(mapString(cfg.PageInfo, "footer")) == "" {
cfg.PageInfo["footer"] = footerMarkup(mapString(cfg.PageInfo, "icp"),
mapString(cfg.PageInfo, "gongan"), mottoLines(cfg.PageInfo))
} // 认证页 / 门户页文案:空串表示回落到内置默认值
setPageStr := func(k string, v *string) {
if v != nil {
if strings.TrimSpace(*v) == "" {
delete(cfg.PageInfo, k)
} else {
cfg.PageInfo[k] = strings.TrimSpace(*v)
}
}
}
setPageStr("authTitle", body.AuthTitle)
setPageStr("authSubtitle", body.AuthSubtitle)
setPageStr("authLogo", body.AuthLogo)
setPageStr("portalTitle", body.PortalTitle)
setPageStr("heroTitle", body.HeroTitle)
setPageStr("heroSubtitle", body.HeroSubtitle)
if body.NavLinks != nil {
cfg.PageInfo["navLinks"] = *body.NavLinks
}
if err := saveConfig(cfg); err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, cfg.PageInfo)
default:
jsonErr(w, 405, "method not allowed")
}
}
// ── 图标上传 ──
//
// 只接受 Dashy 能渲染的格式:其 isImage() 白名单是 [svg,png,jpg]。
// .ico 渲染不出来,由前端先用 canvas 转成 PNG 再上传。
const iconMaxBytes = 512 << 10
func handleIconUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonErr(w, 405, "method not allowed")
return
}
if !requireAdminToken(w, r) {
return
}
r.Body = http.MaxBytesReader(w, r.Body, 4<<20)
if err := r.ParseMultipartForm(4 << 20); err != nil {
jsonErr(w, 400, "解析上传失败: "+err.Error())
return
}
file, hdr, err := r.FormFile("file")
if err != nil {
jsonErr(w, 400, "缺少 file 字段")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, iconMaxBytes+1))
if err != nil {
jsonErr(w, 400, err.Error())
return
}
if len(data) > iconMaxBytes {
jsonErr(w, 400, "图标过大(上限 512KB)")
return
}
ext, ok := detectIconExt(data)
if !ok {
jsonErr(w, 400, "只支持 png / svg / jpg(.ico 请在页面上选择,会自动转成 png)")
return
}
name := safeIconName(r.FormValue("name"), hdr.Filename, ext)
if err := os.MkdirAll(iconDir, 0755); err != nil {
jsonErr(w, 500, err.Error())
return
}
if err := os.WriteFile(filepath.Join(iconDir, name), data, 0644); err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, map[string]string{"icon": name})
}
// detectIconExt 以**正文特征**判定类型,不信任上传的文件名/Cotent-Type。
func detectIconExt(data []byte) (string, bool) {
switch {
case bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")):
return ".png", true
case bytes.HasPrefix(data, []byte("\xff\xd8\xff")):
return ".jpg", true
}
trim := bytes.TrimSpace(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}))
low := bytes.ToLower(trim)
if len(low) > 512 {
low = low[:512]
}
if bytes.HasPrefix(low, []byte("<svg")) ||
(bytes.HasPrefix(low, []byte("<?xml")) && bytes.Contains(low, []byte("<svg"))) {
return ".svg", true
}
return "", false
}
// safeIconName 用「提示名 + 时间戳」生成安全文件名:时间戳兼做缓存失效,
// 避免换图后 Dashy 或浏览器还沿用旧图。
func safeIconName(hint, filename, ext string) string {
base := strings.TrimSpace(hint)
if base == "" {
base = filename
}
base = strings.ToLower(strings.TrimSuffix(base, filepath.Ext(base)))
var b strings.Builder
for _, r := range base {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
case r == '.' || r == ' ':
b.WriteByte('-')
}
}
s := b.String()
if s == "" {
s = "icon"
}
if len(s) > 48 {
s = s[:48]
}
return fmt.Sprintf("%s-%d%s", s, time.Now().Unix(), ext)
}
// firstLines 取前 n 行,避免把 nginx 的长输出整段塞进界面。
func firstLines(s string, n int) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
if len(lines) > n {
lines = lines[:n]
}
return strings.Join(lines, "\n")
}
// ── 统一入口(gateway)路由表 API ──
//
// GET /api/gateway/routes → 当前路由表(含"哪些域名走统一认证")
// POST /api/gateway/routes → 整体替换并保存(不立即生效)
// POST /api/gateway/apply → 生成配置 → nginx -t 校验 → 通过才 reload
//
// 校验失败时保留上一份可用配置,并把 nginx 的原始报错回给界面,
// 避免"点了应用,整个入口挂了"。
func handleGatewayRoutes(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
cfg, err := loadGateway()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonResp(w, cfg)
case http.MethodPost:
if !requireAdminToken(w, r) {
return
}
mu.Lock()
defer mu.Unlock()
// 只替换路由表:入口端口、TLS、认证地址等必须原样保留。
// 旧实现把整个 GatewayConfig 解成零值再保存,提交部分字段会把其它配置
// 重置为默认(实测把入口端口 13080 变成 8081,对 agent 尤其危险)。
var body struct {
Routes *[]Route `json:"routes"`
Force bool `json:"force"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonErr(w, 400, "请求体解析失败")
return
}
if body.Routes == nil {
jsonErr(w, 400, `请用 {"routes":[...]} 提交完整路由表;本接口只替换路由表,不改动其它配置`)
return
}
cur, err := loadGateway()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
if len(*body.Routes) == 0 && len(cur.Routes) > 0 && !body.Force {
jsonErr(w, 400, "拒绝把非空路由表清空:如确要清空请传 force:true")
return
}
cur.Routes = *body.Routes
req := *cur
for _, rt := range req.Routes {
if err := ValidateRoute(rt); err != nil {
jsonErr(w, 400, err.Error())
return
}
}
if _, err := RenderGatewayConf(&req); err != nil {
jsonErr(w, 400, err.Error())
return
}
if req.ListenPort == 0 {
req.ListenPort = 8081
}
if req.ListenTLSPort == 0 {
req.ListenTLSPort = 8443
}
// 路由与 Dashy 导航是一份操作:先确认导航分组都存在,再同时更新。
// 任一步失败都恢复 gateway.yml 与 conf.yml,避免后台显示和导航页各说各话。
dashy, err := loadConfig()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
if err := validateGatewayNavigation(&req, dashy); err != nil {
jsonErr(w, 400, err.Error())
return
}
oldGateway, gatewayExisted := os.ReadFile(gatewayFile)
oldConf, confErr := os.ReadFile(confFile)
if confErr != nil {
jsonErr(w, 500, confErr.Error())
return
}
if err := saveGateway(&req); err != nil {
jsonErr(w, 500, err.Error())
return
}
if err := syncGatewayNavigation(&req); err != nil {
if gatewayExisted == nil {
_ = os.WriteFile(gatewayFile, oldGateway, 0o600)
} else {
_ = os.Remove(gatewayFile)
}
_ = os.WriteFile(confFile, oldConf, 0o644)
jsonErr(w, 500, "同步 Dashy 导航失败,已回滚:"+err.Error())
return
}
jsonResp(w, map[string]any{"status": "saved", "routes": len(req.Routes)})
default:
jsonErr(w, 405, "method not allowed")
}
}
func nginxCommand(args ...string) (string, error) {
cmd := exec.Command("nginx", args...)
out, err := cmd.CombinedOutput()
return string(out), err
}
func handleGatewayApply(w http.ResponseWriter, r *http.Request) {
// 安全阀:路由表为空但现有配置里含路由 → 拒绝覆盖。
// 起因(真实练兵抓到):路由表若被写到错位置,容器会读到空表并生成"零路由"配置,
// 等于把入口路由表静默清空。宁可报错让人检查。
if cur, err := loadGateway(); err == nil && len(cur.Routes) == 0 {
if prev, err := os.ReadFile(gatewayFileFor()); err == nil && bytes.Contains(prev, []byte("server_name")) {
jsonErr(w, 400, "路由表为空,但现有配置含路由——已拒绝覆盖。若确要清空,请先删除 gateway.conf。")
return
}
}
if r.Method != http.MethodPost {
jsonErr(w, 405, "method not allowed")
return
}
if !requireAdminToken(w, r) {
return
}
mu.Lock()
defer mu.Unlock()
cfg, err := loadGateway()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
conf, err := RenderGatewayConf(cfg)
if err != nil {
jsonErr(w, 400, err.Error())
return
}
if err := os.MkdirAll(gatewayOutDir, 0o755); err != nil {
jsonErr(w, 500, err.Error())
return
}
target := gatewayFileFor()
prev, hadPrev := []byte{}, false
if b, err := os.ReadFile(target); err == nil {
prev, hadPrev = b, true
}
if err := os.WriteFile(target, []byte(conf), 0o644); err != nil {
jsonErr(w, 500, err.Error())
return
}
// 校验:不通过就把上一份写回去,保证入口始终有一份可用配置
if out, err := nginxCommand("-t"); err != nil {
if hadPrev {
_ = os.WriteFile(target, prev, 0o644)
} else {
_ = os.Remove(target)
}
jsonErr(w, 400, "配置未通过 nginx 校验,已保留上一份可用配置:\n"+firstLines(out, 12))
return
}
if out, err := nginxCommand("-s", "reload"); err != nil {
jsonErr(w, 500, "reload 失败:"+firstLines(out, 6))
return
}
jsonResp(w, map[string]any{"status": "applied", "routes": len(cfg.Routes), "file": target})
}
func main() {
// Ensure conf file exists
if _, err := os.Stat(confFile); os.IsNotExist(err) {
defaultConf := envOr("NAVI_DEFAULT_CONFIG", "/app/defaults/conf.yml")
if data, err := os.ReadFile(defaultConf); err == nil {
os.MkdirAll(filepath.Dir(confFile), 0755)
os.WriteFile(confFile, data, 0644)
}
}
// 内置统一登录服务:与 gateway 共处一个容器,不再需要 portal-auth 容器。
registerPortalAuth()
// Serve admin UI static files (nginx strips /admin/ prefix)
http.Handle("/", http.FileServer(http.Dir("/app/admin")))
http.HandleFunc("/api/sections", cors(handleSections))
http.HandleFunc("/api/item", cors(handleAddItem))
http.HandleFunc("/api/item/delete", cors(handleDeleteItem))
http.HandleFunc("/api/section", cors(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
handleAddSection(w, r)
case http.MethodDelete:
handleDeleteSection(w, r)
default:
jsonErr(w, 405, "method not allowed")
}
}))
http.HandleFunc("/api/section/update", cors(handleUpdateSection))
http.HandleFunc("/api/pageinfo", cors(handlePageInfo))
http.HandleFunc("/api/icon", cors(handleIconUpload))
http.HandleFunc("/api/wallpaper", cors(handleWallpaper))
http.HandleFunc("/api/sync-info", cors(handleSyncInfo))
http.HandleFunc("/api/frameable", cors(handleFrameable))
http.HandleFunc("/api/gateway/routes", cors(handleGatewayRoutes))
http.HandleFunc("/api/gateway/apply", cors(handleGatewayApply))
// 从现有 nginx 配置导入路由(只解析,不写盘)
http.HandleFunc("/api/gateway/import", cors(handleGatewayImport))
// 统一登录的账号设置(存数据卷,改完立即生效,不用重新部署)
http.HandleFunc("/api/portal-auth", cors(handlePortalAuthSettings))
// 门户首页渲染(可配置文案 + 数据卷整页覆盖),供 gateway 的 kind=portal 反代
http.HandleFunc("/portal-page", handlePortalPage)
// 门户页服务卡片片段(数据卷 portal/cards.html),改链接不必重建镜像
http.HandleFunc("/api/portal-cards", cors(handlePortalCards))
// 全部可配置项 + 写入口清单(供 agent/脚本按图配置)
http.HandleFunc("/api/schema", cors(handleSchema))
// 历史记录管理:列表 / 清空全部 / 删除单条
http.HandleFunc("/api/history", cors(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
handleHistoryList(w, r)
case http.MethodDelete:
handleHistoryClear(w, r)
default:
jsonErr(w, 405, "method not allowed")
}
}))
http.HandleFunc("/api/history/item", cors(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
jsonErr(w, 405, "method not allowed")
return
}
handleHistoryDeleteItem(w, r)
}))
http.HandleFunc("/api/reload", cors(func(w http.ResponseWriter, r *http.Request) {
jsonResp(w, map[string]string{"status": "saved"})
}))
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
jsonResp(w, map[string]string{"status": "ok"})
})
log.Println("admin-server listening on :3005")
log.Fatal(http.ListenAndServe(":3005", nil))
}