真实练兵(干净数据目录跑 install-host.sh)抓到: 1. 路由表源文件被写到 <数据卷>/gateway/gateway.yml,容器实际读取 <数据卷>/gateway.yml → apply 报 routes: 0,用户自备/生成的初始路由被静默忽略 2. 自检在 apply 后立即探测,nginx 还没 reload 完 → 误判连接失败; code_of 失败时重复输出 000 3. 缺安全阀:路由表为空时 apply 会照常生成零路由配置并覆盖, 等于静默清空入口路由表 修复: - install-host.sh 改写 <数据卷>/gateway.yml(并注释说明两个目录的区别) - 自检前轮询等待入口监听就绪(最多 10s),code_of 只在成功时输出状态码 - handleGatewayApply 增加安全阀:路由表为空但现有配置含 server_name → 400 拒绝覆盖 - 新增测试 TestApplyRefusesEmptyRoutesOverExisting
1271 lines
38 KiB
Go
1271 lines
38 KiB
Go
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("<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 仅在显式开启时才校验。
|
||
func requireAdminToken(w http.ResponseWriter, r *http.Request) bool {
|
||
if !requireTokenForWrite {
|
||
return true
|
||
}
|
||
if adminToken == "" {
|
||
return true // 开了开关却没配 token,等同于不校验
|
||
}
|
||
return requireToken(w, r, adminToken)
|
||
}
|
||
|
||
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) {
|
||
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}
|
||
}
|
||
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}
|
||
}
|
||
|
||
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, `<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 {
|
||
return ""
|
||
}
|
||
return `<div class="navi-footer">` + strings.Join(parts, `<span class="navi-footer-sep">·</span>`) + `</div>`
|
||
}
|
||
|
||
// 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("<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()
|
||
var req GatewayConfig
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
jsonErr(w, 400, "请求体解析失败")
|
||
return
|
||
}
|
||
if req.Routes == nil {
|
||
req.Routes = []Route{}
|
||
}
|
||
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)
|
||
// 历史记录管理:列表 / 清空全部 / 删除单条
|
||
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))
|
||
}
|