diff --git a/caddy/br.go b/caddy/br.go index 6522cb67a4..2efe385381 100644 --- a/caddy/br.go +++ b/caddy/br.go @@ -2,4 +2,21 @@ package caddy +import ( + "runtime/debug" + + "github.com/dunglas/frankenphp" +) + var brotli = true + +func init() { + if buildInfo, ok := debug.ReadBuildInfo(); ok { + for _, dep := range buildInfo.Deps { + if dep.Path == "github.com/dunglas/caddy-cbrotli" { + frankenphp.AddPHPInfoEntry("dunglas/caddy-cbrotli", dep.Version) + break + } + } + } +} diff --git a/caddy/caddy.go b/caddy/caddy.go index 24c5011900..ed1ad5fde0 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -9,6 +9,7 @@ import ( "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" + "github.com/dunglas/frankenphp" ) const ( @@ -26,6 +27,14 @@ func init() { caddy.RegisterModule(&FrankenPHPModule{}) caddy.RegisterModule(&FrankenPHPAdmin{}) + // Report Caddy version in phpinfo() + simpleVersion, fullVersion := caddy.Version() + if fullVersion != "" { + frankenphp.AddPHPInfoEntry("caddy", fullVersion) + } else if simpleVersion != "" { + frankenphp.AddPHPInfoEntry("caddy", simpleVersion) + } + httpcaddyfile.RegisterGlobalOption("frankenphp", parseGlobalOption) httpcaddyfile.RegisterHandlerDirective("php", parseCaddyfile) diff --git a/cli.go b/cli.go index a96153a14a..9e02c8497e 100644 --- a/cli.go +++ b/cli.go @@ -9,6 +9,7 @@ import "unsafe" func ExecuteScriptCLI(script string, args []string) int { // Ensure extensions are registered before CLI execution registerExtensions() + initPHPInfoEntries() cScript := C.CString(script) defer C.free(unsafe.Pointer(cScript)) diff --git a/cli_test.go b/cli_test.go index 5a07dd8d2c..d47331e772 100644 --- a/cli_test.go +++ b/cli_test.go @@ -46,6 +46,28 @@ func TestExecuteCLICode(t *testing.T) { assert.Equal(t, stdoutStderrStr, `Hello World`) } +// The CLI must print phpinfo() as plain text, like the CLI SAPI does. +func TestExecuteCLICodePHPInfoAsText(t *testing.T) { + if _, err := os.Stat("internal/testcli/testcli"); err != nil { + t.Skip("internal/testcli/testcli has not been compiled, run `cd internal/testcli/ && go build`") + } + + cmd := exec.Command("internal/testcli/testcli", "-r", "phpinfo();") + stdoutStderr, err := cmd.CombinedOutput() + assert.NoError(t, err) + + stdoutStderrStr := string(stdoutStderr) + + assert.Contains(t, stdoutStderrStr, "PHP Version => ") + assert.Contains(t, stdoutStderrStr, "frankenphp => ") + assert.Contains(t, stdoutStderrStr, "go => go") + assert.Contains(t, stdoutStderrStr, "Go modules") + assert.Contains(t, stdoutStderrStr, "Module => Version") + assert.NotContains(t, stdoutStderrStr, "") + assert.NotContains(t, stdoutStderrStr, "
") +} + // `-i` (and any other invocation without a script) is only supported since PHP // 8.6, where the real CLI SAPI is reused. older versions must fail cleanly. func TestExecuteCLIPHPInfo(t *testing.T) { diff --git a/emulate_php_cli.c b/emulate_php_cli.c index f33f360359..38d77260e3 100644 --- a/emulate_php_cli.c +++ b/emulate_php_cli.c @@ -165,6 +165,8 @@ void *emulate_script_cli(void *arg) { php_embed_module.name = "cli"; php_embed_module.pretty_name = "PHP CLI embedded in FrankenPHP"; php_embed_module.register_server_variables = sapi_cli_register_variables; + /* the CLI SAPI prints phpinfo() as plain text, not as HTML */ + php_embed_module.phpinfo_as_text = 1; php_embed_init(cli_args->argc, cli_args->argv); diff --git a/frankenphp.c b/frankenphp.c index b15507f69d..c4161f60fe 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -6,6 +6,7 @@ #include #include #include +#include #ifdef HAVE_PHP_SESSION #include #endif @@ -113,6 +114,9 @@ frankenphp_config frankenphp_get_config() { }; } +const char **frankenphp_phpinfo_entries = NULL; +const char **frankenphp_go_modules = NULL; + bool should_filter_var = 0; bool original_user_abort_setting = 0; frankenphp_interned_strings_t frankenphp_strings = {0}; @@ -1115,6 +1119,45 @@ PHP_MINIT_FUNCTION(frankenphp) { return SUCCESS; } +static void frankenphp_print_info_rows(const char **entries) { + for (int i = 0; entries[i] != NULL; i += 2) { + php_info_print_table_row(2, entries[i], entries[i + 1]); + } +} + +PHP_MINFO_FUNCTION(frankenphp) { + php_info_print_table_start(); + php_info_print_table_row(2, "frankenphp", TOSTRING(FRANKENPHP_VERSION)); + if (frankenphp_phpinfo_entries) { + frankenphp_print_info_rows(frankenphp_phpinfo_entries); + } + php_info_print_table_end(); + + if (frankenphp_go_modules == NULL) { + return; + } + + /* The list of Go modules is long, collapse it by default when rendering + * HTML */ + if (sapi_module.phpinfo_as_text) { + php_info_print_table_start(); + php_info_print_table_header(1, "Go modules"); + php_info_print_table_end(); + } else { + php_printf("
Go " + "modules\n"); + } + + php_info_print_table_start(); + php_info_print_table_header(2, "Module", "Version"); + frankenphp_print_info_rows(frankenphp_go_modules); + php_info_print_table_end(); + + if (!sapi_module.phpinfo_as_text) { + php_printf("
\n"); + } +} + static zend_module_entry frankenphp_module = { STANDARD_MODULE_HEADER, "frankenphp", @@ -1123,7 +1166,7 @@ static zend_module_entry frankenphp_module = { NULL, /* shutdown */ NULL, /* request initialization */ NULL, /* request shutdown */ - NULL, /* information */ + PHP_MINFO(frankenphp), /* information */ TOSTRING(FRANKENPHP_VERSION), STANDARD_MODULE_PROPERTIES}; @@ -1772,6 +1815,19 @@ static void *execute_script_cli(void *arg) { #endif } +static int (*previous_php_register_internal_extensions_func)(void) = NULL; + +/* frankenphp_module is passed to php_module_startup() by our own SAPI, but the + * CLI SAPIs take no additional modules: hook their module startup instead */ +static int register_frankenphp_module(void) { + if (previous_php_register_internal_extensions_func() != SUCCESS) { + return FAILURE; + } + + return zend_register_internal_module(&frankenphp_module) == NULL ? FAILURE + : SUCCESS; +} + int frankenphp_execute_script_cli(char *script, int argc, char **argv, bool eval) { pthread_t thread; @@ -1781,6 +1837,10 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv, cli_exec_args_t args = { .script = script, .argc = argc, .argv = argv, .eval = eval}; + previous_php_register_internal_extensions_func = + php_register_internal_extensions_func; + php_register_internal_extensions_func = register_frankenphp_module; + /* * Start the script in a dedicated thread to prevent conflicts between Go and * PHP signal handlers diff --git a/frankenphp.go b/frankenphp.go index 79b135b808..3b5b48c444 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -30,6 +30,8 @@ import ( "os" "os/signal" "runtime" + "runtime/debug" + "sort" "strings" "sync" "sync/atomic" @@ -37,7 +39,7 @@ import ( "time" "unsafe" // debug on Linux - //_ "github.com/ianlancetaylor/cgosymbolizer" + // _ "github.com/ianlancetaylor/cgosymbolizer" ) type contextKeyStruct struct{} @@ -156,6 +158,108 @@ func Config() PHPConfig { } } +type phpinfoEntry struct { + key, value string +} + +var ( + phpinfoEntries []phpinfoEntry + goModuleEntries []phpinfoEntry + cPhpinfoArr []*C.char + cGoModulesArr []*C.char +) + +// Report the Go toolchain and every Go module linked into the binary. Caddy +// modules, FrankenPHP extensions written in Go and even the standard library +// itself. The list is verbose, so it's displayed in a collapsed section. +func init() { + buildInfo, ok := debug.ReadBuildInfo() + if !ok { + return + } + + AddPHPInfoEntry("go", buildInfo.GoVersion) + + goModuleEntries = make([]phpinfoEntry, 0, len(buildInfo.Deps)) + for _, dep := range buildInfo.Deps { + goModuleEntries = append(goModuleEntries, phpinfoEntry{dep.Path, goModuleVersion(dep)}) + } +} + +// goModuleVersion returns the version of the given module, taking "replace" +// directives into account. +func goModuleVersion(module *debug.Module) string { + if module.Replace == nil { + return module.Version + } + + if module.Replace.Version == "" { + // Replaced by a local directory + return module.Replace.Path + } + + return module.Replace.Path + " " + module.Replace.Version +} + +// AddPHPInfoEntry adds an entry to the frankenphp section of phpinfo(). +func AddPHPInfoEntry(key, value string) { + phpinfoEntries = append(phpinfoEntries, phpinfoEntry{key, value}) +} + +func initPHPInfoEntries() { + freeCEntries(cPhpinfoArr) + freeCEntries(cGoModulesArr) + + cPhpinfoArr = newCEntries(phpinfoEntries) + cGoModulesArr = newCEntries(goModuleEntries) + + C.frankenphp_phpinfo_entries = firstCEntry(cPhpinfoArr) + C.frankenphp_go_modules = firstCEntry(cGoModulesArr) +} + +// newCEntries converts entries to a null-terminated C array of key, value, key, +// value, ... sorted by key. The returned slice is backed by memory allocated by +// C, free it with freeCEntries(). +func newCEntries(entries []phpinfoEntry) []*C.char { + if len(entries) == 0 { + return nil + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].key < entries[j].key + }) + + n := 2*len(entries) + 1 + arr := (*[1 << 28]*C.char)(C.malloc(C.size_t(n) * C.size_t(unsafe.Sizeof(uintptr(0)))))[:n:n] + for i, e := range entries { + arr[2*i] = C.CString(e.key) + arr[2*i+1] = C.CString(e.value) + } + arr[n-1] = nil + + return arr +} + +func freeCEntries(arr []*C.char) { + for _, cstr := range arr { + if cstr != nil { + C.free(unsafe.Pointer(cstr)) + } + } + + if arr != nil { + C.free(unsafe.Pointer(&arr[0])) + } +} + +func firstCEntry(arr []*C.char) **C.char { + if arr == nil { + return nil + } + + return &arr[0] +} + func calculateMaxThreads(opt *opt) (numWorkers int, _ error) { maxProcs := runtime.GOMAXPROCS(0) * 2 maxThreadsFromWorkers := 0 @@ -252,6 +356,7 @@ func Init(options ...Option) error { signal.Ignore(syscall.SIGPIPE) registerExtensions() + initPHPInfoEntries() opt := &opt{} for _, o := range options { diff --git a/frankenphp.h b/frankenphp.h index 99ac0ab7ec..7ec4e17d56 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -47,6 +47,14 @@ typedef struct { #define STRINGIFY(x) #x #define TOSTRING(x) STRINGIFY(x) +/* phpinfo entries from Go - null-terminated array of key, value, key, value, + * ... */ +extern const char **frankenphp_phpinfo_entries; + +/* Go modules linked into the binary, same layout, displayed in a section + * collapsed by default */ +extern const char **frankenphp_go_modules; + typedef struct go_string { size_t len; char *data; diff --git a/frankenphp_test.go b/frankenphp_test.go index 19fb3dd06f..f564eb349d 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -467,6 +467,7 @@ func testPhpInfo(t *testing.T, opts *testOptions) { assert.Contains(t, body, "frankenphp") assert.Contains(t, body, fmt.Sprintf("i=%d", i)) + assert.Contains(t, body, runtime.Version()) }, opts) } @@ -575,6 +576,11 @@ func TestException_worker(t *testing.T) { testException(t, &testOptions{workerScript: "exception.php"}) } func testException(t *testing.T, opts *testOptions) { + if opts.phpIni == nil { + opts.phpIni = map[string]string{} + } + opts.phpIni["display_errors"] = "1" + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) { body, _ := testGet(fmt.Sprintf("http://example.com/exception.php?i=%d", i), handler, t) diff --git a/mercure.go b/mercure.go index 821b057915..33599a5a64 100644 --- a/mercure.go +++ b/mercure.go @@ -8,11 +8,23 @@ package frankenphp import "C" import ( "log/slog" + "runtime/debug" "unsafe" "github.com/dunglas/mercure" ) +func init() { + if buildInfo, ok := debug.ReadBuildInfo(); ok { + for _, dep := range buildInfo.Deps { + if dep.Path == "github.com/dunglas/mercure" { + AddPHPInfoEntry("dunglas/mercure", dep.Version) + break + } + } + } +} + type mercureContext struct { mercureHub *mercure.Hub } diff --git a/server_test.go b/server_test.go index f297db7c29..3ad2032bbe 100644 --- a/server_test.go +++ b/server_test.go @@ -102,6 +102,7 @@ func TestServer(t *testing.T) { server2, _ := frankenphp.NewServer(testDataDir) initServers( t, + frankenphp.WithPhpIni(map[string]string{"display_errors": "1"}), frankenphp.WithServer(server1), frankenphp.WithServer(server2), frankenphp.WithWorkers("counter", testDataDir+"worker-with-counter.php", 1, frankenphp.WithWorkerServerScope(server1)), diff --git a/watcher.go b/watcher.go index cfe133e5ab..b738d02546 100644 --- a/watcher.go +++ b/watcher.go @@ -3,12 +3,25 @@ package frankenphp import ( + "runtime/debug" "sync/atomic" "github.com/dunglas/frankenphp/internal/watcher" watcherGo "github.com/e-dant/watcher/watcher-go" ) +func init() { + // watcher doesn't expose the version, so get it from go.mod + if buildInfo, ok := debug.ReadBuildInfo(); ok { + for _, dep := range buildInfo.Deps { + if dep.Path == "github.com/e-dant/watcher" { + AddPHPInfoEntry("e-dant/watcher", dep.Version) + break + } + } + } +} + type hotReloadOpt struct { hotReload []*watcher.PatternGroup } diff --git a/worker_test.go b/worker_test.go index dc423294f6..1a0f1d0ad0 100644 --- a/worker_test.go +++ b/worker_test.go @@ -76,7 +76,7 @@ func TestCannotCallHandleRequestInNonWorkerMode(t *testing.T) { body, _ := io.ReadAll(resp.Body) assert.Contains(t, string(body), "Fatal error: Uncaught RuntimeException: frankenphp_handle_request() called while not in worker mode") - }, nil) + }, &testOptions{phpIni: map[string]string{"display_errors": "1", "html_errors": "1"}}) } func TestWorkerEnv(t *testing.T) {