feat(gateway): 一体化统一入口——路由与 Dashy 导航同一份数据、内置登录、原自定义错误页

目标:一个 HomeDash 容器取代 navi + portal-nginx + portal-auth。

- Route 扩展为"反代 + 导航"一条记录:kind(proxy/portal/auth)、streaming、
  insecureTls、bypassPaths、injectBeian,以及 navEnabled/navSection/navName/
  navDescription/navUrl/navIcon/navAutoIcon
- 保存路由时**同时**写 gateway.yml 与 conf.yml 的导航项(gateway_nav.go):
  同 host 接管而不是重复、未勾选导航的会被清理、无关手工项与系统「历史访问」分组
  一律不动;两步任一步失败都回滚(避免后台显示与导航页各说各话)
- 未知域名与上游故障改用**内置自定义 404/500 页面**(portal/ 目录),
  不再是一行纯文本;HTTP→HTTPS 跳转段用 RedirectPort 复刻原 3081 设计
- 内置统一登录(auth.go):HMAC 签名 cookie、恒定时间比较、登录跳转白名单校验,
  portal-auth 容器可以退场
- 自动抓图标对托管导航项同样生效(history-server 识别 naviGatewayAutoIcon)
- 后台卡片补齐"加入导航/分组/名称/描述/图标/自动抓图标/服务类型"字段
- 内部入口端口 8080 → 18080:为一体化 host 模式让开宿主机 blog(:8080)
- 修掉上一轮编辑写坏的 deploy/zd.sh 一行(会让脚本直接失败)

测试:新增自定义错误页、portal 类型、导航字段校验、导航同步(接管/清理/保留)、
分组不存在必须报错等用例;admin-server 与 history-server 全绿。
This commit is contained in:
HomeDash Bot
2026-09-11 21:27:40 +08:00
parent 5f6fca5c59
commit 994d93cc98
17 changed files with 1660 additions and 143 deletions

View File

@ -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"]

216
admin-server/auth.go Normal file
View File

@ -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(`<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>统一门户登录</title><style>
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:20px;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;color:#172033;background:radial-gradient(900px 520px at 15% 0,#dbeafe,transparent 62%),radial-gradient(700px 500px at 90% 10%,#ede9fe,transparent 58%),#f8fafc}.card{width:min(400px,100%);padding:38px;border:1px solid rgba(15,23,42,.1);border-radius:20px;background:rgba(255,255,255,.82);box-shadow:0 24px 70px rgba(15,23,42,.14);backdrop-filter:blur(16px)}.logo{width:52px;height:52px;display:grid;place-items:center;margin:0 auto 15px;border-radius:15px;color:#2563eb;background:#dbeafe;font-weight:900;font-size:21px}h1{text-align:center;font-size:22px;margin:0 0 5px}.sub{text-align:center;color:#64748b;font-size:13px;margin:0 0 25px}label{display:block;font-size:12px;font-weight:700;color:#475569;margin:13px 0 6px}input{width:100%;padding:11px 13px;border:1px solid #d9e1ec;border-radius:10px;background:#f8fafc;font:inherit;font-size:15px;outline:none}input:focus{border-color:#2563eb;box-shadow:0 0 0 3px rgba(37,99,235,.12)}button{width:100%;margin-top:18px;padding:11px;border:0;border-radius:10px;background:#2563eb;color:#fff;font:inherit;font-weight:750;cursor:pointer}.err{padding:9px 11px;border-radius:9px;background:#fef2f2;color:#b91c1c;font-size:13px;margin-bottom:14px}.foot{text-align:center;margin-top:22px;font-size:11px;color:#94a3b8}.foot a{color:inherit;text-decoration:none}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
</style></head><body><main class="card"><div class="logo">JF</div><h1>统一门户</h1><p class="sub">登录后访问受保护的内部服务</p>{{if .Error}}<div class="err">{{.Error}}</div>{{end}}<form method="post" action="/login?redirect={{.Redirect}}"><label for="u">用户名</label><input id="u" name="username" autocomplete="username" required autofocus><label for="p">密码</label><input id="p" type="password" name="password" autocomplete="current-password" required><button type="submit">登录</button></form><div class="foot"><a href="https://beian.miit.gov.cn" target="_blank" rel="noopener">京ICP备00000000号-1</a></div></main></body></html>`))
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)
}

View File

@ -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 '</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 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")
}

140
admin-server/gateway_nav.go Normal file
View File

@ -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()
}
}

View File

@ -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("分组不存在时应报错")
}
}

View File

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

View File

@ -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)}
<!-- 分组 -->
<div id="app"><div class="card"><p class="hint">加载中…</p></div></div>
<!-- 统一入口:反代 + 认证 -->
<div class="card">
<h2>统一入口(反向代理与认证)</h2>
<p class="sub" style="margin:0 0 12px">
在这里维护「域名 → 内网服务」,并逐条决定是否走<b>统一登录认证</b>。
生成物默认监听旁路端口,验证无误后再接管正式入口(80/443)。
</p>
<div id="gw-routes"><div class="muted">读取中…</div></div>
<div class="row" style="margin-top:14px">
<div class="fg"><label>域名</label><input id="gw-n-host" placeholder="git.example.com"></div>
<div class="fg"><label>后端</label><input id="gw-n-up" placeholder="http://10.0.0.10:3000"></div>
<div class="fg" style="max-width:170px;justify-content:flex-end">
<label>&nbsp;</label><button class="primary" id="gw-add">添加映射</button>
</div>
</div>
<details style="margin-top:10px">
<summary>入口参数(端口 / 证书 / 认证地址)</summary>
<div class="row" style="margin-top:10px">
<div class="fg" style="max-width:150px"><label>HTTP 端口</label><input id="gw-port" inputmode="numeric"></div>
<div class="fg" style="max-width:150px"><label>HTTPS 端口</label><input id="gw-tlsport" inputmode="numeric"></div>
<div class="fg" style="max-width:150px"><label>终结 TLS</label>
<label class="gw-ck" style="padding:9px 0"><input type="checkbox" id="gw-tls"> 本容器持有证书</label>
</div>
</div>
<div class="row">
<div class="fg"><label>证书路径</label><input id="gw-cert" placeholder="/etc/nginx/certs/fullchain.pem"></div>
<div class="fg"><label>私钥路径</label><input id="gw-key" placeholder="/etc/nginx/certs/privkey.pem"></div>
</div>
<div class="row">
<div class="fg"><label>认证校验地址(auth_request)</label><input id="gw-auth" placeholder="http://127.0.0.1:4001/_auth"></div>
<div class="fg"><label>未登录跳转</label><input id="gw-login" placeholder="https://login.example.com/login"></div>
</div>
</details>
<div class="row" style="margin-top:14px">
<button class="primary" id="gw-apply">保存并应用</button>
<button id="gw-saveonly">仅保存</button>
<span class="hint" id="gw-msg"></span>
</div>
</div>
<!-- 外观 -->
<div class="card">
<h2>外观</h2>
@ -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 = ['<option value="">选择分组…</option>'];
(S || []).forEach((s) => {
const n = String(s.name || '');
if (n === '历史访问') return; // 系统分组不接受手工导航
opts.push(`<option value="${esc(n)}"${n === current ? ' selected' : ''}>${esc(n)}</option>`);
});
return opts.join('');
}
function renderGateway() {
const box = document.getElementById('gw-routes');
box.innerHTML = GW.routes.length ? GW.routes.map((r, i) => `
<div class="itm sec-row">
<div class="itm-i sec-fields">
<input class="gw-host" data-gw-host="${i}" value="${esc(r.host || '')}" placeholder="域名">
<input class="gw-up" data-gw-up="${i}" value="${esc(r.upstream || '')}" placeholder="后端 http://ip:port">
<label class="gw-ck"><input type="checkbox" data-gw-auth="${i}" ${r.requireLogin ? 'checked' : ''}>统一认证</label>
<label class="gw-ck"><input type="checkbox" data-gw-ws="${i}" ${r.websocket ? 'checked' : ''}>WebSocket</label>
<input class="gw-mb" data-gw-mb="${i}" value="${r.maxBodyMB || ''}" placeholder="上传MB上限">
<select data-gw-kind="${i}" class="gw-kind">
<option value="proxy"${(r.kind || 'proxy') === 'proxy' ? ' selected' : ''}>反向代理</option>
<option value="portal"${r.kind === 'portal' ? ' selected' : ''}>门户静态页</option>
<option value="auth"${r.kind === 'auth' ? ' selected' : ''}>登录服务</option>
</select>
<label class="gw-ck"><input type="checkbox" data-gw-nav="${i}" ${r.navEnabled ? 'checked' : ''}>加入导航</label>
<select data-gw-sec="${i}" class="gw-sec">${sectionOptions(r.navSection)}</select>
<input class="gw-navname" data-gw-navname="${i}" value="${esc(r.navName || '')}" placeholder="导航名称">
<input class="gw-navdesc" data-gw-navdesc="${i}" value="${esc(r.navDescription || '')}" placeholder="导航描述">
<input class="gw-navicon" data-gw-navicon="${i}" value="${esc(r.navIcon || '')}" placeholder="图标:文件名 / fas fa-*">
<label class="gw-ck"><input type="checkbox" data-gw-auto="${i}" ${r.navAutoIcon ? 'checked' : ''}>自动抓图标</label>
</div>
<div class="sec-acts">
<button class="icon-btn danger" data-gw-del="${i}" title="删除这条映射">✕</button>
</div>
</div>`).join('')
: '<p class="hint" style="margin:0">还没有映射。添加一条「域名 → 内网服务」,需要登录才能访问的勾上「统一认证」。</p>';
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 = '<span style="color:#dc2626;white-space:pre-wrap">' + esc(body.slice(0, 600)) + '</span>';
}
loadGateway();
});
(async function init() {
await loadPageInfo();
await loadSections();
await loadWallpaper();
await loadHistory();
await loadGateway();
})();
</script>
</body>

View File

@ -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" <<EOF
@ -66,6 +67,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
COPY web/extension/install.html /app/dashy/extension/install.html
COPY web/navi.js /app/dashy/navi.js
COPY web/navi.css /app/dashy/navi.css
@ -113,10 +115,11 @@ trap on_err ERR
docker rm -f navi-next >/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"

View File

@ -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

View File

@ -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 {

View File

@ -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;

62
portal/404.html Normal file
View File

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - JianF Git</title>
<link rel="stylesheet" href="/css/portal.css">
<script src="https://code.iconify.design/3/3.1.0/iconify.min.js"></script>
<script>(function(){var s=localStorage.getItem('theme');var p=window.matchMedia('(prefers-color-scheme:dark)').matches;var t=s||(p?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();function toggleTheme(){var n=document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',n);localStorage.setItem('theme',n)}</script>
<style>
.error-page {
display:flex;
align-items:center;
justify-content:center;
min-height:100vh;
text-align:center;
padding:20px;
}
.error-page .card {
padding:48px 40px;
max-width:420px;
width:100%;
}
.error-icon {
width:64px;
height:64px;
border-radius:16px;
background:var(--warning-bg);
color:var(--warning);
display:flex;
align-items:center;
justify-content:center;
margin:0 auto 20px;
}
.error-icon svg { width:32px; height:32px }
.error-page h1 { font-size:3rem; font-weight:800; margin-bottom:4px; color:var(--text-primary) }
.error-page p { color:var(--text-secondary); margin-bottom:28px; font-size:14px }
.error-page .btn { margin:0 4px }
</style>
</head>
<body>
<div class="error-page">
<div class="card">
<div class="error-icon"><span class="iconify" data-icon="mdi:compass-off-outline"></span></div>
<h1>404</h1>
<p>你访问的页面不存在,可能已被移动或删除</p>
<a href="/" class="btn btn-primary">
<span class="iconify" data-icon="mdi:home"></span>
返回首页
</a>
<a href="https://dashy.example.com" class="btn btn-ghost">
<span class="iconify" data-icon="mdi:view-dashboard"></span>
门户导航
</a>
<button class="btn btn-ghost" onclick="toggleTheme()" style="margin-top:12px">
<span class="iconify" data-icon="mdi:theme-light-dark"></span>
切换主题
</button>
</div>
</div>
</body>
</html>

61
portal/500.html Normal file
View File

@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>500 - JianF Git</title>
<link rel="stylesheet" href="/css/portal.css">
<script src="https://code.iconify.design/3/3.1.0/iconify.min.js"></script>
<script>(function(){var s=localStorage.getItem('theme');var p=window.matchMedia('(prefers-color-scheme:dark)').matches;var t=s||(p?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();function toggleTheme(){var n=document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',n);localStorage.setItem('theme',n)}</script>
<style>
.error-page {
display:flex;
align-items:center;
justify-content:center;
min-height:100vh;
text-align:center;
padding:20px;
}
.error-page .card {
padding:48px 40px;
max-width:420px;
width:100%;
}
.error-icon {
width:64px;
height:64px;
border-radius:16px;
background:var(--danger-bg);
color:var(--danger);
display:flex;
align-items:center;
justify-content:center;
margin:0 auto 20px;
}
.error-icon svg { width:32px; height:32px }
.error-page h1 { font-size:3rem; font-weight:800; margin-bottom:4px; color:var(--text-primary) }
.error-page p { color:var(--text-secondary); margin-bottom:28px; font-size:14px }
</style>
</head>
<body>
<div class="error-page">
<div class="card">
<div class="error-icon"><span class="iconify" data-icon="mdi:alert-circle-outline"></span></div>
<h1>500</h1>
<p>服务器内部错误,请稍后重试或联系管理员</p>
<a href="/" class="btn btn-primary">
<span class="iconify" data-icon="mdi:home"></span>
返回首页
</a>
<a href="https://dashy.example.com" class="btn btn-ghost">
<span class="iconify" data-icon="mdi:view-dashboard"></span>
门户导航
</a>
<button class="btn btn-ghost" onclick="toggleTheme()" style="margin-top:12px">
<span class="iconify" data-icon="mdi:theme-light-dark"></span>
切换主题
</button>
</div>
</div>
</body>
</html>

424
portal/css/portal.css Normal file
View File

@ -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 }
}

BIN
portal/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

1
portal/favicon.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#0f172a"/><path stroke="#38bdf8" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" fill="none" d="M20 26h24l-5 7M20 26l15-12h6l-5 7M44 33l-9 3M20 26l7 18l10-6M27 44l11-6"/></svg>

After

Width:  |  Height:  |  Size: 295 B

95
portal/index.html Normal file
View File

@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JianF Git - 个人开发环境</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/css/portal.css">
<script>(function(){var s=localStorage.getItem('theme');var p=window.matchMedia('(prefers-color-scheme:dark)').matches;var t=s||(p?'dark':'light');document.documentElement.setAttribute('data-theme',t)})();function toggleTheme(){var n=document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',n);localStorage.setItem('theme',n)}</script>
</head>
<body>
<div class="bg-decor" aria-hidden="true">
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
<div class="grid-lines"></div>
</div>
<nav>
<a href="/" class="brand">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M2.6 10.59L8.38 4.8l1.69 1.7c-.24.85.15 1.78.93 2.23v5.54c-.6.34-1 .99-1 1.73a2 2 0 0 0 2 2a2 2 0 0 0 2-2c0-.74-.4-1.39-1-1.73V9.41l2.07 2.09c-.07.15-.07.32-.07.5a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2c-.18 0-.35 0-.5.07L13.93 7.5a1.98 1.98 0 0 0-1.15-2.34c-.43-.16-.88-.2-1.28-.09L9.8 3.38l.79-.78c.78-.79 2.04-.79 2.82 0l7.99 7.99c.79.78.79 2.04 0 2.82l-7.99 7.99c-.78.79-2.04.79-2.82 0L2.6 13.41c-.79-.78-.79-2.04 0-2.82"/></svg>
JianF Git
</a>
<a href="https://gitea.example.com">Gitea</a>
<a href="https://blog.example.com">博客</a>
<div class="spacer"></div>
<button class="theme-btn" onclick="toggleTheme()" title="切换主题">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M7.5 2c-1.79 1.15-3 3.18-3 5.5s1.21 4.35 3.03 5.5C4.46 13 2 10.54 2 7.5A5.5 5.5 0 0 1 7.5 2m11.57 1.5l1.43 1.43L4.93 20.5L3.5 19.07zm-6.18 2.43L11.41 5L9.97 6l.42-1.7L9 3.24l1.75-.12l.58-1.65L12 3.1l1.73.03l-1.35 1.13zm-3.3 3.61l-1.16-.73l-1.12.78l.34-1.32l-1.09-.83l1.36-.09l.45-1.29l.51 1.27l1.36.03l-1.05.87zM19 13.5a5.5 5.5 0 0 1-5.5 5.5c-1.22 0-2.35-.4-3.26-1.07l7.69-7.69c.67.91 1.07 2.04 1.07 3.26m-4.4 6.58l2.77-1.15l-.24 3.35zm4.33-2.7l1.15-2.77l2.2 2.54zm1.15-4.96l-1.14-2.78l3.34.24zM9.63 18.93l2.77 1.15l-2.53 2.19z"/></svg>
</button>
<a href="https://dashy.example.com" class="nav-btn">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M11 7L9.6 8.4l2.6 2.6H2v2h10.2l-2.6 2.6L11 17l5-5zm9 12h-8v2h8c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-8v2h8z"/></svg>
登录
</a>
</nav>
<div class="container">
<section class="hero">
<span class="hero-badge">
<span class="hero-dot"></span>
个人开发环境 </span>
<h1 class="hero-title">代码、博客与<span class="hero-grad">灵感</span>之站</h1>
<p class="hero-sub">JianF Git · 代码托管 · 技术博客 · 私有服务入口</p>
<div class="hero-actions">
<a href="https://gitea.example.com" class="btn btn-primary btn-lg">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="m14.6 16.6l4.6-4.6l-4.6-4.6L16 6l6 6l-6 6zm-5.2 0L4.8 12l4.6-4.6L8 6l-6 6l6 6z"/></svg>
浏览代码仓库
</a>
<a href="https://dashy.example.com" class="btn btn-ghost btn-lg">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M13 3v6h8V3m-8 18h8V11h-8M3 21h8v-6H3m0-2h8V3H3z"/></svg>
进入门户
</a>
</div>
</section>
<section class="services">
<a href="https://gitea.example.com" class="service-card">
<div class="service-icon icon-teal"><svg viewBox="0 0 24 24" width="25" height="25" fill="currentColor" aria-hidden="true"><path d="M4.209 4.603c-.247 0-.525.02-.84.088c-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768c1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367c2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068c0 0 2.107-4.471 2.107-8.823c-.042-1.318-.367-1.55-.443-1.627c-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17c-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12m8.33 2.554c.26.003.509.127.509.127l.868.422l-.529 1.075a.69.69 0 0 0-.614.359a.69.69 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527a.69.69 0 0 0 .347.763a.686.686 0 0 0 .867-.206a.69.69 0 0 0-.069-.882l.916-1.874a.7.7 0 0 0 .237-.02a.66.66 0 0 0 .271-.137a9 9 0 0 1 1.016.512a.76.76 0 0 1 .286.282c.073.21-.073.569-.073.569c-.087.29-.702 1.55-.702 1.55a.69.69 0 0 0-.676.477a.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431c.19-.397.515-1.16.515-1.16c.035-.066.218-.394.103-.814c-.095-.435-.48-.638-.48-.638c-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.7.7 0 0 0-.148-.241l.516-1.062l2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657c-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.1 1.1 0 0 1-.393-.045l-.202-.08l-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.9.9 0 0 1 .35-.077"/></svg></div>
<div class="service-info">
<div class="service-name">Gitea
<span class="badge badge-blue">Git 托管</span>
</div>
<div class="service-desc">私有代码仓库,版本管理与协作</div>
</div>
<span class="card-arrow"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M4 11v2h12l-5.5 5.5l1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5L16 11z"/></svg></span>
</a>
<a href="https://blog.example.com" class="service-card">
<div class="service-icon icon-violet"><svg viewBox="0 0 24 24" width="25" height="25" fill="currentColor" aria-hidden="true"><path d="M19 5v14H5V5zm2-2H3v18h18zm-4 14H7v-1h10zm0-2H7v-1h10zm0-3H7V7h10z"/></svg></div>
<div class="service-info">
<div class="service-name">VanBlog
<span class="badge badge-violet">技术博客</span>
</div>
<div class="service-desc">开发笔记、踩坑记录与思考沉淀</div>
</div>
<span class="card-arrow"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M4 11v2h12l-5.5 5.5l1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5L16 11z"/></svg></span>
</a>
<a href="https://dashy.example.com" class="service-card service-card-accent">
<div class="service-icon icon-grad"><svg viewBox="0 0 24 24" width="25" height="25" fill="currentColor" aria-hidden="true"><path d="M13 3v6h8V3m-8 18h8V11h-8M3 21h8v-6H3m0-2h8V3H3z"/></svg></div>
<div class="service-info">
<div class="service-name">门户中心
<span class="badge badge-blue">全部服务</span>
</div>
<div class="service-desc">登录后使用 AI 编码、系统管理、媒体影音等全部内部服务</div>
</div>
<span class="card-arrow"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M4 11v2h12l-5.5 5.5l1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5L16 11z"/></svg></span>
</a>
</section>
</div>
<div class="footer">
<a href="https://beian.miit.gov.cn" target="_blank">京ICP备00000000号-1</a>
&nbsp;|&nbsp;
<a href="http://www.beian.gov.cn/portal/registerSystemInfo" target="_blank">豫公网安备 00000000000000号</a>
</div>
</body>
</html>