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("), "+ "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 )") 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, ``+html.EscapeString(icp)+``) } if gongan != "" { parts = append(parts, ``+html.EscapeString(gongan)+``) } if len(parts) == 0 && len(motto) == 0 { return "" } inner := strings.Join(parts, `·`) for i, line := range motto { cls := "navi-quote" if i == 1 { cls += " navi-quote-2" } inner += `` + html.EscapeString(line) + `` } return `` } // 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("= '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)) }