feat(gateway): 统一入口路由表(后端基础层)——"哪些服务走统一认证"变成一条数据
目标:不再手工维护外部 nginx 配置,把"域名→内网后端、是否走统一登录认证"
变成后台可编辑的数据,由本进程生成 nginx 配置并热加载。
本提交只做基础层(纯 Go、可测、零行为变更):
- gateway.go:Route/H gatewayConfig 数据模型 + ValidateRoute + RenderGatewayConf
· 认证**按路由**决定:RequireLogin 为真才生成 auth_request 与登录跳转,
也就是"哪些服务受统一认证保护"完全由路由表决定
· 支持 websocket 透传、单域名上传上限、HTTPS 段(可同时生成旁路端口)
· 路由表单独存 gateway.yml(不混进 Dashy 的 conf.yml),写坏了也不影响导航页
- API:GET/POST /admin/api/gateway/routes(保存)、POST /admin/api/gateway/apply
· apply 的顺序是 生成 → nginx -t 校验 → 通过才 reload;**校验失败把上一份可用
配置写回去并把 nginx 原始报错回给界面**,避免"点了应用整个入口挂掉"
- nginx.conf 加 include /app/data/gateway/*.conf(生成物在容器自己的卷里,
不碰宿主机文件,天然避开"单文件 bind mount 不能用 sed -i"的坑)
- 生成物默认监听旁路端口(8081/8443):可以先与正式入口并存验证,再切 80/443
测试:受保护域名必须有 auth_request、未受保护域名必须没有;websocket/上传上限;
空域名/带协议域名/非 http 后端/域名重复/需认证却无认证地址/启用 HTTPS 却无证书
六种非法输入必须报错;缺文件时回落默认配置、保存后回读一致。
This commit is contained in:
@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -995,6 +996,130 @@ func safeIconName(hint, filename, ext string) string {
|
||||
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
|
||||
}
|
||||
if err := saveGateway(&req); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonResp(w, map[string]string{"status": "saved"})
|
||||
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 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) {
|
||||
@ -1027,6 +1152,8 @@ func main() {
|
||||
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))
|
||||
// 历史记录管理:列表 / 清空全部 / 删除单条
|
||||
http.HandleFunc("/api/history", cors(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
|
||||
Reference in New Issue
Block a user