Files
HomeDash/admin-server/gateway.go
HomeDash Bot 53beff646a feat(admin): 现有配置导入 + 添加表单补齐认证/导航开关 + 统一登录账号设置 + 设置分组
针对反馈的五个问题:

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 类型生成。
2026-09-11 22:00:09 +08:00

332 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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")
}