Files
HomeDash/admin-server/main.go
HomeDash dea722f51c fix(security): 管理 API 默认拒绝 + 堵住渗透测试报告的 5 个攻击面
2026-10-22 渗透测试报告(report.md)实测确认:统一入口的管理 API 在公网
完全未鉴权,可匿名改路由表、拆登录墙、改门户口令、写门户 HTML、读全部
浏览历史、做内网 SSRF 探测。根因有两层,本次一并修复。

## 根因 1:requireAdminToken 默认放行(代码层)

    if !requireTokenForWrite { return true }

线上没有 NAVI_ADMIN_REQUIRE_TOKEN,于是所有"调了鉴权"的 handler 实际全部
放行——形同虚设。现在改为 fail-closed:

  - 配了 NAVI_ADMIN_TOKEN        → 校验 Bearer token
  - 未配且显式 NAVI_ADMIN_ALLOW_UNAUTHENTICATED=1 → 放行(仅本机调试)
  - 其它                          → 401 拒绝

## 根因 2:login 域没有任何 auth_request(部署层)

kind=auth 的 server 块全站 requireLogin=false(它本身就是登录服务,这点
合理),但 location / 把 /api/、/admin/ 原样反代到 127.0.0.1:3005。两层
都没管 → 管理 API 裸奔。

新增 Route.AdminGuard:开启后 server 级挂 auth_request,只白名单放行登录
流程(/_portal/login|logout|verify、/login、/portal-page、favicon),
其余 /api/、/admin/ 一律要求已登录。

## 同时修掉的 4 个具体问题

1. /api/sync-info 明文回显 syncToken → 不再回显,且要求鉴权。
   泄露该 token 等于浏览器历史的全读写权。
2. /api/history GET 无鉴权 → 与 DELETE 分支一致,要求管理员令牌。
3. /api/frameable 未鉴权 SSRF → 加鉴权 + 内网/回环黑名单。除 IP 字面量外
   还对域名解析结果做校验,防止 localtest.me 之类指向 127.0.0.1 的绕过。
4. dashy 域 bypassPaths 里的 "/api/" 过宽 → 收窄到 "/api/history/"
   (本意只放行浏览记录上报,实际把整个管理 API 暴露了)。

## 回归护栏

新增 admin-server/security_test.go(10 条)与 main_test_setup_test.go。
每条断言都是"改回旧行为就会失败",已用 3 次变异测试验证:
  - 恢复默认放行        → TestAdminAPIFailsClosedByDefault FAIL
  - sync-info 回显 token → TestSyncInfoDoesNotLeakToken FAIL
  - 移除内网黑名单      → TestFrameableBlocksInternalTargets FAIL
  - /api/ 豁免认证      → TestAuthKindWithAdminGuardProtectsAPI FAIL
  - 不给 server 级 auth → 同上 FAIL

测试 56 项全绿。

## 顺带

searxng-settings.yml:停用 360search / ddgw / wikidata / wikipedia
(各自的实测理由见文件内注释),request_timeout 8.0→4.0。
Dockerfile:给 duckduckgo_web 引擎硬编码的 timeout=2 打补丁(经代理必超时)。
2026-10-22 14:08:06 +08:00

1427 lines
45 KiB
Go
Raw 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
}
func requireToken(w http.ResponseWriter, r *http.Request, expected string) bool {
if expected == "" {
return true // 未配置 token 则不校验
}
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))
}