From 13e6bc4faccb0df548d05cc35a92e7bc1d210798 Mon Sep 17 00:00:00 2001 From: ssongliu Date: Wed, 29 Jul 2026 16:41:43 +0800 Subject: [PATCH] feat: separate website and standalone FTP identities (#13412) --- agent/app/api/v2/ftp.go | 15 -- agent/app/dto/ftp.go | 1 - agent/app/model/ftp.go | 2 + agent/app/service/ftp.go | 67 +++-- agent/app/service/website.go | 2 +- agent/app/service/website_utils.go | 2 +- agent/cmd/server/docs/x-log.json | 7 - agent/constant/host_tool.go | 7 +- agent/init/migration/migrate.go | 1 + agent/init/migration/migrations/init.go | 15 ++ agent/router/ro_toolbox.go | 1 - agent/utils/toolbox/pure-ftpd.go | 326 ++++++++++++++++------- core/cmd/server/docs/docs.go | 31 --- core/cmd/server/docs/swagger.json | 31 --- core/cmd/server/docs/x-log.json | 7 - frontend/src/api/interface/toolbox.ts | 1 - frontend/src/api/modules/toolbox.ts | 3 - frontend/src/views/toolbox/ftp/index.vue | 91 ++----- 18 files changed, 325 insertions(+), 285 deletions(-) diff --git a/agent/app/api/v2/ftp.go b/agent/app/api/v2/ftp.go index 45aba31b0..a8e83cf80 100644 --- a/agent/app/api/v2/ftp.go +++ b/agent/app/api/v2/ftp.go @@ -24,21 +24,6 @@ func (b *BaseApi) LoadFtpBaseInfo(c *gin.Context) { helper.SuccessWithData(c, data) } -// @Tags FTP -// @Summary Initialize FTP identity -// @Success 200 -// @Security ApiKeyAuth -// @Security Timestamp -// @Router /toolbox/ftp/init [post] -// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"初始化 FTP 用户身份","formatEN":"initialize FTP user identity"} -func (b *BaseApi) InitFtp(c *gin.Context) { - if err := ftpService.Init(); err != nil { - helper.InternalServer(c, err) - return - } - helper.Success(c) -} - // @Tags FTP // @Summary Load FTP operation log // @Accept json diff --git a/agent/app/dto/ftp.go b/agent/app/dto/ftp.go index 8d8917875..fad9376d0 100644 --- a/agent/app/dto/ftp.go +++ b/agent/app/dto/ftp.go @@ -18,7 +18,6 @@ type FtpInfo struct { type FtpBaseInfo struct { IsActive bool `json:"isActive"` IsExist bool `json:"isExist"` - IsInit bool `json:"isInit"` } type FtpLogSearch struct { diff --git a/agent/app/model/ftp.go b/agent/app/model/ftp.go index 6e88a4aa8..e4bb359c7 100644 --- a/agent/app/model/ftp.go +++ b/agent/app/model/ftp.go @@ -8,4 +8,6 @@ type Ftp struct { Status string `gorm:"not null" json:"status"` Path string `gorm:"not null" json:"path"` Description string `gorm:"not null" json:"description"` + UID uint `gorm:"column:uid;not null;default:1000" json:"-"` + GID uint `gorm:"column:gid;not null;default:1000" json:"-"` } diff --git a/agent/app/service/ftp.go b/agent/app/service/ftp.go index 317440ff2..35fd186ed 100644 --- a/agent/app/service/ftp.go +++ b/agent/app/service/ftp.go @@ -1,6 +1,8 @@ package service import ( + "errors" + "fmt" "os" "sort" @@ -19,10 +21,10 @@ type FtpService struct{} type IFtpService interface { LoadBaseInfo() (dto.FtpBaseInfo, error) - Init() error SearchWithPage(search dto.SearchWithPage) (int64, interface{}, error) Operate(operation string) error Create(req dto.FtpCreate) (uint, error) + CreateWebsite(req dto.FtpCreate) (uint, error) Delete(req dto.BatchDeleteReq) error Update(req dto.FtpUpdate) error Sync() error @@ -35,19 +37,10 @@ func NewIFtpService() IFtpService { func (f *FtpService) LoadBaseInfo() (dto.FtpBaseInfo, error) { var baseInfo dto.FtpBaseInfo - isInit, err := toolbox.IsFtpInitialized() - if err != nil { - return baseInfo, err - } - baseInfo.IsInit = isInit baseInfo.IsActive, baseInfo.IsExist = toolbox.FtpStatus() return baseInfo, nil } -func (f *FtpService) Init() error { - return toolbox.InitFtp() -} - func (f *FtpService) LoadLog(req dto.FtpLogSearch) (int64, interface{}, error) { client, err := toolbox.NewFtpClient() if err != nil { @@ -82,9 +75,6 @@ func (u *FtpService) Operate(operation string) error { } func (f *FtpService) SearchWithPage(req dto.SearchWithPage) (int64, interface{}, error) { - if _, err := toolbox.NewFtpClient(); err != nil { - return 0, nil, err - } total, lists, err := ftpRepo.Page(req.Page, req.PageSize, ftpRepo.WithLikeUser(req.Info), repo.WithOrderDesc("created_at")) if err != nil { return 0, nil, err @@ -108,7 +98,7 @@ func (f *FtpService) Sync() error { } lists, err := client.LoadList() if err != nil { - return nil + return err } listsInDB, err := ftpRepo.GetList() if err != nil { @@ -122,13 +112,24 @@ func (f *FtpService) Sync() error { for _, item := range lists { if itemInDB, ok := currentData[item.User]; ok { sameData[item.User] = struct{}{} - if item.Path != itemInDB.Path || item.Status != itemInDB.Status { - if err := ftpRepo.Update(itemInDB.ID, map[string]interface{}{"path": item.Path, "status": item.Status}); err != nil { + if item.Path != itemInDB.Path || item.Status != itemInDB.Status || item.UID != itemInDB.UID || item.GID != itemInDB.GID { + if err := ftpRepo.Update(itemInDB.ID, map[string]interface{}{ + "path": item.Path, + "status": item.Status, + "uid": item.UID, + "gid": item.GID, + }); err != nil { return err } } } else { - if err := ftpRepo.Create(&model.Ftp{User: item.User, Path: item.Path, Status: item.Status}); err != nil { + if err := ftpRepo.Create(&model.Ftp{ + User: item.User, + Path: item.Path, + Status: item.Status, + UID: item.UID, + GID: item.GID, + }); err != nil { return err } } @@ -142,6 +143,14 @@ func (f *FtpService) Sync() error { } func (f *FtpService) Create(req dto.FtpCreate) (uint, error) { + return f.create(req, false) +} + +func (f *FtpService) CreateWebsite(req dto.FtpCreate) (uint, error) { + return f.create(req, true) +} + +func (f *FtpService) create(req dto.FtpCreate, website bool) (uint, error) { if err := toolbox.ValidateFtpRootPath(req.Path); err != nil { return 0, err } @@ -166,16 +175,28 @@ func (f *FtpService) Create(req dto.FtpCreate) (uint, error) { if userInDB.ID != 0 { return 0, buserr.New("ErrRecordExist") } - if err := client.UserAdd(req.User, req.Password, req.Path); err != nil { - return 0, err - } var ftp model.Ftp if err := copier.Copy(&ftp, &req); err != nil { return 0, buserr.WithDetail("ErrStructTransform", err.Error(), nil) } + uid, gid := uint(constant.WebsiteUID), uint(constant.WebsiteGID) + if !website { + uid, gid, err = toolbox.EnsureStandaloneFtpIdentity() + if err != nil { + return 0, err + } + } + if err := client.UserAdd(req.User, req.Password, req.Path, uid, gid); err != nil { + return 0, err + } ftp.Status = constant.StatusEnable ftp.Password = pass + ftp.UID = uid + ftp.GID = gid if err := ftpRepo.Create(&ftp); err != nil { + if rollbackErr := client.UserDel(req.User); rollbackErr != nil { + return 0, errors.Join(err, fmt.Errorf("rollback FTP user %s failed: %w", req.User, rollbackErr)) + } return 0, err } return ftp.ID, nil @@ -245,7 +266,11 @@ func (f *FtpService) Update(req dto.FtpUpdate) error { needReload = true } if req.Path != ftpItem.Path { - if err := client.SetPath(ftpItem.User, req.Path); err != nil { + uid, gid := ftpItem.UID, ftpItem.GID + if uid == 0 || gid == 0 { + uid, gid = uint(constant.WebsiteUID), uint(constant.WebsiteGID) + } + if err := client.SetPath(ftpItem.User, req.Path, uid, gid); err != nil { return err } updates["path"] = req.Path diff --git a/agent/app/service/website.go b/agent/app/service/website.go index 414a5b407..40c6a1aec 100644 --- a/agent/app/service/website.go +++ b/agent/app/service/website.go @@ -561,7 +561,7 @@ func (w WebsiteService) CreateWebsite(create request.WebsiteCreate) (err error) if len(create.FtpUser) != 0 && len(create.FtpPassword) != 0 { createFtpUser := func(t *task.Task) error { indexDir := GetSitePath(*website, SiteIndexDir) - itemID, err := NewIFtpService().Create(dto.FtpCreate{User: create.FtpUser, Password: create.FtpPassword, Path: indexDir}) + itemID, err := NewIFtpService().CreateWebsite(dto.FtpCreate{User: create.FtpUser, Password: create.FtpPassword, Path: indexDir}) if err != nil { return err } diff --git a/agent/app/service/website_utils.go b/agent/app/service/website_utils.go index dfff38ed3..e1bc73b9d 100644 --- a/agent/app/service/website_utils.go +++ b/agent/app/service/website_utils.go @@ -1221,7 +1221,7 @@ func checkIsLinkApp(website model.Website) bool { func chownRootDir(path string) error { cmdMgr := cmd.NewCommandMgr(cmd.WithTimeout(1 * time.Second)) - owner := fmt.Sprintf("%d:%d", constant.FTPUid, constant.FTPGid) + owner := fmt.Sprintf("%d:%d", constant.WebsiteUID, constant.WebsiteGID) if err := cmdMgr.Run("chown", "-R", owner, path); err != nil { return err } diff --git a/agent/cmd/server/docs/x-log.json b/agent/cmd/server/docs/x-log.json index 3f251eb62..2d60faeeb 100644 --- a/agent/cmd/server/docs/x-log.json +++ b/agent/cmd/server/docs/x-log.json @@ -4166,13 +4166,6 @@ "formatZH": "删除 FTP 账户 [users]", "formatEN": "delete FTP users [users]" }, - "/toolbox/ftp/init": { - "bodyKeys": [], - "paramKeys": [], - "beforeFunctions": [], - "formatZH": "初始化 FTP 用户身份", - "formatEN": "initialize FTP user identity" - }, "/toolbox/ftp/operate": { "bodyKeys": [ "operation" diff --git a/agent/constant/host_tool.go b/agent/constant/host_tool.go index 1cfaa035f..d7c90d5f0 100644 --- a/agent/constant/host_tool.go +++ b/agent/constant/host_tool.go @@ -6,7 +6,8 @@ const ( SupervisorConfigPath = "SupervisorConfigPath" SupervisorServiceName = "SupervisorServiceName" - FTPUser = "1panel" - FTPUid = 1000 - FTPGid = 1000 + WebsiteUID = 1000 + WebsiteGID = 1000 + + FTPUser = "1panel-ftp" ) diff --git a/agent/init/migration/migrate.go b/agent/init/migration/migrate.go index 2685d60c2..4803e028c 100644 --- a/agent/init/migration/migrate.go +++ b/agent/init/migration/migrate.go @@ -94,6 +94,7 @@ func InitAgentDB() { migrations.InitFirewallPortWhiteList, migrations.AddDatabaseUserTable, migrations.AddBackupRecordArgs, + migrations.AddFtpIdentity, }) if err := m.Migrate(); err != nil { global.LOG.Error(err) diff --git a/agent/init/migration/migrations/init.go b/agent/init/migration/migrations/init.go index 874387e7b..29d295782 100644 --- a/agent/init/migration/migrations/init.go +++ b/agent/init/migration/migrations/init.go @@ -1683,3 +1683,18 @@ var AddBackupRecordArgs = &gormigrate.Migration{ return tx.AutoMigrate(&model.BackupRecord{}) }, } + +var AddFtpIdentity = &gormigrate.Migration{ + ID: "20260729-add-ftp-identity", + Migrate: func(tx *gorm.DB) error { + if err := tx.AutoMigrate(&model.Ftp{}); err != nil { + return err + } + return tx.Model(&model.Ftp{}). + Where("1 = 1"). + Updates(map[string]interface{}{ + "uid": constant.WebsiteUID, + "gid": constant.WebsiteGID, + }).Error + }, +} diff --git a/agent/router/ro_toolbox.go b/agent/router/ro_toolbox.go index 6f624bb94..06693e8e7 100644 --- a/agent/router/ro_toolbox.go +++ b/agent/router/ro_toolbox.go @@ -34,7 +34,6 @@ func (s *ToolboxRouter) InitRouter(Router *gin.RouterGroup) { toolboxRouter.POST("/fail2ban/update/byconf", baseApi.UpdateFail2BanConfByFile) toolboxRouter.GET("/ftp/base", baseApi.LoadFtpBaseInfo) - toolboxRouter.POST("/ftp/init", baseApi.InitFtp) toolboxRouter.POST("/ftp/log/search", baseApi.LoadFtpLogInfo) toolboxRouter.POST("/ftp/operate", baseApi.OperateFtp) toolboxRouter.POST("/ftp/search", baseApi.SearchFtp) diff --git a/agent/utils/toolbox/pure-ftpd.go b/agent/utils/toolbox/pure-ftpd.go index d9a0b0167..d744729e1 100644 --- a/agent/utils/toolbox/pure-ftpd.go +++ b/agent/utils/toolbox/pure-ftpd.go @@ -27,6 +27,8 @@ type FtpList struct { User string Path string Status string + UID uint + GID uint } type FtpLog struct { @@ -42,19 +44,14 @@ type FtpClient interface { Status() (bool, bool) Operate(operate string) error LoadList() ([]FtpList, error) - UserAdd(username, path, passwd string) error + UserAdd(username, passwd, path string, uid, gid uint) error UserDel(username string) error SetPasswd(username, passwd string) error + SetPath(username, path string, uid, gid uint) error Reload() error LoadLogs() ([]FtpLog, error) } -var ErrFtpNotInitialized = fmt.Errorf( - "FTP identity %d:%d is not initialized", - constant.FTPUid, - constant.FTPGid, -) - var ErrFtpUnsafePath = errors.New("FTP root path is unsafe") var ftpUnsafeRootPaths = map[string]struct{}{ @@ -76,63 +73,93 @@ var ftpUnsafeRootPaths = map[string]struct{}{ "/sys": {}, } -var ftpInitMu sync.Mutex +var ftpIdentityMu sync.Mutex -func IsFtpInitialized() (bool, error) { - userExists, groupExists, err := loadFtpIdentityStatus() - if err != nil { - return false, err +const ( + standaloneFTPMinID = 10000 + standaloneFTPMaxID = 60000 +) + +func EnsureStandaloneFtpIdentity() (uint, uint, error) { + ftpIdentityMu.Lock() + defer ftpIdentityMu.Unlock() + + userItem, userErr := user.Lookup(constant.FTPUser) + if userErr != nil && !isUnknownUser(userErr) { + return 0, 0, userErr } - return userExists && groupExists, nil -} - -func InitFtp() error { - ftpInitMu.Lock() - defer ftpInitMu.Unlock() - - uid := strconv.Itoa(constant.FTPUid) - gid := strconv.Itoa(constant.FTPGid) - userExists, groupExists, err := loadFtpIdentityStatus() - if err != nil { - return err + groupItem, groupErr := user.LookupGroup(constant.FTPUser) + if groupErr != nil && !isUnknownGroup(groupErr) { + return 0, 0, groupErr } - if !userExists { - userItem, err := user.Lookup(constant.FTPUser) - if err == nil { - if userItem.Uid != uid { - return fmt.Errorf("user %s already exists with UID %s", constant.FTPUser, userItem.Uid) - } - userExists = true - } else { - var unknownUser user.UnknownUserError - if !errors.As(err, &unknownUser) { - return err - } + if groupErr == nil { + gid, err := strconv.ParseUint(groupItem.Gid, 10, 32) + if err != nil { + return 0, 0, err + } + if gid < standaloneFTPMinID || gid > standaloneFTPMaxID { + return 0, 0, fmt.Errorf( + "FTP group %s must use a GID between %d and %d, got %d", + constant.FTPUser, + standaloneFTPMinID, + standaloneFTPMaxID, + gid, + ) } } - if !groupExists { - groupItem, err := user.LookupGroup(constant.FTPUser) - if err == nil { - if groupItem.Gid != gid { - return fmt.Errorf("group %s already exists with GID %s", constant.FTPUser, groupItem.Gid) + if groupErr != nil { + groupID := "" + if userErr == nil { + uid, err := strconv.ParseUint(userItem.Uid, 10, 32) + if err != nil { + return 0, 0, err } - groupExists = true - } else { - var unknownGroup user.UnknownGroupError + gid, err := strconv.ParseUint(userItem.Gid, 10, 32) + if err != nil { + return 0, 0, err + } + if uid < standaloneFTPMinID || uid > standaloneFTPMaxID || + gid < standaloneFTPMinID || gid > standaloneFTPMaxID { + return 0, 0, fmt.Errorf( + "FTP user %s must use UID and GID between %d and %d, got %d:%d", + constant.FTPUser, + standaloneFTPMinID, + standaloneFTPMaxID, + uid, + gid, + ) + } + groupByID, err := user.LookupGroupId(userItem.Gid) + if err == nil { + return 0, 0, fmt.Errorf( + "FTP user %s uses GID %s owned by group %s", + constant.FTPUser, + userItem.Gid, + groupByID.Name, + ) + } + var unknownGroup user.UnknownGroupIdError if !errors.As(err, &unknownGroup) { - return err + return 0, 0, err } + groupID = userItem.Gid + } else { + identityID, err := findAvailableFtpIdentityID(true, true) + if err != nil { + return 0, 0, err + } + groupID = identityID + } + if err := cmd.NewCommandMgr().Run("groupadd", "-g", groupID, constant.FTPUser); err != nil { + return 0, 0, err } } - - cmdMgr := cmd.NewCommandMgr() - if !groupExists { - if err := cmdMgr.Run("groupadd", "-g", gid, constant.FTPUser); err != nil { - return err + if userErr != nil { + uid, err := findAvailableFtpIdentityID(true, false) + if err != nil { + return 0, 0, err } - } - if !userExists { noLoginShell := "/bin/false" for _, item := range []string{"/usr/sbin/nologin", "/sbin/nologin"} { if _, err := os.Stat(item); err == nil { @@ -140,56 +167,96 @@ func InitFtp() error { break } } - if err := cmdMgr.Run( + if err := cmd.NewCommandMgr().Run( "useradd", "-u", uid, - "-g", gid, + "-g", constant.FTPUser, "-M", + "-d", "/nonexistent", "-s", noLoginShell, constant.FTPUser, ); err != nil { - return err + return 0, 0, err } } - isInitialized, err := IsFtpInitialized() + + userItem, err := user.Lookup(constant.FTPUser) if err != nil { - return err + return 0, 0, err } - if !isInitialized { - return ErrFtpNotInitialized + groupItem, err = user.LookupGroup(constant.FTPUser) + if err != nil { + return 0, 0, err } - return nil + if userItem.Gid != groupItem.Gid { + return 0, 0, fmt.Errorf( + "FTP user %s has GID %s, expected group GID %s", + constant.FTPUser, + userItem.Gid, + groupItem.Gid, + ) + } + uid, err := strconv.ParseUint(userItem.Uid, 10, 32) + if err != nil { + return 0, 0, err + } + gid, err := strconv.ParseUint(groupItem.Gid, 10, 32) + if err != nil { + return 0, 0, err + } + if uid < standaloneFTPMinID || uid > standaloneFTPMaxID || + gid < standaloneFTPMinID || gid > standaloneFTPMaxID { + return 0, 0, fmt.Errorf( + "FTP identity %s must use UID and GID between %d and %d, got %d:%d", + constant.FTPUser, + standaloneFTPMinID, + standaloneFTPMaxID, + uid, + gid, + ) + } + return uint(uid), uint(gid), nil } -func loadFtpIdentityStatus() (bool, bool, error) { - uid := strconv.Itoa(constant.FTPUid) - _, userErr := user.LookupId(uid) - if userErr != nil { - var unknownUser user.UnknownUserIdError - if !errors.As(userErr, &unknownUser) { - return false, false, userErr +func findAvailableFtpIdentityID(checkUser, checkGroup bool) (string, error) { + for id := standaloneFTPMinID; id <= standaloneFTPMaxID; id++ { + idItem := strconv.Itoa(id) + if checkUser { + if _, err := user.LookupId(idItem); err == nil { + continue + } else { + var unknownUser user.UnknownUserIdError + if !errors.As(err, &unknownUser) { + return "", err + } + } } + if checkGroup { + if _, err := user.LookupGroupId(idItem); err == nil { + continue + } else { + var unknownGroup user.UnknownGroupIdError + if !errors.As(err, &unknownGroup) { + return "", err + } + } + } + return idItem, nil } + return "", fmt.Errorf("no available FTP identity ID between %d and %d", standaloneFTPMinID, standaloneFTPMaxID) +} - gid := strconv.Itoa(constant.FTPGid) - _, groupErr := user.LookupGroupId(gid) - if groupErr != nil { - var unknownGroup user.UnknownGroupIdError - if !errors.As(groupErr, &unknownGroup) { - return false, false, groupErr - } - } - return userErr == nil, groupErr == nil, nil +func isUnknownUser(err error) bool { + var unknownUser user.UnknownUserError + return errors.As(err, &unknownUser) +} + +func isUnknownGroup(err error) bool { + var unknownGroup user.UnknownGroupError + return errors.As(err, &unknownGroup) } func NewFtpClient() (*Ftp, error) { - isInitialized, err := IsFtpInitialized() - if err != nil { - return nil, err - } - if !isInitialized { - return nil, ErrFtpNotInitialized - } return &Ftp{}, nil } @@ -216,14 +283,14 @@ func (f *Ftp) Operate(operate string) error { } } -func (f *Ftp) UserAdd(username, passwd, path string) error { +func (f *Ftp) UserAdd(username, passwd, path string, uid, gid uint) error { if cmd.CheckIllegal(username, path) { return buserr.New("ErrCmdIllegal") } if err := ValidateFtpRootPath(path); err != nil { return err } - entry, err := generatePureFtpEntrySimple(username, passwd, path) + entry, err := generatePureFtpEntry(username, passwd, path, uid, gid) if err != nil { return fmt.Errorf("generate pure-ftpd entry failed, err: %v", err) } @@ -231,20 +298,31 @@ func (f *Ftp) UserAdd(username, passwd, path string) error { if err != nil { return err } - defer pwdFile.Close() _, err = pwdFile.WriteString("\n" + entry + "\n") if err != nil { + _ = pwdFile.Close() return err } - _ = f.Reload() - owner := fmt.Sprintf("%d:%d", constant.FTPUid, constant.FTPGid) - if err := cmd.NewCommandMgr().Run("chown", "-R", owner, "--", path); err != nil { + if err := pwdFile.Close(); err != nil { return err } + if err := f.Reload(); err != nil { + return f.rollbackAddedUser(username, fmt.Errorf("reload FTP database after adding user failed: %w", err)) + } + if err := chownFtpRoot(path, uid, gid); err != nil { + return f.rollbackAddedUser(username, fmt.Errorf("change FTP root ownership failed: %w", err)) + } return nil } +func (f *Ftp) rollbackAddedUser(username string, cause error) error { + if rollbackErr := f.UserDel(username); rollbackErr != nil { + return errors.Join(cause, fmt.Errorf("rollback FTP user %s failed: %w", username, rollbackErr)) + } + return cause +} + func (f *Ftp) UserDel(username string) error { if cmd.CheckIllegal(username) { return buserr.New("ErrCmdIllegal") @@ -252,8 +330,7 @@ func (f *Ftp) UserDel(username string) error { if err := cmd.NewCommandMgr().Run("pure-pw", "userdel", username); err != nil { return err } - _ = f.Reload() - return nil + return f.Reload() } func (f *Ftp) SetPasswd(username, passwd string) error { @@ -306,7 +383,7 @@ func (f *Ftp) SetPasswd(username, passwd string) error { return nil } -func (f *Ftp) SetPath(username, path string) error { +func (f *Ftp) SetPath(username, path string, uid, gid uint) error { if cmd.CheckIllegal(username, path) { return buserr.New("ErrCmdIllegal") } @@ -316,13 +393,17 @@ func (f *Ftp) SetPath(username, path string) error { if err := cmd.NewCommandMgr().Run("pure-pw", "usermod", username, "-d", path); err != nil { return err } - owner := fmt.Sprintf("%d:%d", constant.FTPUid, constant.FTPGid) - if err := cmd.NewCommandMgr().Run("chown", "-R", owner, "--", path); err != nil { + if err := chownFtpRoot(path, uid, gid); err != nil { return err } return nil } +func chownFtpRoot(rootPath string, uid, gid uint) error { + owner := fmt.Sprintf("%d:%d", uid, gid) + return cmd.NewCommandMgr().Run("chown", "-R", owner, "--", rootPath) +} + func ValidateFtpRootPath(rootPath string) error { if strings.TrimSpace(rootPath) == "" { return fmt.Errorf("%w: path is required", ErrFtpUnsafePath) @@ -368,6 +449,10 @@ func (f *Ftp) LoadList() ([]FtpList, error) { if err != nil { return nil, err } + identities, err := loadPureFtpIdentities("/etc/pure-ftpd/pureftpd.passwd") + if err != nil { + return nil, err + } var lists []FtpList lines := strings.Split(std, "\n") for _, line := range lines { @@ -391,11 +476,53 @@ func (f *Ftp) LoadList() ([]FtpList, error) { if len(strings.TrimSpace(strings.ReplaceAll(allowedLine, "Allowed client IPs :", ""))) == 0 { status = constant.StatusEnable } - lists = append(lists, FtpList{User: parts[0], Path: strings.ReplaceAll(parts[1], "/./", ""), Status: status}) + identity, ok := identities[parts[0]] + if !ok { + return nil, fmt.Errorf("FTP identity for user %s was not found", parts[0]) + } + lists = append(lists, FtpList{ + User: parts[0], + Path: strings.ReplaceAll(parts[1], "/./", ""), + Status: status, + UID: identity.UID, + GID: identity.GID, + }) } return lists, nil } +type ftpIdentity struct { + UID uint + GID uint +} + +func loadPureFtpIdentities(passwdPath string) (map[string]ftpIdentity, error) { + pwdFile, err := os.Open(passwdPath) + if err != nil { + return nil, err + } + defer pwdFile.Close() + + identities := make(map[string]ftpIdentity) + scanner := bufio.NewScanner(pwdFile) + for scanner.Scan() { + parts := strings.Split(scanner.Text(), ":") + if len(parts) < 6 || parts[0] == "" { + continue + } + uid, uidErr := strconv.ParseUint(parts[2], 10, 32) + gid, gidErr := strconv.ParseUint(parts[3], 10, 32) + if uidErr != nil || gidErr != nil { + continue + } + identities[parts[0]] = ftpIdentity{UID: uint(uid), GID: uint(gid)} + } + if err := scanner.Err(); err != nil { + return nil, err + } + return identities, nil +} + func (f *Ftp) Reload() error { if err := cmd.NewCommandMgr().Run("pure-pw", "mkdb"); err != nil { return err @@ -508,7 +635,10 @@ func loadLogsByFiles(fileList []string, user, operation string) []FtpLog { return logs } -func generatePureFtpEntrySimple(username, password, path string) (string, error) { +func generatePureFtpEntry(username, password, path string, uid, gid uint) (string, error) { + if uid == 0 || gid == 0 { + return "", errors.New("FTP UID and GID must be greater than zero") + } passwdAfterSha512, err := helper.Generate([]byte(password)) if err != nil { return "", err @@ -517,8 +647,8 @@ func generatePureFtpEntrySimple(username, password, path string) (string, error) "%s:%s:%d:%d::%s/./::::::::::::", username, passwdAfterSha512, - constant.FTPUid, - constant.FTPGid, + uid, + gid, path, ), nil } diff --git a/core/cmd/server/docs/docs.go b/core/cmd/server/docs/docs.go index 529d66f60..dbcce4993 100644 --- a/core/cmd/server/docs/docs.go +++ b/core/cmd/server/docs/docs.go @@ -24361,34 +24361,6 @@ const docTemplate = `{ } } }, - "/toolbox/ftp/init": { - "post": { - "responses": { - "200": { - "description": "OK" - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "Timestamp": [] - } - ], - "summary": "Initialize FTP identity", - "tags": [ - "FTP" - ], - "x-panel-log": { - "BeforeFunctions": [], - "bodyKeys": [], - "formatEN": "initialize FTP user identity", - "formatZH": "初始化 FTP 用户身份", - "paramKeys": [] - } - } - }, "/toolbox/ftp/log/search": { "post": { "consumes": [ @@ -34709,9 +34681,6 @@ const docTemplate = `{ }, "isExist": { "type": "boolean" - }, - "isInit": { - "type": "boolean" } }, "type": "object" diff --git a/core/cmd/server/docs/swagger.json b/core/cmd/server/docs/swagger.json index 306cc7129..dced38cc2 100644 --- a/core/cmd/server/docs/swagger.json +++ b/core/cmd/server/docs/swagger.json @@ -24357,34 +24357,6 @@ } } }, - "/toolbox/ftp/init": { - "post": { - "responses": { - "200": { - "description": "OK" - } - }, - "security": [ - { - "ApiKeyAuth": [] - }, - { - "Timestamp": [] - } - ], - "summary": "Initialize FTP identity", - "tags": [ - "FTP" - ], - "x-panel-log": { - "BeforeFunctions": [], - "bodyKeys": [], - "formatEN": "initialize FTP user identity", - "formatZH": "初始化 FTP 用户身份", - "paramKeys": [] - } - } - }, "/toolbox/ftp/log/search": { "post": { "consumes": [ @@ -34705,9 +34677,6 @@ }, "isExist": { "type": "boolean" - }, - "isInit": { - "type": "boolean" } }, "type": "object" diff --git a/core/cmd/server/docs/x-log.json b/core/cmd/server/docs/x-log.json index 3f251eb62..2d60faeeb 100644 --- a/core/cmd/server/docs/x-log.json +++ b/core/cmd/server/docs/x-log.json @@ -4166,13 +4166,6 @@ "formatZH": "删除 FTP 账户 [users]", "formatEN": "delete FTP users [users]" }, - "/toolbox/ftp/init": { - "bodyKeys": [], - "paramKeys": [], - "beforeFunctions": [], - "formatZH": "初始化 FTP 用户身份", - "formatEN": "initialize FTP user identity" - }, "/toolbox/ftp/operate": { "bodyKeys": [ "operation" diff --git a/frontend/src/api/interface/toolbox.ts b/frontend/src/api/interface/toolbox.ts index 99056d65c..741c0100e 100644 --- a/frontend/src/api/interface/toolbox.ts +++ b/frontend/src/api/interface/toolbox.ts @@ -85,7 +85,6 @@ export namespace Toolbox { export interface FtpBaseInfo { isActive: boolean; isExist: boolean; - isInit: boolean; } export interface FtpInfo { id: number; diff --git a/frontend/src/api/modules/toolbox.ts b/frontend/src/api/modules/toolbox.ts index af9ec598f..c3346d34a 100644 --- a/frontend/src/api/modules/toolbox.ts +++ b/frontend/src/api/modules/toolbox.ts @@ -76,9 +76,6 @@ export const updateFail2banByFile = (param: UpdateByFile) => { export const getFtpBase = () => { return http.get(`/toolbox/ftp/base`); }; -export const initFtp = () => { - return http.post(`/toolbox/ftp/init`); -}; export const searchFtpLog = (param: Toolbox.FtpSearchLog) => { return http.post>(`/toolbox/ftp/log/search`, param); }; diff --git a/frontend/src/views/toolbox/ftp/index.vue b/frontend/src/views/toolbox/ftp/index.vue index bf04b35e6..230b8ab86 100644 --- a/frontend/src/views/toolbox/ftp/index.vue +++ b/frontend/src/views/toolbox/ftp/index.vue @@ -8,34 +8,29 @@
- - - {{ $t('commons.button.init') }} + + {{ $t('commons.button.stop') }} + + + {{ $t('commons.button.start') }} + + + + {{ $t('commons.button.restart') }}
@@ -45,7 +40,7 @@ - - {{ $t(form.isInit ? 'toolbox.ftp.notStart' : 'toolbox.ftp.initHelper') }} + + {{ $t('toolbox.ftp.notStart') }} @@ -182,7 +177,7 @@ import { onMounted, reactive, ref } from 'vue'; import i18n from '@/lang'; import { MsgError, MsgSuccess } from '@/utils/message'; -import { deleteFtp, searchFtp, updateFtp, syncFtp, operateFtp, getFtpBase, initFtp } from '@/api/modules/toolbox'; +import { deleteFtp, searchFtp, updateFtp, syncFtp, operateFtp, getFtpBase } from '@/api/modules/toolbox'; import OperateDialog from '@/views/toolbox/ftp/operate/index.vue'; import LogDialog from '@/views/toolbox/ftp/log/index.vue'; import { Toolbox } from '@/api/interface/toolbox'; @@ -209,7 +204,6 @@ const searchName = ref(); const form = reactive({ isActive: true, isExist: true, - isInit: false, }); const opRef = ref(); @@ -223,14 +217,7 @@ const search = async (column?: any) => { .then(async (res) => { form.isActive = res.data.isActive; form.isExist = res.data.isExist; - form.isInit = res.data.isInit; baseLoaded.value = true; - if (!form.isInit) { - loading.value = false; - data.value = []; - paginationConfig.total = 0; - return; - } paginationConfig.orderBy = column?.order ? column.prop : paginationConfig.orderBy; paginationConfig.order = column?.order ? column.order : paginationConfig.order; let params = { @@ -253,30 +240,6 @@ const search = async (column?: any) => { }); }; -const onInit = async () => { - ElMessageBox.confirm( - i18n.global.t('toolbox.ftp.operation', [i18n.global.t('commons.button.init')]), - i18n.global.t('commons.button.init'), - { - confirmButtonText: i18n.global.t('commons.button.confirm'), - cancelButtonText: i18n.global.t('commons.button.cancel'), - type: 'info', - }, - ) - .then(async () => { - loading.value = true; - await initFtp() - .then(() => { - MsgSuccess(i18n.global.t('commons.msg.operationSuccess')); - search(); - }) - .catch(() => { - loading.value = false; - }); - }) - .catch(() => {}); -}; - const onOperate = async (operation: string) => { let msg = operation === 'enable' || operation === 'disable' ? 'ssh.' : 'commons.button.'; ElMessageBox.confirm(i18n.global.t('toolbox.ftp.operation', [i18n.global.t(msg + operation)]), 'FTP', {