feat(pgadmin): 全量同步 PG 服务器并保留增强 Cookie 保护

一键登录改为一次性合并面板全部 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 <noreply@anthropic.com>
This commit is contained in:
耗子
2026-07-20 10:14:03 +08:00
parent b5b5388b5c
commit 8fc21039c0
3 changed files with 91 additions and 54 deletions
+89 -48
View File
@@ -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)
}
-4
View File
@@ -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"`
}
+2 -2
View File
@@ -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 }),
}