package main import ( "bytes" "encoding/json" "fmt" "html" "io" "log" "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/ 在生产里整体位于外层统一认证反向代理(portal-nginx 的 auth_request) // 之后,再叠一层 token 是重复的,而且会让 admin UI 的写操作静默 401。 // 所以默认**不校验**;只有当你把容器端口直接暴露给不受信的网段、 // 而没有外层认证时才设 NAVI_ADMIN_REQUIRE_TOKEN=1。 requireTokenForWrite = isTruthy(os.Getenv("NAVI_ADMIN_REQUIRE_TOKEN")) // 浏览器插件同步用的令牌(供安装引导页展示,方便用户拷贝) 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(" 299 { return frameVerdict{Frameable: true, Conclusive: false, Reason: fmt.Sprintf("探测返回 HTTP %d", res.StatusCode)} } return frameVerdict{Frameable: true, Conclusive: true} } func handleFrameable(w http.ResponseWriter, r *http.Request) { 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 把浏览器插件需要的接入信息给已登录用户。 // // 为何可以在这里返回令牌:/admin/ 整体在门户登录墙之内; // 而 /api/history/sync 是被门户放行的(否则插件无法上报), // 所以这个 token 仍然是有意义的凭据,并且不能挂在 /api/ 下暴露。 func handleSyncInfo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { jsonErr(w, 405, "method not allowed") return } jsonResp(w, map[string]any{ "syncToken": syncToken, "hasToken": syncToken != "", "requireAdminToken": requireTokenForWrite, }) } // beianFooter 按固定模板生成页脚备案号 HTML。 // // 备案号在 /admin/ 里就是两个普通输入框,模板统一由服务端生成, // 用户不必写 HTML;类名与 web/navi.css 里的 .navi-footer 对应。 // footer 字段本身仍然保留:传了自定义 HTML 就以它为准(高级用法)。 func beianFooter(icp, gongan 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 { return "" } return `` } // 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"` 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 strings.TrimSpace(mapString(cfg.PageInfo, "footer")) == "" { cfg.PageInfo["footer"] = beianFooter(mapString(cfg.PageInfo, "icp"), mapString(cfg.PageInfo, "gongan")) } // 认证页 / 门户页文案:空串表示回落到内置默认值 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)) }