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:
244
admin-server/gateway.go
Normal file
244
admin-server/gateway.go
Normal file
@ -0,0 +1,244 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ── 统一入口(gateway)路由表 ──
|
||||
//
|
||||
// 目标:把"哪个域名反代到哪个内网服务、要不要走统一登录认证"变成**后台可编辑的
|
||||
// 数据**,由本进程生成 nginx 配置并热加载,从而不再需要手工维护一份外部 nginx
|
||||
// 配置(也就避开了"单文件 bind mount 不能用 sed -i"那类坑)。
|
||||
//
|
||||
// 设计要点:
|
||||
// - 路由表单独存一个文件(不是 conf.yml):即使路由写坏了也不会影响导航页本身,
|
||||
// 而且 conf.yml 是 Dashy 的配置,语义上不该混进来。
|
||||
// - 生成物写在**容器自己的数据卷**里(include 目录),不碰宿主机文件。
|
||||
// - 应用配置的顺序永远是:生成 → nginx -t 校验 → 通过才替换并 reload;
|
||||
// 校验失败保留上一份可用配置并把 nginx 的报错回给调用方。
|
||||
|
||||
// Route 一条域名→后端映射。
|
||||
type Route struct {
|
||||
Host string `yaml:"host" json:"host"` // 例如 dashy.example.com
|
||||
Upstream string `yaml:"upstream" json:"upstream"` // 例如 http://10.0.0.11:8080
|
||||
RequireLogin bool `yaml:"requireLogin" json:"requireLogin"` // true=走统一认证;false=直接放行
|
||||
Websocket bool `yaml:"websocket" json:"websocket"` // 是否透传 Upgrade(面板/终端类)
|
||||
MaxBodyMB int `yaml:"maxBodyMB,omitempty" json:"maxBodyMB,omitempty"` // 上传体积上限,0=不限
|
||||
Note string `yaml:"note,omitempty" json:"note,omitempty"` // 备注,仅界面展示
|
||||
}
|
||||
|
||||
// GatewayConfig 路由表 + 入口参数。
|
||||
type GatewayConfig struct {
|
||||
// ListenHTTPS 是否由本容器终结 TLS(证书挂进来时开启;否则只监听 HTTP)
|
||||
ListenHTTPS bool `yaml:"listenHttps" json:"listenHttps"`
|
||||
// CertFile/KeyFile 在 ListenHTTPS 为真时使用
|
||||
CertFile string `yaml:"certFile,omitempty" json:"certFile,omitempty"`
|
||||
KeyFile string `yaml:"keyFile,omitempty" json:"keyFile,omitempty"`
|
||||
// AuthURL 统一认证的校验地址(auth_request)。例如 http://127.0.0.1:4001/_auth
|
||||
AuthURL string `yaml:"authUrl,omitempty" json:"authUrl,omitempty"`
|
||||
// LoginURL 未通过认证时的跳转地址
|
||||
LoginURL string `yaml:"loginUrl,omitempty" json:"loginUrl,omitempty"`
|
||||
// ListenHTTP/ListenHTTPSPort 监听端口,便于旁路验证(先跑 8081 再切 80)
|
||||
ListenPort int `yaml:"listenPort,omitempty" json:"listenPort,omitempty"`
|
||||
ListenTLSPort int `yaml:"listenTlsPort,omitempty" json:"listenTlsPort,omitempty"`
|
||||
Routes []Route `yaml:"routes" json:"routes"`
|
||||
}
|
||||
|
||||
func defaultGatewayConfig() *GatewayConfig {
|
||||
return &GatewayConfig{
|
||||
ListenPort: 8081,
|
||||
ListenTLSPort: 8443,
|
||||
AuthURL: "http://127.0.0.1:4001/_auth",
|
||||
LoginURL: "https://login.example.com/login",
|
||||
Routes: []Route{},
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
gatewayFile = envOr("NAVI_GATEWAY_FILE", filepath.Join(filepath.Dir(confFile), "gateway.yml"))
|
||||
gatewayOutDir = envOr("NAVI_GATEWAY_OUT", filepath.Join(filepath.Dir(confFile), "gateway"))
|
||||
)
|
||||
|
||||
func loadGateway() (*GatewayConfig, error) {
|
||||
cfg := defaultGatewayConfig()
|
||||
data, err := os.ReadFile(gatewayFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cfg, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("路由表解析失败: %w", err)
|
||||
}
|
||||
if cfg.ListenPort == 0 {
|
||||
cfg.ListenPort = 8081
|
||||
}
|
||||
if cfg.ListenTLSPort == 0 {
|
||||
cfg.ListenTLSPort = 8443
|
||||
}
|
||||
if cfg.Routes == nil {
|
||||
cfg.Routes = []Route{}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func saveGateway(cfg *GatewayConfig) error {
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(gatewayFile), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := gatewayFile + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, gatewayFile)
|
||||
}
|
||||
|
||||
// ValidateRoute 只做语法层面的检查;连通性不在这里判断。
|
||||
func ValidateRoute(r Route) error {
|
||||
host := strings.TrimSpace(r.Host)
|
||||
if host == "" {
|
||||
return fmt.Errorf("域名不能为空")
|
||||
}
|
||||
if strings.ContainsAny(host, " \t/\\?#") || strings.Contains(host, "://") {
|
||||
return fmt.Errorf("域名格式不对: %q", r.Host)
|
||||
}
|
||||
up := strings.TrimSpace(r.Upstream)
|
||||
if up == "" {
|
||||
return fmt.Errorf("%s: 后端地址不能为空", host)
|
||||
}
|
||||
if !strings.HasPrefix(up, "http://") && !strings.HasPrefix(up, "https://") {
|
||||
return fmt.Errorf("%s: 后端必须是 http:// 或 https:// 开头", host)
|
||||
}
|
||||
if strings.ContainsAny(up, " \t\"'") {
|
||||
return fmt.Errorf("%s: 后端地址含非法字符", host)
|
||||
}
|
||||
if r.MaxBodyMB < 0 {
|
||||
return fmt.Errorf("%s: 上传上限不能为负", host)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nginxQuote 把值安全地放进单引号里(host/upstream 都已拒绝引号,这里是兜底)。
|
||||
func nginxQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "") + "'"
|
||||
}
|
||||
|
||||
// RenderGatewayConf 生成 include 片段的全部 server 块。
|
||||
//
|
||||
// 认证是按**每条路由**决定的:RequireLogin 为真才生成 auth_request,
|
||||
// 也就是说"哪些服务受统一认证保护"完全由后台的路由表决定。
|
||||
func RenderGatewayConf(cfg *GatewayConfig) (string, error) {
|
||||
if cfg == nil {
|
||||
return "", fmt.Errorf("配置为空")
|
||||
}
|
||||
hosts := make(map[string]bool, len(cfg.Routes))
|
||||
for _, r := range cfg.Routes {
|
||||
if err := ValidateRoute(r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
h := strings.TrimSpace(r.Host)
|
||||
if hosts[h] {
|
||||
return "", fmt.Errorf("域名重复: %s", h)
|
||||
}
|
||||
hosts[h] = true
|
||||
}
|
||||
|
||||
// 先按域名排序,生成结果稳定,便于 diff 与人工核对
|
||||
routes := append([]Route(nil), cfg.Routes...)
|
||||
sort.Slice(routes, func(i, j int) bool { return routes[i].Host < routes[j].Host })
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# 由 HomeDash 后台生成,请勿手改:文件每次应用都会被整体覆盖。\n")
|
||||
b.WriteString("# 源: " + gatewayFile + "\n\n")
|
||||
|
||||
for _, r := range routes {
|
||||
host := strings.TrimSpace(r.Host)
|
||||
up := strings.TrimSpace(r.Upstream)
|
||||
port := cfg.ListenPort
|
||||
if port == 0 {
|
||||
port = 8081
|
||||
}
|
||||
fmt.Fprintf(&b, "server {\n listen %d;\n server_name %s;\n", port, host)
|
||||
if r.MaxBodyMB > 0 {
|
||||
fmt.Fprintf(&b, " client_max_body_size %dm;\n", r.MaxBodyMB)
|
||||
}
|
||||
if r.RequireLogin {
|
||||
if cfg.AuthURL == "" {
|
||||
return "", fmt.Errorf("%s: 需要认证但未配置认证地址", host)
|
||||
}
|
||||
fmt.Fprintf(&b, " # 该域名受统一认证保护\n")
|
||||
fmt.Fprintf(&b, " location = /_auth { internal; proxy_pass %s; proxy_pass_request_body off;\n", cfg.AuthURL)
|
||||
b.WriteString(" proxy_set_header Content-Length \"\";\n proxy_set_header X-Original-URI $request_uri;\n }\n")
|
||||
b.WriteString(" auth_request /_auth;\n")
|
||||
b.WriteString(" error_page 401 = @login;\n")
|
||||
fmt.Fprintf(&b, " location @login { return 302 %s?redirect=$scheme://$host$request_uri; }\n", cfg.LoginURL)
|
||||
} else {
|
||||
b.WriteString(" # 该域名不走统一认证\n")
|
||||
}
|
||||
b.WriteString(" location / {\n")
|
||||
fmt.Fprintf(&b, " proxy_pass %s;\n", up)
|
||||
b.WriteString(" proxy_http_version 1.1;\n")
|
||||
b.WriteString(" proxy_set_header Host $host;\n")
|
||||
b.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n")
|
||||
b.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n")
|
||||
b.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n")
|
||||
if r.Websocket {
|
||||
b.WriteString(" proxy_set_header Upgrade $http_upgrade;\n")
|
||||
b.WriteString(" proxy_set_header Connection \"upgrade\";\n")
|
||||
b.WriteString(" proxy_read_timeout 3600s;\n")
|
||||
}
|
||||
b.WriteString(" proxy_redirect off;\n")
|
||||
b.WriteString(" }\n}\n\n")
|
||||
}
|
||||
|
||||
if cfg.ListenHTTPS {
|
||||
if cfg.CertFile == "" || cfg.KeyFile == "" {
|
||||
return "", fmt.Errorf("启用 HTTPS 但未配置证书路径")
|
||||
}
|
||||
tlsPort := cfg.ListenTLSPort
|
||||
if tlsPort == 0 {
|
||||
tlsPort = 8443
|
||||
}
|
||||
for _, r := range routes {
|
||||
host := strings.TrimSpace(r.Host)
|
||||
fmt.Fprintf(&b, "server {\n listen %d ssl;\n server_name %s;\n", tlsPort, host)
|
||||
fmt.Fprintf(&b, " ssl_certificate %s;\n ssl_certificate_key %s;\n",
|
||||
nginxQuote(cfg.CertFile), nginxQuote(cfg.KeyFile))
|
||||
b.WriteString(" ssl_protocols TLSv1.2 TLSv1.3;\n")
|
||||
// TLS 段复用同一条路由的认证与反代设置
|
||||
if r.RequireLogin {
|
||||
fmt.Fprintf(&b, " location = /_auth { internal; proxy_pass %s; proxy_pass_request_body off;\n", cfg.AuthURL)
|
||||
b.WriteString(" proxy_set_header Content-Length \"\";\n proxy_set_header X-Original-URI $request_uri;\n }\n")
|
||||
b.WriteString(" auth_request /_auth;\n error_page 401 = @login;\n")
|
||||
fmt.Fprintf(&b, " location @login { return 302 %s?redirect=$scheme://$host$request_uri; }\n", cfg.LoginURL)
|
||||
}
|
||||
b.WriteString(" location / {\n")
|
||||
fmt.Fprintf(&b, " proxy_pass %s;\n", strings.TrimSpace(r.Upstream))
|
||||
b.WriteString(" proxy_http_version 1.1;\n proxy_set_header Host $host;\n")
|
||||
b.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n")
|
||||
b.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n")
|
||||
b.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n")
|
||||
if r.Websocket {
|
||||
b.WriteString(" proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection \"upgrade\";\n")
|
||||
}
|
||||
b.WriteString(" proxy_redirect off;\n }\n}\n\n")
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// gatewayFileFor 生成物落地路径(include 目录里的固定文件名)。
|
||||
func gatewayFileFor() string {
|
||||
return filepath.Join(gatewayOutDir, "gateway.conf")
|
||||
}
|
||||
132
admin-server/gateway_test.go
Normal file
132
admin-server/gateway_test.go
Normal file
@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRenderGatewayConfAuthPerRoute(t *testing.T) {
|
||||
cfg := &GatewayConfig{
|
||||
ListenPort: 8081,
|
||||
AuthURL: "http://127.0.0.1:4001/_auth",
|
||||
LoginURL: "https://login.example.com/login",
|
||||
Routes: []Route{
|
||||
{Host: "protected.example.com", Upstream: "http://10.0.0.1:3000", RequireLogin: true, Websocket: true, MaxBodyMB: 64},
|
||||
{Host: "open.example.com", Upstream: "http://10.0.0.2:8000"},
|
||||
},
|
||||
}
|
||||
out, err := RenderGatewayConf(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("渲染失败: %v", err)
|
||||
}
|
||||
// 受保护的域名要有 auth_request 与登录跳转
|
||||
if !contains(out, "server_name protected.example.com;") {
|
||||
t.Fatalf("缺少受保护域名的 server 块:\n%s", out)
|
||||
}
|
||||
if !contains(out, "auth_request /_auth;") {
|
||||
t.Fatalf("受保护域名缺少 auth_request:\n%s", out)
|
||||
}
|
||||
if !contains(out, "location @login") {
|
||||
t.Fatalf("缺少登录跳转")
|
||||
}
|
||||
if !contains(out, "proxy_set_header Upgrade $http_upgrade;") {
|
||||
t.Fatalf("缺少 websocket 透传")
|
||||
}
|
||||
if !contains(out, "client_max_body_size 64m;") {
|
||||
t.Fatalf("缺少上传上限")
|
||||
}
|
||||
// 不受保护的域名不得出现 auth_request("哪些服务受保护"是按路由决定的)
|
||||
open := section(out, "server_name open.example.com;")
|
||||
if open == "" {
|
||||
t.Fatalf("缺少开放域名的 server 块")
|
||||
}
|
||||
if contains(open, "auth_request") {
|
||||
t.Fatalf("未受保护的域名不应有 auth_request:\n%s", open)
|
||||
}
|
||||
if contains(open, "client_max_body_size") {
|
||||
t.Fatalf("未设置上限时不应生成 client_max_body_size:\n%s", open)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderGatewayConfRejectsBadInput(t *testing.T) {
|
||||
base := func(r Route) *GatewayConfig {
|
||||
return &GatewayConfig{ListenPort: 8081, AuthURL: "http://x/_auth", Routes: []Route{r}}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg *GatewayConfig
|
||||
}{
|
||||
{"空域名", base(Route{Host: "", Upstream: "http://a"})},
|
||||
{"域名带协议", base(Route{Host: "http://a.com", Upstream: "http://a"})},
|
||||
{"后端非 http", base(Route{Host: "a.com", Upstream: "10.0.0.1:80"})},
|
||||
{"域名重复", &GatewayConfig{ListenPort: 8081, AuthURL: "http://x/_auth", Routes: []Route{
|
||||
{Host: "a.com", Upstream: "http://a"}, {Host: "a.com", Upstream: "http://b"},
|
||||
}}},
|
||||
{"需认证但无认证地址", &GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "a.com", Upstream: "http://a", RequireLogin: true}}}},
|
||||
{"启用HTTPS但无证书", &GatewayConfig{ListenPort: 8081, ListenHTTPS: true, Routes: []Route{{Host: "a.com", Upstream: "http://a"}}}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, err := RenderGatewayConf(c.cfg); err == nil {
|
||||
t.Errorf("%s: 应当报错但没有", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldFile, oldOut := gatewayFile, gatewayOutDir
|
||||
gatewayFile = dir + "/gateway.yml"
|
||||
gatewayOutDir = dir + "/gateway"
|
||||
defer func() { gatewayFile, gatewayOutDir = oldFile, oldOut }()
|
||||
|
||||
// 文件不存在时应给出默认配置而不是报错
|
||||
cfg, err := loadGateway()
|
||||
if err != nil {
|
||||
t.Fatalf("缺文件应回落默认配置: %v", err)
|
||||
}
|
||||
if cfg.ListenPort != 8081 || len(cfg.Routes) != 0 {
|
||||
t.Fatalf("默认配置不对: %+v", cfg)
|
||||
}
|
||||
cfg.Routes = []Route{{Host: "a.com", Upstream: "http://a", RequireLogin: true}}
|
||||
if err := saveGateway(cfg); err != nil {
|
||||
t.Fatalf("保存失败: %v", err)
|
||||
}
|
||||
got, err := loadGateway()
|
||||
if err != nil || len(got.Routes) != 1 || !got.Routes[0].RequireLogin {
|
||||
t.Fatalf("回读不一致: %+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(hay, needle string) bool {
|
||||
return len(hay) >= len(needle) && (func() bool {
|
||||
for i := 0; i+len(needle) <= len(hay); i++ {
|
||||
if hay[i:i+len(needle)] == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})()
|
||||
}
|
||||
|
||||
// section 取出 server_name 所属的那个 server 块(到下一个 server{ 之前)
|
||||
func section(all, marker string) string {
|
||||
i := indexOf(all, marker)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
start := i
|
||||
for start > 0 && all[start-1] != '\n' {
|
||||
start--
|
||||
}
|
||||
rest := all[i:]
|
||||
if j := indexOf(rest, "\nserver {"); j >= 0 {
|
||||
return rest[:j]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
func indexOf(hay, needle string) int {
|
||||
for i := 0; i+len(needle) <= len(hay); i++ {
|
||||
if hay[i:i+len(needle)] == needle {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@ -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