diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f002157f4..7b517409f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,6 +41,10 @@ jobs: run: go build env: GOEXPERIMENT: cgocheck2 + - + name: Build testcli binary + working-directory: internal/testcli/ + run: go build - name: Run library tests run: go test -race -v ./... diff --git a/.gitignore b/.gitignore index d7a674278..82e22c67b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /caddy/frankenphp/frankenphp /internal/testserver/testserver +internal/testcli/testcli .idea/ .vscode/ __debug_bin diff --git a/caddy/php-cli.go b/caddy/php-cli.go new file mode 100644 index 000000000..0d9f38e16 --- /dev/null +++ b/caddy/php-cli.go @@ -0,0 +1,37 @@ +package caddy + +import ( + "errors" + "os" + + caddycmd "github.com/caddyserver/caddy/v2/cmd" + "github.com/dunglas/frankenphp" + + "github.com/spf13/cobra" +) + +func init() { + caddycmd.RegisterCommand(caddycmd.Command{ + Name: "php-cli", + Usage: "script.php [args ...]", + Short: "Runs a PHP command", + Long: ` +Executes a PHP script similarly to the CLI SAPI.`, + CobraFunc: func(cmd *cobra.Command) { + cmd.DisableFlagParsing = true + cmd.RunE = caddycmd.WrapCommandFuncForCobra(cmdPHPCLI) + }, + }) +} + +func cmdPHPCLI(fs caddycmd.Flags) (int, error) { + args := os.Args[2:] + if len(args) < 1 { + return 1, errors.New("the path to the PHP script is required") + } + + status := frankenphp.ExecuteScriptCLI(args[0], args) + os.Exit(status) + + return status, nil +} diff --git a/caddy/command.go b/caddy/php-server.go similarity index 99% rename from caddy/command.go rename to caddy/php-server.go index 49e3e6170..a6b3fbbcc 100644 --- a/caddy/command.go +++ b/caddy/php-server.go @@ -23,7 +23,7 @@ import ( func init() { caddycmd.RegisterCommand(caddycmd.Command{ Name: "php-server", - Usage: "[--domain ] [--root ] [--listen ] [--access-log]", + Usage: "[--domain ] [--root ] [--listen ] [--access-log] [--debug] [--no-compress]", Short: "Spins up a production-ready PHP server", Long: ` A simple but production-ready PHP server. Useful for quick deployments, diff --git a/frankenphp.c b/frankenphp.c index 20f51fee4..316ab3e49 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -663,3 +664,133 @@ int frankenphp_execute_script(const char* file_name) return status; } + +// Use global variables to store CLI arguments to prevent useless allocations +static char *cli_script; +static int cli_argc; +static char **cli_argv; + +// Adapted from https://github.com/php/php-src/sapi/cli/php_cli.c (The PHP Group, The PHP License) +static void cli_register_file_handles(bool no_close) /* {{{ */ +{ + php_stream *s_in, *s_out, *s_err; + php_stream_context *sc_in=NULL, *sc_out=NULL, *sc_err=NULL; + zend_constant ic, oc, ec; + + s_in = php_stream_open_wrapper_ex("php://stdin", "rb", 0, NULL, sc_in); + s_out = php_stream_open_wrapper_ex("php://stdout", "wb", 0, NULL, sc_out); + s_err = php_stream_open_wrapper_ex("php://stderr", "wb", 0, NULL, sc_err); + + if (s_in==NULL || s_out==NULL || s_err==NULL) { + if (s_in) php_stream_close(s_in); + if (s_out) php_stream_close(s_out); + if (s_err) php_stream_close(s_err); + return; + } + + if (no_close) { + s_in->flags |= PHP_STREAM_FLAG_NO_CLOSE; + s_out->flags |= PHP_STREAM_FLAG_NO_CLOSE; + s_err->flags |= PHP_STREAM_FLAG_NO_CLOSE; + } + + //s_in_process = s_in; + + php_stream_to_zval(s_in, &ic.value); + php_stream_to_zval(s_out, &oc.value); + php_stream_to_zval(s_err, &ec.value); + + ZEND_CONSTANT_SET_FLAGS(&ic, CONST_CS, 0); + ic.name = zend_string_init_interned("STDIN", sizeof("STDIN")-1, 0); + zend_register_constant(&ic); + + ZEND_CONSTANT_SET_FLAGS(&oc, CONST_CS, 0); + oc.name = zend_string_init_interned("STDOUT", sizeof("STDOUT")-1, 0); + zend_register_constant(&oc); + + ZEND_CONSTANT_SET_FLAGS(&ec, CONST_CS, 0); + ec.name = zend_string_init_interned("STDERR", sizeof("STDERR")-1, 0); + zend_register_constant(&ec); +} +/* }}} */ + +static void sapi_cli_register_variables(zval *track_vars_array) /* {{{ */ +{ + size_t len; + char *docroot = ""; + + /* In CGI mode, we consider the environment to be a part of the server + * variables + */ + php_import_environment_variables(track_vars_array); + + /* Build the special-case PHP_SELF variable for the CLI version */ + len = strlen(cli_script); + if (sapi_module.input_filter(PARSE_SERVER, "PHP_SELF", &cli_script, len, &len)) { + php_register_variable("PHP_SELF", cli_script, track_vars_array); + } + if (sapi_module.input_filter(PARSE_SERVER, "SCRIPT_NAME", &cli_script, len, &len)) { + php_register_variable("SCRIPT_NAME", cli_script, track_vars_array); + } + /* filenames are empty for stdin */ + if (sapi_module.input_filter(PARSE_SERVER, "SCRIPT_FILENAME", &cli_script, len, &len)) { + php_register_variable("SCRIPT_FILENAME", cli_script, track_vars_array); + } + if (sapi_module.input_filter(PARSE_SERVER, "PATH_TRANSLATED", &cli_script, len, &len)) { + php_register_variable("PATH_TRANSLATED", cli_script, track_vars_array); + } + /* just make it available */ + len = 0U; + if (sapi_module.input_filter(PARSE_SERVER, "DOCUMENT_ROOT", &docroot, len, &len)) { + php_register_variable("DOCUMENT_ROOT", docroot, track_vars_array); + } +} +/* }}} */ + +static void * execute_script_cli(void *arg) { + void *exit_status; + + // The SAPI name "cli" is hardcoded into too many programs... let's usurp it. + 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_init(cli_argc, cli_argv); + + cli_register_file_handles(false); + zend_first_try { + zend_file_handle file_handle; + zend_stream_init_filename(&file_handle, cli_script); + + php_execute_script(&file_handle); + } zend_end_try(); + + exit_status = (void *) (intptr_t) EG(exit_status); + + php_embed_shutdown(); + + return exit_status; +} + +int frankenphp_execute_script_cli(char *script, int argc, char **argv) { + pthread_t thread; + int err; + void *exit_status; + + cli_script = script; + cli_argc = argc; + cli_argv = argv; + + // Start the script in a dedicated thread to prevent conflicts between Go and PHP signal handlers + err = pthread_create(&thread, NULL, execute_script_cli, NULL); + if (err != 0) { + return err; + } + + err = pthread_join(thread, &exit_status); + if (err != 0) { + return err; + } + + return (intptr_t) exit_status; +} diff --git a/frankenphp.go b/frankenphp.go index 04bfeba18..6b8e5e058 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -666,3 +666,18 @@ func go_log(message *C.char, level C.int) { l.Info(m, zap.Stringer("syslog_level", syslogLevel(level))) } } + +// ExecuteScriptCLI executes the PHP script passed as parameter. +// It returns the exit status code of the script. +func ExecuteScriptCLI(script string, args []string) int { + cScript := C.CString(script) + defer C.free(unsafe.Pointer(cScript)) + + argc := C.int(len(args)) + argv := make([]*C.char, argc) + for i, arg := range args { + argv[i] = C.CString(arg) + } + + return int(C.frankenphp_execute_script_cli(cScript, argc, (**C.char)(unsafe.Pointer(&argv[0])))) +} diff --git a/frankenphp.h b/frankenphp.h index 3e85e287b..64e854134 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -53,4 +53,6 @@ int frankenphp_execute_script(const char *file_name); uintptr_t frankenphp_request_shutdown(); void frankenphp_register_bulk_variables(char **variables, size_t size, zval *track_vars_array); +int frankenphp_execute_script_cli(char *script, int argc, char **argv); + #endif diff --git a/frankenphp_test.go b/frankenphp_test.go index eb4b50bb6..a899b0665 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -12,6 +12,7 @@ import ( "net/textproto" "net/url" "os" + "os/exec" "strconv" "strings" "sync" @@ -85,29 +86,6 @@ func runTest(t *testing.T, test func(func(http.ResponseWriter, *http.Request), * wg.Wait() } -func BenchmarkHelloWorld(b *testing.B) { - if err := frankenphp.Init(frankenphp.WithLogger(zap.NewNop())); err != nil { - panic(err) - } - defer frankenphp.Shutdown() - cwd, _ := os.Getwd() - testDataDir := cwd + "/testdata/" - - handler := func(w http.ResponseWriter, r *http.Request) { - req := frankenphp.NewRequestWithContext(r, testDataDir, nil) - if err := frankenphp.ServeHTTP(w, req); err != nil { - panic(err) - } - } - - req := httptest.NewRequest("GET", "http://example.com/index.php", nil) - w := httptest.NewRecorder() - - for i := 0; i < b.N; i++ { - handler(w, req) - } -} - func TestHelloWorld_module(t *testing.T) { testHelloWorld(t, nil) } func TestHelloWorld_worker(t *testing.T) { testHelloWorld(t, &testOptions{workerScript: "index.php"}) @@ -557,6 +535,43 @@ func TestVersion(t *testing.T) { assert.NotEmpty(t, v.Version, 0) } +func TestFiberNoCgo_module(t *testing.T) { testFiberNoCgo(t, &testOptions{}) } +func TestFiberNonCgo_worker(t *testing.T) { + testFiberNoCgo(t, &testOptions{workerScript: "fiber-no-cgo.php"}) +} +func testFiberNoCgo(t *testing.T, opts *testOptions) { + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) { + req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/fiber-no-cgo.php?i=%d", i), nil) + w := httptest.NewRecorder() + handler(w, req) + + resp := w.Result() + body, _ := io.ReadAll(resp.Body) + + assert.Equal(t, string(body), fmt.Sprintf("Fiber %d", i)) + }, opts) +} + +func TestExecuteScriptCLI(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", "testdata/command.php", "foo", "bar") + stdoutStderr, err := cmd.CombinedOutput() + assert.Error(t, err) + + if exitError, ok := err.(*exec.ExitError); ok { + assert.Equal(t, 3, exitError.ExitCode()) + } + + stdoutStderrStr := string(stdoutStderr) + + assert.Contains(t, stdoutStderrStr, `"foo"`) + assert.Contains(t, stdoutStderrStr, `"bar"`) + assert.Contains(t, stdoutStderrStr, "From the CLI") +} + func ExampleServeHTTP() { if err := frankenphp.Init(); err != nil { panic(err) @@ -572,19 +587,34 @@ func ExampleServeHTTP() { log.Fatal(http.ListenAndServe(":8080", nil)) } -func TestFiberNoCgo_module(t *testing.T) { testFiberNoCgo(t, &testOptions{}) } -func TestFiberNonCgo_worker(t *testing.T) { - testFiberNoCgo(t, &testOptions{workerScript: "fiber-no-cgo.php"}) +func ExampleExecuteScriptCLI() { + if len(os.Args) <= 1 { + log.Println("Usage: my-program script.php") + os.Exit(1) + } + + os.Exit(frankenphp.ExecuteScriptCLI(os.Args[1], os.Args)) } -func testFiberNoCgo(t *testing.T, opts *testOptions) { - runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) { - req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/fiber-no-cgo.php?i=%d", i), nil) - w := httptest.NewRecorder() - handler(w, req) - resp := w.Result() - body, _ := io.ReadAll(resp.Body) +func BenchmarkHelloWorld(b *testing.B) { + if err := frankenphp.Init(frankenphp.WithLogger(zap.NewNop())); err != nil { + panic(err) + } + defer frankenphp.Shutdown() + cwd, _ := os.Getwd() + testDataDir := cwd + "/testdata/" - assert.Equal(t, string(body), fmt.Sprintf("Fiber %d", i)) - }, opts) + handler := func(w http.ResponseWriter, r *http.Request) { + req := frankenphp.NewRequestWithContext(r, testDataDir, nil) + if err := frankenphp.ServeHTTP(w, req); err != nil { + panic(err) + } + } + + req := httptest.NewRequest("GET", "http://example.com/index.php", nil) + w := httptest.NewRecorder() + + for i := 0; i < b.N; i++ { + handler(w, req) + } } diff --git a/internal/testcli/main.go b/internal/testcli/main.go new file mode 100644 index 000000000..47a4ca186 --- /dev/null +++ b/internal/testcli/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "log" + "os" + + "github.com/dunglas/frankenphp" +) + +func main() { + if len(os.Args) <= 1 { + log.Println("Usage: testcli script.php") + os.Exit(1) + } + + os.Exit(frankenphp.ExecuteScriptCLI(os.Args[1], os.Args)) +} diff --git a/testdata/command.php b/testdata/command.php new file mode 100644 index 000000000..75fcf136b --- /dev/null +++ b/testdata/command.php @@ -0,0 +1,6 @@ +