fix(lsapi): 修审查发现的协议与接入问题

- 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 <noreply@anthropic.com>
This commit is contained in:
耗子
2026-09-21 02:52:23 +08:00
co-authored by Claude Opus 5
parent bc54fa573f
commit 718ca52d20
4 changed files with 405 additions and 24 deletions
+11 -3
View File
@@ -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
}
+78 -20
View File
@@ -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
}
+310
View File
@@ -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)
}
+6 -1
View File
@@ -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
}