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 9cbc219f3d..70a242ac51 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 96821a2392..2b1592294d 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)) @@ -22,6 +23,7 @@ func ExecuteScriptCLI(script string, args []string) int { func ExecutePHPCode(phpCode string) int { // Ensure extensions are registered before CLI execution registerExtensions() + initPHPInfoEntries() cCode := C.CString(phpCode) defer C.free(unsafe.Pointer(cCode)) diff --git a/cli_test.go b/cli_test.go index 964bb49907..c3a1aed1f2 100644 --- a/cli_test.go +++ b/cli_test.go @@ -46,6 +46,23 @@ 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.NotContains(t, stdoutStderrStr, "") +} + // Regression test for https://github.com/php/frankenphp/issues/1902. A // long-running CLI script that installs pcntl_signal handlers must // receive its own signals reliably diff --git a/frankenphp.c b/frankenphp.c index a47b6d80a7..bdafc88568 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -6,6 +6,7 @@ #include #include #include +#include #ifdef HAVE_PHP_SESSION #include #endif @@ -107,6 +108,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}; @@ -1101,6 +1105,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", @@ -1109,7 +1152,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}; @@ -1857,6 +1900,7 @@ static void *execute_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; + php_embed_module.phpinfo_as_text = 1; php_embed_init(cli_argc, cli_argv); diff --git a/frankenphp.go b/frankenphp.go index ad2dedc42a..f7171634fd 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 @@ -250,6 +354,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 db32a82fe0..19fb9e1fe7 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -74,6 +74,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 409e644634..9d81c11c18 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -454,6 +454,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) } diff --git a/mercure.go b/mercure.go index d7cf33609e..a06842bb7f 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/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 }