From 718ca52d2053dc4f980457c5666eeadfabdea79b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Mon, 21 Sep 2026 02:52:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(lsapi):=20=E4=BF=AE=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=E7=9A=84=E5=8D=8F=E8=AE=AE=E4=B8=8E=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lsphp 在 prefork 下借 stderr 通道上报 worker pid,这 8 字节让 stderr 恒非空, 错误判断失效且报错被二进制前缀污染,现在识别并丢弃 - 正常收尾一定有 RESP_END,原先把 EOF 当完整响应会把子进程异常当成功 - 请求包超过 lsphp 的 readReq 上限时自己拦下,否则只能收到一个没头没尾的 EOF - 补响应体总量上限与兜底超时,调用方没给期限时不再无限等 - 解析 RESP_HEADER 的状态码,PHP 报错且无输出时有线索 - 状态接口漏改成按协议选套接字,LSAPI 下读状态这条路此前根本没接上 - 标记在但 lsphp 不在时当作没开,否则外部应用指向不存在的二进制且 fpm 已停 - 探针文件改成先写临时文件再改名,并发下不会被读到半截 测试覆盖请求包布局与响应循环,另配真实 lsphp 8.3 验证。 Co-Authored-By: Claude Opus 5 --- internal/service/environment_php.go | 14 +- pkg/lsapi/lsapi.go | 98 +++++++-- pkg/lsapi/lsapi_test.go | 310 ++++++++++++++++++++++++++++ pkg/webserver/openlitespeed/php.go | 7 +- 4 files changed, 405 insertions(+), 24 deletions(-) create mode 100644 pkg/lsapi/lsapi_test.go diff --git a/internal/service/environment_php.go b/internal/service/environment_php.go index ac5adb277..40772b205 100644 --- a/internal/service/environment_php.go +++ b/internal/service/environment_php.go @@ -14,6 +14,7 @@ import ( "time" "github.com/leonelquinteros/gotext" + "github.com/libtnb/utils/str" "github.com/samber/lo" "github.com/spf13/cast" "resty.dev/v3" @@ -587,7 +588,8 @@ func (s *EnvironmentPHPService) Opcache(w http.ResponseWriter, r *http.Request) return } - if _, err = os.Stat(s.phpFPMSocket(req.Version)); err != nil { + socket, _ := s.probeSocket(req.Version) + if _, err = os.Stat(socket); err != nil { Success(w, types.EnvironmentPHPOpcache{Enabled: false}) return } @@ -989,8 +991,14 @@ if (($_GET['action'] ?? '') === 'reset') { } 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 { + // PHP 以 www 用户执行,探针放在 /tmp 保证可读;先写临时文件再改名,避免并发读到半截。 + // 路径固定,每次换名字会让 OPcache 把每一次探针都缓存成一条脚本 + tmpPath := probePath + "." + str.Random(8) + if err := io.Write(tmpPath, probe, 0644); err != nil { + return nil, err + } + if err := os.Rename(tmpPath, probePath); err != nil { + _ = os.Remove(tmpPath) return nil, err } diff --git a/pkg/lsapi/lsapi.go b/pkg/lsapi/lsapi.go index 5a0049811..35beecb95 100644 --- a/pkg/lsapi/lsapi.go +++ b/pkg/lsapi/lsapi.go @@ -4,11 +4,13 @@ import ( "bytes" "context" "encoding/binary" - "errors" "fmt" "io" + "maps" "net" "runtime" + "slices" + "time" ) const ( @@ -24,8 +26,22 @@ const ( headerIndexLen = 25*2 + 2 + 25*4 maxPacketLen = 16 << 20 + + // lsphp 的 readReq 超过这个长度直接拒绝并关连接,自己先拦住才能给出像样的错误 + maxRequestLen = 256 << 10 + // 响应体总量上限,异常的 lsphp 可能一直吐数据 + maxBodyLen = 32 << 20 + // 调用方没给期限时的兜底,否则读循环会一直等 + defaultTimeout = 30 * time.Second + + // 环境变量的键值长度各用两字节表示,含结尾的 0;数量上限取自 lsphp 的 parseEnv + maxEnvLen = 65534 + maxEnvCount = 8192 ) +// fixedKeys 这四项的值会被请求头的偏移字段引用,必须存在且排在最前 +var fixedKeys = []string{"SCRIPT_FILENAME", "SCRIPT_NAME", "QUERY_STRING", "REQUEST_METHOD"} + // endianFlag lsapidef.h 的字节序判断只认 x86,其它架构一律标成大端;两端一致时都不转换,各自按本机顺序读写 var endianFlag = func() byte { if runtime.GOARCH == "amd64" || runtime.GOARCH == "386" { @@ -36,27 +52,35 @@ var endianFlag = func() byte { // Request 发起一次请求,返回的响应体不含响应头 func Request(ctx context.Context, network, address string, params map[string]string) ([]byte, error) { + req, err := buildRequest(params) + if err != nil { + return nil, err + } + 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) + deadline, ok := ctx.Deadline() + if !ok { + deadline = time.Now().Add(defaultTimeout) } + _ = conn.SetDeadline(deadline) - if _, err = conn.Write(buildRequest(params)); err != nil { + if _, err = conn.Write(req); err != nil { return nil, err } var body, stderr bytes.Buffer + var status uint32 header := make([]byte, packetHeaderLen) for { if _, err = io.ReadFull(conn, header); err != nil { - // lsphp 处理完可能直接关连接,已经收到的就是完整响应 - if errors.Is(err, io.EOF) && body.Len() > 0 { - return body.Bytes(), nil + // 正常收尾一定有 RESP_END,走到这里多半是子进程挂了,stderr 里有线索 + if stderr.Len() > 0 { + return nil, fmt.Errorf("lsapi stderr: %s", stderr.String()) } return nil, err } @@ -64,9 +88,10 @@ func Request(ctx context.Context, network, address string, params map[string]str return nil, fmt.Errorf("lsapi: bad packet signature %q", header[:2]) } - length := int(binary.NativeEndian.Uint32(header[4:8])) - packetHeaderLen + size := binary.NativeEndian.Uint32(header[4:8]) + length := int(size) - packetHeaderLen if length < 0 || length > maxPacketLen { - return nil, fmt.Errorf("lsapi: bad packet length %d", length) + return nil, fmt.Errorf("lsapi: bad packet length %d", size) } payload := make([]byte, length) if _, err = io.ReadFull(conn, payload); err != nil { @@ -75,40 +100,69 @@ func Request(ctx context.Context, network, address string, params map[string]str switch header[2] { case typeRespStream: + if body.Len()+len(payload) > maxBodyLen { + return nil, fmt.Errorf("lsapi: response exceeds %d bytes", maxBodyLen) + } body.Write(payload) case typeStderrStream: + // prefork 出来的子进程借 stderr 通道上报自己的 pid,不是错误输出 + if bytes.HasPrefix(payload, []byte("\x00PID")) { + continue + } stderr.Write(payload) + case typeRespHeader: + if len(payload) >= 8 { + status = binary.NativeEndian.Uint32(payload[4:8]) + } case typeRespEnd: - if stderr.Len() > 0 && body.Len() == 0 { + if body.Len() > 0 { + return body.Bytes(), nil + } + if stderr.Len() > 0 { return nil, fmt.Errorf("lsapi stderr: %s", stderr.String()) } - return body.Bytes(), nil + if status >= 400 { + return nil, fmt.Errorf("lsapi: php returned status %d", status) + } + return nil, nil } } } // buildRequest 定长头 + 环境变量区 + 8 字节对齐后的已知头索引表。 // 头里的四个偏移指向环境变量区中的值,所以这四项要排在最前 -func buildRequest(params map[string]string) []byte { - keys := []string{"SCRIPT_FILENAME", "SCRIPT_NAME", "QUERY_STRING", "REQUEST_METHOD"} - for name := range params { - switch name { - case "SCRIPT_FILENAME", "SCRIPT_NAME", "QUERY_STRING", "REQUEST_METHOD": - default: - keys = append(keys, name) +func buildRequest(params map[string]string) ([]byte, error) { + keys := make([]string, len(fixedKeys), len(params)+len(fixedKeys)) + copy(keys, fixedKeys) + for _, name := range slices.Sorted(maps.Keys(params)) { + // 超长的可选项直接丢掉,写进去只会让包错位 + if slices.Contains(fixedKeys, name) || len(name) > maxEnvLen || len(params[name]) > maxEnvLen { + continue } + keys = append(keys, name) + } + if len(keys) > maxEnvCount { + keys = keys[:maxEnvCount] } reqHeaderLen := packetHeaderLen + 4*9 envLen := 4 + 4 // 两个环境变量区各有 4 字节结束符,特殊变量区只有结束符 for _, name := range keys { - envLen += 4 + len(name) + 1 + len(params[name]) + 1 + value := params[name] + if len(value) > maxEnvLen { + value = "" + } + envLen += 4 + len(name) + 1 + len(value) + 1 } total := reqHeaderLen + envLen pad := (8 - total%8) % 8 total += pad + headerIndexLen + if total > maxRequestLen { + return nil, fmt.Errorf("lsapi: request packet too large (%d bytes)", total) + } + buf := make([]byte, total) copy(buf, []byte{'L', 'S', typeBeginRequest, endianFlag}) binary.NativeEndian.PutUint32(buf[4:8], uint32(total)) @@ -118,6 +172,10 @@ func buildRequest(params map[string]string) []byte { offsets := make([]uint32, 4) for i, name := range keys { value := params[name] + // 固定项不能丢,超长时退化成空值 + if len(value) > maxEnvLen { + value = "" + } binary.BigEndian.PutUint16(buf[pos:], uint16(len(name)+1)) //nolint:gosec binary.BigEndian.PutUint16(buf[pos+2:], uint16(len(value)+1)) //nolint:gosec pos += 4 @@ -141,5 +199,5 @@ func buildRequest(params map[string]string) []byte { binary.NativeEndian.PutUint32(buf[packetHeaderLen+i*4:], v) } - return buf + return buf, nil } diff --git a/pkg/lsapi/lsapi_test.go b/pkg/lsapi/lsapi_test.go new file mode 100644 index 000000000..54dcb1df9 --- /dev/null +++ b/pkg/lsapi/lsapi_test.go @@ -0,0 +1,310 @@ +package lsapi + +import ( + "context" + "encoding/binary" + "io" + "net" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/libtnb/assert/check" + "github.com/libtnb/assert/must" +) + +func mustBuild(t *testing.T, params map[string]string) []byte { + t.Helper() + buf, err := buildRequest(params) + must.NoError(t, err) + return buf +} + +// parsed 按 lsphp 侧 parseRequest 的步骤还原出来的请求包内容 +type parsed struct { + total int + env map[string]string + scriptFile string + scriptName string + queryStr string + method string +} + +// parseAsServer 复刻 lsapilib.c 的 parseRequest:依次是定长头、特殊环境变量区、 +// 环境变量区、8 字节对齐的已知头索引表,最后位置必须正好落在包尾 +func parseAsServer(t *testing.T, buf []byte) parsed { + t.Helper() + must.True(t, len(buf) > packetHeaderLen) + check.Equal(t, string(buf[:2]), "LS") + check.Equal(t, int(buf[2]), typeBeginRequest) + check.Equal(t, int(buf[3]), int(endianFlag)) + + total := int(binary.NativeEndian.Uint32(buf[4:8])) + must.Equal(t, total, len(buf)) + + field := func(i int) int { + return int(binary.NativeEndian.Uint32(buf[packetHeaderLen+i*4:])) + } + httpHeaderLen, cntUnknown := field(0), field(6) + cntEnv, cntSpecial := field(7), field(8) + check.Equal(t, httpHeaderLen, 0) + check.Equal(t, cntUnknown, 0) + check.Equal(t, cntSpecial, 0) + + pos := packetHeaderLen + 4*9 + readEnv := func(count int) map[string]string { + out := make(map[string]string, count) + for range count { + keyLen := int(binary.BigEndian.Uint16(buf[pos:])) + valLen := int(binary.BigEndian.Uint16(buf[pos+2:])) + must.True(t, keyLen > 0 && valLen > 0) + pos += 4 + key := string(buf[pos : pos+keyLen-1]) + check.Equal(t, int(buf[pos+keyLen-1]), 0) + pos += keyLen + value := string(buf[pos : pos+valLen-1]) + check.Equal(t, int(buf[pos+valLen-1]), 0) + pos += valLen + out[key] = value + } + // 每个区以 4 个 0 收尾 + check.Equal(t, binary.NativeEndian.Uint32(buf[pos:]), uint32(0)) + pos += 4 + return out + } + + readEnv(cntSpecial) + env := readEnv(cntEnv) + + pos = (pos + 7) &^ 7 + pos += headerIndexLen + pos += cntUnknown * 16 + pos += httpHeaderLen + // parseRequest 最后要求位置正好等于包尾,差一个字节就整包被拒 + must.Equal(t, pos, total) + + str := func(off int) string { + end := off + for buf[end] != 0 { + end++ + } + return string(buf[off:end]) + } + + return parsed{ + total: total, + env: env, + scriptFile: str(field(2)), + scriptName: str(field(3)), + queryStr: str(field(4)), + method: str(field(5)), + } +} + +func TestBuildRequest(t *testing.T) { + params := map[string]string{ + "SCRIPT_FILENAME": "/tmp/probe.php", + "SCRIPT_NAME": "/probe.php", + "QUERY_STRING": "action=reset", + "REQUEST_METHOD": "GET", + "SERVER_PROTOCOL": "HTTP/1.1", + "HTTP_X_TEST": "hello", + } + + got := parseAsServer(t, mustBuild(t, params)) + + check.DeepEqual(t, got.env, params) + check.Equal(t, got.scriptFile, "/tmp/probe.php") + check.Equal(t, got.scriptName, "/probe.php") + check.Equal(t, got.queryStr, "action=reset") + check.Equal(t, got.method, "GET") + check.Equal(t, got.total%8, headerIndexLen%8) +} + +// 四个固定键即使调用方没传也要占位,它们的偏移被请求头引用 +func TestBuildRequestMissingFixedKeys(t *testing.T) { + got := parseAsServer(t, mustBuild(t, map[string]string{"SERVER_NAME": "localhost"})) + + check.Len(t, got.env, 5) + check.Equal(t, got.scriptFile, "") + check.Equal(t, got.method, "") + check.Equal(t, got.env["SERVER_NAME"], "localhost") +} + +func TestBuildRequestEmptyParams(t *testing.T) { + got := parseAsServer(t, mustBuild(t, nil)) + + check.Len(t, got.env, 4) +} + +// 长度字段只有两字节,超长的值必须被挡住而不是截断成错位的包 +func TestBuildRequestOversizedValue(t *testing.T) { + long := make([]byte, maxEnvLen+1) + for i := range long { + long[i] = 'a' + } + + got := parseAsServer(t, mustBuild(t, map[string]string{ + "HTTP_LONG": string(long), + "SERVER_NAME": "localhost", + })) + + check.Equal(t, got.env["SERVER_NAME"], "localhost") + _, ok := got.env["HTTP_LONG"] + check.False(t, ok) +} + +// 超过 lsphp 的 readReq 上限时要自己拦下,否则只会收到一个没头没尾的 EOF +func TestBuildRequestTooLarge(t *testing.T) { + params := make(map[string]string, 3000) + value := string(make([]byte, 100)) + for i := range 3000 { + params["HTTP_X_"+strconv.Itoa(i)] = value + } + + _, err := buildRequest(params) + check.Error(t, err) +} + +// fakeLSPHP 起一个假 lsphp,按给定的包序列应答一次请求 +func fakeLSPHP(t *testing.T, packets [][]byte, closeEarly bool) string { + t.Helper() + socket := filepath.Join(t.TempDir(), "s.sock") + ln, err := net.Listen("unix", socket) + must.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + _, _ = io.CopyN(io.Discard, conn, 8) // 请求包头 + for _, p := range packets { + _, _ = conn.Write(p) + } + if !closeEarly { + time.Sleep(50 * time.Millisecond) + } + }() + + return socket +} + +func packet(typ byte, payload []byte) []byte { + buf := make([]byte, packetHeaderLen+len(payload)) + copy(buf, []byte{'L', 'S', typ, endianFlag}) + binary.NativeEndian.PutUint32(buf[4:8], uint32(packetHeaderLen+len(payload))) //nolint:gosec // 测试数据长度可控 + copy(buf[packetHeaderLen:], payload) + return buf +} + +func respHeader(status uint32) []byte { + payload := make([]byte, 8) + binary.NativeEndian.PutUint32(payload[4:], status) + return packet(typeRespHeader, payload) +} + +// pidPacket lsphp fork 后借 stderr 通道上报 pid,客户端必须忽略它 +func pidPacket() []byte { + return packet(typeStderrStream, append([]byte("\x00PID"), 1, 2, 3, 4)) +} + +func request(t *testing.T, socket string) ([]byte, error) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + return Request(ctx, "unix", socket, map[string]string{"SCRIPT_FILENAME": "/tmp/x.php"}) +} + +func TestRequestBody(t *testing.T) { + socket := fakeLSPHP(t, [][]byte{ + pidPacket(), + respHeader(200), + packet(typeRespStream, []byte("hello ")), + packet(typeRespStream, []byte("world")), + packet(typeRespEnd, nil), + }, false) + + body, err := request(t, socket) + must.NoError(t, err) + check.Equal(t, string(body), "hello world") +} + +// pid 通知包不能被当成错误输出,否则每个请求的 stderr 都非空 +func TestRequestIgnoresPIDNotify(t *testing.T) { + socket := fakeLSPHP(t, [][]byte{pidPacket(), respHeader(200), packet(typeRespEnd, nil)}, false) + + body, err := request(t, socket) + check.NoError(t, err) + check.Empty(t, body) +} + +func TestRequestStderr(t *testing.T) { + socket := fakeLSPHP(t, [][]byte{ + pidPacket(), + packet(typeStderrStream, []byte("PHP Fatal error: boom")), + packet(typeRespEnd, nil), + }, false) + + _, err := request(t, socket) + must.Error(t, err) + check.Contains(t, err.Error(), "PHP Fatal error: boom") + check.NotContains(t, err.Error(), "PID") +} + +// 有正文时 stderr 里的 warning 不该让整个请求失败 +func TestRequestStderrWithBody(t *testing.T) { + socket := fakeLSPHP(t, [][]byte{ + packet(typeStderrStream, []byte("PHP Warning: deprecated")), + packet(typeRespStream, []byte("ok")), + packet(typeRespEnd, nil), + }, false) + + body, err := request(t, socket) + must.NoError(t, err) + check.Equal(t, string(body), "ok") +} + +// 没等到 RESP_END 就断开是子进程异常,不能把半截正文当成功 +func TestRequestTruncated(t *testing.T) { + socket := fakeLSPHP(t, [][]byte{packet(typeRespStream, []byte("half"))}, true) + + _, err := request(t, socket) + check.Error(t, err) +} + +func TestRequestBadSignature(t *testing.T) { + socket := fakeLSPHP(t, [][]byte{{'X', 'X', 0, 0, 0, 0, 0, 8}}, true) + + _, err := request(t, socket) + must.Error(t, err) + check.Contains(t, err.Error(), "bad packet signature") +} + +func TestRequestBadLength(t *testing.T) { + bad := make([]byte, packetHeaderLen) + copy(bad, []byte{'L', 'S', typeRespStream, endianFlag}) + binary.NativeEndian.PutUint32(bad[4:8], 3) // 比包头还短 + socket := fakeLSPHP(t, [][]byte{bad}, true) + + _, err := request(t, socket) + must.Error(t, err) + check.Contains(t, err.Error(), "bad packet length 3") +} + +// PHP 报 500 且没有任何输出时,状态码是唯一线索 +func TestRequestErrorStatus(t *testing.T) { + socket := fakeLSPHP(t, [][]byte{respHeader(500), packet(typeRespEnd, nil)}, false) + + _, err := request(t, socket) + must.Error(t, err) + check.Contains(t, err.Error(), "status 500") +} + +func TestRequestDialError(t *testing.T) { + _, err := request(t, filepath.Join(t.TempDir(), "missing.sock")) + check.Error(t, err) +} diff --git a/pkg/webserver/openlitespeed/php.go b/pkg/webserver/openlitespeed/php.go index e287466ef..e271f3e84 100644 --- a/pkg/webserver/openlitespeed/php.go +++ b/pkg/webserver/openlitespeed/php.go @@ -32,8 +32,13 @@ func LSPHPPath(version uint) string { return fmt.Sprintf("/opt/ace/server/php/%d/bin/lsphp", version) } +// LSAPIEnabled 标记在但 lsphp 不在时要当作没开,否则外部应用会指向不存在的二进制, +// 而 php-fpm 又被停掉,该版本 PHP 就整体不可用了 func LSAPIEnabled(version uint) bool { - _, err := os.Stat(filepath.Join(lsapiDir, strconv.FormatUint(uint64(version), 10))) + if _, err := os.Stat(filepath.Join(lsapiDir, strconv.FormatUint(uint64(version), 10))); err != nil { + return false + } + _, err := os.Stat(LSPHPPath(version)) return err == nil }