针对反馈的五个问题: 1. 后台"没有映射":新增 /admin/api/gateway/import 与界面「从现有 nginx 配置导入」, 把老统一入口的 nginx 配置贴进来即可解析出全部「域名 → 后端」初稿 (含 requireLogin / 免认证路径 / SSE 流式 / 备案注入 / 自签证书 / auth 与 portal 类型)。 解析只读不落盘,确认后才保存;导入默认不接管导航,避免一导入就改掉手工导航。 2. 添加转发时没有导航选项:添加表单补齐「同时加入 Dashy 导航 / 分组 / 名称 / 描述 / 图标 / 自动抓取」。 3. 添加转发时没有 portal 保护选项:添加表单补齐「启用 portal 统一登录保护」, 并补上 WebSocket 透传与上传上限。 4. 设置里无法配置 portal 账号:新增统一登录账号卡片,账号存数据卷 (portal-auth.json,只存加盐哈希),优先级 数据卷 > 环境变量, 改完立即生效、无需重新部署;会话校验按当前用户名,改账号后旧会话失效。 5. 设置项太乱:按用途分组(站点与外观 / 导航内容 / 历史记录 / 转发与统一登录 / 高级), 外观卡片并入站点组。 另修两个本轮引入的缺陷: - 容器假 unhealthy:内部端口改 18080 后,基础镜像的 HEALTHCHECK 仍指向 8080; 部署脚本生成的 Dockerfile 现在显式覆盖为 :18080/healthz。 - kind=auth 不依赖 portal-auth 容器,指向内置登录服务;避免生成空 proxy_pass。 测试:nginx 配置解析(含兜底段忽略、多域名段、类型判定、默认不接管导航)、 垃圾输入报错、账号存储(不明文、可校验、改名后旧口令失效)、auth 类型生成。
332 lines
12 KiB
Go
332 lines
12 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
)
|
||
|
||
// Route 同时描述一条反向代理及其在 Dashy 中的导航信息。
|
||
// Kind: proxy(普通反代)、portal(内置门户静态页)、auth(内置登录服务)。
|
||
type Route struct {
|
||
Host string `yaml:"host" json:"host"`
|
||
Kind string `yaml:"kind,omitempty" json:"kind,omitempty"`
|
||
Upstream string `yaml:"upstream,omitempty" json:"upstream,omitempty"`
|
||
RequireLogin bool `yaml:"requireLogin" json:"requireLogin"`
|
||
Websocket bool `yaml:"websocket" json:"websocket"`
|
||
Streaming bool `yaml:"streaming,omitempty" json:"streaming,omitempty"`
|
||
InsecureTLS bool `yaml:"insecureTls,omitempty" json:"insecureTls,omitempty"`
|
||
MaxBodyMB int `yaml:"maxBodyMB,omitempty" json:"maxBodyMB,omitempty"`
|
||
BypassPaths []string `yaml:"bypassPaths,omitempty" json:"bypassPaths,omitempty"`
|
||
InjectBeian bool `yaml:"injectBeian,omitempty" json:"injectBeian,omitempty"`
|
||
Note string `yaml:"note,omitempty" json:"note,omitempty"`
|
||
|
||
NavEnabled bool `yaml:"navEnabled,omitempty" json:"navEnabled,omitempty"`
|
||
NavSection string `yaml:"navSection,omitempty" json:"navSection,omitempty"`
|
||
NavName string `yaml:"navName,omitempty" json:"navName,omitempty"`
|
||
NavDescription string `yaml:"navDescription,omitempty" json:"navDescription,omitempty"`
|
||
NavURL string `yaml:"navUrl,omitempty" json:"navUrl,omitempty"`
|
||
NavIcon string `yaml:"navIcon,omitempty" json:"navIcon,omitempty"`
|
||
NavAutoIcon bool `yaml:"navAutoIcon,omitempty" json:"navAutoIcon,omitempty"`
|
||
}
|
||
|
||
// GatewayConfig 是统一入口的完整配置。ListenHTTPS=false 时 ListenPort 是普通
|
||
// HTTP 旁路端口;开启后 ListenPort/ListenTLSPort 都是 TLS 端口,RedirectPort
|
||
// 是 HTTP→HTTPS 入口。这样可复刻原 portal-nginx 的 3080/8443 + 3081 设计。
|
||
type GatewayConfig struct {
|
||
ListenHTTPS bool `yaml:"listenHttps" json:"listenHttps"`
|
||
CertFile string `yaml:"certFile,omitempty" json:"certFile,omitempty"`
|
||
KeyFile string `yaml:"keyFile,omitempty" json:"keyFile,omitempty"`
|
||
AuthURL string `yaml:"authUrl,omitempty" json:"authUrl,omitempty"`
|
||
LoginURL string `yaml:"loginUrl,omitempty" json:"loginUrl,omitempty"`
|
||
|
||
ListenPort int `yaml:"listenPort,omitempty" json:"listenPort,omitempty"`
|
||
ListenTLSPort int `yaml:"listenTlsPort,omitempty" json:"listenTlsPort,omitempty"`
|
||
RedirectPort int `yaml:"redirectPort,omitempty" json:"redirectPort,omitempty"`
|
||
Routes []Route `yaml:"routes" json:"routes"`
|
||
}
|
||
|
||
func defaultGatewayConfig() *GatewayConfig {
|
||
return &GatewayConfig{
|
||
ListenPort: 8081,
|
||
ListenTLSPort: 8443,
|
||
AuthURL: "http://127.0.0.1:3005/auth/verify",
|
||
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)
|
||
}
|
||
|
||
func routeKind(r Route) string {
|
||
k := strings.ToLower(strings.TrimSpace(r.Kind))
|
||
if k == "" {
|
||
return "proxy"
|
||
}
|
||
return k
|
||
}
|
||
|
||
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)
|
||
}
|
||
kind := routeKind(r)
|
||
if kind != "proxy" && kind != "portal" && kind != "auth" {
|
||
return fmt.Errorf("%s: 未知服务类型 %q", host, r.Kind)
|
||
}
|
||
if kind == "proxy" {
|
||
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)
|
||
}
|
||
for _, p := range r.BypassPaths {
|
||
if !strings.HasPrefix(p, "/") || strings.ContainsAny(p, " \t\"';{}") {
|
||
return fmt.Errorf("%s: 免认证路径格式不对: %q", host, p)
|
||
}
|
||
}
|
||
if r.NavEnabled {
|
||
if strings.TrimSpace(r.NavName) == "" {
|
||
return fmt.Errorf("%s: 已加入导航但名称为空", host)
|
||
}
|
||
if strings.TrimSpace(r.NavSection) == "" {
|
||
return fmt.Errorf("%s: 已加入导航但未选择分组", host)
|
||
}
|
||
if isHistorySectionName(r.NavSection) {
|
||
return fmt.Errorf("%s: 历史访问是系统分组,不能放手工导航", host)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func isHistorySectionName(name string) bool {
|
||
n := strings.ToLower(strings.TrimSpace(name))
|
||
return n == "历史访问" || n == "history" || n == "recent"
|
||
}
|
||
|
||
func nginxQuote(s string) string {
|
||
return "'" + strings.ReplaceAll(s, "'", "") + "'"
|
||
}
|
||
|
||
func writeTLS(b *strings.Builder, cfg *GatewayConfig) {
|
||
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 http2 on;\n")
|
||
}
|
||
|
||
func writeListeners(b *strings.Builder, cfg *GatewayConfig, defaultServer bool) {
|
||
suffix := ""
|
||
if defaultServer {
|
||
suffix = " default_server"
|
||
}
|
||
if cfg.ListenHTTPS {
|
||
fmt.Fprintf(b, " listen %d ssl%s;\n", cfg.ListenPort, suffix)
|
||
if cfg.ListenTLSPort > 0 && cfg.ListenTLSPort != cfg.ListenPort {
|
||
fmt.Fprintf(b, " listen %d ssl%s;\n", cfg.ListenTLSPort, suffix)
|
||
}
|
||
return
|
||
}
|
||
fmt.Fprintf(b, " listen %d%s;\n", cfg.ListenPort, suffix)
|
||
}
|
||
|
||
func writeErrorPages(b *strings.Builder, include404 bool) {
|
||
b.WriteString(" root /app/portal;\n")
|
||
if include404 {
|
||
b.WriteString(" error_page 404 /404.html;\n location = /404.html { internal; }\n")
|
||
}
|
||
b.WriteString(" error_page 500 502 503 504 /500.html;\n location = /500.html { internal; }\n")
|
||
b.WriteString(" location ^~ /css/ { try_files $uri =404; }\n")
|
||
b.WriteString(" location = /favicon.svg { try_files $uri =404; }\n")
|
||
b.WriteString(" location = /favicon.ico { try_files $uri =404; }\n")
|
||
}
|
||
|
||
func writeProxyLocation(b *strings.Builder, r Route, path string, authOff bool) {
|
||
if path == "" {
|
||
b.WriteString(" location / {\n")
|
||
} else {
|
||
fmt.Fprintf(b, " location ^~ %s {\n", path)
|
||
}
|
||
if authOff {
|
||
b.WriteString(" auth_request off;\n")
|
||
}
|
||
fmt.Fprintf(b, " proxy_pass %s;\n", strings.TrimSpace(r.Upstream))
|
||
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")
|
||
}
|
||
if r.Streaming {
|
||
b.WriteString(" postpone_output 0;\n tcp_nopush off;\n tcp_nodelay on;\n")
|
||
b.WriteString(" sendfile off;\n proxy_buffering off;\n proxy_request_buffering off;\n")
|
||
b.WriteString(" gzip off;\n chunked_transfer_encoding on;\n")
|
||
}
|
||
b.WriteString(" proxy_read_timeout 86400s;\n proxy_send_timeout 86400s;\n")
|
||
if r.InsecureTLS || strings.HasPrefix(strings.ToLower(r.Upstream), "https://") {
|
||
b.WriteString(" proxy_ssl_verify off;\n")
|
||
}
|
||
if r.InjectBeian {
|
||
b.WriteString(" sub_filter '</footer>' '</footer><div class=\"navi-proxy-beian\"><a href=\"https://beian.miit.gov.cn\" target=\"_blank\">京ICP备00000000号-1</a> | <a href=\"https://beian.mps.gov.cn\" target=\"_blank\">京公网安备00000000000000号</a></div>';\n")
|
||
b.WriteString(" sub_filter_once off;\n")
|
||
b.WriteString(" proxy_set_header Accept-Encoding \"\";\n")
|
||
}
|
||
b.WriteString(" proxy_redirect off;\n }\n")
|
||
}
|
||
|
||
func writeRouteServer(b *strings.Builder, cfg *GatewayConfig, r Route) error {
|
||
host := strings.TrimSpace(r.Host)
|
||
b.WriteString("server {\n")
|
||
writeListeners(b, cfg, false)
|
||
fmt.Fprintf(b, " server_name %s;\n", host)
|
||
if cfg.ListenHTTPS {
|
||
writeTLS(b, cfg)
|
||
}
|
||
if r.MaxBodyMB > 0 {
|
||
fmt.Fprintf(b, " client_max_body_size %dm;\n", r.MaxBodyMB)
|
||
}
|
||
writeErrorPages(b, routeKind(r) == "portal")
|
||
|
||
if routeKind(r) == "portal" {
|
||
b.WriteString(" index index.html;\n location / { try_files $uri $uri/ /404.html; }\n}\n\n")
|
||
return nil
|
||
}
|
||
if routeKind(r) == "auth" {
|
||
// 登录域名由容器内自带的登录服务直接响应,替代原先的 portal-auth 容器
|
||
r.Upstream = "http://127.0.0.1:3005"
|
||
r.RequireLogin = false
|
||
}
|
||
if r.RequireLogin {
|
||
if strings.TrimSpace(cfg.AuthURL) == "" || strings.TrimSpace(cfg.LoginURL) == "" {
|
||
return fmt.Errorf("%s: 需要认证但未配置认证/登录地址", host)
|
||
}
|
||
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")
|
||
b.WriteString(" proxy_set_header Cookie $http_cookie;\n")
|
||
b.WriteString(" 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)
|
||
}
|
||
for _, p := range r.BypassPaths {
|
||
writeProxyLocation(b, r, p, true)
|
||
}
|
||
writeProxyLocation(b, r, "", false)
|
||
b.WriteString("}\n\n")
|
||
return nil
|
||
}
|
||
|
||
// RenderGatewayConf 生成完整统一入口配置。未知域名使用内置自定义 404 页面,
|
||
// 反向代理故障使用自定义 500 页面;不会再返回一行纯文本。
|
||
func RenderGatewayConf(cfg *GatewayConfig) (string, error) {
|
||
if cfg == nil {
|
||
return "", fmt.Errorf("配置为空")
|
||
}
|
||
if cfg.ListenPort == 0 {
|
||
cfg.ListenPort = 8081
|
||
}
|
||
if cfg.ListenHTTPS && (cfg.CertFile == "" || cfg.KeyFile == "") {
|
||
return "", fmt.Errorf("启用 HTTPS 但未配置证书路径")
|
||
}
|
||
hosts := make(map[string]bool, len(cfg.Routes))
|
||
for _, r := range cfg.Routes {
|
||
if err := ValidateRoute(r); err != nil {
|
||
return "", err
|
||
}
|
||
h := strings.ToLower(strings.TrimSpace(r.Host))
|
||
if hosts[h] {
|
||
return "", fmt.Errorf("域名重复: %s", h)
|
||
}
|
||
hosts[h] = true
|
||
}
|
||
|
||
routes := append([]Route(nil), cfg.Routes...)
|
||
sort.SliceStable(routes, func(i, j int) bool { return routes[i].Host < routes[j].Host })
|
||
var b strings.Builder
|
||
b.WriteString("# 由 HomeDash 后台生成,请勿手改。源: " + gatewayFile + "\n\n")
|
||
|
||
if cfg.ListenHTTPS && cfg.RedirectPort > 0 {
|
||
fmt.Fprintf(&b, "server {\n listen %d default_server;\n server_name _;\n return 302 https://$host$request_uri;\n}\n\n", cfg.RedirectPort)
|
||
}
|
||
|
||
// 默认服务器:保留原统一代理的自定义错误页设计。
|
||
b.WriteString("server {\n")
|
||
writeListeners(&b, cfg, true)
|
||
b.WriteString(" server_name _;\n")
|
||
if cfg.ListenHTTPS {
|
||
writeTLS(&b, cfg)
|
||
}
|
||
writeErrorPages(&b, true)
|
||
b.WriteString(" location / { return 404; }\n}\n\n")
|
||
|
||
for _, r := range routes {
|
||
if err := writeRouteServer(&b, cfg, r); err != nil {
|
||
return "", err
|
||
}
|
||
}
|
||
return b.String(), nil
|
||
}
|
||
|
||
func gatewayFileFor() string {
|
||
return filepath.Join(gatewayOutDir, "gateway.conf")
|
||
}
|