From 92ac563d75edb66c58b6ef1f82222aace7ba6330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Sat, 28 Sep 2024 02:43:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=90=E4=BA=A4=E5=89=A9=E4=BD=99?= =?UTF-8?q?=E7=9A=84=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/apps/fail2ban/service.go | 5 +- .../apps/fail2ban/types.go | 4 +- internal/apps/init.go | 8 + internal/apps/php/service.go | 12 +- .../php.go => internal/apps/php/types.go | 4 +- internal/apps/podman/init.go | 21 + internal/apps/podman/request.go | 5 + internal/apps/podman/service.go | 75 ++++ internal/apps/postgresql/init.go | 24 ++ internal/apps/postgresql/request.go | 5 + internal/apps/postgresql/service.go | 153 +++++++ internal/apps/pureftpd/init.go | 23 ++ internal/apps/pureftpd/request.go | 20 + internal/apps/pureftpd/service.go | 173 ++++++++ .../apps/pureftpd/types.go | 4 +- internal/apps/redis/init.go | 20 + internal/apps/redis/request.go | 5 + internal/apps/redis/service.go | 96 +++++ internal/apps/rsync/init.go | 23 ++ internal/apps/rsync/request.go | 27 ++ internal/apps/rsync/service.go | 319 +++++++++++++++ .../rsync.go => internal/apps/rsync/types.go | 4 +- internal/apps/s3fs/init.go | 20 + internal/apps/s3fs/request.go | 13 + internal/apps/s3fs/service.go | 206 ++++++++++ .../s3fs.go => internal/apps/s3fs/types.go | 4 +- internal/apps/supervisor/init.go | 32 ++ internal/apps/supervisor/request.go | 22 + internal/apps/supervisor/service.go | 379 ++++++++++++++++++ internal/apps/supervisor/types.go | 8 + internal/apps/toolbox/init.go | 26 ++ internal/apps/toolbox/request.go | 22 + internal/apps/toolbox/service.go | 275 +++++++++++++ internal/apps/toolbox/types.go | 6 + pkg/apploader/apploader.go | 3 + 35 files changed, 2027 insertions(+), 19 deletions(-) rename pkg/types/fail2ban.go => internal/apps/fail2ban/types.go (83%) rename pkg/types/php.go => internal/apps/php/types.go (78%) create mode 100644 internal/apps/podman/init.go create mode 100644 internal/apps/podman/request.go create mode 100644 internal/apps/podman/service.go create mode 100644 internal/apps/postgresql/init.go create mode 100644 internal/apps/postgresql/request.go create mode 100644 internal/apps/postgresql/service.go create mode 100644 internal/apps/pureftpd/init.go create mode 100644 internal/apps/pureftpd/request.go create mode 100644 internal/apps/pureftpd/service.go rename pkg/types/pureftpd.go => internal/apps/pureftpd/types.go (62%) create mode 100644 internal/apps/redis/init.go create mode 100644 internal/apps/redis/request.go create mode 100644 internal/apps/redis/service.go create mode 100644 internal/apps/rsync/init.go create mode 100644 internal/apps/rsync/request.go create mode 100644 internal/apps/rsync/service.go rename pkg/types/rsync.go => internal/apps/rsync/types.go (86%) create mode 100644 internal/apps/s3fs/init.go create mode 100644 internal/apps/s3fs/request.go create mode 100644 internal/apps/s3fs/service.go rename pkg/types/s3fs.go => internal/apps/s3fs/types.go (75%) create mode 100644 internal/apps/supervisor/init.go create mode 100644 internal/apps/supervisor/request.go create mode 100644 internal/apps/supervisor/service.go create mode 100644 internal/apps/supervisor/types.go create mode 100644 internal/apps/toolbox/init.go create mode 100644 internal/apps/toolbox/request.go create mode 100644 internal/apps/toolbox/service.go create mode 100644 internal/apps/toolbox/types.go diff --git a/internal/apps/fail2ban/service.go b/internal/apps/fail2ban/service.go index f8846865..faee0240 100644 --- a/internal/apps/fail2ban/service.go +++ b/internal/apps/fail2ban/service.go @@ -17,7 +17,6 @@ import ( "github.com/TheTNB/panel/pkg/os" "github.com/TheTNB/panel/pkg/shell" "github.com/TheTNB/panel/pkg/str" - "github.com/TheTNB/panel/pkg/types" ) type Service struct { @@ -44,7 +43,7 @@ func (s *Service) List(w http.ResponseWriter, r *http.Request) { return } - var jails []types.Fail2banJail + var jails []Jail for i, jail := range jailList { if i == 0 { continue @@ -61,7 +60,7 @@ func (s *Service) List(w http.ResponseWriter, r *http.Request) { jailFindTime := regexp.MustCompile(`findtime = (.*)`).FindStringSubmatch(jailRaw) jailBanTime := regexp.MustCompile(`bantime = (.*)`).FindStringSubmatch(jailRaw) - jails = append(jails, types.Fail2banJail{ + jails = append(jails, Jail{ Name: jailName, Enabled: jailEnabled, LogPath: jailLogPath[1], diff --git a/pkg/types/fail2ban.go b/internal/apps/fail2ban/types.go similarity index 83% rename from pkg/types/fail2ban.go rename to internal/apps/fail2ban/types.go index 248e47f4..e9aaa3ff 100644 --- a/pkg/types/fail2ban.go +++ b/internal/apps/fail2ban/types.go @@ -1,6 +1,6 @@ -package types +package fail2ban -type Fail2banJail struct { +type Jail struct { Name string `json:"name"` Enabled bool `json:"enabled"` LogPath string `json:"log_path"` diff --git a/internal/apps/init.go b/internal/apps/init.go index 7c6c35b9..aaafa0eb 100644 --- a/internal/apps/init.go +++ b/internal/apps/init.go @@ -10,6 +10,14 @@ import ( _ "github.com/TheTNB/panel/internal/apps/percona" _ "github.com/TheTNB/panel/internal/apps/php" _ "github.com/TheTNB/panel/internal/apps/phpmyadmin" + _ "github.com/TheTNB/panel/internal/apps/podman" + _ "github.com/TheTNB/panel/internal/apps/postgresql" + _ "github.com/TheTNB/panel/internal/apps/pureftpd" + _ "github.com/TheTNB/panel/internal/apps/redis" + _ "github.com/TheTNB/panel/internal/apps/rsync" + _ "github.com/TheTNB/panel/internal/apps/s3fs" + _ "github.com/TheTNB/panel/internal/apps/supervisor" + _ "github.com/TheTNB/panel/internal/apps/toolbox" "github.com/TheTNB/panel/pkg/apploader" ) diff --git a/internal/apps/php/service.go b/internal/apps/php/service.go index d69bdd30..d8ee525d 100644 --- a/internal/apps/php/service.go +++ b/internal/apps/php/service.go @@ -234,7 +234,7 @@ func (s *Service) ExtensionList(w http.ResponseWriter, r *http.Request) { // ionCube 只支持 PHP 8.3 以下版本 if cast.ToUint(s.version) < 83 { - extensions = append(extensions, types.PHPExtension{ + extensions = append(extensions, Extension{ Name: "ionCube", Slug: "ionCube Loader", Description: "ionCube 是一个专业级的 PHP 加密解密工具。", @@ -248,7 +248,7 @@ func (s *Service) ExtensionList(w http.ResponseWriter, r *http.Request) { return } - extensionMap := make(map[string]*types.PHPExtension) + extensionMap := make(map[string]*Extension) for i := range extensions { extensionMap[extensions[i].Slug] = &extensions[i] } @@ -345,8 +345,8 @@ func (s *Service) UninstallExtension(w http.ResponseWriter, r *http.Request) { service.Success(w, nil) } -func (s *Service) getExtensions() []types.PHPExtension { - extensions := []types.PHPExtension{ +func (s *Service) getExtensions() []Extension { + extensions := []Extension{ { Name: "fileinfo", Slug: "fileinfo", @@ -476,7 +476,7 @@ func (s *Service) getExtensions() []types.PHPExtension { // ionCube 只支持 PHP 8.3 以下版本 if cast.ToUint(s.version) < 83 { - extensions = append(extensions, types.PHPExtension{ + extensions = append(extensions, Extension{ Name: "ionCube", Slug: "ionCube Loader", Description: "ionCube 是一个专业级的 PHP 加密解密工具。", @@ -485,7 +485,7 @@ func (s *Service) getExtensions() []types.PHPExtension { } raw, _ := shell.Execf("%s/server/php/%d/bin/php -m", panel.Root, s.version) - extensionMap := make(map[string]*types.PHPExtension) + extensionMap := make(map[string]*Extension) for i := range extensions { extensionMap[extensions[i].Slug] = &extensions[i] } diff --git a/pkg/types/php.go b/internal/apps/php/types.go similarity index 78% rename from pkg/types/php.go rename to internal/apps/php/types.go index 7ee51ea3..bdfedc16 100644 --- a/pkg/types/php.go +++ b/internal/apps/php/types.go @@ -1,6 +1,6 @@ -package types +package php -type PHPExtension struct { +type Extension struct { Name string `json:"name"` Slug string `json:"slug"` Description string `json:"description"` diff --git a/internal/apps/podman/init.go b/internal/apps/podman/init.go new file mode 100644 index 00000000..bf500e88 --- /dev/null +++ b/internal/apps/podman/init.go @@ -0,0 +1,21 @@ +package podman + +import ( + "github.com/go-chi/chi/v5" + + "github.com/TheTNB/panel/pkg/apploader" + "github.com/TheTNB/panel/pkg/types" +) + +func init() { + apploader.Register(&types.App{ + Slug: "podman", + Route: func(r chi.Router) { + service := NewService() + r.Get("/registryConfig", service.GetRegistryConfig) + r.Post("/registryConfig", service.UpdateRegistryConfig) + r.Get("/storageConfig", service.GetStorageConfig) + r.Post("/storageConfig", service.UpdateStorageConfig) + }, + }) +} diff --git a/internal/apps/podman/request.go b/internal/apps/podman/request.go new file mode 100644 index 00000000..0758f5ca --- /dev/null +++ b/internal/apps/podman/request.go @@ -0,0 +1,5 @@ +package podman + +type UpdateConfig struct { + Config string `form:"config" json:"config"` +} diff --git a/internal/apps/podman/service.go b/internal/apps/podman/service.go new file mode 100644 index 00000000..981de27d --- /dev/null +++ b/internal/apps/podman/service.go @@ -0,0 +1,75 @@ +package podman + +import ( + "net/http" + + "github.com/TheTNB/panel/internal/service" + "github.com/TheTNB/panel/pkg/io" + "github.com/TheTNB/panel/pkg/systemctl" +) + +type Service struct{} + +func NewService() *Service { + return &Service{} +} + +func (s *Service) GetRegistryConfig(w http.ResponseWriter, r *http.Request) { + config, err := io.Read("/etc/containers/registries.conf") + if err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + service.Success(w, config) +} + +func (s *Service) UpdateRegistryConfig(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[UpdateConfig](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if err = io.Write("/etc/containers/registries.conf", req.Config, 0644); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + if err = systemctl.Restart("podman"); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + service.Success(w, nil) +} + +func (s *Service) GetStorageConfig(w http.ResponseWriter, r *http.Request) { + config, err := io.Read("/etc/containers/storage.conf") + if err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + service.Success(w, config) +} + +func (s *Service) UpdateStorageConfig(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[UpdateConfig](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if err = io.Write("/etc/containers/storage.conf", req.Config, 0644); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + if err = systemctl.Restart("podman"); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + service.Success(w, nil) +} diff --git a/internal/apps/postgresql/init.go b/internal/apps/postgresql/init.go new file mode 100644 index 00000000..f1a13bff --- /dev/null +++ b/internal/apps/postgresql/init.go @@ -0,0 +1,24 @@ +package postgresql + +import ( + "github.com/go-chi/chi/v5" + + "github.com/TheTNB/panel/pkg/apploader" + "github.com/TheTNB/panel/pkg/types" +) + +func init() { + apploader.Register(&types.App{ + Slug: "postgresql", + Route: func(r chi.Router) { + service := NewService() + r.Get("/config", service.GetConfig) + r.Post("/config", service.UpdateConfig) + r.Get("/userConfig", service.GetUserConfig) + r.Post("/userConfig", service.UpdateUserConfig) + r.Get("/load", service.Load) + r.Get("/log", service.Log) + r.Post("/clearLog", service.ClearLog) + }, + }) +} diff --git a/internal/apps/postgresql/request.go b/internal/apps/postgresql/request.go new file mode 100644 index 00000000..f0520f44 --- /dev/null +++ b/internal/apps/postgresql/request.go @@ -0,0 +1,5 @@ +package postgresql + +type UpdateConfig struct { + Config string `form:"config" json:"config"` +} diff --git a/internal/apps/postgresql/service.go b/internal/apps/postgresql/service.go new file mode 100644 index 00000000..86e21ecc --- /dev/null +++ b/internal/apps/postgresql/service.go @@ -0,0 +1,153 @@ +package postgresql + +import ( + "fmt" + "net/http" + + "github.com/golang-module/carbon/v2" + + "github.com/TheTNB/panel/internal/panel" + "github.com/TheTNB/panel/internal/service" + "github.com/TheTNB/panel/pkg/io" + "github.com/TheTNB/panel/pkg/shell" + "github.com/TheTNB/panel/pkg/systemctl" + "github.com/TheTNB/panel/pkg/types" +) + +type Service struct{} + +func NewService() *Service { + return &Service{} +} + +// GetConfig 获取配置 +func (s *Service) GetConfig(w http.ResponseWriter, r *http.Request) { + // 获取配置 + config, err := io.Read(fmt.Sprintf("%s/server/postgresql/data/postgresql.conf", panel.Root)) + if err != nil { + service.Error(w, http.StatusInternalServerError, "获取PostgreSQL配置失败") + return + } + + service.Success(w, config) +} + +// UpdateConfig 保存配置 +func (s *Service) UpdateConfig(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[UpdateConfig](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if err := io.Write(fmt.Sprintf("%s/server/postgresql/data/postgresql.conf", panel.Root), req.Config, 0644); err != nil { + service.Error(w, http.StatusInternalServerError, "写入PostgreSQL配置失败") + return + } + + if err := systemctl.Reload("postgresql"); err != nil { + service.Error(w, http.StatusInternalServerError, "重载服务失败") + return + } + + service.Success(w, nil) +} + +// GetUserConfig 获取用户配置 +func (s *Service) GetUserConfig(w http.ResponseWriter, r *http.Request) { + // 获取配置 + config, err := io.Read(fmt.Sprintf("%s/server/postgresql/data/pg_hba.conf", panel.Root)) + if err != nil { + service.Error(w, http.StatusInternalServerError, "获取PostgreSQL配置失败") + return + } + + service.Success(w, config) +} + +// UpdateUserConfig 保存用户配置 +func (s *Service) UpdateUserConfig(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[UpdateConfig](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if err := io.Write(fmt.Sprintf("%s/server/postgresql/data/pg_hba.conf", panel.Root), req.Config, 0644); err != nil { + service.Error(w, http.StatusInternalServerError, "写入PostgreSQL配置失败") + return + } + + if err := systemctl.Reload("postgresql"); err != nil { + service.Error(w, http.StatusInternalServerError, "重载服务失败") + return + } + + service.Success(w, nil) +} + +// Load 获取负载 +func (s *Service) Load(w http.ResponseWriter, r *http.Request) { + status, _ := systemctl.Status("postgresql") + if !status { + service.Success(w, []types.NV{}) + return + } + + time, err := shell.Execf(`echo "select pg_postmaster_start_time();" | su - postgres -c "psql" | sed -n 3p | cut -d'.' -f1`) + if err != nil { + service.Error(w, http.StatusInternalServerError, "获取PostgreSQL启动时间失败") + return + } + pid, err := shell.Execf(`echo "select pg_backend_pid();" | su - postgres -c "psql" | sed -n 3p`) + if err != nil { + service.Error(w, http.StatusInternalServerError, "获取PostgreSQL进程PID失败") + return + } + process, err := shell.Execf(`ps aux | grep postgres | grep -v grep | wc -l`) + if err != nil { + service.Error(w, http.StatusInternalServerError, "获取PostgreSQL进程数失败") + return + } + connections, err := shell.Execf(`echo "SELECT count(*) FROM pg_stat_activity WHERE NOT pid=pg_backend_pid();" | su - postgres -c "psql" | sed -n 3p`) + if err != nil { + service.Error(w, http.StatusInternalServerError, "获取PostgreSQL连接数失败") + return + } + storage, err := shell.Execf(`echo "select pg_size_pretty(pg_database_size('postgres'));" | su - postgres -c "psql" | sed -n 3p`) + if err != nil { + service.Error(w, http.StatusInternalServerError, "获取PostgreSQL空间占用失败") + return + } + + data := []types.NV{ + {Name: "启动时间", Value: carbon.Parse(time).ToDateTimeString()}, + {Name: "进程 PID", Value: pid}, + {Name: "进程数", Value: process}, + {Name: "总连接数", Value: connections}, + {Name: "空间占用", Value: storage}, + } + + service.Success(w, data) +} + +// Log 获取日志 +func (s *Service) Log(w http.ResponseWriter, r *http.Request) { + log, err := shell.Execf("tail -n 100 %s/server/postgresql/logs/postgresql-%s.log", panel.Root, carbon.Now().ToDateString()) + if err != nil { + service.Error(w, http.StatusInternalServerError, log) + return + } + + service.Success(w, log) +} + +// ClearLog 清空日志 +func (s *Service) ClearLog(w http.ResponseWriter, r *http.Request) { + if out, err := shell.Execf("rm -rf %s/server/postgresql/logs/postgresql-*.log", panel.Root); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + + service.Success(w, nil) +} diff --git a/internal/apps/pureftpd/init.go b/internal/apps/pureftpd/init.go new file mode 100644 index 00000000..fce3886c --- /dev/null +++ b/internal/apps/pureftpd/init.go @@ -0,0 +1,23 @@ +package pureftpd + +import ( + "github.com/go-chi/chi/v5" + + "github.com/TheTNB/panel/pkg/apploader" + "github.com/TheTNB/panel/pkg/types" +) + +func init() { + apploader.Register(&types.App{ + Slug: "pureftpd", + Route: func(r chi.Router) { + service := NewService() + r.Get("/users", service.List) + r.Post("/users", service.Create) + r.Delete("/users/{name}", service.Delete) + r.Post("/users/{name}/password", service.ChangePassword) + r.Get("/port", service.GetPort) + r.Post("/port", service.UpdatePort) + }, + }) +} diff --git a/internal/apps/pureftpd/request.go b/internal/apps/pureftpd/request.go new file mode 100644 index 00000000..4d0beddf --- /dev/null +++ b/internal/apps/pureftpd/request.go @@ -0,0 +1,20 @@ +package pureftpd + +type Create struct { + Username string `form:"username" json:"username"` + Password string `form:"password" json:"password"` + Path string `form:"path" json:"path"` +} + +type Delete struct { + Username string `form:"username" json:"username"` +} + +type ChangePassword struct { + Username string `form:"username" json:"username"` + Password string `form:"password" json:"password"` +} + +type UpdatePort struct { + Port uint `form:"port" json:"port"` +} diff --git a/internal/apps/pureftpd/service.go b/internal/apps/pureftpd/service.go new file mode 100644 index 00000000..6d8663be --- /dev/null +++ b/internal/apps/pureftpd/service.go @@ -0,0 +1,173 @@ +package pureftpd + +import ( + "net/http" + "regexp" + "strings" + + "github.com/go-rat/chix" + "github.com/spf13/cast" + + "github.com/TheTNB/panel/internal/panel" + "github.com/TheTNB/panel/internal/service" + "github.com/TheTNB/panel/pkg/firewall" + "github.com/TheTNB/panel/pkg/io" + "github.com/TheTNB/panel/pkg/shell" + "github.com/TheTNB/panel/pkg/systemctl" +) + +type Service struct{} + +func NewService() *Service { + return &Service{} +} + +// List 获取用户列表 +func (s *Service) List(w http.ResponseWriter, r *http.Request) { + listRaw, err := shell.Execf("pure-pw list") + if err != nil { + service.Success(w, chix.M{ + "total": 0, + "items": []User{}, + }) + } + + listArr := strings.Split(listRaw, "\n") + var users []User + for _, v := range listArr { + if len(v) == 0 { + continue + } + + match := regexp.MustCompile(`(\S+)\s+(\S+)`).FindStringSubmatch(v) + users = append(users, User{ + Username: match[1], + Path: strings.Replace(match[2], "/./", "/", 1), + }) + } + + paged, total := service.Paginate(r, users) + + service.Success(w, chix.M{ + "total": total, + "items": paged, + }) +} + +// Create 创建用户 +func (s *Service) Create(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[Create](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if !strings.HasPrefix(req.Path, "/") { + req.Path = "/" + req.Path + } + if !io.Exists(req.Path) { + service.Error(w, http.StatusUnprocessableEntity, "目录不存在") + return + } + + if err = io.Chmod(req.Path, 0755); err != nil { + service.Error(w, http.StatusUnprocessableEntity, "修改目录权限失败") + return + } + if err = io.Chown(req.Path, "www", "www"); err != nil { + service.Error(w, http.StatusUnprocessableEntity, "修改目录权限失败") + return + } + if out, err := shell.Execf(`yes '%s' | pure-pw useradd '%s' -u www -g www -d '%s'`, req.Password, req.Username, req.Path); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + if out, err := shell.Execf("pure-pw mkdb"); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + + service.Success(w, nil) +} + +// Delete 删除用户 +func (s *Service) Delete(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[Delete](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if out, err := shell.Execf("pure-pw userdel '%s' -m", req.Username); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + if out, err := shell.Execf("pure-pw mkdb"); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + + service.Success(w, nil) +} + +// ChangePassword 修改密码 +func (s *Service) ChangePassword(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[ChangePassword](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if out, err := shell.Execf(`yes '%s' | pure-pw passwd '%s' -m`, req.Password, req.Username); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + if out, err := shell.Execf("pure-pw mkdb"); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + + service.Success(w, nil) +} + +// GetPort 获取端口 +func (s *Service) GetPort(w http.ResponseWriter, r *http.Request) { + port, err := shell.Execf(`cat %s/server/pure-ftpd/etc/pure-ftpd.conf | grep "Bind" | awk '{print $2}' | awk -F "," '{print $2}'`, panel.Root) + if err != nil { + service.Error(w, http.StatusInternalServerError, "获取PureFtpd端口失败") + return + } + + service.Success(w, cast.ToInt(port)) +} + +// UpdatePort 设置端口 +func (s *Service) UpdatePort(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[UpdatePort](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if out, err := shell.Execf(`sed -i "s/Bind.*/Bind 0.0.0.0,%d/g" %s/server/pure-ftpd/etc/pure-ftpd.conf`, req.Port, panel.Root); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + + fw := firewall.NewFirewall() + err = fw.Port(firewall.FireInfo{ + Port: req.Port, + Protocol: "tcp", + }, firewall.OperationAdd) + if err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + if err = systemctl.Restart("pure-ftpd"); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + service.Success(w, nil) +} diff --git a/pkg/types/pureftpd.go b/internal/apps/pureftpd/types.go similarity index 62% rename from pkg/types/pureftpd.go rename to internal/apps/pureftpd/types.go index d1208bff..28965505 100644 --- a/pkg/types/pureftpd.go +++ b/internal/apps/pureftpd/types.go @@ -1,6 +1,6 @@ -package types +package pureftpd -type PureFtpdUser struct { +type User struct { Username string `json:"username"` Path string `json:"path"` } diff --git a/internal/apps/redis/init.go b/internal/apps/redis/init.go new file mode 100644 index 00000000..eefed569 --- /dev/null +++ b/internal/apps/redis/init.go @@ -0,0 +1,20 @@ +package redis + +import ( + "github.com/go-chi/chi/v5" + + "github.com/TheTNB/panel/pkg/apploader" + "github.com/TheTNB/panel/pkg/types" +) + +func init() { + apploader.Register(&types.App{ + Slug: "redis", + Route: func(r chi.Router) { + service := NewService() + r.Get("/load", service.Load) + r.Get("/config", service.GetConfig) + r.Post("/config", service.UpdateConfig) + }, + }) +} diff --git a/internal/apps/redis/request.go b/internal/apps/redis/request.go new file mode 100644 index 00000000..2bdcb83c --- /dev/null +++ b/internal/apps/redis/request.go @@ -0,0 +1,5 @@ +package redis + +type UpdateConfig struct { + Config string `form:"config" json:"config"` +} diff --git a/internal/apps/redis/service.go b/internal/apps/redis/service.go new file mode 100644 index 00000000..e26194d1 --- /dev/null +++ b/internal/apps/redis/service.go @@ -0,0 +1,96 @@ +package redis + +import ( + "fmt" + "net/http" + "strings" + + "github.com/TheTNB/panel/internal/panel" + "github.com/TheTNB/panel/internal/service" + "github.com/TheTNB/panel/pkg/io" + "github.com/TheTNB/panel/pkg/shell" + "github.com/TheTNB/panel/pkg/systemctl" + "github.com/TheTNB/panel/pkg/types" +) + +type Service struct{} + +func NewService() *Service { + return &Service{} +} + +func (s *Service) Load(w http.ResponseWriter, r *http.Request) { + status, err := systemctl.Status("redis") + if err != nil { + service.Error(w, http.StatusInternalServerError, "获取 Redis 状态失败") + return + } + if !status { + service.Success(w, []types.NV{}) + return + } + + raw, err := shell.Execf("redis-cli info") + if err != nil { + service.Error(w, http.StatusInternalServerError, "获取 Redis 负载失败") + return + } + + infoLines := strings.Split(raw, "\n") + dataRaw := make(map[string]string) + + for _, item := range infoLines { + parts := strings.Split(item, ":") + if len(parts) == 2 { + dataRaw[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + + data := []types.NV{ + {Name: "TCP 端口", Value: dataRaw["tcp_port"]}, + {Name: "已运行天数", Value: dataRaw["uptime_in_days"]}, + {Name: "连接的客户端数", Value: dataRaw["connected_clients"]}, + {Name: "已分配的内存总量", Value: dataRaw["used_memory_human"]}, + {Name: "占用内存总量", Value: dataRaw["used_memory_rss_human"]}, + {Name: "占用内存峰值", Value: dataRaw["used_memory_peak_human"]}, + {Name: "内存碎片比率", Value: dataRaw["mem_fragmentation_ratio"]}, + {Name: "运行以来连接过的客户端的总数", Value: dataRaw["total_connections_received"]}, + {Name: "运行以来执行过的命令的总数", Value: dataRaw["total_commands_processed"]}, + {Name: "每秒执行的命令数", Value: dataRaw["instantaneous_ops_per_sec"]}, + {Name: "查找数据库键成功次数", Value: dataRaw["keyspace_hits"]}, + {Name: "查找数据库键失败次数", Value: dataRaw["keyspace_misses"]}, + {Name: "最近一次 fork() 操作耗费的毫秒数", Value: dataRaw["latest_fork_usec"]}, + } + + service.Success(w, data) +} + +func (s *Service) GetConfig(w http.ResponseWriter, r *http.Request) { + config, err := io.Read(fmt.Sprintf("%s/server/redis/redis.conf", panel.Root)) + if err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + service.Success(w, config) +} + +func (s *Service) UpdateConfig(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[UpdateConfig](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if err = io.Write(fmt.Sprintf("%s/server/redis/redis.conf", panel.Root), req.Config, 0644); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + if err = systemctl.Restart("redis"); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + service.Success(w, nil) +} diff --git a/internal/apps/rsync/init.go b/internal/apps/rsync/init.go new file mode 100644 index 00000000..55155457 --- /dev/null +++ b/internal/apps/rsync/init.go @@ -0,0 +1,23 @@ +package rsync + +import ( + "github.com/go-chi/chi/v5" + + "github.com/TheTNB/panel/pkg/apploader" + "github.com/TheTNB/panel/pkg/types" +) + +func init() { + apploader.Register(&types.App{ + Slug: "rsync", + Route: func(r chi.Router) { + service := NewService() + r.Get("/modules", service.List) + r.Post("/modules", service.Create) + r.Post("/modules/{name}", service.Update) + r.Delete("/modules/{name}", service.Delete) + r.Get("/config", service.GetConfig) + r.Post("/config", service.UpdateConfig) + }, + }) +} diff --git a/internal/apps/rsync/request.go b/internal/apps/rsync/request.go new file mode 100644 index 00000000..99d98cc2 --- /dev/null +++ b/internal/apps/rsync/request.go @@ -0,0 +1,27 @@ +package rsync + +type Create struct { + Name string `form:"name" json:"name"` + Path string `form:"path" json:"path"` + Comment string `form:"comment" json:"comment"` + AuthUser string `form:"auth_user" json:"auth_user"` + Secret string `form:"secret" json:"secret"` + HostsAllow string `form:"hosts_allow" json:"hosts_allow"` +} + +type Delete struct { + Name string `form:"name" json:"name"` +} + +type Update struct { + Name string `form:"name" json:"name"` + Path string `form:"path" json:"path"` + Comment string `form:"comment" json:"comment"` + AuthUser string `form:"auth_user" json:"auth_user"` + Secret string `form:"secret" json:"secret"` + HostsAllow string `form:"hosts_allow" json:"hosts_allow"` +} + +type UpdateConfig struct { + Config string `form:"config" json:"config"` +} diff --git a/internal/apps/rsync/service.go b/internal/apps/rsync/service.go new file mode 100644 index 00000000..e9361eec --- /dev/null +++ b/internal/apps/rsync/service.go @@ -0,0 +1,319 @@ +package rsync + +import ( + "net/http" + "regexp" + "strings" + + "github.com/go-rat/chix" + + "github.com/TheTNB/panel/internal/service" + "github.com/TheTNB/panel/pkg/io" + "github.com/TheTNB/panel/pkg/shell" + "github.com/TheTNB/panel/pkg/str" + "github.com/TheTNB/panel/pkg/systemctl" +) + +type Service struct{} + +func NewService() *Service { + return &Service{} +} + +// List +// +// @Summary 列出模块 +// @Description 列出所有 Rsync 模块 +// @Tags 插件-Rsync +// @Produce json +// @Security BearerToken +// @Param data query commonrequests.Paginate true "request" +// @Success 200 {object} controllers.SuccessResponse +// @Router /plugins/rsync/modules [get] +func (s *Service) List(w http.ResponseWriter, r *http.Request) { + config, err := io.Read("/etc/rsyncd.conf") + if err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + var modules []Module + lines := strings.Split(config, "\n") + var currentModule *Module + + for _, line := range lines { + line = strings.TrimSpace(line) + + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + if currentModule != nil { + modules = append(modules, *currentModule) + } + moduleName := line[1 : len(line)-1] + currentModule = &Module{ + Name: moduleName, + } + } else if currentModule != nil { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + switch key { + case "path": + currentModule.Path = value + case "comment": + currentModule.Comment = value + case "read only": + currentModule.ReadOnly = value == "yes" || value == "true" + case "auth users": + currentModule.AuthUser = value + currentModule.Secret, err = shell.Execf("grep -E '^" + currentModule.AuthUser + ":.*$' /etc/rsyncd.secrets | awk -F ':' '{print $2}'") + if err != nil { + service.Error(w, http.StatusInternalServerError, "获取模块"+currentModule.AuthUser+"的密钥失败") + return + } + case "hosts allow": + currentModule.HostsAllow = value + } + } + } + } + + if currentModule != nil { + modules = append(modules, *currentModule) + } + + paged, total := service.Paginate(r, modules) + + service.Success(w, chix.M{ + "total": total, + "items": paged, + }) +} + +// Create +// +// @Summary 添加模块 +// @Description 添加 Rsync 模块 +// @Tags 插件-Rsync +// @Produce json +// @Security BearerToken +// @Param data body requests.Create true "request" +// @Success 200 {object} controllers.SuccessResponse +// @Router /plugins/rsync/modules [post] +func (s *Service) Create(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[Create](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + config, err := io.Read("/etc/rsyncd.conf") + if err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + if strings.Contains(config, "["+req.Name+"]") { + service.Error(w, http.StatusUnprocessableEntity, "模块 "+req.Name+" 已存在") + return + } + + conf := `# ` + req.Name + `-START +[` + req.Name + `] +path = ` + req.Path + ` +comment = ` + req.Comment + ` +read only = no +auth users = ` + req.AuthUser + ` +hosts allow = ` + req.HostsAllow + ` +secrets file = /etc/rsyncd.secrets +# ` + req.Name + `-END +` + + if err = io.WriteAppend("/etc/rsyncd.conf", conf); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + if out, err := shell.Execf("echo '" + req.AuthUser + ":" + req.Secret + "' >> /etc/rsyncd.secrets"); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + + if err = systemctl.Restart("rsyncd"); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + service.Success(w, nil) +} + +// Delete +// +// @Summary 删除模块 +// @Description 删除 Rsync 模块 +// @Tags 插件-Rsync +// @Produce json +// @Security BearerToken +// @Param name path string true "模块名称" +// @Success 200 {object} controllers.SuccessResponse +// @Router /plugins/rsync/modules/{name} [delete] +func (s *Service) Delete(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[Delete](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + config, err := io.Read("/etc/rsyncd.conf") + if err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + if !strings.Contains(config, "["+req.Name+"]") { + service.Error(w, http.StatusUnprocessableEntity, "模块 "+req.Name+" 不存在") + return + } + + module := str.Cut(config, "# "+req.Name+"-START", "# "+req.Name+"-END") + config = strings.Replace(config, "\n# "+req.Name+"-START"+module+"# "+req.Name+"-END", "", -1) + + match := regexp.MustCompile(`auth users = ([^\n]+)`).FindStringSubmatch(module) + if len(match) == 2 { + authUser := match[1] + if out, err := shell.Execf("sed -i '/^" + authUser + ":.*$/d' /etc/rsyncd.secrets"); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + } + + if err = io.Write("/etc/rsyncd.conf", config, 0644); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + if err = systemctl.Restart("rsyncd"); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + service.Success(w, nil) +} + +// Update +// +// @Summary 更新模块 +// @Description 更新 Rsync 模块 +// @Tags 插件-Rsync +// @Produce json +// @Security BearerToken +// @Param name path string true "模块名称" +// @Param data body requests.Update true "request" +// @Success 200 {object} controllers.SuccessResponse +// @Router /plugins/rsync/modules/{name} [post] +func (s *Service) Update(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[Update](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + config, err := io.Read("/etc/rsyncd.conf") + if err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + if !strings.Contains(config, "["+req.Name+"]") { + service.Error(w, http.StatusUnprocessableEntity, "模块 "+req.Name+" 不存在") + return + } + + newConf := `# ` + req.Name + `-START +[` + req.Name + `] +path = ` + req.Path + ` +comment = ` + req.Comment + ` +read only = no +auth users = ` + req.AuthUser + ` +hosts allow = ` + req.HostsAllow + ` +secrets file = /etc/rsyncd.secrets +# ` + req.Name + `-END` + + module := str.Cut(config, "# "+req.Name+"-START", "# "+req.Name+"-END") + config = strings.Replace(config, "# "+req.Name+"-START"+module+"# "+req.Name+"-END", newConf, -1) + + match := regexp.MustCompile(`auth users = ([^\n]+)`).FindStringSubmatch(module) + if len(match) == 2 { + authUser := match[1] + if out, err := shell.Execf("sed -i '/^" + authUser + ":.*$/d' /etc/rsyncd.secrets"); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + } + + if err = io.Write("/etc/rsyncd.conf", config, 0644); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + if out, err := shell.Execf("echo '" + req.AuthUser + ":" + req.Secret + "' >> /etc/rsyncd.secrets"); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + + if err = systemctl.Restart("rsyncd"); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + service.Success(w, nil) +} + +// GetConfig +// +// @Summary 获取配置 +// @Description 获取 Rsync 配置 +// @Tags 插件-Rsync +// @Produce json +// @Security BearerToken +// @Success 200 {object} controllers.SuccessResponse +// @Router /plugins/rsync/config [get] +func (s *Service) GetConfig(w http.ResponseWriter, r *http.Request) { + config, err := io.Read("/etc/rsyncd.conf") + if err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + service.Success(w, config) +} + +// UpdateConfig +// +// @Summary 更新配置 +// @Description 更新 Rsync 配置 +// @Tags 插件-Rsync +// @Produce json +// @Security BearerToken +// @Param data body requests.UpdateConfig true "request" +// @Success 200 {object} controllers.SuccessResponse +// @Router /plugins/rsync/config [post] +func (s *Service) UpdateConfig(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[UpdateConfig](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if err = io.Write("/etc/rsyncd.conf", req.Config, 0644); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + if err = systemctl.Restart("rsyncd"); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + service.Success(w, nil) +} diff --git a/pkg/types/rsync.go b/internal/apps/rsync/types.go similarity index 86% rename from pkg/types/rsync.go rename to internal/apps/rsync/types.go index 0acb339c..8876120f 100644 --- a/pkg/types/rsync.go +++ b/internal/apps/rsync/types.go @@ -1,6 +1,6 @@ -package types +package rsync -type RsyncModule struct { +type Module struct { Name string `json:"name"` Path string `json:"path"` Comment string `json:"comment"` diff --git a/internal/apps/s3fs/init.go b/internal/apps/s3fs/init.go new file mode 100644 index 00000000..0b7e1001 --- /dev/null +++ b/internal/apps/s3fs/init.go @@ -0,0 +1,20 @@ +package s3fs + +import ( + "github.com/go-chi/chi/v5" + + "github.com/TheTNB/panel/pkg/apploader" + "github.com/TheTNB/panel/pkg/types" +) + +func init() { + apploader.Register(&types.App{ + Slug: "s3fs", + Route: func(r chi.Router) { + service := NewService() + r.Get("/mounts", service.List) + r.Post("/mounts", service.Create) + r.Delete("/mounts", service.Delete) + }, + }) +} diff --git a/internal/apps/s3fs/request.go b/internal/apps/s3fs/request.go new file mode 100644 index 00000000..7fe4120c --- /dev/null +++ b/internal/apps/s3fs/request.go @@ -0,0 +1,13 @@ +package s3fs + +type Create struct { + Ak string `form:"ak" json:"ak"` + Sk string `form:"sk" json:"sk"` + Bucket string `form:"bucket" json:"bucket"` + URL string `form:"url" json:"url"` + Path string `form:"path" json:"path"` +} + +type Delete struct { + ID int64 `form:"id" json:"id"` +} diff --git a/internal/apps/s3fs/service.go b/internal/apps/s3fs/service.go new file mode 100644 index 00000000..e9af30ee --- /dev/null +++ b/internal/apps/s3fs/service.go @@ -0,0 +1,206 @@ +package s3fs + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/go-rat/chix" + "github.com/golang-module/carbon/v2" + "github.com/spf13/cast" + + "github.com/TheTNB/panel/internal/biz" + "github.com/TheTNB/panel/internal/data" + "github.com/TheTNB/panel/internal/service" + "github.com/TheTNB/panel/pkg/io" + "github.com/TheTNB/panel/pkg/shell" +) + +type Service struct { + settingRepo biz.SettingRepo +} + +func NewService() *Service { + return &Service{ + settingRepo: data.NewSettingRepo(), + } +} + +// List 所有 S3fs 挂载 +func (s *Service) List(w http.ResponseWriter, r *http.Request) { + var s3fsList []Mount + list, err := s.settingRepo.Get("s3fs", "[]") + if err != nil { + service.Error(w, http.StatusInternalServerError, "获取 S3fs 挂载失败") + return + } + + if err = json.Unmarshal([]byte(list), &s3fsList); err != nil { + service.Error(w, http.StatusInternalServerError, "获取 S3fs 挂载失败") + return + } + + paged, total := service.Paginate(r, s3fsList) + + service.Success(w, chix.M{ + "total": total, + "items": paged, + }) +} + +// Create 添加 S3fs 挂载 +func (s *Service) Create(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[Create](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + // 检查下地域节点中是否包含bucket,如果包含了,肯定是错误的 + if strings.Contains(req.URL, req.Bucket) { + service.Error(w, http.StatusUnprocessableEntity, "地域节点不能包含 Bucket 名称") + return + } + + // 检查挂载目录是否存在且为空 + if !io.Exists(req.Path) { + if err = io.Mkdir(req.Path, 0755); err != nil { + service.Error(w, http.StatusUnprocessableEntity, "挂载目录创建失败") + return + } + } + if !io.Empty(req.Path) { + service.Error(w, http.StatusUnprocessableEntity, "挂载目录必须为空") + return + } + + var s3fsList []Mount + list, err := s.settingRepo.Get("s3fs", "[]") + if err != nil { + service.Error(w, http.StatusInternalServerError, "获取 S3fs 挂载失败") + return + } + if err = json.Unmarshal([]byte(list), &s3fsList); err != nil { + service.Error(w, http.StatusInternalServerError, "获取 S3fs 挂载失败") + return + } + + for _, s := range s3fsList { + if s.Path == req.Path { + service.Error(w, http.StatusUnprocessableEntity, "路径已存在") + return + } + } + + id := carbon.Now().TimestampMilli() + password := req.Ak + ":" + req.Sk + if err = io.Write("/etc/passwd-s3fs-"+cast.ToString(id), password, 0600); err != nil { + service.Error(w, http.StatusInternalServerError, "添加 S3fs 挂载失败") + return + } + out, err := shell.Execf(`echo 's3fs#%s %s fuse _netdev,allow_other,nonempty,url=%s,passwd_file=/etc/passwd-s3fs-%s 0 0' >> /etc/fstab`, req.Bucket, req.Path, req.URL, cast.ToString(id)) + if err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + if mountCheck, err := shell.Execf("mount -a 2>&1"); err != nil { + _, _ = shell.Execf(`sed -i 's@^s3fs#%s\s%s.*$@@g' /etc/fstab`, req.Bucket, req.Path) + service.Error(w, http.StatusInternalServerError, "/etc/fstab 有误: "+mountCheck) + return + } + if _, err := shell.Execf("df -h | grep " + req.Path + " 2>&1"); err != nil { + _, _ = shell.Execf(`sed -i 's@^s3fs#%s\s%s.*$@@g' /etc/fstab`, req.Bucket, req.Path) + service.Error(w, http.StatusInternalServerError, "挂载失败,请检查配置是否正确") + return + } + + s3fsList = append(s3fsList, Mount{ + ID: id, + Path: req.Path, + Bucket: req.Bucket, + Url: req.URL, + }) + encoded, err := json.Marshal(s3fsList) + if err != nil { + service.Error(w, http.StatusInternalServerError, "添加 S3fs 挂载失败") + return + } + + if err = s.settingRepo.Set("s3fs", string(encoded)); err != nil { + service.Error(w, http.StatusInternalServerError, "添加 S3fs 挂载失败") + return + } + + service.Success(w, nil) +} + +// Delete 删除 S3fs 挂载 +func (s *Service) Delete(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[Delete](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + var s3fsList []Mount + list, err := s.settingRepo.Get("s3fs", "[]") + if err != nil { + service.Error(w, http.StatusInternalServerError, "获取 S3fs 挂载失败") + return + } + if err = json.Unmarshal([]byte(list), &s3fsList); err != nil { + service.Error(w, http.StatusInternalServerError, "获取 S3fs 挂载失败") + return + } + + var mount Mount + for _, item := range s3fsList { + if item.ID == req.ID { + mount = item + break + } + } + if mount.ID == 0 { + service.Error(w, http.StatusUnprocessableEntity, "挂载不存在") + return + } + + if out, err := shell.Execf(`fusermount -u '` + mount.Path + `' 2>&1`); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + if out, err := shell.Execf(`umount '` + mount.Path + `' 2>&1`); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + if out, err := shell.Execf(`sed -i 's@^s3fs#` + mount.Bucket + `\s` + mount.Path + `.*$@@g' /etc/fstab`); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + if mountCheck, err := shell.Execf("mount -a 2>&1"); err != nil { + service.Error(w, http.StatusInternalServerError, "/etc/fstab 有误: "+mountCheck) + return + } + if err = io.Remove("/etc/passwd-s3fs-" + cast.ToString(mount.ID)); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + var newS3fsList []Mount + for _, item := range s3fsList { + if item.ID != mount.ID { + newS3fsList = append(newS3fsList, item) + } + } + encoded, err := json.Marshal(newS3fsList) + if err != nil { + service.Error(w, http.StatusInternalServerError, "删除 S3fs 挂载失败") + return + } + if err = s.settingRepo.Set("s3fs", string(encoded)); err != nil { + service.Error(w, http.StatusInternalServerError, "删除 S3fs 挂载失败") + return + } + + service.Success(w, nil) +} diff --git a/pkg/types/s3fs.go b/internal/apps/s3fs/types.go similarity index 75% rename from pkg/types/s3fs.go rename to internal/apps/s3fs/types.go index f9ef0753..8b2e644c 100644 --- a/pkg/types/s3fs.go +++ b/internal/apps/s3fs/types.go @@ -1,6 +1,6 @@ -package types +package s3fs -type S3fsMount struct { +type Mount struct { ID int64 `json:"id"` Path string `json:"path"` Bucket string `json:"bucket"` diff --git a/internal/apps/supervisor/init.go b/internal/apps/supervisor/init.go new file mode 100644 index 00000000..70884272 --- /dev/null +++ b/internal/apps/supervisor/init.go @@ -0,0 +1,32 @@ +package supervisor + +import ( + "github.com/go-chi/chi/v5" + + "github.com/TheTNB/panel/pkg/apploader" + "github.com/TheTNB/panel/pkg/types" +) + +func init() { + apploader.Register(&types.App{ + Slug: "supervisor", + Route: func(r chi.Router) { + service := NewService() + r.Get("/service", service.Service) + r.Get("/log", service.Log) + r.Post("/clearLog", service.ClearLog) + r.Get("/config", service.GetConfig) + r.Post("/config", service.UpdateConfig) + r.Get("/processes", service.Processes) + r.Post("/processes/{name}/start", service.StartProcess) + r.Post("/processes/{name}/stop", service.StopProcess) + r.Post("/processes/{name}/restart", service.RestartProcess) + r.Get("/processes/{name}/log", service.ProcessLog) + r.Post("/processes/{name}/clearLog", service.ClearProcessLog) + r.Get("/processes/{name}", service.ProcessConfig) + r.Post("/processes/{name}", service.UpdateProcessConfig) + r.Delete("/processes/{name}", service.DeleteProcess) + r.Post("/processes", service.CreateProcess) + }, + }) +} diff --git a/internal/apps/supervisor/request.go b/internal/apps/supervisor/request.go new file mode 100644 index 00000000..82096f3c --- /dev/null +++ b/internal/apps/supervisor/request.go @@ -0,0 +1,22 @@ +package supervisor + +type UpdateConfig struct { + Config string `form:"config" json:"config"` +} + +type UpdateProcessConfig struct { + Process string `form:"config" json:"process"` + Config string `form:"config" json:"config"` +} + +type ProcessName struct { + Process string `form:"config" json:"process"` +} + +type CreateProcess struct { + Name string `form:"name" json:"name"` + User string `form:"user" json:"user"` + Path string `form:"path" json:"path"` + Command string `form:"command" json:"command"` + Num int `form:"num" json:"num"` +} diff --git a/internal/apps/supervisor/service.go b/internal/apps/supervisor/service.go new file mode 100644 index 00000000..9715203a --- /dev/null +++ b/internal/apps/supervisor/service.go @@ -0,0 +1,379 @@ +package supervisor + +import ( + "fmt" + "net/http" + "strings" + + "github.com/go-rat/chix" + "github.com/spf13/cast" + + "github.com/TheTNB/panel/internal/service" + "github.com/TheTNB/panel/pkg/io" + "github.com/TheTNB/panel/pkg/os" + "github.com/TheTNB/panel/pkg/shell" + "github.com/TheTNB/panel/pkg/systemctl" +) + +type Service struct { + name string +} + +func NewService() *Service { + var name string + if os.IsRHEL() { + name = "supervisord" + } else { + name = "supervisor" + } + + return &Service{ + name: name, + } +} + +// Service 获取服务名称 +func (s *Service) Service(w http.ResponseWriter, r *http.Request) { + service.Success(w, s.name) +} + +// Log 日志 +func (s *Service) Log(w http.ResponseWriter, r *http.Request) { + log, err := shell.Execf(`tail -n 200 /var/log/supervisor/supervisord.log`) + if err != nil { + service.Error(w, http.StatusInternalServerError, log) + return + } + + service.Success(w, log) +} + +// ClearLog 清空日志 +func (s *Service) ClearLog(w http.ResponseWriter, r *http.Request) { + if out, err := shell.Execf(`echo "" > /var/log/supervisor/supervisord.log`); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + + service.Success(w, nil) +} + +// GetConfig 获取配置 +func (s *Service) GetConfig(w http.ResponseWriter, r *http.Request) { + var config string + var err error + if os.IsRHEL() { + config, err = io.Read(`/etc/supervisord.conf`) + } else { + config, err = io.Read(`/etc/supervisor/supervisord.conf`) + } + + if err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + service.Success(w, config) +} + +// UpdateConfig 保存配置 +func (s *Service) UpdateConfig(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[UpdateConfig](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if os.IsRHEL() { + err = io.Write(`/etc/supervisord.conf`, req.Config, 0644) + } else { + err = io.Write(`/etc/supervisor/supervisord.conf`, req.Config, 0644) + } + + if err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + if err = systemctl.Restart(s.name); err != nil { + service.Error(w, http.StatusInternalServerError, fmt.Sprintf("重启 %s 服务失败", s.name)) + return + } + + service.Success(w, nil) +} + +// Processes 进程列表 +func (s *Service) Processes(w http.ResponseWriter, r *http.Request) { + out, err := shell.Execf(`supervisorctl status | awk '{print $1}'`) + if err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + + var processes []Process + for _, line := range strings.Split(out, "\n") { + if len(line) == 0 { + continue + } + + var p Process + p.Name = line + if status, err := shell.Execf(`supervisorctl status '%s' | awk '{print $2}'`, line); err == nil { + p.Status = status + } + if p.Status == "RUNNING" { + pid, _ := shell.Execf(`supervisorctl status '%s' | awk '{print $4}'`, line) + p.Pid = strings.ReplaceAll(pid, ",", "") + uptime, _ := shell.Execf(`supervisorctl status '%s' | awk '{print $6}'`, line) + p.Uptime = uptime + } else { + p.Pid = "-" + p.Uptime = "-" + } + processes = append(processes, p) + } + + paged, total := service.Paginate(r, processes) + + service.Success(w, chix.M{ + "total": total, + "items": paged, + }) +} + +// StartProcess 启动进程 +func (s *Service) StartProcess(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[ProcessName](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if out, err := shell.Execf(`supervisorctl start %s`, req.Process); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + + service.Success(w, nil) +} + +// StopProcess 停止进程 +func (s *Service) StopProcess(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[ProcessName](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if out, err := shell.Execf(`supervisorctl stop %s`, req.Process); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + + service.Success(w, nil) +} + +// RestartProcess 重启进程 +func (s *Service) RestartProcess(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[ProcessName](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if out, err := shell.Execf(`supervisorctl restart %s`, req.Process); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + + service.Success(w, nil) +} + +// ProcessLog 进程日志 +func (s *Service) ProcessLog(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[ProcessName](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + var logPath string + if os.IsRHEL() { + logPath, err = shell.Execf(`cat '/etc/supervisord.d/%s.conf' | grep stdout_logfile= | awk -F "=" '{print $2}'`, req.Process) + } else { + logPath, err = shell.Execf(`cat '/etc/supervisor/conf.d/%s.conf' | grep stdout_logfile= | awk -F "=" '{print $2}'`, req.Process) + } + + if err != nil { + service.Error(w, http.StatusInternalServerError, fmt.Sprintf("无法从进程 %s 的配置文件中获取日志路径", req.Process)) + return + } + + log, err := shell.Execf(`tail -n 200 '%s'`, logPath) + if err != nil { + service.Error(w, http.StatusInternalServerError, log) + return + } + + service.Success(w, log) +} + +// ClearProcessLog 清空进程日志 +func (s *Service) ClearProcessLog(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[ProcessName](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + var logPath string + if os.IsRHEL() { + logPath, err = shell.Execf(`cat '/etc/supervisord.d/%s.conf' | grep stdout_logfile= | awk -F "=" '{print $2}'`, req.Process) + } else { + logPath, err = shell.Execf(`cat '/etc/supervisor/conf.d/%s.conf' | grep stdout_logfile= | awk -F "=" '{print $2}'`, req.Process) + } + + if err != nil { + service.Error(w, http.StatusInternalServerError, fmt.Sprintf("无法从进程 %s 的配置文件中获取日志路径", req.Process)) + return + } + + if out, err := shell.Execf(`echo "" > '%s'`, logPath); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + + service.Success(w, nil) +} + +// ProcessConfig 获取进程配置 +func (s *Service) ProcessConfig(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[ProcessName](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + var config string + if os.IsRHEL() { + config, err = io.Read(`/etc/supervisord.d/` + req.Process + `.conf`) + } else { + config, err = io.Read(`/etc/supervisor/conf.d/` + req.Process + `.conf`) + } + + if err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + + service.Success(w, config) +} + +// UpdateProcessConfig 保存进程配置 +func (s *Service) UpdateProcessConfig(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[UpdateProcessConfig](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if os.IsRHEL() { + err = io.Write(`/etc/supervisord.d/`+req.Process+`.conf`, req.Config, 0644) + } else { + err = io.Write(`/etc/supervisor/conf.d/`+req.Process+`.conf`, req.Config, 0644) + } + + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + _, _ = shell.Execf(`supervisorctl reread`) + _, _ = shell.Execf(`supervisorctl update`) + _, _ = shell.Execf(`supervisorctl restart '%s'`, req.Process) + + service.Success(w, nil) +} + +// CreateProcess 添加进程 +func (s *Service) CreateProcess(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[CreateProcess](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + config := `[program:` + req.Name + `] +command=` + req.Command + ` +process_name=%(program_name)s +directory=` + req.Path + ` +autostart=true +autorestart=true +user=` + req.User + ` +numprocs=` + cast.ToString(req.Num) + ` +redirect_stderr=true +stdout_logfile=/var/log/supervisor/` + req.Name + `.log +stdout_logfile_maxbytes=2MB +` + + if os.IsRHEL() { + err = io.Write(`/etc/supervisord.d/`+req.Name+`.conf`, config, 0644) + } else { + err = io.Write(`/etc/supervisor/conf.d/`+req.Name+`.conf`, config, 0644) + } + + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + _, _ = shell.Execf(`supervisorctl reread`) + _, _ = shell.Execf(`supervisorctl update`) + _, _ = shell.Execf(`supervisorctl start '%s'`, req.Name) + + service.Success(w, nil) +} + +// DeleteProcess 删除进程 +func (s *Service) DeleteProcess(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[ProcessName](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if out, err := shell.Execf(`supervisorctl stop '%s'`, req.Process); err != nil { + service.Error(w, http.StatusInternalServerError, out) + return + } + + var logPath string + if os.IsRHEL() { + logPath, err = shell.Execf(`cat '/etc/supervisord.d/%s.conf' | grep stdout_logfile= | awk -F "=" '{print $2}'`, req.Process) + if err := io.Remove(`/etc/supervisord.d/` + req.Process + `.conf`); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + } else { + logPath, err = shell.Execf(`cat '/etc/supervisor/conf.d/%s.conf' | grep stdout_logfile= | awk -F "=" '{print $2}'`, req.Process) + if err := io.Remove(`/etc/supervisor/conf.d/` + req.Process + `.conf`); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + } + + if err != nil { + service.Error(w, http.StatusInternalServerError, fmt.Sprintf("无法从进程 %s 的配置文件中获取日志路径", req.Process)) + return + } + + if err = io.Remove(logPath); err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + _, _ = shell.Execf(`supervisorctl reread`) + _, _ = shell.Execf(`supervisorctl update`) + + service.Success(w, nil) +} diff --git a/internal/apps/supervisor/types.go b/internal/apps/supervisor/types.go new file mode 100644 index 00000000..74166fe9 --- /dev/null +++ b/internal/apps/supervisor/types.go @@ -0,0 +1,8 @@ +package supervisor + +type Process struct { + Name string `json:"name"` + Status string `json:"status"` + Pid string `json:"pid"` + Uptime string `json:"uptime"` +} diff --git a/internal/apps/toolbox/init.go b/internal/apps/toolbox/init.go new file mode 100644 index 00000000..2d03b141 --- /dev/null +++ b/internal/apps/toolbox/init.go @@ -0,0 +1,26 @@ +package toolbox + +import ( + "github.com/go-chi/chi/v5" + + "github.com/TheTNB/panel/pkg/apploader" + "github.com/TheTNB/panel/pkg/types" +) + +func init() { + apploader.Register(&types.App{ + Slug: "toolbox", + Route: func(r chi.Router) { + service := NewService() + r.Get("/dns", service.GetDNS) + r.Post("/dns", service.UpdateDNS) + r.Get("/swap", service.GetSWAP) + r.Post("/swap", service.UpdateSWAP) + r.Get("/timezone", service.GetTimezone) + r.Post("/timezone", service.UpdateTimezone) + r.Get("/hosts", service.GetHosts) + r.Post("/hosts", service.UpdateHosts) + r.Post("/rootPassword", service.UpdateRootPassword) + }, + }) +} diff --git a/internal/apps/toolbox/request.go b/internal/apps/toolbox/request.go new file mode 100644 index 00000000..1e99f256 --- /dev/null +++ b/internal/apps/toolbox/request.go @@ -0,0 +1,22 @@ +package toolbox + +type DNS struct { + DNS1 string `form:"dns1" json:"dns1"` + DNS2 string `form:"dns2" json:"dns2"` +} + +type SWAP struct { + Size int64 `form:"size" json:"size"` +} + +type Timezone struct { + Timezone string `form:"timezone" json:"timezone"` +} + +type Hosts struct { + Hosts string `form:"hosts" json:"hosts"` +} + +type Password struct { + Password string `form:"password" json:"password"` +} diff --git a/internal/apps/toolbox/service.go b/internal/apps/toolbox/service.go new file mode 100644 index 00000000..e346db2c --- /dev/null +++ b/internal/apps/toolbox/service.go @@ -0,0 +1,275 @@ +package toolbox + +import ( + "net/http" + "path/filepath" + "regexp" + "strings" + + "github.com/go-rat/chix" + "github.com/spf13/cast" + + "github.com/TheTNB/panel/internal/panel" + "github.com/TheTNB/panel/internal/service" + "github.com/TheTNB/panel/pkg/io" + "github.com/TheTNB/panel/pkg/shell" + "github.com/TheTNB/panel/pkg/str" + "github.com/TheTNB/panel/pkg/types" +) + +type Service struct{} + +func NewService() *Service { + return &Service{} +} + +// GetDNS 获取 DNS 信息 +func (s *Service) GetDNS(w http.ResponseWriter, r *http.Request) { + raw, err := io.Read("/etc/resolv.conf") + if err != nil { + service.Error(w, http.StatusInternalServerError, err.Error()) + return + } + match := regexp.MustCompile(`nameserver\s+(\S+)`).FindAllStringSubmatch(raw, -1) + if len(match) == 0 { + service.Error(w, http.StatusInternalServerError, "找不到 DNS 信息") + return + } + + var dns []string + for _, m := range match { + dns = append(dns, m[1]) + } + + service.Success(w, dns) +} + +// UpdateDNS 设置 DNS 信息 +func (s *Service) UpdateDNS(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[DNS](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if len(req.DNS1) == 0 || len(req.DNS2) == 0 { + service.Error(w, http.StatusUnprocessableEntity, "DNS 信息不能为空") + return + } + + var dns string + dns += "nameserver " + req.DNS1 + "\n" + dns += "nameserver " + req.DNS2 + "\n" + + if err := io.Write("/etc/resolv.conf", dns, 0644); err != nil { + service.Error(w, http.StatusInternalServerError, "写入 DNS 信息失败") + return + } + + service.Success(w, nil) +} + +// GetSWAP 获取 SWAP 信息 +func (s *Service) GetSWAP(w http.ResponseWriter, r *http.Request) { + var total, used, free string + var size int64 + if io.Exists(filepath.Join(panel.Root, "swap")) { + file, err := io.FileInfo(filepath.Join(panel.Root, "swap")) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, "获取 SWAP 信息失败") + return + } + + size = file.Size() / 1024 / 1024 + total = str.FormatBytes(float64(file.Size())) + } else { + size = 0 + total = "0.00 B" + } + + raw, err := shell.Execf("free | grep Swap") + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, "获取 SWAP 信息失败") + return + } + + match := regexp.MustCompile(`Swap:\s+(\d+)\s+(\d+)\s+(\d+)`).FindStringSubmatch(raw) + if len(match) > 0 { + used = str.FormatBytes(cast.ToFloat64(match[2]) * 1024) + free = str.FormatBytes(cast.ToFloat64(match[3]) * 1024) + } + + service.Success(w, chix.M{ + "total": total, + "size": size, + "used": used, + "free": free, + }) +} + +// UpdateSWAP 设置 SWAP 信息 +func (s *Service) UpdateSWAP(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[SWAP](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if io.Exists(filepath.Join(panel.Root, "swap")) { + if out, err := shell.Execf("swapoff '%s'", filepath.Join(panel.Root, "swap")); err != nil { + service.Error(w, http.StatusUnprocessableEntity, out) + return + } + if out, err := shell.Execf("rm -f '%s'", filepath.Join(panel.Root, "swap")); err != nil { + service.Error(w, http.StatusUnprocessableEntity, out) + return + } + if out, err := shell.Execf(`sed -i '%s/d' /etc/fstab`, filepath.Join(panel.Root, "swap")); err != nil { + service.Error(w, http.StatusUnprocessableEntity, out) + return + } + } + + if req.Size > 1 { + free, err := shell.Execf("df -k %s | awk '{print $4}' | tail -n 1", panel.Root) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, "获取磁盘空间失败") + return + } + if cast.ToInt64(free)*1024 < req.Size*1024*1024 { + service.Error(w, http.StatusUnprocessableEntity, "磁盘空间不足,当前剩余 "+str.FormatBytes(cast.ToFloat64(free))) + return + } + + btrfsCheck, _ := shell.Execf("df -T %s | awk '{print $2}' | tail -n 1", panel.Root) + if strings.Contains(btrfsCheck, "btrfs") { + if out, err := shell.Execf("btrfs filesystem mkswapfile --size %dM --uuid clear %s", req.Size, filepath.Join(panel.Root, "swap")); err != nil { + service.Error(w, http.StatusUnprocessableEntity, out) + return + } + } else { + if out, err := shell.Execf("dd if=/dev/zero of=%s bs=1M count=%d", filepath.Join(panel.Root, "swap"), req.Size); err != nil { + service.Error(w, http.StatusUnprocessableEntity, out) + return + } + if out, err := shell.Execf("mkswap -f '%s'", filepath.Join(panel.Root, "swap")); err != nil { + service.Error(w, http.StatusUnprocessableEntity, out) + return + } + if err := io.Chmod(filepath.Join(panel.Root, "swap"), 0600); err != nil { + service.Error(w, http.StatusUnprocessableEntity, "设置 SWAP 权限失败") + return + } + } + if out, err := shell.Execf("swapon '%s'", filepath.Join(panel.Root, "swap")); err != nil { + service.Error(w, http.StatusUnprocessableEntity, out) + return + } + if out, err := shell.Execf("echo '%s swap swap defaults 0 0' >> /etc/fstab", filepath.Join(panel.Root, "swap")); err != nil { + service.Error(w, http.StatusUnprocessableEntity, out) + return + } + } + + service.Success(w, nil) +} + +// GetTimezone 获取时区 +func (s *Service) GetTimezone(w http.ResponseWriter, r *http.Request) { + raw, err := shell.Execf("timedatectl | grep zone") + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, "获取时区信息失败") + return + } + + match := regexp.MustCompile(`zone:\s+(\S+)`).FindStringSubmatch(raw) + if len(match) == 0 { + service.Error(w, http.StatusUnprocessableEntity, "找不到时区信息") + return + } + + zonesRaw, err := shell.Execf("timedatectl list-timezones") + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, "获取时区列表失败") + return + } + zones := strings.Split(zonesRaw, "\n") + + var zonesList []types.LV + for _, z := range zones { + zonesList = append(zonesList, types.LV{ + Label: z, + Value: z, + }) + } + + service.Success(w, chix.M{ + "timezone": match[1], + "timezones": zonesList, + }) +} + +// UpdateTimezone 设置时区 +func (s *Service) UpdateTimezone(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[Timezone](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if out, err := shell.Execf("timedatectl set-timezone '%s'", req.Timezone); err != nil { + service.Error(w, http.StatusUnprocessableEntity, out) + return + } + + service.Success(w, nil) +} + +// GetHosts 获取 hosts 信息 +func (s *Service) GetHosts(w http.ResponseWriter, r *http.Request) { + hosts, err := io.Read("/etc/hosts") + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + service.Success(w, hosts) +} + +// UpdateHosts 设置 hosts 信息 +func (s *Service) UpdateHosts(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[Hosts](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if err = io.Write("/etc/hosts", req.Hosts, 0644); err != nil { + service.Error(w, http.StatusUnprocessableEntity, "写入 hosts 信息失败") + return + } + + service.Success(w, nil) +} + +// UpdateRootPassword 设置 root 密码 +func (s *Service) UpdateRootPassword(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[Password](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + if !regexp.MustCompile(`^[a-zA-Z0-9·~!@#$%^&*()_+-=\[\]{};:'",./<>?]{6,20}$`).MatchString(req.Password) { + service.Error(w, http.StatusUnprocessableEntity, "密码必须为 6-20 位字母、数字或特殊字符") + return + } + + req.Password = strings.ReplaceAll(req.Password, `'`, `\'`) + if out, err := shell.Execf(`yes '%s' | passwd root`, req.Password); err != nil { + service.Error(w, http.StatusUnprocessableEntity, out) + return + } + + service.Success(w, nil) +} diff --git a/internal/apps/toolbox/types.go b/internal/apps/toolbox/types.go new file mode 100644 index 00000000..a61bbe79 --- /dev/null +++ b/internal/apps/toolbox/types.go @@ -0,0 +1,6 @@ +package toolbox + +type zone struct { + Label string `json:"label"` + Value string `json:"value"` +} diff --git a/pkg/apploader/apploader.go b/pkg/apploader/apploader.go index 6b9c459e..21b6de47 100644 --- a/pkg/apploader/apploader.go +++ b/pkg/apploader/apploader.go @@ -13,6 +13,9 @@ import ( var plugins sync.Map func Register(plugin *types.App) { + if _, ok := plugins.Load(plugin.Slug); ok { + panic(fmt.Sprintf("plugin %s already exists", plugin.Slug)) + } plugins.Store(plugin.Slug, plugin) }