diff --git a/Dockerfile b/Dockerfile index 8f4098d..2faa32b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -82,6 +82,7 @@ COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf COPY searxng-settings.yml /etc/searxng/settings.yml COPY conf.example.yml /app/defaults/conf.yml COPY admin/index.html /app/admin/index.html +COPY portal /app/portal # 浏览器扩展安装页与安装包(通过 /extension/ 访问) COPY web/extension/install.html /app/dashy/extension/install.html @@ -96,10 +97,10 @@ COPY web/patch-index.py /tmp/patch-index.py RUN python3 /tmp/patch-index.py /app/dashy/index.html # Data directory -RUN mkdir -p /app/data/item-icons /var/log/supervisor /var/cache/searxng /run/nginx +RUN mkdir -p /app/data/item-icons /app/data/gateway /var/log/supervisor /var/cache/searxng /run/nginx VOLUME ["/app/data"] -EXPOSE 8080 +EXPOSE 18080 3080 3081 8443 HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ - CMD curl -fs http://localhost:8080/conf.yml || exit 1 + CMD curl -fs http://localhost:18080/conf.yml || exit 1 CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] diff --git a/admin-server/auth.go b/admin-server/auth.go new file mode 100644 index 0000000..0493190 --- /dev/null +++ b/admin-server/auth.go @@ -0,0 +1,216 @@ +package main + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "html/template" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const portalCookieName = "portal_session" + +var ( + portalUsername = envOr("PORTAL_USERNAME", "admin") + portalPassword = envOr("PORTAL_PASSWORD", "changeme") + portalCookieDomain = envOr("PORTAL_COOKIE_DOMAIN", ".example.com") + portalHomeURL = envOr("PORTAL_HOME_URL", "https://dashy.example.com") + portalSessionTTL = 24 * time.Hour + portalSecretOnce sync.Once + portalSecret []byte +) + +type portalClaims struct { + User string `json:"u"` + Expiry int64 `json:"e"` +} + +func sessionSecret() []byte { + portalSecretOnce.Do(func() { + if raw := strings.TrimSpace(os.Getenv("PORTAL_SESSION_SECRET")); raw != "" { + sum := sha256.Sum256([]byte(raw)) + portalSecret = sum[:] + return + } + path := filepath.Join(filepath.Dir(confFile), "portal-session.key") + if b, err := os.ReadFile(path); err == nil { + if decoded, err := hex.DecodeString(strings.TrimSpace(string(b))); err == nil && len(decoded) >= 32 { + portalSecret = decoded + return + } + } + portalSecret = make([]byte, 32) + if _, err := rand.Read(portalSecret); err != nil { + sum := sha256.Sum256([]byte(fmt.Sprintf("%d-%d", time.Now().UnixNano(), os.Getpid()))) + portalSecret = sum[:] + } + _ = os.MkdirAll(filepath.Dir(path), 0o755) + _ = os.WriteFile(path, []byte(hex.EncodeToString(portalSecret)), 0o600) + }) + return portalSecret +} + +func signClaims(c portalClaims) (string, error) { + payload, err := json.Marshal(c) + if err != nil { + return "", err + } + body := base64.RawURLEncoding.EncodeToString(payload) + mac := hmac.New(sha256.New, sessionSecret()) + _, _ = mac.Write([]byte(body)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return body + "." + sig, nil +} + +func verifyClaims(token string) bool { + parts := strings.Split(token, ".") + if len(parts) != 2 { + return false + } + mac := hmac.New(sha256.New, sessionSecret()) + _, _ = mac.Write([]byte(parts[0])) + got, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil || !hmac.Equal(got, mac.Sum(nil)) { + return false + } + payload, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return false + } + var c portalClaims + if json.Unmarshal(payload, &c) != nil { + return false + } + return c.User == portalUsername && c.Expiry >= time.Now().Unix() +} + +func portalAuthenticated(r *http.Request) bool { + c, err := r.Cookie(portalCookieName) + return err == nil && verifyClaims(c.Value) +} + +func setPortalCookie(w http.ResponseWriter, token string, maxAge int) { + http.SetCookie(w, &http.Cookie{ + Name: portalCookieName, Value: token, Path: "/", Domain: portalCookieDomain, + MaxAge: maxAge, Expires: time.Now().Add(time.Duration(maxAge) * time.Second), + HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, + }) +} + +func secureEqual(a, b string) bool { + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +func safePortalRedirect(raw string) string { + if strings.TrimSpace(raw) == "" { + return portalHomeURL + } + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.Hostname() == "" { + return portalHomeURL + } + cfg, err := loadGateway() + if err != nil { + return portalHomeURL + } + for _, r := range cfg.Routes { + if strings.EqualFold(strings.TrimSpace(r.Host), u.Hostname()) { + return u.String() + } + } + return portalHomeURL +} + +var portalLoginTemplate = template.Must(template.New("login").Parse(` + +统一门户登录

统一门户

登录后访问受保护的内部服务

{{if .Error}}
{{.Error}}
{{end}}
京ICP备00000000号-1
`)) + +func renderPortalLogin(w http.ResponseWriter, redirect, errText string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = portalLoginTemplate.Execute(w, map[string]string{ + "Redirect": url.QueryEscape(redirect), "Error": errText, + }) +} + +func handlePortalLogin(w http.ResponseWriter, r *http.Request) { + redirect := safePortalRedirect(r.URL.Query().Get("redirect")) + if r.Method == http.MethodGet { + if portalAuthenticated(r) { + http.Redirect(w, r, redirect, http.StatusFound) + return + } + renderPortalLogin(w, redirect, "") + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + renderPortalLogin(w, redirect, "请求解析失败") + return + } + if !secureEqual(r.FormValue("username"), portalUsername) || !secureEqual(r.FormValue("password"), portalPassword) { + w.WriteHeader(http.StatusUnauthorized) + renderPortalLogin(w, redirect, "用户名或密码错误") + return + } + token, err := signClaims(portalClaims{User: portalUsername, Expiry: time.Now().Add(portalSessionTTL).Unix()}) + if err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + return + } + setPortalCookie(w, token, int(portalSessionTTL/time.Second)) + http.Redirect(w, r, redirect, http.StatusFound) +} + +func handlePortalVerify(w http.ResponseWriter, r *http.Request) { + if !portalAuthenticated(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Length", "0") + w.WriteHeader(http.StatusOK) +} + +func handlePortalLogout(w http.ResponseWriter, r *http.Request) { + setPortalCookie(w, "", -1) + http.Redirect(w, r, "/login", http.StatusFound) +} + +func handlePortalRoot(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + if portalAuthenticated(r) { + http.Redirect(w, r, portalHomeURL, http.StatusFound) + return + } + http.Redirect(w, r, "/login", http.StatusFound) +} + +func registerPortalAuth() { + http.HandleFunc("/login", handlePortalLogin) + http.HandleFunc("/verify", handlePortalVerify) + http.HandleFunc("/_auth", handlePortalVerify) + http.HandleFunc("/logout", handlePortalLogout) + http.HandleFunc("/auth/verify", handlePortalVerify) +} diff --git a/admin-server/gateway.go b/admin-server/gateway.go index 5cde081..d5c7de0 100644 --- a/admin-server/gateway.go +++ b/admin-server/gateway.go @@ -10,43 +10,43 @@ import ( "gopkg.in/yaml.v3" ) -// ── 统一入口(gateway)路由表 ── -// -// 目标:把"哪个域名反代到哪个内网服务、要不要走统一登录认证"变成**后台可编辑的 -// 数据**,由本进程生成 nginx 配置并热加载,从而不再需要手工维护一份外部 nginx -// 配置(也就避开了"单文件 bind mount 不能用 sed -i"那类坑)。 -// -// 设计要点: -// - 路由表单独存一个文件(不是 conf.yml):即使路由写坏了也不会影响导航页本身, -// 而且 conf.yml 是 Dashy 的配置,语义上不该混进来。 -// - 生成物写在**容器自己的数据卷**里(include 目录),不碰宿主机文件。 -// - 应用配置的顺序永远是:生成 → nginx -t 校验 → 通过才替换并 reload; -// 校验失败保留上一份可用配置并把 nginx 的报错回给调用方。 - -// Route 一条域名→后端映射。 +// Route 同时描述一条反向代理及其在 Dashy 中的导航信息。 +// Kind: proxy(普通反代)、portal(内置门户静态页)、auth(内置登录服务)。 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"` // 备注,仅界面展示 + 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 路由表 + 入口参数。 +// GatewayConfig 是统一入口的完整配置。ListenHTTPS=false 时 ListenPort 是普通 +// HTTP 旁路端口;开启后 ListenPort/ListenTLSPort 都是 TLS 端口,RedirectPort +// 是 HTTP→HTTPS 入口。这样可复刻原 portal-nginx 的 3080/8443 + 3081 设计。 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) + 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"` } @@ -54,7 +54,7 @@ func defaultGatewayConfig() *GatewayConfig { return &GatewayConfig{ ListenPort: 8081, ListenTLSPort: 8443, - AuthURL: "http://127.0.0.1:4001/_auth", + AuthURL: "http://127.0.0.1:3005/auth/verify", LoginURL: "https://login.example.com/login", Routes: []Route{}, } @@ -104,149 +104,223 @@ func saveGateway(cfg *GatewayConfig) error { return os.Rename(tmp, gatewayFile) } -// ValidateRoute 只做语法层面的检查;连通性不在这里判断。 +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, "://") { + 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) + kind := routeKind(r) + if kind != "proxy" && kind != "portal" && kind != "auth" { + return fmt.Errorf("%s: 未知服务类型 %q", host, r.Kind) } - 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 kind != "portal" { + 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 } -// nginxQuote 把值安全地放进单引号里(host/upstream 都已拒绝引号,这里是兜底)。 +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, "'", "") + "'" } -// RenderGatewayConf 生成 include 片段的全部 server 块。 -// -// 认证是按**每条路由**决定的:RequireLogin 为真才生成 auth_request, -// 也就是说"哪些服务受统一认证保护"完全由后台的路由表决定。 +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 '' '
京ICP备00000000号-1 | 京公网安备00000000000000号
';\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 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.TrimSpace(r.Host) + h := strings.ToLower(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 }) - + sort.SliceStable(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") + b.WriteString("# 由 HomeDash 后台生成,请勿手改。源: " + gatewayFile + "\n\n") - port := cfg.ListenPort - if port == 0 { - port = 8081 + 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) } - // 兜底 server:没有匹配到任何 server_name 的请求必须当场拒绝。 - // 不加这一段,nginx 会把未知域名交给该端口上排序第一的 server 块—— - // 也就是"打错域名会访问到别的服务"。404 而不是 444,便于排查。 - fmt.Fprintf(&b, "server {\n listen %d default_server;\n server_name _;\n return 404 \"unknown host\\n\";\n}\n\n", port) + + // 默认服务器:保留原统一代理的自定义错误页设计。 + 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 { - host := strings.TrimSpace(r.Host) - up := strings.TrimSpace(r.Upstream) - 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 - } - fmt.Fprintf(&b, "server {\n listen %d ssl default_server;\n server_name _;\n", tlsPort) - 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 return 404 \"unknown host\\n\";\n}\n\n") - 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") + if err := writeRouteServer(&b, cfg, r); err != nil { + return "", err } } return b.String(), nil } -// gatewayFileFor 生成物落地路径(include 目录里的固定文件名)。 func gatewayFileFor() string { return filepath.Join(gatewayOutDir, "gateway.conf") } diff --git a/admin-server/gateway_nav.go b/admin-server/gateway_nav.go new file mode 100644 index 0000000..9a7cbbb --- /dev/null +++ b/admin-server/gateway_nav.go @@ -0,0 +1,140 @@ +package main + +import ( + "bytes" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + gatewayManagedKey = "naviGateway" + gatewayHostKey = "naviGatewayHost" + gatewayAutoIconKey = "naviGatewayAutoIcon" +) + +func normalizedHost(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return "" + } + return strings.ToLower(u.Hostname()) +} + +func navURLForRoute(r Route) string { + if r.NavURL != "" { + return strings.TrimSpace(r.NavURL) + } + return "https://" + strings.TrimSpace(r.Host) +} + +// validateGatewayNavigation ensures every selected Dashy group exists before either +// gateway.yml or conf.yml is changed. +func validateGatewayNavigation(gw *GatewayConfig, dashy *Config) error { + sections := make(map[string]bool, len(dashy.Sections)) + for _, s := range dashy.Sections { + sections[strings.TrimSpace(s.Name)] = true + } + for _, r := range gw.Routes { + if !r.NavEnabled { + continue + } + if !sections[strings.TrimSpace(r.NavSection)] { + return fmt.Errorf("%s: 导航分组 %q 不存在", r.Host, r.NavSection) + } + } + return nil +} + +// syncGatewayNavigation makes gateway.yml the source of truth for proxy-managed +// Dashy items. Manually-created items that are unrelated to a managed host remain +// untouched. Existing entries for a newly-managed host are adopted instead of +// duplicated. +func syncGatewayNavigation(gw *GatewayConfig) error { + dashy, err := loadConfig() + if err != nil { + return err + } + if err := validateGatewayNavigation(gw, dashy); err != nil { + return err + } + + managedHosts := make(map[string]bool) + for _, r := range gw.Routes { + if r.NavEnabled { + managedHosts[strings.ToLower(strings.TrimSpace(r.Host))] = true + } + } + + // Remove stale proxy-managed items and adopt existing items for hosts that are + // now managed by the gateway. The system-owned history group is never touched. + for si := range dashy.Sections { + if isHistorySection(dashy.Sections[si]) { + continue + } + kept := dashy.Sections[si].Items[:0] + for _, item := range dashy.Sections[si].Items { + itemHost := normalizedHost(item.URL) + wasManaged, _ := item.Extra[gatewayManagedKey].(bool) + if wasManaged || managedHosts[itemHost] { + continue + } + kept = append(kept, item) + } + dashy.Sections[si].Items = kept + } + + for _, r := range gw.Routes { + if !r.NavEnabled { + continue + } + idx := -1 + for i := range dashy.Sections { + if strings.TrimSpace(dashy.Sections[i].Name) == strings.TrimSpace(r.NavSection) { + idx = i + break + } + } + if idx < 0 { + return fmt.Errorf("%s: 导航分组 %q 不存在", r.Host, r.NavSection) + } + icon := strings.TrimSpace(r.NavIcon) + if icon == "" { + icon = "fas fa-globe" + } + item := NavItem{ + Title: strings.TrimSpace(r.NavName), + Description: strings.TrimSpace(r.NavDescription), + Icon: icon, + URL: navURLForRoute(r), + Extra: map[string]any{ + gatewayManagedKey: true, + gatewayHostKey: strings.ToLower(strings.TrimSpace(r.Host)), + gatewayAutoIconKey: r.NavAutoIcon, + }, + } + dashy.Sections[idx].Items = append(dashy.Sections[idx].Items, item) + } + if err := saveConfig(dashy); err != nil { + return err + } + triggerHistoryRebuild() + return nil +} + +func triggerHistoryRebuild() { + req, err := http.NewRequest(http.MethodPost, historyBaseURL+"/rebuild", bytes.NewReader(nil)) + if err != nil { + return + } + if adminToken != "" { + req.Header.Set("Authorization", "Bearer "+adminToken) + } + client := &http.Client{Timeout: 3 * time.Second} + res, err := client.Do(req) + if err == nil { + _ = res.Body.Close() + } +} diff --git a/admin-server/gateway_test.go b/admin-server/gateway_test.go index 6b4c06e..b75a40c 100644 --- a/admin-server/gateway_test.go +++ b/admin-server/gateway_test.go @@ -1,6 +1,9 @@ package main -import "testing" +import ( + "os" + "testing" +) func TestRenderGatewayConfAuthPerRoute(t *testing.T) { cfg := &GatewayConfig{ @@ -134,3 +137,164 @@ func indexOf(hay, needle string) int { } return -1 } + +// 未知域名必须走内置自定义 404 页面,而不是一行纯文本。 +func TestRenderGatewayConfUsesCustomErrorPages(t *testing.T) { + out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, AuthURL: "http://x/_auth", + LoginURL: "https://l/login", Routes: []Route{{Host: "a.com", Upstream: "http://a"}}}) + if err != nil { + t.Fatalf("渲染失败: %v", err) + } + for _, want := range []string{ + "default_server", + "error_page 404 /404.html;", + "error_page 500 502 503 504 /500.html;", + "root /app/portal;", + } { + if !contains(out, want) { + t.Fatalf("缺少 %q:\n%s", want, out) + } + } + if contains(out, `return 404 "unknown host`) { + t.Fatalf("不应再返回纯文本 404:\n%s", out) + } +} + +// 门户静态页类型:不需要后端,且必须能直接给出 404 页面。 +func TestRenderGatewayConfPortalKind(t *testing.T) { + out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{ + {Host: "example.com", Kind: "portal"}, + }}) + if err != nil { + t.Fatalf("portal 类型不应要求后端: %v", err) + } + if !contains(out, "server_name example.com;") || !contains(out, "try_files $uri $uri/ /404.html;") { + t.Fatalf("portal 段生成不对:\n%s", out) + } +} + +// 加入导航必须同时给出分组名与名称;历史访问是系统分组。 +func TestValidateRouteNavigation(t *testing.T) { + cases := []struct { + name string + r Route + wantErr bool + }{ + {"缺少导航名称", Route{Host: "a.com", Upstream: "http://a", NavEnabled: true, NavSection: "常用服务"}, true}, + {"缺少分组", Route{Host: "a.com", Upstream: "http://a", NavEnabled: true, NavName: "A"}, true}, + {"占用历史分组", Route{Host: "a.com", Upstream: "http://a", NavEnabled: true, NavName: "A", NavSection: "历史访问"}, true}, + {"合法", Route{Host: "a.com", Upstream: "http://a", NavEnabled: true, NavName: "A", NavSection: "常用服务"}, false}, + {"未加入导航则不校验", Route{Host: "a.com", Upstream: "http://a"}, false}, + {"免认证路径非法", Route{Host: "a.com", Upstream: "http://a", BypassPaths: []string{"search"}}, true}, + } + for _, c := range cases { + err := ValidateRoute(c.r) + if c.wantErr && err == nil { + t.Errorf("%s: 应当报错", c.name) + } + if !c.wantErr && err != nil { + t.Errorf("%s: 不应报错,得到 %v", c.name, err) + } + } +} + +// 路由即导航:写入分组、同 host 去重、清理已移除的托管项、不动历史分组。 +func TestSyncGatewayNavigation(t *testing.T) { + dir := t.TempDir() + oldConf, oldURL := confFile, historyBaseURL + confFile, historyBaseURL = dir+"/conf.yml", "http://127.0.0.1:1" + defer func() { confFile, historyBaseURL = oldConf, oldURL }() + + seed := `appConfig: {} +pageInfo: {} +sections: + - name: 常用服务 + icon: fas fa-star + items: + - title: Gitea + url: https://gitea.example.com + - title: 手工项 + url: https://manual.example.com + - name: 历史访问 + icon: fas fa-history + items: + - title: 历史项 + url: https://history.example.com +` + if err := os.WriteFile(confFile, []byte(seed), 0o644); err != nil { + t.Fatal(err) + } + + gw := &GatewayConfig{Routes: []Route{ + {Host: "gitea.example.com", Upstream: "http://127.0.0.1:3000", NavEnabled: true, NavSection: "常用服务", NavName: "Gitea", NavDescription: "代码仓库", NavAutoIcon: true}, + {Host: "new.example.com", Upstream: "http://127.0.0.1:9000", NavEnabled: true, NavSection: "常用服务", NavName: "新服务"}, + {Host: "hidden.example.com", Upstream: "http://127.0.0.1:9001", NavEnabled: false}, + }} + if err := syncGatewayNavigation(gw); err != nil { + t.Fatalf("同步失败: %v", err) + } + cfg, err := loadConfig() + if err != nil { + t.Fatal(err) + } + names := map[string]int{} + for _, s := range cfg.Sections { + for _, it := range s.Items { + names[it.Title]++ + if s.Name == "历史访问" && it.Title != "历史项" { + t.Fatalf("历史分组被写入手工项: %+v", it) + } + } + } + if names["Gitea"] != 1 { + t.Fatalf("同 host 应被接管而不是重复,Gitea 出现 %d 次", names["Gitea"]) + } + if names["新服务"] != 1 { + t.Fatalf("新托管项未写入") + } + if names["手工项"] != 1 { + t.Fatalf("无关手工项被误删") + } + if names["历史项"] != 1 { + t.Fatalf("历史分组内容被改动") + } + // 未加入导航的路由不得出现在导航里 + if names["hidden.example.com"] != 0 { + t.Fatalf("未勾选导航的路由不应写入") + } + + // 再次同步:勾选取消后,托管项应被清理;手工项与历史项保留 + gw.Routes = []Route{{Host: "gitea.example.com", Upstream: "http://127.0.0.1:3000"}} + if err := syncGatewayNavigation(gw); err != nil { + t.Fatal(err) + } + cfg, _ = loadConfig() + after := map[string]int{} + for _, s := range cfg.Sections { + for _, it := range s.Items { + after[it.Title]++ + } + } + if after["Gitea"] != 0 || after["新服务"] != 0 { + t.Fatalf("托管项未被清理: %+v", after) + } + if after["手工项"] != 1 || after["历史项"] != 1 { + t.Fatalf("非托管项被误删: %+v", after) + } +} + +// 分组不存在时必须报错,避免把导航写进不存在的栏目。 +func TestSyncGatewayNavigationMissingSection(t *testing.T) { + dir := t.TempDir() + oldConf, oldURL := confFile, historyBaseURL + confFile, historyBaseURL = dir+"/conf.yml", "http://127.0.0.1:1" + defer func() { confFile, historyBaseURL = oldConf, oldURL }() + _ = os.WriteFile(confFile, []byte("appConfig: {}\npageInfo: {}\nsections: []\n"), 0o644) + + err := syncGatewayNavigation(&GatewayConfig{Routes: []Route{ + {Host: "a.com", Upstream: "http://a", NavEnabled: true, NavSection: "不存在", NavName: "A"}, + }}) + if err == nil { + t.Fatalf("分组不存在时应报错") + } +} diff --git a/admin-server/main.go b/admin-server/main.go index a628355..54560d4 100644 --- a/admin-server/main.go +++ b/admin-server/main.go @@ -1053,11 +1053,38 @@ func handleGatewayRoutes(w http.ResponseWriter, r *http.Request) { if req.ListenTLSPort == 0 { req.ListenTLSPort = 8443 } + // 路由与 Dashy 导航是一份操作:先确认导航分组都存在,再同时更新。 + // 任一步失败都恢复 gateway.yml 与 conf.yml,避免后台显示和导航页各说各话。 + dashy, err := loadConfig() + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + if err := validateGatewayNavigation(&req, dashy); err != nil { + jsonErr(w, 400, err.Error()) + return + } + oldGateway, gatewayExisted := os.ReadFile(gatewayFile) + oldConf, confErr := os.ReadFile(confFile) + if confErr != nil { + jsonErr(w, 500, confErr.Error()) + return + } if err := saveGateway(&req); err != nil { jsonErr(w, 500, err.Error()) return } - jsonResp(w, map[string]string{"status": "saved"}) + if err := syncGatewayNavigation(&req); err != nil { + if gatewayExisted == nil { + _ = os.WriteFile(gatewayFile, oldGateway, 0o600) + } else { + _ = os.Remove(gatewayFile) + } + _ = os.WriteFile(confFile, oldConf, 0o644) + jsonErr(w, 500, "同步 Dashy 导航失败,已回滚:"+err.Error()) + return + } + jsonResp(w, map[string]any{"status": "saved", "routes": len(req.Routes)}) default: jsonErr(w, 405, "method not allowed") } @@ -1130,6 +1157,9 @@ func main() { } } + // 内置统一登录服务:与 gateway 共处一个容器,不再需要 portal-auth 容器。 + registerPortalAuth() + // Serve admin UI static files (nginx strips /admin/ prefix) http.Handle("/", http.FileServer(http.Dir("/app/admin"))) diff --git a/admin/index.html b/admin/index.html index ade07f6..adfe23c 100644 --- a/admin/index.html +++ b/admin/index.html @@ -83,6 +83,16 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)} .sec-in-name{flex:1 1 120px;max-width:200px} .sec-in-icon{flex:1 1 140px;max-width:220px} .sec-acts{display:flex;gap:6px;flex:0 0 auto} +.gw-host{flex:1 1 170px;max-width:280px;min-width:0} +.gw-up{flex:1 1 190px;max-width:320px;min-width:0} +.gw-mb{flex:0 0 110px;min-width:0} +.gw-ck{display:flex;align-items:center;gap:5px;font-size:12.5px;color:var(--tx2);white-space:nowrap} +.gw-kind,.gw-sec{flex:0 0 118px;min-width:0} +.gw-navname{flex:1 1 130px;min-width:0} +.gw-navdesc{flex:1 1 150px;min-width:0} +.gw-navicon{flex:1 1 150px;min-width:0} +.gw-ck input{width:auto;padding:0} +#gw-msg{word-break:break-all} @media (max-width:560px){ body{padding:16px 12px 48px} @@ -94,7 +104,8 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)} .fg[style]{max-width:none!important} .itm{flex-wrap:wrap;gap:8px} .sec-row .itm-i{flex-basis:100%;min-width:0} - .sec-in-name,.sec-in-icon{flex:1 1 100%;max-width:none!important} + .sec-in-name,.sec-in-icon,.gw-host,.gw-up{flex:1 1 100%;max-width:none!important} + .gw-mb,.gw-kind,.gw-sec,.gw-navname,.gw-navdesc,.gw-navicon{flex:1 1 100%;max-width:none!important} .sec-acts{width:100%;justify-content:flex-end} .link-row{flex-wrap:wrap} .link-row input{flex:1 1 100%} @@ -185,6 +196,46 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}

加载中…

+ +
+

统一入口(反向代理与认证)

+

+ 在这里维护「域名 → 内网服务」,并逐条决定是否走统一登录认证。 + 生成物默认监听旁路端口,验证无误后再接管正式入口(80/443)。 +

+
读取中…
+
+
+
+
+ +
+
+
+ 入口参数(端口 / 证书 / 认证地址) +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+

外观

@@ -700,11 +751,168 @@ document.getElementById('file-wall').addEventListener('change', async (ev) => { toast('壁纸已上传并应用'); }); + +/* ── 统一入口:反代 + 认证 ── + 路由表存 gateway.yml;"是否走统一认证"就是每条记录上的一个开关。 + "保存并应用"会先让服务端做 nginx -t 校验,不通过会把原始报错显示出来, + 并保留上一份可用配置(不会把入口改坏)。 */ +let GW = { routes: [] }; + +const gwVal = (id) => { const e = document.getElementById(id); return e ? e.value.trim() : ''; }; +const gwChk = (id) => { const e = document.getElementById(id); return !!(e && e.checked); }; +const gwSet = (id, v) => { const e = document.getElementById(id); if (e) e.value = (v === undefined || v === null) ? '' : v; }; + +async function loadGateway() { + try { + const r = await fetch(A + '/gateway/routes', { headers: authHeaders() }); + if (r.ok) GW = Object.assign(GW, await r.json()); + } catch (e) { /* 读不到就先给空表 */ } + if (!Array.isArray(GW.routes)) GW.routes = []; + renderGateway(); +} + +/* 把界面上的编辑回读到 GW(保存/应用前必调) */ +function collectGateway() { + const routes = GW.routes; + document.querySelectorAll('input[data-gw-host]').forEach((el) => { + const i = +el.dataset.gwHost; + if (!routes[i]) return; + routes[i].host = el.value.trim(); + routes[i].upstream = (document.querySelector(`input[data-gw-up="${i}"]`) || {}).value?.trim() || ''; + routes[i].requireLogin = !!(document.querySelector(`input[data-gw-auth="${i}"]`) || {}).checked; + routes[i].websocket = !!(document.querySelector(`input[data-gw-ws="${i}"]`) || {}).checked; + const mb = parseInt((document.querySelector(`input[data-gw-mb="${i}"]`) || {}).value, 10); + routes[i].maxBodyMB = Number.isFinite(mb) && mb > 0 ? mb : 0; + routes[i].kind = (document.querySelector(`select[data-gw-kind="${i}"]`) || {}).value || 'proxy'; + routes[i].navEnabled = !!(document.querySelector(`input[data-gw-nav="${i}"]`) || {}).checked; + routes[i].navSection = (document.querySelector(`select[data-gw-sec="${i}"]`) || {}).value || ''; + routes[i].navName = ((document.querySelector(`input[data-gw-navname="${i}"]`) || {}).value || '').trim(); + routes[i].navDescription = ((document.querySelector(`input[data-gw-navdesc="${i}"]`) || {}).value || '').trim(); + routes[i].navIcon = ((document.querySelector(`input[data-gw-navicon="${i}"]`) || {}).value || '').trim(); + routes[i].navAutoIcon = !!(document.querySelector(`input[data-gw-auto="${i}"]`) || {}).checked; + }); + GW.listenPort = parseInt(gwVal('gw-port'), 10) || 0; + GW.listenTlsPort = parseInt(gwVal('gw-tlsport'), 10) || 0; + GW.listenHttps = gwChk('gw-tls'); + GW.certFile = gwVal('gw-cert'); + GW.keyFile = gwVal('gw-key'); + GW.authUrl = gwVal('gw-auth'); + GW.loginUrl = gwVal('gw-login'); +} + +/* 导航分组下拉:分组来自「导航分组」卡片,必须已存在(服务端会校验) */ +function sectionOptions(current) { + const opts = ['']; + (S || []).forEach((s) => { + const n = String(s.name || ''); + if (n === '历史访问') return; // 系统分组不接受手工导航 + opts.push(``); + }); + return opts.join(''); +} + +function renderGateway() { + const box = document.getElementById('gw-routes'); + box.innerHTML = GW.routes.length ? GW.routes.map((r, i) => ` +
+
+ + + + + + + + + + + + +
+
+ +
+
`).join('') + : '

还没有映射。添加一条「域名 → 内网服务」,需要登录才能访问的勾上「统一认证」。

'; + + gwSet('gw-port', GW.listenPort); + gwSet('gw-tlsport', GW.listenTlsPort); + gwSet('gw-cert', GW.certFile); + gwSet('gw-key', GW.keyFile); + gwSet('gw-auth', GW.authUrl); + gwSet('gw-login', GW.loginUrl); + const tls = document.getElementById('gw-tls'); + if (tls) tls.checked = !!GW.listenHttps; + + document.querySelectorAll('button[data-gw-del]').forEach((el) => { + el.addEventListener('click', () => { + collectGateway(); + const i = +el.dataset.gwDel; + if (!confirm('删除映射「' + (GW.routes[i] || {}).host + '」?')) return; + GW.routes.splice(i, 1); + renderGateway(); + }); + }); +} + +async function gatewaySave() { + collectGateway(); + const r = await fetch(A + '/gateway/routes', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(GW), + }); + if (!r.ok) { toast('保存失败: ' + (await r.text()).slice(0, 160), '#ef4444'); return false; } + return true; +} + +document.getElementById('gw-add').addEventListener('click', () => { + const host = gwVal('gw-n-host'); + const up = gwVal('gw-n-up'); + if (!host || !up) { toast('请填写域名和后端', '#ef4444'); return; } + collectGateway(); + const firstSection = (S || []).map(s => String(s.name || '')).find(n => n && n !== '历史访问') || ''; + GW.routes.push({ + host: host, upstream: up, requireLogin: true, websocket: false, maxBodyMB: 0, kind: 'proxy', + navEnabled: !!firstSection, navSection: firstSection, navName: host.split('.')[0], + navDescription: '', navIcon: '', navAutoIcon: true, + }); + document.getElementById('gw-n-host').value = ''; + document.getElementById('gw-n-up').value = ''; + renderGateway(); + toast('已加入列表,记得点「保存并应用」'); +}); + +document.getElementById('gw-saveonly').addEventListener('click', async () => { + if (await gatewaySave()) toast('已保存(未应用)'); +}); + +document.getElementById('gw-apply').addEventListener('click', async () => { + const msg = document.getElementById('gw-msg'); + if (msg) msg.textContent = '校验中…'; + if (!(await gatewaySave())) { if (msg) msg.textContent = ''; return; } + const r = await fetch(A + '/gateway/apply', { method: 'POST', headers: authHeaders() }); + const body = await r.text(); + if (r.ok) { + toast('已应用并热加载'); + if (msg) msg.textContent = '已应用 ✓'; + } else { + toast('应用失败', '#ef4444'); + if (msg) msg.innerHTML = '' + esc(body.slice(0, 600)) + ''; + } + loadGateway(); +}); + (async function init() { await loadPageInfo(); await loadSections(); await loadWallpaper(); await loadHistory(); + await loadGateway(); })(); diff --git a/deploy/zd.sh b/deploy/zd.sh index 833eec8..45e7330 100755 --- a/deploy/zd.sh +++ b/deploy/zd.sh @@ -50,11 +50,12 @@ cd "$SRC" node --check web/navi.js (cd admin-server && gofmt -l . && go test ./... >/dev/null) (cd history-server && gofmt -l . && go test ./... >/dev/null) -rm -rf "$CTX"; mkdir -p "$CTX/runtime" "$CTX/web/extension" "$CTX/admin" +rm -rf "$CTX"; mkdir -p "$CTX/runtime" "$CTX/web/extension" "$CTX/admin" "$CTX/portal" (cd history-server && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o "$CTX/runtime/history-server" .) (cd admin-server && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o "$CTX/runtime/admin-server" .) cp nginx.conf supervisord.conf searxng-settings.yml conf.example.yml "$CTX/" cp admin/index.html "$CTX/admin/index.html" +cp -a portal/. "$CTX/portal/" cp web/extension/install.html "$CTX/web/extension/install.html" cp web/navi.js web/navi.css web/patch-index.py "$CTX/web/" cat > "$CTX/Dockerfile" </dev/null 2>&1 || true docker run -d --name navi-next --restart unless-stopped --network bridge \ - -p "${NEW_PORT}:8080" --env-file "/tmp/navi-${STAMP}.env" \ + -p "${NEW_PORT}:18080" --env-file "/tmp/navi-${STAMP}.env" \ -v "${DATA_DIR}:/app/data" "homedash:ui-${VER}" >/dev/null w200() { for _ in $(seq 1 90); do [ "$(curl -s -o /dev/null -w '%{http_code}' "$1" 2>/dev/null || true)" = "200" ] && { echo " ready $1"; return 0; }; sleep 2; done; echo " TIMEOUT $1" >&2; return 1; } +w200 "http://127.0.0.1:${NEW_PORT}/healthz" w200 "http://127.0.0.1:${NEW_PORT}/conf.yml" w200 "http://127.0.0.1:${NEW_PORT}/admin" w200 "http://127.0.0.1:${NEW_PORT}/navi.js" diff --git a/docker-compose.yml b/docker-compose.yml index a224071..f884ff2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: restart: unless-stopped ports: # 宿主机端口:容器内 nginx 端口(Dashy/搜索/管理 统一入口) - - "${NAVI_PORT:-3003}:8080" + - "${NAVI_PORT:-3003}:18080" volumes: # 持久化:导航配置 conf.yml + 历史 history.json + 图标 item-icons/ - ./data:/app/data @@ -26,8 +26,14 @@ services: # ---- 历史条目图标抓取(可选)---- # 留空则自动复用 searxng-settings.yml 里 outgoing.proxies 的代理 - NAVI_FAVICON_PROXY=${NAVI_FAVICON_PROXY:-} + + # ---- 一体化统一登录(gateway 模式)---- + - PORTAL_USERNAME=${PORTAL_USERNAME:-admin} + - PORTAL_PASSWORD=${PORTAL_PASSWORD:-changeme} + - PORTAL_SESSION_SECRET=${PORTAL_SESSION_SECRET:-} + - PORTAL_COOKIE_DOMAIN=${PORTAL_COOKIE_DOMAIN:-.example.com} healthcheck: - test: ["CMD", "curl", "-fs", "http://localhost:8080/healthz"] + test: ["CMD", "curl", "-fs", "http://localhost:18080/healthz"] interval: 30s timeout: 5s start_period: 30s diff --git a/history-server/main.go b/history-server/main.go index eb1e47b..17f2a84 100644 --- a/history-server/main.go +++ b/history-server/main.go @@ -392,6 +392,18 @@ func rebuildDashySections() { if pu, err := url.Parse(u); err == nil { host = strings.ToLower(pu.Hostname()) } + // 由 gateway 管理且勾选“自动获取图标”的导航项也复用同一套 + // favicon 缓存。首次没有缓存先显示 globe,后台抓完会再次重建。 + if auto, _ := m["naviGatewayAutoIcon"].(bool); auto { + if hp := hostPortOf(u); hp != "" { + if name := cachedFavicon(hp); name != "" { + icon = name + m["icon"] = name + } else { + queueFavicon(hp) + } + } + } v := 0 if host != "" { v = hv[host] @@ -639,6 +651,20 @@ func main() { } })) http.HandleFunc("/stats", cors(handleStats)) + // 管理后台保存 gateway 路由后触发一次导航/收藏重建;只允许容器内 + // admin-server 用 admin token 调用,公网 nginx 没有暴露这个路径。 + http.HandleFunc("/rebuild", cors(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !requireToken(w, r, adminToken) { + return + } + rebuildDashySections() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "rebuilt"}) + })) // 逐条删除历史(?url=),需 admin token;供管理后台转发调用 http.HandleFunc("/item", cors(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { diff --git a/nginx.conf b/nginx.conf index 48a3060..54f774d 100644 --- a/nginx.conf +++ b/nginx.conf @@ -20,12 +20,13 @@ http { gzip on; gzip_types text/plain text/css application/json application/javascript text/xml; - # 全部服务通过统一 8080 端口对外 + # HomeDash 内部入口固定 18080。选择这个端口是为了在 --network host + # 的一体化模式下避开宿主机现有的 blog(:8080) 等服务。 server { - listen 8080; + listen 18080; server_name _; - # 容器内部监听 8080,但对外是 443/80(经门户反代)。 + # 容器内部监听 18080,但对外由 gateway 的 3080/8443 终结 TLS。 # nginx 生成重定向时默认拼绝对地址 $scheme://$host:$server_port, # 会把内部端口泄漏到公网(曾把 /admin 跳到 http://<域名>:8080/admin/), # 而且 https 会被降级成 http。关掉绝对重定向,改用相对 Location。 @@ -139,6 +140,11 @@ http { rewrite ^ /extension/install.html last; } + # 容器健康检查(部署脚本与 Docker HEALTHCHECK 使用) + location = /healthz { + proxy_pass http://127.0.0.1:3005/healthz; + } + # Dashy SPA 路由兜底 location / { try_files $uri $uri/ /index.html; diff --git a/portal/404.html b/portal/404.html new file mode 100644 index 0000000..0f232f6 --- /dev/null +++ b/portal/404.html @@ -0,0 +1,62 @@ + + + + + +404 - JianF Git + + + + + + +
+
+
+

404

+

你访问的页面不存在,可能已被移动或删除

+ + + 返回首页 + + + + 门户导航 + + +
+
+ + diff --git a/portal/500.html b/portal/500.html new file mode 100644 index 0000000..bd180fc --- /dev/null +++ b/portal/500.html @@ -0,0 +1,61 @@ + + + + + +500 - JianF Git + + + + + + +
+
+
+

500

+

服务器内部错误,请稍后重试或联系管理员

+ + + 返回首页 + + + + 门户导航 + + +
+
+ + diff --git a/portal/css/portal.css b/portal/css/portal.css new file mode 100644 index 0000000..bb0ed5d --- /dev/null +++ b/portal/css/portal.css @@ -0,0 +1,424 @@ +:root, +[data-theme="light"] { + --bg-primary: #f8fafc; + --bg-secondary: #ffffff; + --bg-card: #ffffff; + --bg-input: #f1f5f9; + --bg-hover: rgba(241,245,249,0.8); + --text-primary: #1e293b; + --text-secondary: #64748b; + --text-muted: #94a3b8; + --border-color: #e2e8f0; + --accent: #2563eb; + --accent-bg: #dbeafe; + --accent-hover: #1d4ed8; + --danger: #dc2626; + --danger-bg: #fef2f2; + --success: #16a34a; + --success-bg: #f0fdf4; + --warning: #d97706; + --warning-bg: #fffbeb; + --shadow-sm: 0 1px 2px rgba(0,0,0,0.04); + --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.06), 0 2px 4px -2px rgba(0,0,0,0.05); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.08), 0 4px 6px -4px rgba(0,0,0,0.04); + --radius: 10px; + --radius-sm: 6px; + --font: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; + --mono: 'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace; +} + +[data-theme="dark"] { + --bg-primary: #0f172a; + --bg-secondary: #1e293b; + --bg-card: #1e293b; + --bg-input: #334155; + --bg-hover: rgba(51,65,85,0.8); + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --border-color: #334155; + --accent: #60a5fa; + --accent-bg: #1e3a5f; + --accent-hover: #93bbfd; + --danger: #f87171; + --danger-bg: #3b1a1a; + --success: #4ade80; + --success-bg: #1a3b1a; + --warning: #fbbf24; + --warning-bg: #3b2f1a; + --shadow-sm: 0 1px 2px rgba(0,0,0,0.2); + --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.3), 0 2px 4px -2px rgba(0,0,0,0.25); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.35), 0 4px 6px -4px rgba(0,0,0,0.2); +} + +* { margin:0; padding:0; box-sizing:border-box } +html { height:100% } +body { + font-family:var(--font); + background:var(--bg-primary); + color:var(--text-primary); + min-height:100%; + line-height:1.5; + -webkit-font-smoothing:antialiased; + display:flex; + flex-direction:column; +} + +nav { + background:var(--bg-secondary); + border-bottom:1px solid var(--border-color); + height:52px; + display:flex; + align-items:center; + padding:0 24px; + position:sticky; + top:0; + z-index:100; + gap:4px; +} +nav .brand { + font-size:16px; + font-weight:700; + color:var(--accent); + margin-right:24px; + text-decoration:none; + white-space:nowrap; + display:flex; + align-items:center; + gap:8px; +} +nav .brand svg { width:20px; height:20px } +nav a { + padding:14px 16px; + color:var(--text-secondary); + text-decoration:none; + font-size:13px; + font-weight:500; + border-bottom:2px solid transparent; + transition:all .12s; +} +nav a:hover { color:var(--text-primary) } +nav a.active { color:var(--accent); border-bottom-color:var(--accent) } +nav .spacer { margin-left:auto } +nav .nav-btn { + background:none; + border:1px solid var(--border-color); + color:var(--text-secondary); + cursor:pointer; + padding:6px 12px; + border-radius:var(--radius-sm); + font-size:13px; + transition:all .15s; + text-decoration:none; + display:flex; + align-items:center; + gap:6px; +} +nav .nav-btn:hover { color:var(--accent); border-color:var(--accent) } + +.container { padding:32px 24px; max-width:1200px; margin:0 auto; flex:1; width:100% } + +.card { + background:var(--bg-card); + border:1px solid var(--border-color); + border-radius:var(--radius); + padding:20px; + transition:box-shadow .2s,border-color .2s; + box-shadow:var(--shadow-sm); +} + +.btn { + display:inline-flex; + align-items:center; + gap:6px; + padding:8px 16px; + border-radius:var(--radius-sm); + border:none; + font-size:13px; + font-weight:500; + cursor:pointer; + text-decoration:none; + transition:all .15s; + font-family:var(--font); + line-height:1.4; +} +.btn:active { transform:scale(.97) } +.btn-primary { background:var(--accent); color:#fff } +.btn-primary:hover { background:var(--accent-hover) } +.btn-ghost { + background:transparent; + border:1px solid var(--border-color); + color:var(--text-secondary); +} +.btn-ghost:hover { background:var(--bg-hover); color:var(--text-primary) } +.btn-sm { padding:5px 10px; font-size:12px } +.btn-lg { padding:10px 24px; font-size:15px } + +input,textarea,select { + background:var(--bg-input); + border:1px solid var(--border-color); + border-radius:var(--radius-sm); + padding:10px 14px; + color:var(--text-primary); + font-size:14px; + width:100%; + outline:none; + transition:border-color .15s,box-shadow .15s; + font-family:var(--font); +} +input:focus,textarea:focus,select:focus { + border-color:var(--accent); + box-shadow:0 0 0 3px var(--accent-bg); +} + +label { + display:block; + font-size:13px; + font-weight:500; + color:var(--text-secondary); + margin-bottom:4px; +} + +.grid-2 { display:grid; grid-template-columns:1fr 1fr; gap:16px } +.grid-3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:16px } +.grid-4 { display:grid; grid-template-columns:repeat(4,1fr); gap:16px } + +.badge { + display:inline-flex; + align-items:center; + gap:4px; + padding:2px 8px; + border-radius:4px; + font-size:11px; + font-weight:500; +} +.badge-blue { background:var(--accent-bg); color:var(--accent) } +.badge-green { background:var(--success-bg); color:var(--success) } +.badge-red { background:var(--danger-bg); color:var(--danger) } +.badge-warning { background:var(--warning-bg); color:var(--warning) } + +.error { color:var(--danger); font-size:13px; text-align:center; margin-bottom:12px } +.success { color:var(--success); font-size:13px; text-align:center; margin-bottom:12px } + +.footer { + text-align:center; + padding:20px; + color:var(--text-muted); + font-size:12px; + border-top:1px solid var(--border-color); + background:var(--bg-secondary); + margin-top:auto; +} + +.theme-btn { + background:none; + border:1px solid var(--border-color); + color:var(--text-secondary); + cursor:pointer; + padding:6px 10px; + border-radius:var(--radius-sm); + font-size:13px; + display:flex; + align-items:center; + gap:6px; + transition:all .15s; +} +.theme-btn:hover { color:var(--accent); border-color:var(--accent) } +.theme-btn svg { width:16px; height:16px } +.footer a { color:var(--text-muted); text-decoration:none } +.footer a:hover { color:var(--accent) } + +@media(max-width:768px) { + nav { padding:0 12px; gap:2px } + nav .brand { font-size:14px; margin-right:12px } + nav a { padding:16px 8px; font-size:12px } + .container { padding:16px } + .card { padding:14px } + .grid-2,.grid-3,.grid-4 { grid-template-columns:1fr } +} + +/* ===== enhanced decorative & hero styles (added 2026-08-13) ===== */ +body { position:relative } + +.bg-decor { + position:fixed; + inset:0; + z-index:-1; + overflow:hidden; + pointer-events:none; +} +.bg-decor .orb { + position:absolute; + border-radius:50%; + filter:blur(90px); + opacity:.55; +} +[data-theme="light"] .orb { opacity:.5 } +.bg-decor .orb-1 { + width:520px;height:520px; + top:-180px;right:-120px; + background:radial-gradient(circle,#7c3aed,#6366f1 55%,transparent 75%); + animation:drift 26s ease-in-out infinite alternate; +} +.bg-decor .orb-2 { + width:460px;height:460px; + bottom:-200px;left:-140px; + background:radial-gradient(circle,#3b82f6,#22d3ee 55%,transparent 78%); + animation:drift 32s ease-in-out infinite alternate-reverse; +} +[data-theme="dark"] .bg-decor .orb { opacity:.28 } +.bg-decor .grid-lines { + position:absolute;inset:0; + background-image:linear-gradient(rgba(99,102,241,.06) 1px,transparent 1px), + linear-gradient(90deg,rgba(99,102,241,.06) 1px,transparent 1px); + background-size:56px 56px; + mask-image:radial-gradient(ellipse 80% 55% at 50% 0%,#000 30%,transparent 75%); + -webkit-mask-image:radial-gradient(ellipse 80% 55% at 50% 0%,#000 30%,transparent 75%); +} +@keyframes drift { + from { transform:translate(0,0) scale(1) } + to { transform:translate(60px,40px) scale(1.12) } +} + +nav { + background:rgba(255,255,255,.72); + backdrop-filter:blur(14px) saturate(1.5); + -webkit-backdrop-filter:blur(14px) saturate(1.5); + border-bottom:1px solid var(--border-color); +} +[data-theme="dark"] nav { + background:rgba(15,23,42,.68); + backdrop-filter:blur(14px) saturate(1.4); + -webkit-backdrop-filter:blur(14px) saturate(1.4); +} + +.hero { + text-align:center; + padding:76px 20px 40px; +} +.hero-badge { + display:inline-flex; + align-items:center; + gap:8px; + padding:6px 16px; + border-radius:999px; + font-size:13px; + font-weight:600; + color:var(--accent); + background:var(--accent-bg); + border:1px solid transparent; + margin-bottom:22px; +} +.hero-dot { + width:8px;height:8px;border-radius:50%; + background:var(--success); + box-shadow:0 0 0 4px var(--success-bg); +} +.hero-title { + font-size:clamp(2.4rem,5.5vw,3.6rem); + font-weight:800; + letter-spacing:-0.03em; + line-height:1.12; + color:var(--text-primary); + margin-bottom:16px; +} +.hero-grad { + background:linear-gradient(120deg,#3b82f6,#8b5cf6 55%,#06b6d4); + -webkit-background-clip:text; + background-clip:text; + -webkit-text-fill-color:transparent; +} +.hero-sub { + color:var(--text-secondary); + font-size:1.08rem; + max-width:520px; + margin:0 auto 30px; +} +.hero-actions { + display:flex; + gap:14px; + justify-content:center; + flex-wrap:wrap; +} + +.services { + display:grid; + grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); + gap:18px; + margin:0 auto 56px; + max-width:1080px; +} +.service-card { + position:relative; + background:var(--bg-card); + border:1px solid var(--border-color); + border-radius:16px; + padding:22px; + text-decoration:none; + color:var(--text-primary); + transition:transform .2s ease,border-color .2s ease,box-shadow .25s ease; + box-shadow:var(--shadow-sm); + display:flex; + align-items:center; + gap:16px; + overflow:hidden; +} +.service-card::before { + content:""; + position:absolute; + inset:0 0 auto 0; + height:2px; + background:linear-gradient(90deg,transparent,var(--accent),transparent); + opacity:0; + transition:opacity .25s ease; +} +.service-card:hover { + border-color:var(--accent); + box-shadow:var(--shadow-lg); + transform:translateY(-3px); +} +.service-card:hover::before { opacity:1 } +.service-card:hover .card-arrow { transform:translateX(3px); color:var(--accent) } +[data-theme="light"] .service-card { background:rgba(255,255,255,.8) } +.service-icon { + width:48px;height:48px; + border-radius:13px; + display:flex; + align-items:center; + justify-content:center; + flex-shrink:0; + color:#fff; + transition:transform .25s ease; +} +.service-card:hover .service-icon { transform:scale(1.08) rotate(-3deg) } +.service-icon svg { width:25px; height:25px } +.icon-teal { background:linear-gradient(135deg,#10b981,#0ea5e9) } +.icon-violet { background:linear-gradient(135deg,#8b5cf6,#6366f1) } +.icon-grad { background:linear-gradient(135deg,#3b82f6,#8b5cf6) } +.service-info { min-width:0; flex:1 } +.service-name { + font-size:15px; + font-weight:700; + margin-bottom:4px; + display:flex; + align-items:center; + gap:8px; +} +.service-desc { font-size:12.5px; color:var(--text-muted); line-height:1.45 } +.card-arrow { + flex-shrink:0; + color:var(--text-muted); + transition:transform .25s ease,color .2s; + display:flex; + opacity:.75; +} +.card-arrow svg { width:18px; height:18px } +.badge-violet { background:rgba(139,92,246,.14); color:#8b5cf6 } +[data-theme="dark"] .badge-violet { background:rgba(139,92,246,.18); color:#a78bfa } + +@media(max-width:768px) { + .hero { padding:52px 16px 30px } + .services { max-width:100%; grid-template-columns:1fr } + .hero-actions { flex-direction:column; align-items:stretch } + .hero-actions .btn { justify-content:center } +} \ No newline at end of file diff --git a/portal/favicon.ico b/portal/favicon.ico new file mode 100644 index 0000000..806d4d1 Binary files /dev/null and b/portal/favicon.ico differ diff --git a/portal/favicon.svg b/portal/favicon.svg new file mode 100644 index 0000000..376e686 --- /dev/null +++ b/portal/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/portal/index.html b/portal/index.html new file mode 100644 index 0000000..af093f8 --- /dev/null +++ b/portal/index.html @@ -0,0 +1,95 @@ + + + + + +JianF Git - 个人开发环境 + + + + + + + + + + + + + + +