From 8fc21039c054e5dc025743bb160d54453c739d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Mon, 20 Jul 2026 10:14:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(pgadmin):=20=E5=85=A8=E9=87=8F=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=20PG=20=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=B9=B6=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E5=A2=9E=E5=BC=BA=20Cookie=20=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一键登录改为一次性合并面板全部 PostgreSQL 服务器(dump 查缺仅追加, 不影响手动添加的条目);代理登录透传浏览器 IP/UA,通过 Flask-Paranoid 的 sha256(IP|UA) 会话绑定校验,无需关闭 ENHANCED_COOKIE_PROTECTION; 适配 9.x React 登录页的 JSON 形态 CSRF token,Docker 实测全链路通过 Co-Authored-By: Claude Fable 5 --- internal/apps/pgadmin/app.go | 137 +++++++++++++++++++----------- internal/apps/pgadmin/request.go | 4 - web/src/api/apps/pgadmin/index.ts | 4 +- 3 files changed, 91 insertions(+), 54 deletions(-) diff --git a/internal/apps/pgadmin/app.go b/internal/apps/pgadmin/app.go index be383846..d943f3d7 100644 --- a/internal/apps/pgadmin/app.go +++ b/internal/apps/pgadmin/app.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" stdio "io" + "net" "net/http" "net/url" "os" @@ -22,6 +23,7 @@ import ( "github.com/acepanel/panel/v3/internal/app" "github.com/acepanel/panel/v3/internal/biz" "github.com/acepanel/panel/v3/internal/service" + "github.com/acepanel/panel/v3/pkg/config" "github.com/acepanel/panel/v3/pkg/firewall" "github.com/acepanel/panel/v3/pkg/io" "github.com/acepanel/panel/v3/pkg/shell" @@ -31,16 +33,30 @@ import ( type App struct { t *gotext.Locale + conf *config.Config databaseServerRepo biz.DatabaseServerRepo } func NewApp(i do.Injector) (*App, error) { return &App{ t: do.MustInvoke[*gotext.Locale](i), + conf: do.MustInvoke[*config.Config](i), databaseServerRepo: do.MustInvoke[biz.DatabaseServerRepo](i), }, nil } +// clientIP 获取请求来源 IP,面板位于反代之后时优先取配置的 IP 头 +func (s *App) clientIP(r *http.Request) string { + ip := r.RemoteAddr + if header := s.conf.HTTP.IPHeader; header != "" && r.Header.Get(header) != "" { + ip = strings.TrimSpace(strings.Split(r.Header.Get(header), ",")[0]) + } + if host, _, err := net.SplitHostPort(ip); err == nil { + ip = host + } + return ip +} + func (s *App) Route(r chi.Router) { r.Get("/info", s.Info) r.Post("/port", s.UpdatePort) @@ -161,62 +177,90 @@ func escapePgpass(s string) string { return strings.NewReplacer(`\`, `\\`, `:`, `\:`).Replace(s) } -// syncServer 将面板中的 PostgreSQL 服务器同步注册到 pgAdmin,凭据写入 pgpass 实现免密 -func (s *App) syncServer(server *biz.DatabaseServer, email string) error { +// syncServers 将面板中全部 PostgreSQL 服务器合并注册到 pgAdmin,凭据写入 pgpass 实现免密 +// 仅追加 pgAdmin 中缺失的服务器,不影响用户在 pgAdmin 中手动添加的内容 +func (s *App) syncServers(email string) error { + servers, _, err := s.databaseServerRepo.List(1, 10000, string(biz.DatabaseTypePostgresql)) + if err != nil { + return err + } + if len(servers) == 0 { + return nil + } + // pgAdmin server 模式下 PassFile 以用户 storage 目录为根,目录名为邮箱 @ 转 _ storageDir := fmt.Sprintf("%s/data/storage/%s", s.path(), strings.ReplaceAll(email, "@", "_")) pgpass := filepath.Join(storageDir, "pgpass") - // 更新 pgpass 中该服务器的凭据行 - entryPrefix := fmt.Sprintf("%s:%d:*:%s:", escapePgpass(server.Host), server.Port, escapePgpass(server.Username)) + // 重写 pgpass 中面板服务器的凭据行,保留其他行 + prefixes := make([]string, 0, len(servers)) + entries := make([]string, 0, len(servers)) + for _, server := range servers { + prefix := fmt.Sprintf("%s:%d:*:%s:", escapePgpass(server.Host), server.Port, escapePgpass(server.Username)) + prefixes = append(prefixes, prefix) + entries = append(entries, prefix+escapePgpass(server.Password)) + } var lines []string if raw, err := io.Read(pgpass); err == nil { for line := range strings.SplitSeq(strings.TrimSpace(raw), "\n") { - if line != "" && !strings.HasPrefix(line, entryPrefix) { + if line == "" { + continue + } + panelOwned := false + for _, prefix := range prefixes { + if strings.HasPrefix(line, prefix) { + panelOwned = true + break + } + } + if !panelOwned { lines = append(lines, line) } } } - lines = append(lines, entryPrefix+escapePgpass(server.Password)) - if err := os.MkdirAll(storageDir, 0700); err != nil { + lines = append(lines, entries...) + if err = os.MkdirAll(storageDir, 0700); err != nil { return err } - if err := io.Write(pgpass, strings.Join(lines, "\n")+"\n", 0600); err != nil { + if err = io.Write(pgpass, strings.Join(lines, "\n")+"\n", 0600); err != nil { return err } - // 已注册的服务器无需重复导入 + // 导出 pgAdmin 已有服务器用于查缺,dump 为只读操作 dump := filepath.Join(os.TempDir(), "pgadmin-servers.json") defer func() { _ = io.Remove(dump) }() _, _ = shell.Execf("%s/cli dump-servers '%s' --user '%s'", s.path(), dump, email) - exists := false + existing := make(map[string]struct{}) if raw, err := io.Read(dump); err == nil { var dumped serversFile if err = json.Unmarshal([]byte(raw), &dumped); err == nil { for _, item := range dumped.Servers { - if item.Host == server.Host && item.Port == int(server.Port) && item.Username == server.Username { - exists = true - break - } + existing[fmt.Sprintf("%s:%d:%s", item.Host, item.Port, item.Username)] = struct{}{} } } } - if !exists { + // 一次性合并导入缺失的服务器 + missing := make(map[string]serverEntry) + for i, server := range servers { + if _, ok := existing[fmt.Sprintf("%s:%d:%s", server.Host, server.Port, server.Username)]; ok { + continue + } + missing[cast.ToString(i+1)] = serverEntry{ + Name: server.Name, + Group: "AcePanel", + Host: server.Host, + Port: int(server.Port), + MaintenanceDB: "postgres", + Username: server.Username, + SSLMode: "prefer", + PassFile: "/pgpass", + } + } + if len(missing) > 0 { load := filepath.Join(os.TempDir(), "pgadmin-servers-add.json") defer func() { _ = io.Remove(load) }() - payload, err := json.Marshal(serversFile{Servers: map[string]serverEntry{ - "1": { - Name: server.Name, - Group: "AcePanel", - Host: server.Host, - Port: int(server.Port), - MaintenanceDB: "postgres", - Username: server.Username, - SSLMode: "prefer", - PassFile: "/pgpass", - }, - }}) + payload, err := json.Marshal(serversFile{Servers: missing}) if err != nil { return err } @@ -229,32 +273,16 @@ func (s *App) syncServer(server *biz.DatabaseServer, email string) error { } // CLI 以 root 运行,修正数据目录属主避免服务写入失败 - if _, err := shell.Execf("chown -R www:www %s/data", s.path()); err != nil { + if _, err = shell.Execf("chown -R www:www %s/data", s.path()); err != nil { return err } return nil } -// Login 同步服务器后代理登录 pgAdmin 并将会话 Cookie 转发给浏览器 +// Login 同步面板全部 PostgreSQL 服务器后代理登录 pgAdmin 并将会话 Cookie 转发给浏览器 // 面板与 pgAdmin 同主机不同端口,Cookie 按主机共享,浏览器凭转发的 Cookie 即为已登录态 func (s *App) Login(w http.ResponseWriter, r *http.Request) { - req, err := service.Bind[Login](r) - if err != nil { - service.Error(w, http.StatusUnprocessableEntity, "%v", err) - return - } - - server, err := s.databaseServerRepo.Get(req.ServerID) - if err != nil { - service.Error(w, http.StatusInternalServerError, "%v", err) - return - } - if server.Type != biz.DatabaseTypePostgresql { - service.Error(w, http.StatusUnprocessableEntity, s.t.Get("server %s is not a PostgreSQL server", server.Name)) - return - } - port, err := s.port() if err != nil { service.Error(w, http.StatusInternalServerError, "%v", err) @@ -266,8 +294,8 @@ func (s *App) Login(w http.ResponseWriter, r *http.Request) { return } - if err = s.syncServer(server, email); err != nil { - service.Error(w, http.StatusInternalServerError, s.t.Get("failed to sync server to pgAdmin: %v", err)) + if err = s.syncServers(email); err != nil { + service.Error(w, http.StatusInternalServerError, s.t.Get("failed to sync servers to pgAdmin: %v", err)) return } @@ -278,6 +306,11 @@ func (s *App) Login(w http.ResponseWriter, r *http.Request) { }, } + // 透传浏览器 IP 与 UA,pgAdmin 增强 Cookie 保护将会话绑定到 sha256(IP|UA), + // 伪装成浏览器身份登录后浏览器直连即可通过校验,无需关闭该保护 + clientIP := s.clientIP(r) + clientUA := r.UserAgent() + // 获取登录页以取得会话 Cookie 与 CSRF token loginURL := fmt.Sprintf("http://127.0.0.1:%d/login", port) pageReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, loginURL, nil) @@ -285,6 +318,8 @@ func (s *App) Login(w http.ResponseWriter, r *http.Request) { service.Error(w, http.StatusInternalServerError, "%v", err) return } + pageReq.Header.Set("User-Agent", clientUA) + pageReq.Header.Set("X-Forwarded-For", clientIP) pageResp, err := client.Do(pageReq) if err != nil { service.Error(w, http.StatusInternalServerError, s.t.Get("failed to request pgAdmin: %v", err)) @@ -297,7 +332,11 @@ func (s *App) Login(w http.ResponseWriter, r *http.Request) { return } - csrf := regexp.MustCompile(`name="csrf_token"[^>]*value="([^"]+)"`).FindStringSubmatch(string(page)) + // 登录页为 React 渲染,CSRF token 在内嵌 JSON 中,保留 input 形态兼容旧版本 + csrf := regexp.MustCompile(`"csrfToken":\s*"([^"]+)"`).FindStringSubmatch(string(page)) + if len(csrf) < 2 { + csrf = regexp.MustCompile(`name="csrf_token"[^>]*value="([^"]+)"`).FindStringSubmatch(string(page)) + } if len(csrf) < 2 { service.Error(w, http.StatusInternalServerError, s.t.Get("failed to parse pgAdmin login page")) return @@ -314,6 +353,8 @@ func (s *App) Login(w http.ResponseWriter, r *http.Request) { return } loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + loginReq.Header.Set("User-Agent", clientUA) + loginReq.Header.Set("X-Forwarded-For", clientIP) for _, cookie := range pageResp.Cookies() { loginReq.AddCookie(cookie) } diff --git a/internal/apps/pgadmin/request.go b/internal/apps/pgadmin/request.go index 7c38eafa..6adfd3cc 100644 --- a/internal/apps/pgadmin/request.go +++ b/internal/apps/pgadmin/request.go @@ -7,7 +7,3 @@ type UpdatePort struct { type ResetPassword struct { Password string `form:"password" json:"password" validate:"required && password"` } - -type Login struct { - ServerID uint `form:"server_id" json:"server_id" validate:"required"` -} diff --git a/web/src/api/apps/pgadmin/index.ts b/web/src/api/apps/pgadmin/index.ts index 7c5d2f5a..ebc5706c 100644 --- a/web/src/api/apps/pgadmin/index.ts +++ b/web/src/api/apps/pgadmin/index.ts @@ -5,8 +5,8 @@ export default { info: (): any => http.Get('/apps/pgadmin/info'), // 设置端口 port: (port: number): any => http.Post('/apps/pgadmin/port', { port }), - // 同步指定 PostgreSQL 服务器并登录,下发会话 Cookie - login: (serverID: number): any => http.Post('/apps/pgadmin/login', { server_id: serverID }), + // 同步面板全部 PostgreSQL 服务器并登录,下发会话 Cookie + login: (): any => http.Post('/apps/pgadmin/login'), // 重置管理员密码 resetPassword: (password: string): any => http.Post('/apps/pgadmin/reset_password', { password }), }