mirror of
https://github.com/tnb-labs/panel.git
synced 2026-09-01 14:55:12 +08:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user