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:
@ -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
216
admin-server/auth.go
Normal 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)
|
||||
}
|
||||
@ -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
140
admin-server/gateway_nav.go
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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("分组不存在时应报错")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")))
|
||||
|
||||
|
||||
210
admin/index.html
210
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)}
|
||||
<!-- 分组 -->
|
||||
<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> </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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
12
nginx.conf
12
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;
|
||||
|
||||
62
portal/404.html
Normal file
62
portal/404.html
Normal 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
61
portal/500.html
Normal 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
424
portal/css/portal.css
Normal 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
BIN
portal/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
1
portal/favicon.svg
Normal file
1
portal/favicon.svg
Normal 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
95
portal/index.html
Normal 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>
|
||||
|
|
||||
<a href="http://www.beian.gov.cn/portal/registerSystemInfo" target="_blank">豫公网安备 00000000000000号</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user