mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-09-22 00:00:50 +00:00
feat: separate website and standalone FTP identities (#13412)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:"-"`
|
||||
}
|
||||
|
||||
+46
-21
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -6,7 +6,8 @@ const (
|
||||
SupervisorConfigPath = "SupervisorConfigPath"
|
||||
SupervisorServiceName = "SupervisorServiceName"
|
||||
|
||||
FTPUser = "1panel"
|
||||
FTPUid = 1000
|
||||
FTPGid = 1000
|
||||
WebsiteUID = 1000
|
||||
WebsiteGID = 1000
|
||||
|
||||
FTPUser = "1panel-ftp"
|
||||
)
|
||||
|
||||
@@ -94,6 +94,7 @@ func InitAgentDB() {
|
||||
migrations.InitFirewallPortWhiteList,
|
||||
migrations.AddDatabaseUserTable,
|
||||
migrations.AddBackupRecordArgs,
|
||||
migrations.AddFtpIdentity,
|
||||
})
|
||||
if err := m.Migrate(); err != nil {
|
||||
global.LOG.Error(err)
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -85,7 +85,6 @@ export namespace Toolbox {
|
||||
export interface FtpBaseInfo {
|
||||
isActive: boolean;
|
||||
isExist: boolean;
|
||||
isInit: boolean;
|
||||
}
|
||||
export interface FtpInfo {
|
||||
id: number;
|
||||
|
||||
@@ -76,9 +76,6 @@ export const updateFail2banByFile = (param: UpdateByFile) => {
|
||||
export const getFtpBase = () => {
|
||||
return http.get<Toolbox.FtpBaseInfo>(`/toolbox/ftp/base`);
|
||||
};
|
||||
export const initFtp = () => {
|
||||
return http.post(`/toolbox/ftp/init`);
|
||||
};
|
||||
export const searchFtpLog = (param: Toolbox.FtpSearchLog) => {
|
||||
return http.post<ResPage<Toolbox.FtpLog>>(`/toolbox/ftp/log/search`, param);
|
||||
};
|
||||
|
||||
@@ -8,34 +8,29 @@
|
||||
<Status v-if="baseLoaded" class="mt-0.5" :status="form.isActive ? 'enable' : 'disable'" />
|
||||
</div>
|
||||
<div v-if="baseLoaded" class="mt-0.5">
|
||||
<template v-if="form.isInit">
|
||||
<el-button
|
||||
v-if="form.isActive"
|
||||
v-permission
|
||||
v-node-admin
|
||||
type="primary"
|
||||
@click="onOperate('stop')"
|
||||
link
|
||||
>
|
||||
{{ $t('commons.button.stop') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="!form.isActive"
|
||||
v-permission
|
||||
v-node-admin
|
||||
type="primary"
|
||||
@click="onOperate('start')"
|
||||
link
|
||||
>
|
||||
{{ $t('commons.button.start') }}
|
||||
</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button v-permission v-node-admin type="primary" @click="onOperate('restart')" link>
|
||||
{{ $t('commons.button.restart') }}
|
||||
</el-button>
|
||||
</template>
|
||||
<el-button v-else v-permission v-node-admin type="primary" @click="onInit" link>
|
||||
{{ $t('commons.button.init') }}
|
||||
<el-button
|
||||
v-if="form.isActive"
|
||||
v-permission
|
||||
v-node-admin
|
||||
type="primary"
|
||||
@click="onOperate('stop')"
|
||||
link
|
||||
>
|
||||
{{ $t('commons.button.stop') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="!form.isActive"
|
||||
v-permission
|
||||
v-node-admin
|
||||
type="primary"
|
||||
@click="onOperate('start')"
|
||||
link
|
||||
>
|
||||
{{ $t('commons.button.start') }}
|
||||
</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button v-permission v-node-admin type="primary" @click="onOperate('restart')" link>
|
||||
{{ $t('commons.button.restart') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,7 +40,7 @@
|
||||
<LayoutContent
|
||||
v-loading="loading"
|
||||
:title="$t('toolbox.ftp.ftp', 2)"
|
||||
:class="{ mask: baseLoaded && (!form.isActive || !form.isInit) }"
|
||||
:class="{ mask: baseLoaded && !form.isActive }"
|
||||
>
|
||||
<template #prompt v-if="!isFxplay">
|
||||
<el-alert type="info" :closable="false">
|
||||
@@ -166,8 +161,8 @@
|
||||
</template>
|
||||
</LayoutContent>
|
||||
|
||||
<el-card v-if="baseLoaded && (!form.isActive || !form.isInit)" class="mask-prompt">
|
||||
<span>{{ $t(form.isInit ? 'toolbox.ftp.notStart' : 'toolbox.ftp.initHelper') }}</span>
|
||||
<el-card v-if="baseLoaded && !form.isActive" class="mask-prompt">
|
||||
<span>{{ $t('toolbox.ftp.notStart') }}</span>
|
||||
</el-card>
|
||||
</div>
|
||||
<NoSuchService v-if="!form.isExist" name="FTP (pure-ftpd)" />
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user