目标:不再手工维护外部 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 却无证书
六种非法输入必须报错;缺文件时回落默认配置、保存后回读一致。
245 lines
9.5 KiB
Go
245 lines
9.5 KiB
Go
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")
|
||
}
|