diff --git a/internal/request/environment_php.go b/internal/request/environment_php.go index 4f55a7e0..238ca795 100644 --- a/internal/request/environment_php.go +++ b/internal/request/environment_php.go @@ -14,6 +14,12 @@ type EnvironmentPHPUpdateConfig struct { Config string `form:"config" json:"config" validate:"required"` } +// EnvironmentPHPComposerMirror Composer 镜像源设置 +type EnvironmentPHPComposerMirror struct { + Version uint `json:"version"` + Mirror string `form:"mirror" json:"mirror"` +} + // EnvironmentPHPConfigTune PHP 配置调整 type EnvironmentPHPConfigTune struct { Version uint `json:"version"` diff --git a/internal/route/environment.go b/internal/route/environment.go index 79cfdf42..e70ca0bf 100644 --- a/internal/route/environment.go +++ b/internal/route/environment.go @@ -85,6 +85,18 @@ func EnvironmentRoutes(environmentDotnetService *service.EnvironmentDotnetServic Summary: "更新 PHP 配置调优", Tags: tags, Request: request.EnvironmentPHPConfigTune{}}, {Method: http.MethodPost, Path: "/api/environment/php/{version}/clean_session", Handler: environmentPHP.CleanSession, Summary: "清理 PHP Session", Tags: tags, Request: request.EnvironmentPHPVersion{}}, + {Method: http.MethodGet, Path: "/api/environment/php/{version}/processes", Handler: environmentPHP.Processes, + Summary: "获取 PHP-FPM 进程列表", Tags: tags, Request: request.EnvironmentPHPVersion{}}, + {Method: http.MethodGet, Path: "/api/environment/php/{version}/opcache", Handler: environmentPHP.Opcache, + Summary: "获取 OPcache 状态", Tags: tags, Request: request.EnvironmentPHPVersion{}}, + {Method: http.MethodPost, Path: "/api/environment/php/{version}/opcache/reset", Handler: environmentPHP.ResetOpcache, + Summary: "重置 OPcache", Tags: tags, Request: request.EnvironmentPHPVersion{}}, + {Method: http.MethodGet, Path: "/api/environment/php/{version}/composer", Handler: environmentPHP.Composer, + Summary: "获取 Composer 状态", Tags: tags, Request: request.EnvironmentPHPVersion{}}, + {Method: http.MethodPost, Path: "/api/environment/php/{version}/composer/install", Handler: environmentPHP.InstallComposer, + Summary: "安装 Composer", Tags: tags, Request: request.EnvironmentPHPVersion{}}, + {Method: http.MethodPost, Path: "/api/environment/php/{version}/composer/mirror", Handler: environmentPHP.SetComposerMirror, + Summary: "设置 Composer 镜像源", Tags: tags, Request: request.EnvironmentPHPComposerMirror{}}, // Python {Method: http.MethodPost, Path: "/api/environment/python/{slug}/set_cli", Handler: environmentPython.SetCli, diff --git a/internal/service/environment_php.go b/internal/service/environment_php.go index 8f99c328..c4fa9664 100644 --- a/internal/service/environment_php.go +++ b/internal/service/environment_php.go @@ -1,7 +1,10 @@ package service import ( + "context" + "encoding/json" "fmt" + "math" "net/http" "net/url" "slices" @@ -19,8 +22,10 @@ import ( "github.com/acepanel/panel/v3/internal/biz" "github.com/acepanel/panel/v3/internal/request" "github.com/acepanel/panel/v3/pkg/config" + "github.com/acepanel/panel/v3/pkg/fastcgi" "github.com/acepanel/panel/v3/pkg/io" "github.com/acepanel/panel/v3/pkg/shell" + "github.com/acepanel/panel/v3/pkg/tools" "github.com/acepanel/panel/v3/pkg/types" ) @@ -355,6 +360,364 @@ func (s *EnvironmentPHPService) UninstallModule(w http.ResponseWriter, r *http.R Success(w, nil) } +// GetConfigTune 获取 PHP 配置调整参数 +func (s *EnvironmentPHPService) GetConfigTune(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.EnvironmentPHPVersion](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + if !s.environmentRepo.IsInstalled("php", strconv.FormatUint(uint64(req.Version), 10)) { + Error(w, http.StatusUnprocessableEntity, s.t.Get("PHP-%d is not installed", req.Version)) + return + } + + iniPath := fmt.Sprintf("%s/server/php/%d/etc/php.ini", app.Root, req.Version) + fpmPath := fmt.Sprintf("%s/server/php/%d/etc/php-fpm.conf", app.Root, req.Version) + + ini, err := io.Read(iniPath) + if err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + fpm, err := io.Read(fpmPath) + if err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + + tune := request.EnvironmentPHPConfigTune{ + // php.ini 常规设置 + ShortOpenTag: confval.PHPINI.Get(ini, "short_open_tag"), + DateTimezone: confval.PHPINI.Get(ini, "date.timezone"), + DisplayErrors: confval.PHPINI.Get(ini, "display_errors"), + ErrorReporting: confval.PHPINI.Get(ini, "error_reporting"), + // php.ini 禁用函数 + DisableFunctions: confval.PHPINI.Get(ini, "disable_functions"), + // php.ini 上传限制 + UploadMaxFilesize: confval.PHPINI.Get(ini, "upload_max_filesize"), + PostMaxSize: confval.PHPINI.Get(ini, "post_max_size"), + MaxFileUploads: confval.PHPINI.Get(ini, "max_file_uploads"), + MemoryLimit: confval.PHPINI.Get(ini, "memory_limit"), + // php.ini 超时限制 + MaxExecutionTime: confval.PHPINI.Get(ini, "max_execution_time"), + MaxInputTime: confval.PHPINI.Get(ini, "max_input_time"), + MaxInputVars: confval.PHPINI.Get(ini, "max_input_vars"), + // Session 相关 + SessionSaveHandler: confval.PHPINI.Get(ini, "session.save_handler"), + SessionSavePath: confval.PHPINI.Get(ini, "session.save_path"), + SessionGcMaxlifetime: confval.PHPINI.Get(ini, "session.gc_maxlifetime"), + SessionCookieLifetime: confval.PHPINI.Get(ini, "session.cookie_lifetime"), + // php-fpm.conf 配置 + Pm: confval.PHPINI.Get(fpm, "pm"), + PmMaxChildren: confval.PHPINI.Get(fpm, "pm.max_children"), + PmStartServers: confval.PHPINI.Get(fpm, "pm.start_servers"), + PmMinSpareServers: confval.PHPINI.Get(fpm, "pm.min_spare_servers"), + PmMaxSpareServers: confval.PHPINI.Get(fpm, "pm.max_spare_servers"), + } + + Success(w, tune) +} + +// UpdateConfigTune 更新 PHP 配置调整参数 +func (s *EnvironmentPHPService) UpdateConfigTune(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.EnvironmentPHPConfigTune](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + if !s.environmentRepo.IsInstalled("php", strconv.FormatUint(uint64(req.Version), 10)) { + Error(w, http.StatusUnprocessableEntity, s.t.Get("PHP-%d is not installed", req.Version)) + return + } + + iniPath := fmt.Sprintf("%s/server/php/%d/etc/php.ini", app.Root, req.Version) + fpmPath := fmt.Sprintf("%s/server/php/%d/etc/php-fpm.conf", app.Root, req.Version) + + ini, err := io.Read(iniPath) + if err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + fpm, err := io.Read(fpmPath) + if err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + + // 更新 php.ini 配置 + ini = confval.PHPINI.Set(ini, "short_open_tag", req.ShortOpenTag) + ini = confval.PHPINI.Set(ini, "date.timezone", req.DateTimezone) + ini = confval.PHPINI.Set(ini, "display_errors", req.DisplayErrors) + ini = confval.PHPINI.Set(ini, "error_reporting", req.ErrorReporting) + ini = confval.PHPINI.Set(ini, "disable_functions", req.DisableFunctions) + ini = confval.PHPINI.Set(ini, "upload_max_filesize", req.UploadMaxFilesize) + ini = confval.PHPINI.Set(ini, "post_max_size", req.PostMaxSize) + ini = confval.PHPINI.Set(ini, "max_execution_time", req.MaxExecutionTime) + ini = confval.PHPINI.Set(ini, "max_input_time", req.MaxInputTime) + ini = confval.PHPINI.Set(ini, "memory_limit", req.MemoryLimit) + ini = confval.PHPINI.Set(ini, "max_input_vars", req.MaxInputVars) + ini = confval.PHPINI.Set(ini, "max_file_uploads", req.MaxFileUploads) + ini = confval.PHPINI.Set(ini, "session.save_handler", req.SessionSaveHandler) + ini = confval.PHPINI.Set(ini, "session.save_path", req.SessionSavePath) + ini = confval.PHPINI.Set(ini, "session.gc_maxlifetime", req.SessionGcMaxlifetime) + ini = confval.PHPINI.Set(ini, "session.cookie_lifetime", req.SessionCookieLifetime) + + // 更新 php-fpm.conf 配置 + fpm = confval.PHPINI.Set(fpm, "pm", req.Pm) + fpm = confval.PHPINI.Set(fpm, "pm.max_children", req.PmMaxChildren) + fpm = confval.PHPINI.Set(fpm, "pm.start_servers", req.PmStartServers) + fpm = confval.PHPINI.Set(fpm, "pm.min_spare_servers", req.PmMinSpareServers) + fpm = confval.PHPINI.Set(fpm, "pm.max_spare_servers", req.PmMaxSpareServers) + + if err = io.Write(iniPath, ini, 0644); err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + if err = io.Write(fpmPath, fpm, 0644); err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + + Success(w, nil) +} + +// CleanSession 清理 PHP Session 文件 +func (s *EnvironmentPHPService) CleanSession(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.EnvironmentPHPVersion](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + if !s.environmentRepo.IsInstalled("php", strconv.FormatUint(uint64(req.Version), 10)) { + Error(w, http.StatusUnprocessableEntity, s.t.Get("PHP-%d is not installed", req.Version)) + return + } + + iniPath := fmt.Sprintf("%s/server/php/%d/etc/php.ini", app.Root, req.Version) + ini, err := io.Read(iniPath) + if err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + + handler := confval.PHPINI.Get(ini, "session.save_handler") + if handler != "files" { + Error(w, http.StatusUnprocessableEntity, s.t.Get("Session save handler is not files, cannot clean")) + return + } + + savePath := confval.PHPINI.Get(ini, "session.save_path") + if savePath == "" { + savePath = "/tmp" + } + + if _, err = shell.Execf("find '%s' -name 'sess_*' -type f -delete", savePath); err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + + Success(w, nil) +} + +// Processes 获取 PHP-FPM 工作进程列表 +func (s *EnvironmentPHPService) Processes(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.EnvironmentPHPVersion](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + if !s.environmentRepo.IsInstalled("php", strconv.FormatUint(uint64(req.Version), 10)) { + Error(w, http.StatusUnprocessableEntity, s.t.Get("PHP-%d is not installed", req.Version)) + return + } + + var raw struct { + Processes []struct { + PID int64 `json:"pid"` + State string `json:"state"` + StartSince int64 `json:"start since"` + Requests int64 `json:"requests"` + RequestDuration int64 `json:"request duration"` + Method string `json:"request method"` + URI string `json:"request uri"` + Script string `json:"script"` + LastCPU float64 `json:"last request cpu"` + LastMemory int64 `json:"last request memory"` + } `json:"processes"` + } + client := resty.New().SetTimeout(10 * time.Second) + defer func(client *resty.Client) { _ = client.Close() }(client) + if _, err = client.R().SetResult(&raw).Get(fmt.Sprintf("http://127.0.0.1/phpfpm_status/%d?json&full", req.Version)); err != nil { + Success(w, []types.EnvironmentPHPProcess{}) + return + } + + processes := make([]types.EnvironmentPHPProcess, 0, len(raw.Processes)) + for _, item := range raw.Processes { + processes = append(processes, types.EnvironmentPHPProcess{ + PID: item.PID, + State: item.State, + StartSince: item.StartSince, + Requests: item.Requests, + RequestDuration: item.RequestDuration, + Method: item.Method, + URI: item.URI, + Script: item.Script, + LastRequestCPU: item.LastCPU, + LastRequestMem: item.LastMemory, + }) + } + + Success(w, processes) +} + +// Opcache 获取 OPcache 状态 +func (s *EnvironmentPHPService) Opcache(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.EnvironmentPHPVersion](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + if !s.environmentRepo.IsInstalled("php", strconv.FormatUint(uint64(req.Version), 10)) { + Error(w, http.StatusUnprocessableEntity, s.t.Get("PHP-%d is not installed", req.Version)) + return + } + + body, err := s.opcacheProbe(r.Context(), req.Version, "") + if err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to get OPcache status: %v", err)) + return + } + + // OPcache 未启用时探针返回 false,无法解析为对象 + var raw map[string]any + if err = json.Unmarshal(body, &raw); err != nil || raw == nil { + Success(w, types.EnvironmentPHPOpcache{Enabled: false}) + return + } + + memory := cast.ToStringMap(raw["memory_usage"]) + stats := cast.ToStringMap(raw["opcache_statistics"]) + jit := cast.ToStringMap(raw["jit"]) + + Success(w, types.EnvironmentPHPOpcache{ + Enabled: cast.ToBool(raw["opcache_enabled"]), + MemoryUsed: tools.FormatBytes(cast.ToFloat64(memory["used_memory"])), + MemoryFree: tools.FormatBytes(cast.ToFloat64(memory["free_memory"])), + MemoryWasted: tools.FormatBytes(cast.ToFloat64(memory["wasted_memory"])), + WastedPercent: math.Round(cast.ToFloat64(memory["current_wasted_percentage"])*100) / 100, + HitRate: math.Round(cast.ToFloat64(stats["opcache_hit_rate"])*100) / 100, + Hits: cast.ToInt64(stats["hits"]), + Misses: cast.ToInt64(stats["misses"]), + CachedScripts: cast.ToInt64(stats["num_cached_scripts"]), + CachedKeys: cast.ToInt64(stats["num_cached_keys"]), + MaxCachedKeys: cast.ToInt64(stats["max_cached_keys"]), + OomRestarts: cast.ToInt64(stats["oom_restarts"]), + JitEnabled: cast.ToBool(jit["enabled"]) && cast.ToBool(jit["on"]), + JitBufferSize: tools.FormatBytes(cast.ToFloat64(jit["buffer_size"])), + JitBufferFree: tools.FormatBytes(cast.ToFloat64(jit["buffer_free"])), + }) +} + +// ResetOpcache 重置 OPcache +func (s *EnvironmentPHPService) ResetOpcache(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.EnvironmentPHPVersion](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + if !s.environmentRepo.IsInstalled("php", strconv.FormatUint(uint64(req.Version), 10)) { + Error(w, http.StatusUnprocessableEntity, s.t.Get("PHP-%d is not installed", req.Version)) + return + } + + body, err := s.opcacheProbe(r.Context(), req.Version, "action=reset") + if err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to reset OPcache: %v", err)) + return + } + + var raw map[string]any + if err = json.Unmarshal(body, &raw); err != nil || !cast.ToBool(raw["reset"]) { + Error(w, http.StatusInternalServerError, s.t.Get("failed to reset OPcache, it may not be enabled")) + return + } + + Success(w, nil) +} + +// Composer 获取 Composer 状态 +func (s *EnvironmentPHPService) Composer(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.EnvironmentPHPVersion](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + composer := types.EnvironmentPHPComposer{Installed: io.Exists("/usr/local/bin/composer")} + if composer.Installed { + out, outErr := shell.ExecfWithEnv([]string{"COMPOSER_ALLOW_SUPERUSER=1"}, "%s/server/php/%d/bin/php /usr/local/bin/composer --version --no-ansi 2>/dev/null", app.Root, req.Version) + // 输出形如 Composer version 2.8.4 2025-01-01 00:00:00 + if fields := strings.Fields(out); outErr == nil && len(fields) >= 3 { + composer.Version = fields[2] + } + composer.Mirror = s.composerMirror() + } + + Success(w, composer) +} + +// InstallComposer 安装/更新 Composer(异步任务) +func (s *EnvironmentPHPService) InstallComposer(w http.ResponseWriter, r *http.Request) { + cmd := fmt.Sprintf(`curl -sSLm 10 --retry 3 'https://%s/php/composer.sh' | bash -s -- 'install'`, s.conf.App.DownloadEndpoint) + + task := new(biz.Task) + task.Key = "php:composer" + task.Name = s.t.Get("Install Composer") + task.Status = biz.TaskStatusWaiting + task.Shell = cmd + if err := s.taskRepo.Push(task); err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + + Success(w, nil) +} + +// SetComposerMirror 设置 Composer 全局镜像源 +func (s *EnvironmentPHPService) SetComposerMirror(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.EnvironmentPHPComposerMirror](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + if !io.Exists("/usr/local/bin/composer") { + Error(w, http.StatusUnprocessableEntity, s.t.Get("Composer is not installed")) + return + } + + php := fmt.Sprintf("%s/server/php/%d/bin/php", app.Root, req.Version) + env := []string{"COMPOSER_ALLOW_SUPERUSER=1"} + if req.Mirror == "" { + // 恢复官方源,未设置过镜像时报错可忽略 + _, _ = shell.ExecfWithEnv(env, "%s /usr/local/bin/composer config -g --unset repos.packagist", php) + Success(w, nil) + return + } + + if !strings.HasPrefix(req.Mirror, "https://") && !strings.HasPrefix(req.Mirror, "http://") { + Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid mirror url")) + return + } + if _, err = shell.ExecfWithEnv(env, "%s /usr/local/bin/composer config -g repos.packagist composer '%s'", php, req.Mirror); err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + + Success(w, nil) +} + func (s *EnvironmentPHPService) getModules(version uint) []types.EnvironmentPHPModule { modules := []types.EnvironmentPHPModule{ { @@ -602,162 +965,58 @@ func (s *EnvironmentPHPService) checkModule(version uint, slug string) bool { }) } -// GetConfigTune 获取 PHP 配置调整参数 -func (s *EnvironmentPHPService) GetConfigTune(w http.ResponseWriter, r *http.Request) { - req, err := Bind[request.EnvironmentPHPVersion](r) - if err != nil { - Error(w, http.StatusUnprocessableEntity, "%v", err) - return - } - if !s.environmentRepo.IsInstalled("php", strconv.FormatUint(uint64(req.Version), 10)) { - Error(w, http.StatusUnprocessableEntity, s.t.Get("PHP-%d is not installed", req.Version)) - return +// opcacheProbe 通过 FastCGI 请求 PHP-FPM 执行 OPcache 探针脚本 +func (s *EnvironmentPHPService) opcacheProbe(ctx context.Context, version uint, query string) ([]byte, error) { + probePath := "/tmp/acepanel_opcache_probe.php" + probe := ` function_exists('opcache_reset') && opcache_reset()]); + exit; +} +echo json_encode(function_exists('opcache_get_status') ? opcache_get_status(false) : false); +` + // FPM 以 www 用户执行,探针放在 /tmp 保证可读,每次覆盖写入保证内容正确 + if err := io.Write(probePath, probe, 0644); err != nil { + return nil, err } - iniPath := fmt.Sprintf("%s/server/php/%d/etc/php.ini", app.Root, req.Version) - fpmPath := fmt.Sprintf("%s/server/php/%d/etc/php-fpm.conf", app.Root, req.Version) - - ini, err := io.Read(iniPath) - if err != nil { - Error(w, http.StatusInternalServerError, "%v", err) - return - } - fpm, err := io.Read(fpmPath) - if err != nil { - Error(w, http.StatusInternalServerError, "%v", err) - return - } - - tune := request.EnvironmentPHPConfigTune{ - // php.ini 常规设置 - ShortOpenTag: confval.PHPINI.Get(ini, "short_open_tag"), - DateTimezone: confval.PHPINI.Get(ini, "date.timezone"), - DisplayErrors: confval.PHPINI.Get(ini, "display_errors"), - ErrorReporting: confval.PHPINI.Get(ini, "error_reporting"), - // php.ini 禁用函数 - DisableFunctions: confval.PHPINI.Get(ini, "disable_functions"), - // php.ini 上传限制 - UploadMaxFilesize: confval.PHPINI.Get(ini, "upload_max_filesize"), - PostMaxSize: confval.PHPINI.Get(ini, "post_max_size"), - MaxFileUploads: confval.PHPINI.Get(ini, "max_file_uploads"), - MemoryLimit: confval.PHPINI.Get(ini, "memory_limit"), - // php.ini 超时限制 - MaxExecutionTime: confval.PHPINI.Get(ini, "max_execution_time"), - MaxInputTime: confval.PHPINI.Get(ini, "max_input_time"), - MaxInputVars: confval.PHPINI.Get(ini, "max_input_vars"), - // Session 相关 - SessionSaveHandler: confval.PHPINI.Get(ini, "session.save_handler"), - SessionSavePath: confval.PHPINI.Get(ini, "session.save_path"), - SessionGcMaxlifetime: confval.PHPINI.Get(ini, "session.gc_maxlifetime"), - SessionCookieLifetime: confval.PHPINI.Get(ini, "session.cookie_lifetime"), - // php-fpm.conf 配置 - Pm: confval.PHPINI.Get(fpm, "pm"), - PmMaxChildren: confval.PHPINI.Get(fpm, "pm.max_children"), - PmStartServers: confval.PHPINI.Get(fpm, "pm.start_servers"), - PmMinSpareServers: confval.PHPINI.Get(fpm, "pm.min_spare_servers"), - PmMaxSpareServers: confval.PHPINI.Get(fpm, "pm.max_spare_servers"), - } - - Success(w, tune) + timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + return fastcgi.Request(timeoutCtx, "unix", fmt.Sprintf("/tmp/php-cgi-%d.sock", version), map[string]string{ + "SCRIPT_FILENAME": probePath, + "SCRIPT_NAME": "/acepanel_opcache_probe.php", + "REQUEST_METHOD": "GET", + "QUERY_STRING": query, + "SERVER_PROTOCOL": "HTTP/1.1", + "GATEWAY_INTERFACE": "CGI/1.1", + "REMOTE_ADDR": "127.0.0.1", + "SERVER_ADDR": "127.0.0.1", + "SERVER_PORT": "80", + "SERVER_NAME": "localhost", + }) } -// UpdateConfigTune 更新 PHP 配置调整参数 -func (s *EnvironmentPHPService) UpdateConfigTune(w http.ResponseWriter, r *http.Request) { - req, err := Bind[request.EnvironmentPHPConfigTune](r) - if err != nil { - Error(w, http.StatusUnprocessableEntity, "%v", err) - return - } - if !s.environmentRepo.IsInstalled("php", strconv.FormatUint(uint64(req.Version), 10)) { - Error(w, http.StatusUnprocessableEntity, s.t.Get("PHP-%d is not installed", req.Version)) - return +// composerMirror 读取 Composer 全局镜像源配置,未设置时返回空 +func (s *EnvironmentPHPService) composerMirror() string { + for _, path := range []string{"/root/.config/composer/config.json", "/root/.composer/config.json"} { + content, err := io.Read(path) + if err != nil { + continue + } + var cfg struct { + Repositories map[string]struct { + URL string `json:"url"` + } `json:"repositories"` + } + if json.Unmarshal([]byte(content), &cfg) != nil { + continue + } + for _, key := range []string{"packagist", "packagist.org"} { + if repo, ok := cfg.Repositories[key]; ok && repo.URL != "" { + return repo.URL + } + } } - iniPath := fmt.Sprintf("%s/server/php/%d/etc/php.ini", app.Root, req.Version) - fpmPath := fmt.Sprintf("%s/server/php/%d/etc/php-fpm.conf", app.Root, req.Version) - - ini, err := io.Read(iniPath) - if err != nil { - Error(w, http.StatusInternalServerError, "%v", err) - return - } - fpm, err := io.Read(fpmPath) - if err != nil { - Error(w, http.StatusInternalServerError, "%v", err) - return - } - - // 更新 php.ini 配置 - ini = confval.PHPINI.Set(ini, "short_open_tag", req.ShortOpenTag) - ini = confval.PHPINI.Set(ini, "date.timezone", req.DateTimezone) - ini = confval.PHPINI.Set(ini, "display_errors", req.DisplayErrors) - ini = confval.PHPINI.Set(ini, "error_reporting", req.ErrorReporting) - ini = confval.PHPINI.Set(ini, "disable_functions", req.DisableFunctions) - ini = confval.PHPINI.Set(ini, "upload_max_filesize", req.UploadMaxFilesize) - ini = confval.PHPINI.Set(ini, "post_max_size", req.PostMaxSize) - ini = confval.PHPINI.Set(ini, "max_execution_time", req.MaxExecutionTime) - ini = confval.PHPINI.Set(ini, "max_input_time", req.MaxInputTime) - ini = confval.PHPINI.Set(ini, "memory_limit", req.MemoryLimit) - ini = confval.PHPINI.Set(ini, "max_input_vars", req.MaxInputVars) - ini = confval.PHPINI.Set(ini, "max_file_uploads", req.MaxFileUploads) - ini = confval.PHPINI.Set(ini, "session.save_handler", req.SessionSaveHandler) - ini = confval.PHPINI.Set(ini, "session.save_path", req.SessionSavePath) - ini = confval.PHPINI.Set(ini, "session.gc_maxlifetime", req.SessionGcMaxlifetime) - ini = confval.PHPINI.Set(ini, "session.cookie_lifetime", req.SessionCookieLifetime) - - // 更新 php-fpm.conf 配置 - fpm = confval.PHPINI.Set(fpm, "pm", req.Pm) - fpm = confval.PHPINI.Set(fpm, "pm.max_children", req.PmMaxChildren) - fpm = confval.PHPINI.Set(fpm, "pm.start_servers", req.PmStartServers) - fpm = confval.PHPINI.Set(fpm, "pm.min_spare_servers", req.PmMinSpareServers) - fpm = confval.PHPINI.Set(fpm, "pm.max_spare_servers", req.PmMaxSpareServers) - - if err = io.Write(iniPath, ini, 0644); err != nil { - Error(w, http.StatusInternalServerError, "%v", err) - return - } - if err = io.Write(fpmPath, fpm, 0644); err != nil { - Error(w, http.StatusInternalServerError, "%v", err) - return - } - - Success(w, nil) -} - -// CleanSession 清理 PHP Session 文件 -func (s *EnvironmentPHPService) CleanSession(w http.ResponseWriter, r *http.Request) { - req, err := Bind[request.EnvironmentPHPVersion](r) - if err != nil { - Error(w, http.StatusUnprocessableEntity, "%v", err) - return - } - if !s.environmentRepo.IsInstalled("php", strconv.FormatUint(uint64(req.Version), 10)) { - Error(w, http.StatusUnprocessableEntity, s.t.Get("PHP-%d is not installed", req.Version)) - return - } - - iniPath := fmt.Sprintf("%s/server/php/%d/etc/php.ini", app.Root, req.Version) - ini, err := io.Read(iniPath) - if err != nil { - Error(w, http.StatusInternalServerError, "%v", err) - return - } - - handler := confval.PHPINI.Get(ini, "session.save_handler") - if handler != "files" { - Error(w, http.StatusUnprocessableEntity, s.t.Get("Session save handler is not files, cannot clean")) - return - } - - savePath := confval.PHPINI.Get(ini, "session.save_path") - if savePath == "" { - savePath = "/tmp" - } - - if _, err = shell.Execf("find '%s' -name 'sess_*' -type f -delete", savePath); err != nil { - Error(w, http.StatusInternalServerError, "%v", err) - return - } - - Success(w, nil) + return "" } diff --git a/pkg/fastcgi/fastcgi.go b/pkg/fastcgi/fastcgi.go new file mode 100644 index 00000000..4c33b3f5 --- /dev/null +++ b/pkg/fastcgi/fastcgi.go @@ -0,0 +1,114 @@ +package fastcgi + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "io" + "net" +) + +const ( + typeBeginRequest byte = 1 + typeEndRequest byte = 3 + typeParams byte = 4 + typeStdin byte = 5 + typeStdout byte = 6 + typeStderr byte = 7 + + roleResponder = 1 +) + +// Request 向 FastCGI 服务发起一次 responder 请求,返回剥离 CGI 响应头后的 body +func Request(ctx context.Context, network, address string, params map[string]string) ([]byte, error) { + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, network, address) + if err != nil { + return nil, err + } + defer func() { _ = conn.Close() }() + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + + const requestID = 1 + // BEGIN_REQUEST:responder 角色,连接不复用 + if err = writeRecord(conn, typeBeginRequest, requestID, []byte{0, roleResponder, 0, 0, 0, 0, 0, 0}); err != nil { + return nil, err + } + var buf bytes.Buffer + for name, value := range params { + encodeNameValue(&buf, name, value) + } + if err = writeRecord(conn, typeParams, requestID, buf.Bytes()); err != nil { + return nil, err + } + if err = writeRecord(conn, typeParams, requestID, nil); err != nil { + return nil, err + } + if err = writeRecord(conn, typeStdin, requestID, nil); err != nil { + return nil, err + } + + var stdout, stderr bytes.Buffer + header := make([]byte, 8) + for { + if _, err = io.ReadFull(conn, header); err != nil { + return nil, err + } + contentLength := binary.BigEndian.Uint16(header[4:6]) + content := make([]byte, int(contentLength)+int(header[6])) + if _, err = io.ReadFull(conn, content); err != nil { + return nil, err + } + switch header[1] { + case typeStdout: + stdout.Write(content[:contentLength]) + case typeStderr: + stderr.Write(content[:contentLength]) + case typeEndRequest: + if stderr.Len() > 0 { + return nil, fmt.Errorf("fastcgi stderr: %s", stderr.String()) + } + // 剥离 CGI 响应头 + if _, body, found := bytes.Cut(stdout.Bytes(), []byte("\r\n\r\n")); found { + return body, nil + } + return stdout.Bytes(), nil + } + } +} + +// writeRecord 写入一条 FastCGI 记录 +func writeRecord(w io.Writer, typ byte, requestID uint16, content []byte) error { + header := [8]byte{1, typ} + binary.BigEndian.PutUint16(header[2:4], requestID) + binary.BigEndian.PutUint16(header[4:6], uint16(len(content))) //nolint:gosec // params 由面板构造,长度远小于 64KB + if _, err := w.Write(header[:]); err != nil { + return err + } + if len(content) > 0 { + if _, err := w.Write(content); err != nil { + return err + } + } + return nil +} + +// encodeNameValue 按 FastCGI name-value 格式编码 +func encodeNameValue(buf *bytes.Buffer, name, value string) { + writeLength := func(n int) { + if n < 128 { + buf.WriteByte(byte(n)) + return + } + var b [4]byte + binary.BigEndian.PutUint32(b[:], uint32(n)|1<<31) //nolint:gosec // 长度非负 + buf.Write(b[:]) + } + writeLength(len(name)) + writeLength(len(value)) + buf.WriteString(name) + buf.WriteString(value) +} diff --git a/pkg/types/environment_php.go b/pkg/types/environment_php.go index a58eeb39..4d839647 100644 --- a/pkg/types/environment_php.go +++ b/pkg/types/environment_php.go @@ -6,3 +6,43 @@ type EnvironmentPHPModule struct { Description string `json:"description"` Installed bool `json:"installed"` } + +// EnvironmentPHPProcess PHP-FPM 工作进程信息 +type EnvironmentPHPProcess struct { + PID int64 `json:"pid"` + State string `json:"state"` + StartSince int64 `json:"start_since"` + Requests int64 `json:"requests"` + RequestDuration int64 `json:"request_duration"` // 微秒 + Method string `json:"method"` + URI string `json:"uri"` + Script string `json:"script"` + LastRequestCPU float64 `json:"last_request_cpu"` + LastRequestMem int64 `json:"last_request_memory"` +} + +// EnvironmentPHPOpcache OPcache 状态 +type EnvironmentPHPOpcache struct { + Enabled bool `json:"enabled"` + MemoryUsed string `json:"memory_used"` + MemoryFree string `json:"memory_free"` + MemoryWasted string `json:"memory_wasted"` + WastedPercent float64 `json:"wasted_percent"` + HitRate float64 `json:"hit_rate"` + Hits int64 `json:"hits"` + Misses int64 `json:"misses"` + CachedScripts int64 `json:"cached_scripts"` + CachedKeys int64 `json:"cached_keys"` + MaxCachedKeys int64 `json:"max_cached_keys"` + OomRestarts int64 `json:"oom_restarts"` + JitEnabled bool `json:"jit_enabled"` + JitBufferSize string `json:"jit_buffer_size"` + JitBufferFree string `json:"jit_buffer_free"` +} + +// EnvironmentPHPComposer Composer 状态 +type EnvironmentPHPComposer struct { + Installed bool `json:"installed"` + Version string `json:"version"` + Mirror string `json:"mirror"` // 空为官方源 +} diff --git a/web/src/api/panel/environment/php/index.ts b/web/src/api/panel/environment/php/index.ts index edf4fc76..7507d92e 100644 --- a/web/src/api/panel/environment/php/index.ts +++ b/web/src/api/panel/environment/php/index.ts @@ -36,4 +36,17 @@ export default { http.Post(`/environment/php/${slug}/config_tune`, data), // 清理 Session 文件 cleanSession: (slug: number): any => http.Post(`/environment/php/${slug}/clean_session`), + // FPM 进程列表 + processes: (slug: number): any => http.Get(`/environment/php/${slug}/processes`), + // OPcache 状态 + opcache: (slug: number): any => http.Get(`/environment/php/${slug}/opcache`), + // 重置 OPcache + resetOpcache: (slug: number): any => http.Post(`/environment/php/${slug}/opcache/reset`), + // Composer 状态 + composer: (slug: number): any => http.Get(`/environment/php/${slug}/composer`), + // 安装/更新 Composer + installComposer: (slug: number): any => http.Post(`/environment/php/${slug}/composer/install`), + // 设置 Composer 镜像源 + setComposerMirror: (slug: number, mirror: string): any => + http.Post(`/environment/php/${slug}/composer/mirror`, { mirror }), } diff --git a/web/src/views/apps/mysql/MysqlPerformanceView.vue b/web/src/views/apps/mysql/MysqlPerformanceView.vue index 7eb50b8d..93e42787 100644 --- a/web/src/views/apps/mysql/MysqlPerformanceView.vue +++ b/web/src/views/apps/mysql/MysqlPerformanceView.vue @@ -229,23 +229,25 @@ const handleResetTopSQL = async () => { {{ $gettext('performance_schema is configured, restart the service to take effect.') }} - - {{ - $gettext( - 'performance_schema is not enabled. After enabling, SQL performance statistics will be collected, which increases memory usage and requires a restart.', - ) - }} - - {{ $gettext('Enable') }} - - +