From b64f2ce13d5a674f9674943bd336feaedcabda62 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 10 May 2025 20:13:44 +0200 Subject: [PATCH 01/17] v1 --- caddy/caddy.go | 59 +++++++++++++++++++++++++------------------- caddy/config_test.go | 2 +- context.go | 16 ++++++++---- frankenphp.go | 4 +-- request_options.go | 2 +- scaling.go | 4 +-- worker.go | 23 +++++++++++++++++ 7 files changed, 73 insertions(+), 37 deletions(-) diff --git a/caddy/caddy.go b/caddy/caddy.go index 5a54ca8e85..33c9538878 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -373,6 +373,8 @@ type FrankenPHPModule struct { Env map[string]string `json:"env,omitempty"` // Workers configures the worker scripts to start. Workers []workerConfig `json:"workers,omitempty"` + // Workers configures the worker scripts to start. + UseWorkerAsFallback bool `json:"use_worker_as_fallback,omitempty"` resolvedDocumentRoot string preparedEnv frankenphp.PreparedEnv @@ -432,6 +434,21 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error { } } + // copy the prepared env to all module workers + if f.Env != nil { + for _, wc := range f.Workers { + if wc.Env == nil { + wc.Env = make(map[string]string) + } + for k, v := range f.Env { + // Only set if not already defined in the worker + if _, exists := wc.Env[k]; !exists { + wc.Env[k] = v + } + } + } + } + if f.preparedEnv == nil { f.preparedEnv = frankenphp.PrepareEnv(f.Env) @@ -479,12 +496,15 @@ func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c } } - fullScriptPath, _ := fastabs.FastAbs(documentRoot + "/" + r.URL.Path) - workerName := "" - for _, w := range f.Workers { - if p, _ := fastabs.FastAbs(w.FileName); p == fullScriptPath { - workerName = w.Name + if f.UseWorkerAsFallback { + workerName = f.Workers[0].Name + } else if len(f.Workers) > 0 { + fullScriptPath, _ := fastabs.FastAbs(documentRoot + "/" + r.URL.Path) + for _, w := range f.Workers { + if p, _ := fastabs.FastAbs(w.FileName); p == fullScriptPath { + workerName = w.Name + } } } @@ -528,7 +548,8 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { // First pass: Parse all directives except "worker" for d.Next() { for d.NextBlock(0) { - switch d.Val() { + directive := d.Val() + switch directive { case "root": if !d.NextArg() { return d.ArgErr() @@ -566,26 +587,7 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } f.ResolveRootSymlink = &v - case "worker": - for d.NextBlock(1) { - } - for d.NextArg() { - } - // Skip "worker" blocks in the first pass - continue - - default: - allowedDirectives := "root, split, env, resolve_root_symlink, worker" - return wrongSubDirectiveError("php or php_server", allowedDirectives, d.Val()) - } - } - } - - // Second pass: Parse only "worker" blocks - d.Reset() - for d.Next() { - for d.NextBlock(0) { - if d.Val() == "worker" { + case "worker", "index_worker": wc, err := parseWorkerConfig(d) if err != nil { return err @@ -629,7 +631,12 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } f.Workers = append(f.Workers, wc) + f.UseWorkerAsFallback = directive == "index_worker" moduleWorkerConfigs = append(moduleWorkerConfigs, wc) + + default: + allowedDirectives := "root, split, env, resolve_root_symlink, worker" + return wrongSubDirectiveError("php or php_server", allowedDirectives, d.Val()) } } } diff --git a/caddy/config_test.go b/caddy/config_test.go index 9a4c9aa230..ca61fc0a5d 100644 --- a/caddy/config_test.go +++ b/caddy/config_test.go @@ -1,4 +1,4 @@ -package caddy +package caddy import ( "testing" diff --git a/context.go b/context.go index 822a5ff5c6..55cdfd760e 100644 --- a/context.go +++ b/context.go @@ -17,12 +17,12 @@ type frankenPHPContext struct { logger *slog.Logger request *http.Request originalRequest *http.Request + worker *worker docURI string pathInfo string scriptName string scriptFilename string - workerName string // Whether the request is already closed by us isDone bool @@ -89,8 +89,14 @@ func NewRequestWithContext(r *http.Request, opts ...RequestOption) (*http.Reques } } - // SCRIPT_FILENAME is the absolute path of SCRIPT_NAME - fc.scriptFilename = sanitizedPathJoin(fc.documentRoot, fc.scriptName) + if fc.worker != nil { + fc.scriptFilename = fc.worker.fileName + } else { + // SCRIPT_FILENAME is the absolute path of SCRIPT_NAME + fc.scriptFilename = sanitizedPathJoin(fc.documentRoot, fc.scriptName) + fc.worker = getWorkerByPath(fc.scriptFilename) + } + c := context.WithValue(r.Context(), contextKey, fc) return r.WithContext(c), nil @@ -152,9 +158,9 @@ func (fc *frankenPHPContext) reject(statusCode int, message string) { if rw != nil { rw.WriteHeader(statusCode) _, _ = rw.Write([]byte(message)) - + if f, ok := rw.(http.Flusher); ok { - f.Flush() + f.Flush() } } diff --git a/frankenphp.go b/frankenphp.go index eefa7ace36..5ca138adcb 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -400,8 +400,8 @@ func ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) error } // Detect if a worker is available to handle this request - if worker, ok := workers[getWorkerKey(fc.workerName, fc.scriptFilename)]; ok { - worker.handleRequest(fc) + if fc.worker != nil { + fc.worker.handleRequest(fc) return nil } diff --git a/request_options.go b/request_options.go index 6c1cb59491..1e09e4b550 100644 --- a/request_options.go +++ b/request_options.go @@ -127,7 +127,7 @@ func WithRequestLogger(logger *slog.Logger) RequestOption { // WithWorkerName sets the worker that should handle the request func WithWorkerName(name string) RequestOption { return func(o *frankenPHPContext) error { - o.workerName = name + o.worker = getWorkerByName(name) return nil } diff --git a/scaling.go b/scaling.go index ca319a1776..d462b4a541 100644 --- a/scaling.go +++ b/scaling.go @@ -154,8 +154,8 @@ func startUpscalingThreads(maxScaledThreads int, scale chan *frankenPHPContext, } // if the request has been stalled long enough, scale - if worker, ok := workers[getWorkerKey(fc.workerName, fc.scriptFilename)]; ok { - scaleWorkerThread(worker) + if fc.worker != nil { + scaleWorkerThread(fc.worker) } else { scaleRegularThread() } diff --git a/worker.go b/worker.go index 3c9455b775..ecdd8f67b2 100644 --- a/worker.go +++ b/worker.go @@ -73,6 +73,29 @@ func getWorkerKey(name string, filename string) string { return key } +func getWorkerByName(name string) *worker { + if name == "" { + return nil + } + for _, w := range workers { + if w.name == name { + return w + } + } + + return nil +} + +func getWorkerByPath(path string) *worker { + for _, w := range workers { + if w.fileName == path && !strings.HasPrefix(w.name, "m#") { + return w + } + } + + return nil +} + func newWorker(o workerOpt) (*worker, error) { absFileName, err := fastabs.FastAbs(o.fileName) if err != nil { From baa513e8894302d46da1e268b12b6efd21ddebd0 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 11 May 2025 10:48:36 +0200 Subject: [PATCH 02/17] Adds index to worker. --- caddy/caddy.go | 140 +++++++++++++++++++++++++++++++------------------ worker.go | 6 ++- 2 files changed, 93 insertions(+), 53 deletions(-) diff --git a/caddy/caddy.go b/caddy/caddy.go index 33c9538878..483bc2f8c2 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -62,6 +62,8 @@ type workerConfig struct { Env map[string]string `json:"env,omitempty"` // Directories to watch for file changes Watch []string `json:"watch,omitempty"` + // IsIndex determines weather the worker is the first_exist_fallback + IsIndex bool `json:"is:index,omitempty"` } type FrankenPHPApp struct { @@ -230,6 +232,7 @@ func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) { // UnmarshalCaddyfile implements caddyfile.Unmarshaler. func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + moduleWorkerConfigs = []workerConfig{} for d.Next() { for d.NextBlock(0) { // when adding a new directive, also update the allowedDirectives error message @@ -373,8 +376,6 @@ type FrankenPHPModule struct { Env map[string]string `json:"env,omitempty"` // Workers configures the worker scripts to start. Workers []workerConfig `json:"workers,omitempty"` - // Workers configures the worker scripts to start. - UseWorkerAsFallback bool `json:"use_worker_as_fallback,omitempty"` resolvedDocumentRoot string preparedEnv frankenphp.PreparedEnv @@ -497,14 +498,14 @@ func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c } workerName := "" - if f.UseWorkerAsFallback { - workerName = f.Workers[0].Name - } else if len(f.Workers) > 0 { - fullScriptPath, _ := fastabs.FastAbs(documentRoot + "/" + r.URL.Path) - for _, w := range f.Workers { - if p, _ := fastabs.FastAbs(w.FileName); p == fullScriptPath { - workerName = w.Name - } + // check if the request should be handled by a module worker + for _, w := range f.Workers { + if w.IsIndex { + workerName = w.Name + } + absWorkerPath, _ := fastabs.FastAbs(w.FileName) + if absPath, _ := fastabs.FastAbs(documentRoot + "/" + r.URL.Path); absPath == absWorkerPath { + workerName = w.Name } } @@ -545,7 +546,6 @@ outer: // UnmarshalCaddyfile implements caddyfile.Unmarshaler. func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { - // First pass: Parse all directives except "worker" for d.Next() { for d.NextBlock(0) { directive := d.Val() @@ -587,55 +587,32 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } f.ResolveRootSymlink = &v - case "worker", "index_worker": - wc, err := parseWorkerConfig(d) - if err != nil { - return err - } - - // Inherit environment variables from the parent php_server directive - if !filepath.IsAbs(wc.FileName) && f.Root != "" { - wc.FileName = filepath.Join(f.Root, wc.FileName) + // register the worker if 'index' is a worker file + case "index": + if !d.NextArg() { + return d.ArgErr() } - if f.Env != nil { - if wc.Env == nil { - wc.Env = make(map[string]string) - } - for k, v := range f.Env { - // Only set if not already defined in the worker - if _, exists := wc.Env[k]; !exists { - wc.Env[k] = v - } + if d.Val() == "worker" { + wc, err := parseModuleWorker(d, f) + if err != nil { + return err } + wc.IsIndex = true + f.Workers = append(f.Workers, wc) + moduleWorkerConfigs = append(moduleWorkerConfigs, wc) } - if wc.Name == "" { - wc.Name = generateUniqueModuleWorkerName(wc.FileName) - } - if !strings.HasPrefix(wc.Name, "m#") { - wc.Name = "m#" + wc.Name - } - - // Check if a worker with this filename already exists in this module - for _, existingWorker := range f.Workers { - if existingWorker.FileName == wc.FileName { - return fmt.Errorf(`workers in a single "php_server" block must not have duplicate filenames: %q`, wc.FileName) - } - } - // Check if a worker with this name and a different environment or filename already exists - for _, existingWorker := range moduleWorkerConfigs { - if existingWorker.Name == wc.Name { - return fmt.Errorf("workers must not have duplicate names: %q", wc.Name) - } + case "worker": + wc, err := parseModuleWorker(d, f) + if err != nil { + return err } - f.Workers = append(f.Workers, wc) - f.UseWorkerAsFallback = directive == "index_worker" moduleWorkerConfigs = append(moduleWorkerConfigs, wc) default: - allowedDirectives := "root, split, env, resolve_root_symlink, worker" + allowedDirectives := "root, split, env, resolve_root_symlink, worker, index" return wrongSubDirectiveError("php or php_server", allowedDirectives, d.Val()) } } @@ -644,6 +621,64 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { return nil } +// parse a worker inside a php or php_server directive +func parseModuleWorker(d *caddyfile.Dispenser, f *FrankenPHPModule) (workerConfig, error) { + wc, err := parseWorkerConfig(d) + if err != nil { + return wc, err + } + + if !filepath.IsAbs(wc.FileName) && f.Root != "" { + wc.FileName = filepath.Join(f.Root, wc.FileName) + } + // if the worker path is relative, make it absolute + // either with the php_server Root or with the WD + if false && !filepath.IsAbs(wc.FileName) { + if f.Root != "" { + wc.FileName = filepath.Join(f.Root, wc.FileName) + } else { + wc.FileName, err = fastabs.FastAbs(wc.FileName) + if err != nil { + return wc, err + } + } + } + + if f.Env != nil { + if wc.Env == nil { + wc.Env = make(map[string]string) + } + for k, v := range f.Env { + // Only set if not already defined in the worker + if _, exists := wc.Env[k]; !exists { + wc.Env[k] = v + } + } + } + + if wc.Name == "" { + wc.Name = generateUniqueModuleWorkerName(wc.FileName) + } + if !strings.HasPrefix(wc.Name, "m#") { + wc.Name = "m#" + wc.Name + } + + // Check if a worker with this filename already exists in this module + for _, existingWorker := range f.Workers { + if existingWorker.FileName == wc.FileName { + return wc, fmt.Errorf(`workers in a single "php_server" block must not have duplicate filenames: %q`, wc.FileName) + } + } + // Check if a worker with this name and a different environment or filename already exists + for _, existingWorker := range moduleWorkerConfigs { + if existingWorker.Name == wc.Name { + return wc, fmt.Errorf("workers must not have duplicate names: %q", wc.Name) + } + } + + return wc, nil +} + // parseCaddyfile unmarshals tokens from h into a new Middleware. func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { m := &FrankenPHPModule{} @@ -747,10 +782,13 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) case "index": args := dispenser.RemainingArgs() - dispenser.DeleteN(len(args) + 1) if len(args) != 1 { return nil, dispenser.ArgErr() } + if args[0] == "worker" { + // if the index is a worker file, use this as default try files + tryFiles = []string{"{http.request.uri.path}", "worker"} + } indexFile = args[0] case "try_files": diff --git a/worker.go b/worker.go index ecdd8f67b2..61f23d5b6b 100644 --- a/worker.go +++ b/worker.go @@ -35,12 +35,14 @@ func initWorkers(opt []workerOpt) error { watcherIsEnabled = len(directoriesToWatch) > 0 for _, o := range opt { - worker, err := newWorker(o) + _, err := newWorker(o) if err != nil { return err } + } - workersReady.Add(o.num) + for _, worker := range workers { + workersReady.Add(worker.num) for i := 0; i < worker.num; i++ { thread := getInactivePHPThread() convertToWorkerThread(thread, worker) From c849ec52a4bab7675521bb25f7fcc64fe0596124 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 11 May 2025 13:46:49 +0200 Subject: [PATCH 03/17] Adds tests and optimizations. --- caddy/admin_test.go | 12 ++-- caddy/caddy.go | 115 ++++++++++++++++++++++++++++++-------- caddy/caddy_test.go | 109 ++++++++++++++++++++++++++++++++++++ caddy/config_test.go | 48 ++++++++++++++-- phpmainthread_test.go | 10 ++-- scaling_test.go | 2 +- testdata/files/hello.json | 1 + testdata/files/hello.php | 3 + worker.go | 57 ++++++++----------- 9 files changed, 284 insertions(+), 73 deletions(-) create mode 100644 testdata/files/hello.json create mode 100644 testdata/files/hello.php diff --git a/caddy/admin_test.go b/caddy/admin_test.go index 4c84c2348b..35fc3fc4a9 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -72,12 +72,12 @@ func TestShowTheCorrectThreadDebugStatus(t *testing.T) { debugState := getDebugState(t, tester) - // assert that the correct threads are present in the thread info - assert.Equal(t, debugState.ThreadDebugStates[0].State, "ready") - assert.Contains(t, debugState.ThreadDebugStates[1].Name, "worker-with-counter.php") - assert.Contains(t, debugState.ThreadDebugStates[2].Name, "index.php") - assert.Equal(t, debugState.ReservedThreadCount, 3) - assert.Len(t, debugState.ThreadDebugStates, 3) + workerThreadNames := debugState.ThreadDebugStates[1].Name + debugState.ThreadDebugStates[2].Name + assert.Equal(t, debugState.ThreadDebugStates[0].State, "ready", "regular thread should be ready") + assert.Contains(t, workerThreadNames, "worker-with-counter.php", "first worker thread should be present") + assert.Contains(t, workerThreadNames, "index.php", "second worker thread should be present") + assert.Len(t, debugState.ThreadDebugStates, 3, "3 threads should be started") + assert.Equal(t, debugState.ReservedThreadCount, 3, "3 more threads should be reserved") } func TestAutoScaleWorkerThreads(t *testing.T) { diff --git a/caddy/caddy.go b/caddy/caddy.go index 483bc2f8c2..c22b34c47e 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -63,7 +63,7 @@ type workerConfig struct { // Directories to watch for file changes Watch []string `json:"watch,omitempty"` // IsIndex determines weather the worker is the first_exist_fallback - IsIndex bool `json:"is:index,omitempty"` + IsIndex bool `json:"is_index,omitempty"` } type FrankenPHPApp struct { @@ -500,12 +500,14 @@ func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c workerName := "" // check if the request should be handled by a module worker for _, w := range f.Workers { + // always fall back to an index worker if w.IsIndex { workerName = w.Name + break } - absWorkerPath, _ := fastabs.FastAbs(w.FileName) - if absPath, _ := fastabs.FastAbs(documentRoot + "/" + r.URL.Path); absPath == absWorkerPath { + if absPath, _ := fastabs.FastAbs(documentRoot + "/" + r.URL.Path); w.FileName == absWorkerPath { workerName = w.Name + break } } @@ -628,12 +630,9 @@ func parseModuleWorker(d *caddyfile.Dispenser, f *FrankenPHPModule) (workerConfi return wc, err } - if !filepath.IsAbs(wc.FileName) && f.Root != "" { - wc.FileName = filepath.Join(f.Root, wc.FileName) - } // if the worker path is relative, make it absolute // either with the php_server Root or with the WD - if false && !filepath.IsAbs(wc.FileName) { + if !filepath.IsAbs(wc.FileName) { if f.Root != "" { wc.FileName = filepath.Join(f.Root, wc.FileName) } else { @@ -725,6 +724,7 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) // set up file server fsrv := fileserver.FileServer{} disableFsrv := false + useWorkerAsIndex := false // set up the set of file extensions allowed to execute PHP code extensions := []string{".php"} @@ -785,11 +785,10 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) if len(args) != 1 { return nil, dispenser.ArgErr() } + indexFile = args[0] if args[0] == "worker" { - // if the index is a worker file, use this as default try files - tryFiles = []string{"{http.request.uri.path}", "worker"} + useWorkerAsIndex = true } - indexFile = args[0] case "try_files": args := dispenser.RemainingArgs() @@ -810,10 +809,6 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) } } - // reset the dispenser after we're done so that the frankenphp - // unmarshaler can read it from the start - dispenser.Reset() - if frankenphp.EmbeddedAppPath != "" { if phpsrv.Root == "" { phpsrv.Root = filepath.Join(frankenphp.EmbeddedAppPath, defaultDocumentRoot) @@ -832,6 +827,40 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) // set the list of allowed path segments on which to split phpsrv.SplitPath = extensions + // reset the dispenser after we're done so that the frankenphp + // unmarshaler can read it from the start + dispenser.Reset() + // the rest of the config is specified by the user + // using the php directive syntax + dispenser.Next() // consume the directive name + err = phpsrv.UnmarshalCaddyfile(dispenser) + + if err != nil { + return nil, err + } + + if useWorkerAsIndex { + if len(tryFiles) > 0 || len(extensions) != 1 { + caddy.Log().Warn("'try_files' and 'split_path' are inconsequental with index workers") + } + + if disableFsrv { + return []httpcaddyfile.ConfigValue{ + { + Class: "route", + Value: getIndexWorkerWithoutFileServer(h, phpsrv), + }, + }, nil + } + + return []httpcaddyfile.ConfigValue{ + { + Class: "route", + Value: getIndexWorkerSubroute(h, phpsrv, fsrv), + }, + }, nil + } + // if the index is turned off, we skip the redirect and try_files if indexFile != "off" { dirRedir := false @@ -917,15 +946,6 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) "path": h.JSON(pathList), } - // the rest of the config is specified by the user - // using the php directive syntax - dispenser.Next() // consume the directive name - err = phpsrv.UnmarshalCaddyfile(dispenser) - - if err != nil { - return nil, err - } - // create the PHP route which is // conditional on matching PHP files phpRoute := caddyhttp.Route{ @@ -973,6 +993,55 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) }, nil } +// get the routing logic for index workers +// serve non-php file or fallback to index worker +func getIndexWorkerSubroute(h httpcaddyfile.Helper, phpsrv FrankenPHPModule, fsrv caddy.Module) caddyhttp.Subroute { + return caddyhttp.Subroute{ + Routes: caddyhttp.RouteList{ + caddyhttp.Route{ + MatcherSetsRaw: []caddy.ModuleMap{ + caddy.ModuleMap{ + "file": h.JSON(fileserver.MatchFile{ + TryFiles: []string{"{http.request.uri.path}"}, + Root: phpsrv.Root, + }), + "not": h.JSON(caddyhttp.MatchNot{ + MatcherSetsRaw: []caddy.ModuleMap{ + { + "path": h.JSON(caddyhttp.MatchPath{"*.php"}), + }, + }, + }), + }, + }, + HandlersRaw: []json.RawMessage{ + caddyconfig.JSONModuleObject(fsrv, "handler", "file_server", nil), + }, + }, + caddyhttp.Route{ + MatcherSetsRaw: []caddy.ModuleMap{}, + HandlersRaw: []json.RawMessage{ + caddyconfig.JSONModuleObject(phpsrv, "handler", "php", nil), + }, + }, + }, + } +} + +// without file_server, all requests go to the worker +func getIndexWorkerWithoutFileServer(h httpcaddyfile.Helper, phpsrv FrankenPHPModule) caddyhttp.Subroute { + return caddyhttp.Subroute{ + Routes: caddyhttp.RouteList{ + caddyhttp.Route{ + MatcherSetsRaw: []caddy.ModuleMap{}, + HandlersRaw: []json.RawMessage{ + caddyconfig.JSONModuleObject(phpsrv, "handler", "php", nil), + }, + }, + }, + } +} + // return a nice error message func wrongSubDirectiveError(module string, allowedDriectives string, wrongValue string) error { return fmt.Errorf("unknown '%s' subdirective: '%s' (allowed directives are: %s)", module, wrongValue, allowedDriectives) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 1bf6fc066b..bbd457a970 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -227,6 +227,115 @@ func TestNamedModuleWorkers(t *testing.T) { wg.Wait() } +func TestTwoIndexModuleWorkers(t *testing.T) { + var wg sync.WaitGroup + testPortNum, _ := strconv.Atoi(testPort) + testPortTwo := strconv.Itoa(testPortNum + 1) + tester := caddytest.NewTester(t) + indexFileName, _ := fastabs.FastAbs("../testdata/index.php") + + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + + frankenphp { + num_threads 5 + } + } + + http://localhost:`+testPort+` { + route { + php { + root ../testdata/files + index worker { + file `+indexFileName+` + num 2 + } + } + } + } + + http://localhost:`+testPortTwo+` { + route { + php { + root ../testdata/files + index worker `+indexFileName+` 2 + } + } + } + `, "caddyfile") + + nbRequests := 10 + wg.Add(nbRequests) + for i := 0; i < nbRequests; i++ { + go func(i int) { + num := strconv.Itoa(i) + tester.AssertGetResponse("http://localhost:"+testPort+"/some-path?i="+num, http.StatusOK, "I am by birth a Genevese ("+num+")") + tester.AssertGetResponse("http://localhost:"+testPortTwo+"/other-path?i="+num, http.StatusOK, "I am by birth a Genevese ("+num+")") + wg.Done() + }(i) + } + wg.Wait() +} + +func TestIndexWorkerWithFileServer(t *testing.T) { + tester := caddytest.NewTester(t) + indexFileName, _ := fastabs.FastAbs("../testdata/index.php") + + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + + frankenphp { + num_threads 3 + } + } + + http://localhost:`+testPort+` { + route { + root ../testdata/files + php_server { + root ../testdata/files + index worker { + file `+indexFileName+` + num 2 + } + } + } + } + `, "caddyfile") + + // should respond with the index worker file on random path + tester.AssertGetResponse( + "http://localhost:"+testPort+"/test123?i=1", + http.StatusOK, + "I am by birth a Genevese (1)", + ) + + // should respond with the index worker file on index path + tester.AssertGetResponse( + "http://localhost:"+testPort+"/index.php?i=2", + http.StatusOK, + "I am by birth a Genevese (2)", + ) + + // should respond with the file_server + tester.AssertGetResponse( + "http://localhost:"+testPort+"/hello.json", + http.StatusOK, + "{\"Hello\": \"World\"}", + ) + + // should always respond with the index worker on other PHP files + tester.AssertGetResponse( + "http://localhost:"+testPort+"/index.php?i=3", + http.StatusOK, + "I am by birth a Genevese (3)", + ) +} + func TestEnv(t *testing.T) { tester := caddytest.NewTester(t) tester.InitServer(` diff --git a/caddy/config_test.go b/caddy/config_test.go index ca61fc0a5d..9050ede02d 100644 --- a/caddy/config_test.go +++ b/caddy/config_test.go @@ -5,6 +5,8 @@ import ( "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/stretchr/testify/require" + + "github.com/dunglas/frankenphp/internal/fastabs" ) // resetModuleWorkers resets the moduleWorkerConfigs slice for testing @@ -109,8 +111,8 @@ func TestModuleWorkersWithDifferentFilenames(t *testing.T) { // Verify that both workers were added to the module require.Len(t, module.Workers, 2, "Expected two workers to be added to the module") - require.Equal(t, "../testdata/worker-with-env.php", module.Workers[0].FileName, "First worker should have the correct filename") - require.Equal(t, "../testdata/worker-with-counter.php", module.Workers[1].FileName, "Second worker should have the correct filename") + require.Equal(t, fastTestAbs("../testdata/worker-with-env.php"), module.Workers[0].FileName, "First worker should have the correct filename") + require.Equal(t, fastTestAbs("../testdata/worker-with-counter.php"), module.Workers[1].FileName, "Second worker should have the correct filename") resetModuleWorkers() } @@ -192,7 +194,7 @@ func TestModuleWorkerWithEnvironmentVariables(t *testing.T) { // Verify that the worker was added to the module require.Len(t, module.Workers, 1, "Expected one worker to be added to the module") - require.Equal(t, "../testdata/worker-with-env.php", module.Workers[0].FileName, "Worker should have the correct filename") + require.Equal(t, fastTestAbs("../testdata/worker-with-env.php"), module.Workers[0].FileName, "Worker should have the correct filename") // Verify that the environment variables were set correctly require.Len(t, module.Workers[0].Env, 2, "Expected two environment variables") @@ -229,7 +231,7 @@ func TestModuleWorkerWithWatchConfiguration(t *testing.T) { // Verify that the worker was added to the module require.Len(t, module.Workers, 1, "Expected one worker to be added to the module") - require.Equal(t, "../testdata/worker-with-env.php", module.Workers[0].FileName, "Worker should have the correct filename") + require.Equal(t, fastTestAbs("../testdata/worker-with-env.php"), module.Workers[0].FileName, "Worker should have the correct filename") // Verify that the watch directories were set correctly require.Len(t, module.Workers[0].Watch, 3, "Expected three watch patterns") @@ -265,10 +267,46 @@ func TestModuleWorkerWithCustomName(t *testing.T) { // Verify that the worker was added to the module require.Len(t, module.Workers, 1, "Expected one worker to be added to the module") - require.Equal(t, "../testdata/worker-with-env.php", module.Workers[0].FileName, "Worker should have the correct filename") + require.Equal(t, fastTestAbs("../testdata/worker-with-env.php"), module.Workers[0].FileName, "Worker should have the correct filename") + require.False(t, module.Workers[0].IsIndex, "module worker should not be a index worker") // Verify that the worker was added to moduleWorkerConfigs with the m# prefix require.Equal(t, "m#custom-worker-name", module.Workers[0].Name, "Worker should have the custom name") resetModuleWorkers() } + +func TestModuleIndexWorker(t *testing.T) { + // Create a test configuration with a custom worker name + configWithCustomName := ` + { + php { + index worker { + file ../testdata/worker-with-env.php + num 1 + } + } + }` + + // Parse the configuration + d := caddyfile.NewTestDispenser(configWithCustomName) + module := &FrankenPHPModule{} + + // Unmarshal the configuration + err := module.UnmarshalCaddyfile(d) + + // Verify that no error was returned + require.NoError(t, err, "Expected no error when configuring a worker with a custom name") + + // Verify that the worker was added to the module + require.Len(t, module.Workers, 1, "Expected one worker to be added to the module") + require.Len(t, moduleWorkerConfigs, 1, "Expected one worker to be added to the global workers") + require.True(t, module.Workers[0].IsIndex, "module worker should be index worker") + + resetModuleWorkers() +} + +func fastTestAbs(path string) string { + abs, _ := fastabs.FastAbs(path) + return abs +} diff --git a/phpmainthread_test.go b/phpmainthread_test.go index 08020ea625..1a4d5ba25e 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -181,7 +181,7 @@ func TestFinishBootingAWorkerScript(t *testing.T) { } func TestReturnAnErrorIf2WorkersHaveTheSameFileName(t *testing.T) { - workers = make(map[string]*worker) + workers = []*worker{} _, err1 := newWorker(workerOpt{fileName: "filename.php"}) _, err2 := newWorker(workerOpt{fileName: "filename.php"}) @@ -190,7 +190,7 @@ func TestReturnAnErrorIf2WorkersHaveTheSameFileName(t *testing.T) { } func TestReturnAnErrorIf2ModuleWorkersHaveTheSameName(t *testing.T) { - workers = make(map[string]*worker) + workers = []*worker{} _, err1 := newWorker(workerOpt{fileName: "filename.php", name: "workername"}) _, err2 := newWorker(workerOpt{fileName: "filename2.php", name: "workername"}) @@ -200,7 +200,7 @@ func TestReturnAnErrorIf2ModuleWorkersHaveTheSameName(t *testing.T) { func getDummyWorker(fileName string) *worker { if workers == nil { - workers = make(map[string]*worker) + workers = []*worker{} } worker, _ := newWorker(workerOpt{ fileName: testDataPath + "/" + fileName, @@ -232,9 +232,9 @@ func allPossibleTransitions(worker1Path string, worker2Path string) []func(*phpT thread.boot() } }, - func(thread *phpThread) { convertToWorkerThread(thread, workers[worker1Path]) }, + func(thread *phpThread) { convertToWorkerThread(thread, getWorkerByPath(worker1Path)) }, convertToInactiveThread, - func(thread *phpThread) { convertToWorkerThread(thread, workers[worker2Path]) }, + func(thread *phpThread) { convertToWorkerThread(thread, getWorkerByPath(worker2Path)) }, convertToInactiveThread, } } diff --git a/scaling_test.go b/scaling_test.go index 85adc5863f..eeca51ca22 100644 --- a/scaling_test.go +++ b/scaling_test.go @@ -44,7 +44,7 @@ func TestScaleAWorkerThreadUpAndDown(t *testing.T) { autoScaledThread := phpThreads[2] // scale up - scaleWorkerThread(workers[workerPath]) + scaleWorkerThread(getWorkerByPath(workerPath)) assert.Equal(t, stateReady, autoScaledThread.state.get()) // on down-scale, the thread will be marked as inactive diff --git a/testdata/files/hello.json b/testdata/files/hello.json new file mode 100644 index 0000000000..2e57bd1eb6 --- /dev/null +++ b/testdata/files/hello.json @@ -0,0 +1 @@ +{"Hello": "World"} \ No newline at end of file diff --git a/testdata/files/hello.php b/testdata/files/hello.php new file mode 100644 index 0000000000..76b91b14c7 --- /dev/null +++ b/testdata/files/hello.php @@ -0,0 +1,3 @@ + 0 @@ -67,14 +68,6 @@ func initWorkers(opt []workerOpt) error { return nil } -func getWorkerKey(name string, filename string) string { - key := filename - if strings.HasPrefix(name, "m#") { - key = name - } - return key -} - func getWorkerByName(name string) *worker { if name == "" { return nil @@ -90,7 +83,7 @@ func getWorkerByName(name string) *worker { func getWorkerByPath(path string) *worker { for _, w := range workers { - if w.fileName == path && !strings.HasPrefix(w.name, "m#") { + if w.fileName == path && w.allowPathMatching { return w } } @@ -104,14 +97,11 @@ func newWorker(o workerOpt) (*worker, error) { return nil, fmt.Errorf("worker filename is invalid %q: %w", o.fileName, err) } - key := getWorkerKey(o.name, absFileName) - if _, ok := workers[key]; ok { - return nil, fmt.Errorf("two workers cannot use the same key %q", key) + if w := getWorkerByName(o.name); w != nil { + return w, fmt.Errorf("two workers cannot have the same name: %q", o.name) } - for _, w := range workers { - if w.name == o.name { - return w, fmt.Errorf("two workers cannot have the same name: %q", o.name) - } + if w := getWorkerByPath(absFileName); w != nil { + return w, fmt.Errorf("two workers cannot have the same filename: %q", o.name) } if o.env == nil { @@ -124,14 +114,15 @@ func newWorker(o workerOpt) (*worker, error) { o.env["FRANKENPHP_WORKER\x00"] = "1" w := &worker{ - name: o.name, - fileName: absFileName, - num: o.num, - env: o.env, - requestChan: make(chan *frankenPHPContext), - threads: make([]*phpThread, 0, o.num), - } - workers[key] = w + name: o.name, + fileName: absFileName, + num: o.num, + env: o.env, + requestChan: make(chan *frankenPHPContext), + threads: make([]*phpThread, 0, o.num), + allowPathMatching: !strings.HasPrefix(o.name, "m#"), + } + workers = append(workers, w) return w, nil } From b31eb8953f5f89f88212d8a0fa000827d4cda2cc Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 11 May 2025 13:59:19 +0200 Subject: [PATCH 04/17] Fixes tests. --- caddy/caddy.go | 11 +++++------ worker.go | 17 +++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/caddy/caddy.go b/caddy/caddy.go index c22b34c47e..1958d32bbc 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -505,7 +505,7 @@ func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c workerName = w.Name break } - if absPath, _ := fastabs.FastAbs(documentRoot + "/" + r.URL.Path); w.FileName == absWorkerPath { + if absPath, _ := fastabs.FastAbs(documentRoot + "/" + r.URL.Path); w.FileName == absPath { workerName = w.Name break } @@ -635,11 +635,10 @@ func parseModuleWorker(d *caddyfile.Dispenser, f *FrankenPHPModule) (workerConfi if !filepath.IsAbs(wc.FileName) { if f.Root != "" { wc.FileName = filepath.Join(f.Root, wc.FileName) - } else { - wc.FileName, err = fastabs.FastAbs(wc.FileName) - if err != nil { - return wc, err - } + } + wc.FileName, err = fastabs.FastAbs(wc.FileName) + if err != nil { + return wc, err } } diff --git a/worker.go b/worker.go index 1b925f4e79..23788b8b69 100644 --- a/worker.go +++ b/worker.go @@ -93,25 +93,26 @@ func getWorkerByPath(path string) *worker { func newWorker(o workerOpt) (*worker, error) { absFileName, err := fastabs.FastAbs(o.fileName) + isModuleWorker := strings.HasPrefix(o.name, "m#") // module workers may not be matched by path if err != nil { return nil, fmt.Errorf("worker filename is invalid %q: %w", o.fileName, err) } + if o.name == "" { + o.name = absFileName + } + + if w := getWorkerByPath(absFileName); w != nil && !isModuleWorker { + return w, fmt.Errorf("two workers cannot have the same filename: %q", absFileName) + } if w := getWorkerByName(o.name); w != nil { return w, fmt.Errorf("two workers cannot have the same name: %q", o.name) } - if w := getWorkerByPath(absFileName); w != nil { - return w, fmt.Errorf("two workers cannot have the same filename: %q", o.name) - } if o.env == nil { o.env = make(PreparedEnv, 1) } - if o.name == "" { - o.name = absFileName - } - o.env["FRANKENPHP_WORKER\x00"] = "1" w := &worker{ name: o.name, @@ -120,7 +121,7 @@ func newWorker(o workerOpt) (*worker, error) { env: o.env, requestChan: make(chan *frankenPHPContext), threads: make([]*phpThread, 0, o.num), - allowPathMatching: !strings.HasPrefix(o.name, "m#"), + allowPathMatching: !isModuleWorker, } workers = append(workers, w) From 36e87a3be604f4e8f747b427af3ea7d62fb823a4 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 11 May 2025 20:00:51 +0200 Subject: [PATCH 05/17] Splits up configuration into 3 files. --- caddy/caddy.go | 1013 +------------------------------------ caddy/frankenphpapp.go | 250 +++++++++ caddy/frankenphpmodule.go | 796 +++++++++++++++++++++++++++++ caddy/workerconfig.go | 116 +++++ 4 files changed, 1167 insertions(+), 1008 deletions(-) create mode 100644 caddy/frankenphpapp.go create mode 100644 caddy/frankenphpmodule.go create mode 100644 caddy/workerconfig.go diff --git a/caddy/caddy.go b/caddy/caddy.go index 1958d32bbc..969a6c11f4 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -4,26 +4,12 @@ package caddy import ( - "encoding/json" - "errors" "fmt" - "log/slog" - "net/http" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/dunglas/frankenphp/internal/fastabs" "github.com/caddyserver/caddy/v2" - "github.com/caddyserver/caddy/v2/caddyconfig" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" "github.com/caddyserver/caddy/v2/modules/caddyhttp" - "github.com/caddyserver/caddy/v2/modules/caddyhttp/fileserver" - "github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite" - "github.com/dunglas/frankenphp" ) const ( @@ -31,8 +17,6 @@ const ( defaultWatchPattern = "./**/*.{php,yaml,yml,twig,env}" ) -var iniError = errors.New("'php_ini' must be in the format: php_ini \"\" \"\"") - // FrankenPHPModule instances register their workers, and FrankenPHPApp reads them at Start() time. // FrankenPHPApp.Workers may be set by JSON config, so keep them separate. var moduleWorkerConfigs []workerConfig @@ -42,1003 +26,16 @@ func init() { caddy.RegisterModule(FrankenPHPModule{}) caddy.RegisterModule(FrankenPHPAdmin{}) - httpcaddyfile.RegisterGlobalOption("frankenphp", parseGlobalOption) + httpcaddyfile.RegisterGlobalOption("frankenphp", parseFrankenPhpDirective) - httpcaddyfile.RegisterHandlerDirective("php", parseCaddyfile) + httpcaddyfile.RegisterHandlerDirective("php", parsePhpDirective) httpcaddyfile.RegisterDirectiveOrder("php", "before", "file_server") - httpcaddyfile.RegisterDirective("php_server", parsePhpServer) + httpcaddyfile.RegisterDirective("php_server", parsePhpServerDirective) httpcaddyfile.RegisterDirectiveOrder("php_server", "before", "file_server") -} - -type workerConfig struct { - // Name for the worker. Default: the filename for FrankenPHPApp workers, always prefixed with "m#" for FrankenPHPModule workers. - Name string `json:"name,omitempty"` - // FileName sets the path to the worker script. - FileName string `json:"file_name,omitempty"` - // Num sets the number of workers to start. - Num int `json:"num,omitempty"` - // Env sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. - Env map[string]string `json:"env,omitempty"` - // Directories to watch for file changes - Watch []string `json:"watch,omitempty"` - // IsIndex determines weather the worker is the first_exist_fallback - IsIndex bool `json:"is_index,omitempty"` -} - -type FrankenPHPApp struct { - // NumThreads sets the number of PHP threads to start. Default: 2x the number of available CPUs. - NumThreads int `json:"num_threads,omitempty"` - // MaxThreads limits how many threads can be started at runtime. Default 2x NumThreads - MaxThreads int `json:"max_threads,omitempty"` - // Workers configures the worker scripts to start. - Workers []workerConfig `json:"workers,omitempty"` - // Overwrites the default php ini configuration - PhpIni map[string]string `json:"php_ini,omitempty"` - // The maximum amount of time a request may be stalled waiting for a thread - MaxWaitTime time.Duration `json:"max_wait_time,omitempty"` - - metrics frankenphp.Metrics - logger *slog.Logger -} - -// CaddyModule returns the Caddy module information. -func (f FrankenPHPApp) CaddyModule() caddy.ModuleInfo { - return caddy.ModuleInfo{ - ID: "frankenphp", - New: func() caddy.Module { return &f }, - } -} - -// Provision sets up the module. -func (f *FrankenPHPApp) Provision(ctx caddy.Context) error { - f.logger = ctx.Slogger() - - if httpApp, err := ctx.AppIfConfigured("http"); err == nil { - if httpApp.(*caddyhttp.App).Metrics != nil { - f.metrics = frankenphp.NewPrometheusMetrics(ctx.GetMetricsRegistry()) - } - } else { - // if the http module is not configured (this should never happen) then collect the metrics by default - f.metrics = frankenphp.NewPrometheusMetrics(ctx.GetMetricsRegistry()) - } - - return nil -} - -func (f *FrankenPHPApp) Start() error { - repl := caddy.NewReplacer() - - opts := []frankenphp.Option{ - frankenphp.WithNumThreads(f.NumThreads), - frankenphp.WithMaxThreads(f.MaxThreads), - frankenphp.WithLogger(f.logger), - frankenphp.WithMetrics(f.metrics), - frankenphp.WithPhpIni(f.PhpIni), - frankenphp.WithMaxWaitTime(f.MaxWaitTime), - } - // Add workers from FrankenPHPApp and FrankenPHPModule configurations - // f.Workers may have been set by JSON config, so keep them separate - for _, w := range append(f.Workers, moduleWorkerConfigs...) { - opts = append(opts, frankenphp.WithWorkers(w.Name, repl.ReplaceKnown(w.FileName, ""), w.Num, w.Env, w.Watch)) - } - - frankenphp.Shutdown() - if err := frankenphp.Init(opts...); err != nil { - return err - } - - return nil -} - -func (f *FrankenPHPApp) Stop() error { - f.logger.Info("FrankenPHP stopped 🐘") - - // attempt a graceful shutdown if caddy is exiting - // note: Exiting() is currently marked as 'experimental' - // https://github.com/caddyserver/caddy/blob/e76405d55058b0a3e5ba222b44b5ef00516116aa/caddy.go#L810 - if caddy.Exiting() { - frankenphp.DrainWorkers() - } - - // reset the configuration so it doesn't bleed into later tests - f.Workers = nil - f.NumThreads = 0 - f.MaxWaitTime = 0 - moduleWorkerConfigs = nil - - return nil -} - -func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) { - wc := workerConfig{} - if d.NextArg() { - wc.FileName = d.Val() - } - - if d.NextArg() { - if d.Val() == "watch" { - wc.Watch = append(wc.Watch, defaultWatchPattern) - } else { - v, err := strconv.ParseUint(d.Val(), 10, 32) - if err != nil { - return wc, err - } - - wc.Num = int(v) - } - } - - if d.NextArg() { - return wc, errors.New(`FrankenPHP: too many "worker" arguments: ` + d.Val()) - } - - for d.NextBlock(1) { - v := d.Val() - switch v { - case "name": - if !d.NextArg() { - return wc, d.ArgErr() - } - wc.Name = d.Val() - case "file": - if !d.NextArg() { - return wc, d.ArgErr() - } - wc.FileName = d.Val() - case "num": - if !d.NextArg() { - return wc, d.ArgErr() - } - - v, err := strconv.ParseUint(d.Val(), 10, 32) - if err != nil { - return wc, err - } - - wc.Num = int(v) - case "env": - args := d.RemainingArgs() - if len(args) != 2 { - return wc, d.ArgErr() - } - if wc.Env == nil { - wc.Env = make(map[string]string) - } - wc.Env[args[0]] = args[1] - case "watch": - if !d.NextArg() { - // the default if the watch directory is left empty: - wc.Watch = append(wc.Watch, defaultWatchPattern) - } else { - wc.Watch = append(wc.Watch, d.Val()) - } - default: - allowedDirectives := "name, file, num, env, watch" - return wc, wrongSubDirectiveError("worker", allowedDirectives, v) - } - } - - if wc.FileName == "" { - return wc, errors.New(`the "file" argument must be specified`) - } - - if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) { - wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName) - } - - return wc, nil -} - -// UnmarshalCaddyfile implements caddyfile.Unmarshaler. -func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { - moduleWorkerConfigs = []workerConfig{} - for d.Next() { - for d.NextBlock(0) { - // when adding a new directive, also update the allowedDirectives error message - switch d.Val() { - case "num_threads": - if !d.NextArg() { - return d.ArgErr() - } - - v, err := strconv.ParseUint(d.Val(), 10, 32) - if err != nil { - return err - } - - f.NumThreads = int(v) - case "max_threads": - if !d.NextArg() { - return d.ArgErr() - } - - if d.Val() == "auto" { - f.MaxThreads = -1 - continue - } - - v, err := strconv.ParseUint(d.Val(), 10, 32) - if err != nil { - return err - } - - f.MaxThreads = int(v) - case "max_wait_time": - if !d.NextArg() { - return d.ArgErr() - } - - v, err := time.ParseDuration(d.Val()) - if err != nil { - return errors.New("max_wait_time must be a valid duration (example: 10s)") - } - - f.MaxWaitTime = v - case "php_ini": - parseIniLine := func(d *caddyfile.Dispenser) error { - key := d.Val() - if !d.NextArg() { - return iniError - } - if f.PhpIni == nil { - f.PhpIni = make(map[string]string) - } - f.PhpIni[key] = d.Val() - if d.NextArg() { - return iniError - } - - return nil - } - - isBlock := false - for d.NextBlock(1) { - isBlock = true - err := parseIniLine(d) - if err != nil { - return err - } - } - - if !isBlock { - if !d.NextArg() { - return iniError - } - err := parseIniLine(d) - if err != nil { - return err - } - } - - case "worker": - wc, err := parseWorkerConfig(d) - if err != nil { - return err - } - if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) { - wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName) - } - if wc.Name == "" { - // let worker initialization validate if the FileName is valid or not - name, _ := fastabs.FastAbs(wc.FileName) - if name == "" { - name = wc.FileName - } - wc.Name = name - } - if strings.HasPrefix(wc.Name, "m#") { - return fmt.Errorf(`global worker names must not start with "m#": %q`, wc.Name) - } - // check for duplicate workers - for _, existingWorker := range f.Workers { - if existingWorker.FileName == wc.FileName { - return fmt.Errorf("global workers must not have duplicate filenames: %q", wc.FileName) - } - } - - f.Workers = append(f.Workers, wc) - default: - allowedDirectives := "num_threads, max_threads, php_ini, worker, max_wait_time" - return wrongSubDirectiveError("frankenphp", allowedDirectives, d.Val()) - } - } - } - - if f.MaxThreads > 0 && f.NumThreads > 0 && f.MaxThreads < f.NumThreads { - return errors.New("'max_threads' must be greater than or equal to 'num_threads'") - } - - return nil -} - -func parseGlobalOption(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) { - app := &FrankenPHPApp{} - if err := app.UnmarshalCaddyfile(d); err != nil { - return nil, err - } - - // tell Caddyfile adapter that this is the JSON for an app - return httpcaddyfile.App{ - Name: "frankenphp", - Value: caddyconfig.JSON(app, nil), - }, nil -} - -type FrankenPHPModule struct { - // Root sets the root folder to the site. Default: `root` directive, or the path of the public directory of the embed app it exists. - Root string `json:"root,omitempty"` - // SplitPath sets the substrings for splitting the URI into two parts. The first matching substring will be used to split the "path info" from the path. The first piece is suffixed with the matching substring and will be assumed as the actual resource (CGI script) name. The second piece will be set to PATH_INFO for the CGI script to use. Default: `.php`. - SplitPath []string `json:"split_path,omitempty"` - // ResolveRootSymlink enables resolving the `root` directory to its actual value by evaluating a symbolic link, if one exists. - ResolveRootSymlink *bool `json:"resolve_root_symlink,omitempty"` - // Env sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. - Env map[string]string `json:"env,omitempty"` - // Workers configures the worker scripts to start. - Workers []workerConfig `json:"workers,omitempty"` - - resolvedDocumentRoot string - preparedEnv frankenphp.PreparedEnv - preparedEnvNeedsReplacement bool - logger *slog.Logger -} - -// CaddyModule returns the Caddy module information. -func (FrankenPHPModule) CaddyModule() caddy.ModuleInfo { - return caddy.ModuleInfo{ - ID: "http.handlers.php", - New: func() caddy.Module { return new(FrankenPHPModule) }, - } -} - -// Provision sets up the module. -func (f *FrankenPHPModule) Provision(ctx caddy.Context) error { - f.logger = ctx.Slogger() - - if f.Root == "" { - if frankenphp.EmbeddedAppPath == "" { - f.Root = "{http.vars.root}" - } else { - rrs := false - f.Root = filepath.Join(frankenphp.EmbeddedAppPath, defaultDocumentRoot) - f.ResolveRootSymlink = &rrs - } - } else { - if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(f.Root) { - f.Root = filepath.Join(frankenphp.EmbeddedAppPath, f.Root) - } - } - - if len(f.SplitPath) == 0 { - f.SplitPath = []string{".php"} - } - - if f.ResolveRootSymlink == nil { - rrs := true - f.ResolveRootSymlink = &rrs - } - - if !needReplacement(f.Root) { - root, err := fastabs.FastAbs(f.Root) - if err != nil { - return fmt.Errorf("unable to make the root path absolute: %w", err) - } - f.resolvedDocumentRoot = root - - if *f.ResolveRootSymlink { - root, err := filepath.EvalSymlinks(root) - if err != nil { - return fmt.Errorf("unable to resolve root symlink: %w", err) - } - - f.resolvedDocumentRoot = root - } - } - - // copy the prepared env to all module workers - if f.Env != nil { - for _, wc := range f.Workers { - if wc.Env == nil { - wc.Env = make(map[string]string) - } - for k, v := range f.Env { - // Only set if not already defined in the worker - if _, exists := wc.Env[k]; !exists { - wc.Env[k] = v - } - } - } - } - - if f.preparedEnv == nil { - f.preparedEnv = frankenphp.PrepareEnv(f.Env) - - for _, e := range f.preparedEnv { - if needReplacement(e) { - f.preparedEnvNeedsReplacement = true - - break - } - } - } - - return nil -} - -// needReplacement checks if a string contains placeholders. -func needReplacement(s string) bool { - return strings.Contains(s, "{") || strings.Contains(s, "}") -} - -// ServeHTTP implements caddyhttp.MiddlewareHandler. -// TODO: Expose TLS versions as env vars, as Apache's mod_ssl: https://github.com/caddyserver/caddy/blob/master/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go#L298 -func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ caddyhttp.Handler) error { - origReq := r.Context().Value(caddyhttp.OriginalRequestCtxKey).(http.Request) - repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) - - var documentRootOption frankenphp.RequestOption - var documentRoot string - if f.resolvedDocumentRoot == "" { - documentRoot = repl.ReplaceKnown(f.Root, "") - if documentRoot == "" && frankenphp.EmbeddedAppPath != "" { - documentRoot = frankenphp.EmbeddedAppPath - } - documentRootOption = frankenphp.WithRequestDocumentRoot(documentRoot, *f.ResolveRootSymlink) - } else { - documentRoot = f.resolvedDocumentRoot - documentRootOption = frankenphp.WithRequestResolvedDocumentRoot(documentRoot) - } - - env := f.preparedEnv - if f.preparedEnvNeedsReplacement { - env = make(frankenphp.PreparedEnv, len(f.Env)) - for k, v := range f.preparedEnv { - env[k] = repl.ReplaceKnown(v, "") - } - } - - workerName := "" - // check if the request should be handled by a module worker - for _, w := range f.Workers { - // always fall back to an index worker - if w.IsIndex { - workerName = w.Name - break - } - if absPath, _ := fastabs.FastAbs(documentRoot + "/" + r.URL.Path); w.FileName == absPath { - workerName = w.Name - break - } - } - - fr, err := frankenphp.NewRequestWithContext( - r, - documentRootOption, - frankenphp.WithRequestSplitPath(f.SplitPath), - frankenphp.WithRequestPreparedEnv(env), - frankenphp.WithOriginalRequest(&origReq), - frankenphp.WithWorkerName(workerName), - ) - - if err = frankenphp.ServeHTTP(w, fr); err != nil { - return caddyhttp.Error(http.StatusInternalServerError, err) - } - - return nil -} - -func generateUniqueModuleWorkerName(filepath string) string { - var i uint - name := "m#" + filepath - -outer: - for { - for _, wc := range moduleWorkerConfigs { - if wc.Name == name { - name = fmt.Sprintf("m#%s_%d", filepath, i) - i++ - - continue outer - } - } - - return name - } -} - -// UnmarshalCaddyfile implements caddyfile.Unmarshaler. -func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { - for d.Next() { - for d.NextBlock(0) { - directive := d.Val() - switch directive { - case "root": - if !d.NextArg() { - return d.ArgErr() - } - f.Root = d.Val() - - case "split": - f.SplitPath = d.RemainingArgs() - if len(f.SplitPath) == 0 { - return d.ArgErr() - } - - case "env": - args := d.RemainingArgs() - if len(args) != 2 { - return d.ArgErr() - } - if f.Env == nil { - f.Env = make(map[string]string) - f.preparedEnv = make(frankenphp.PreparedEnv) - } - f.Env[args[0]] = args[1] - f.preparedEnv[args[0]+"\x00"] = args[1] - - case "resolve_root_symlink": - if !d.NextArg() { - continue - } - v, err := strconv.ParseBool(d.Val()) - if err != nil { - return err - } - if d.NextArg() { - return d.ArgErr() - } - f.ResolveRootSymlink = &v - - // register the worker if 'index' is a worker file - case "index": - if !d.NextArg() { - return d.ArgErr() - } - - if d.Val() == "worker" { - wc, err := parseModuleWorker(d, f) - if err != nil { - return err - } - wc.IsIndex = true - f.Workers = append(f.Workers, wc) - moduleWorkerConfigs = append(moduleWorkerConfigs, wc) - } - - case "worker": - wc, err := parseModuleWorker(d, f) - if err != nil { - return err - } - f.Workers = append(f.Workers, wc) - moduleWorkerConfigs = append(moduleWorkerConfigs, wc) - - default: - allowedDirectives := "root, split, env, resolve_root_symlink, worker, index" - return wrongSubDirectiveError("php or php_server", allowedDirectives, d.Val()) - } - } - } - - return nil -} - -// parse a worker inside a php or php_server directive -func parseModuleWorker(d *caddyfile.Dispenser, f *FrankenPHPModule) (workerConfig, error) { - wc, err := parseWorkerConfig(d) - if err != nil { - return wc, err - } - - // if the worker path is relative, make it absolute - // either with the php_server Root or with the WD - if !filepath.IsAbs(wc.FileName) { - if f.Root != "" { - wc.FileName = filepath.Join(f.Root, wc.FileName) - } - wc.FileName, err = fastabs.FastAbs(wc.FileName) - if err != nil { - return wc, err - } - } - - if f.Env != nil { - if wc.Env == nil { - wc.Env = make(map[string]string) - } - for k, v := range f.Env { - // Only set if not already defined in the worker - if _, exists := wc.Env[k]; !exists { - wc.Env[k] = v - } - } - } - - if wc.Name == "" { - wc.Name = generateUniqueModuleWorkerName(wc.FileName) - } - if !strings.HasPrefix(wc.Name, "m#") { - wc.Name = "m#" + wc.Name - } - - // Check if a worker with this filename already exists in this module - for _, existingWorker := range f.Workers { - if existingWorker.FileName == wc.FileName { - return wc, fmt.Errorf(`workers in a single "php_server" block must not have duplicate filenames: %q`, wc.FileName) - } - } - // Check if a worker with this name and a different environment or filename already exists - for _, existingWorker := range moduleWorkerConfigs { - if existingWorker.Name == wc.Name { - return wc, fmt.Errorf("workers must not have duplicate names: %q", wc.Name) - } - } - - return wc, nil -} - -// parseCaddyfile unmarshals tokens from h into a new Middleware. -func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { - m := &FrankenPHPModule{} - err := m.UnmarshalCaddyfile(h.Dispenser) - - return m, err -} - -// parsePhpServer parses the php_server directive, which has a similar syntax -// to the php_fastcgi directive. A line such as this: -// -// php_server -// -// is equivalent to a route consisting of: -// -// # Add trailing slash for directory requests -// @canonicalPath { -// file {path}/index.php -// not path */ -// } -// redir @canonicalPath {path}/ 308 -// -// # If the requested file does not exist, try index files -// @indexFiles file { -// try_files {path} {path}/index.php index.php -// split_path .php -// } -// rewrite @indexFiles {http.matchers.file.relative} -// -// # FrankenPHP! -// @phpFiles path *.php -// php @phpFiles -// file_server -// -// parsePhpServer is freely inspired from the php_fastgci directive of the Caddy server (Apache License 2.0, Matthew Holt and The Caddy Authors) -func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) { - if !h.Next() { - return nil, h.ArgErr() - } - - // set up FrankenPHP - phpsrv := FrankenPHPModule{} - - // set up file server - fsrv := fileserver.FileServer{} - disableFsrv := false - useWorkerAsIndex := false - - // set up the set of file extensions allowed to execute PHP code - extensions := []string{".php"} - - // set the default index file for the try_files rewrites - indexFile := "index.php" - - // set up for explicitly overriding try_files - var tryFiles []string - - // if the user specified a matcher token, use that - // matcher in a route that wraps both of our routes; - // either way, strip the matcher token and pass - // the remaining tokens to the unmarshaler so that - // we can gain the rest of the directive syntax - userMatcherSet, err := h.ExtractMatcherSet() - if err != nil { - return nil, err - } - - // make a new dispenser from the remaining tokens so that we - // can reset the dispenser back to this point for the - // php unmarshaler to read from it as well - dispenser := h.NewFromNextSegment() - - // read the subdirectives that we allow as overrides to - // the php_server shortcut - // NOTE: we delete the tokens as we go so that the php - // unmarshal doesn't see these subdirectives which it cannot handle - for dispenser.Next() { - for dispenser.NextBlock(0) { - // ignore any sub-subdirectives that might - // have the same name somewhere within - // the php passthrough tokens - if dispenser.Nesting() != 1 { - continue - } - - // parse the php_server subdirectives - switch dispenser.Val() { - case "root": - if !dispenser.NextArg() { - return nil, dispenser.ArgErr() - } - phpsrv.Root = dispenser.Val() - fsrv.Root = phpsrv.Root - dispenser.DeleteN(2) - - case "split": - extensions = dispenser.RemainingArgs() - dispenser.DeleteN(len(extensions) + 1) - if len(extensions) == 0 { - return nil, dispenser.ArgErr() - } - - case "index": - args := dispenser.RemainingArgs() - if len(args) != 1 { - return nil, dispenser.ArgErr() - } - indexFile = args[0] - if args[0] == "worker" { - useWorkerAsIndex = true - } - - case "try_files": - args := dispenser.RemainingArgs() - dispenser.DeleteN(len(args) + 1) - if len(args) < 1 { - return nil, dispenser.ArgErr() - } - tryFiles = args - - case "file_server": - args := dispenser.RemainingArgs() - dispenser.DeleteN(len(args) + 1) - if len(args) < 1 || args[0] != "off" { - return nil, dispenser.ArgErr() - } - disableFsrv = true - } - } - } - - if frankenphp.EmbeddedAppPath != "" { - if phpsrv.Root == "" { - phpsrv.Root = filepath.Join(frankenphp.EmbeddedAppPath, defaultDocumentRoot) - fsrv.Root = phpsrv.Root - rrs := false - phpsrv.ResolveRootSymlink = &rrs - } else if filepath.IsLocal(fsrv.Root) { - phpsrv.Root = filepath.Join(frankenphp.EmbeddedAppPath, phpsrv.Root) - fsrv.Root = phpsrv.Root - } - } - - // set up a route list that we'll append to - routes := caddyhttp.RouteList{} - - // set the list of allowed path segments on which to split - phpsrv.SplitPath = extensions - - // reset the dispenser after we're done so that the frankenphp - // unmarshaler can read it from the start - dispenser.Reset() - // the rest of the config is specified by the user - // using the php directive syntax - dispenser.Next() // consume the directive name - err = phpsrv.UnmarshalCaddyfile(dispenser) - - if err != nil { - return nil, err - } - - if useWorkerAsIndex { - if len(tryFiles) > 0 || len(extensions) != 1 { - caddy.Log().Warn("'try_files' and 'split_path' are inconsequental with index workers") - } - - if disableFsrv { - return []httpcaddyfile.ConfigValue{ - { - Class: "route", - Value: getIndexWorkerWithoutFileServer(h, phpsrv), - }, - }, nil - } - - return []httpcaddyfile.ConfigValue{ - { - Class: "route", - Value: getIndexWorkerSubroute(h, phpsrv, fsrv), - }, - }, nil - } - - // if the index is turned off, we skip the redirect and try_files - if indexFile != "off" { - dirRedir := false - dirIndex := "{http.request.uri.path}/" + indexFile - tryPolicy := "first_exist_fallback" - - // if tryFiles wasn't overridden, use a reasonable default - if len(tryFiles) == 0 { - if disableFsrv { - tryFiles = []string{dirIndex, indexFile} - } else { - tryFiles = []string{"{http.request.uri.path}", dirIndex, indexFile} - } - - dirRedir = true - } else { - if !strings.HasSuffix(tryFiles[len(tryFiles)-1], ".php") { - // use first_exist strategy if the last file is not a PHP file - tryPolicy = "" - } - - for _, tf := range tryFiles { - if tf == dirIndex { - dirRedir = true - - break - } - } - } - - // route to redirect to canonical path if index PHP file - if dirRedir { - redirMatcherSet := caddy.ModuleMap{ - "file": h.JSON(fileserver.MatchFile{ - TryFiles: []string{dirIndex}, - }), - "not": h.JSON(caddyhttp.MatchNot{ - MatcherSetsRaw: []caddy.ModuleMap{ - { - "path": h.JSON(caddyhttp.MatchPath{"*/"}), - }, - }, - }), - } - redirHandler := caddyhttp.StaticResponse{ - StatusCode: caddyhttp.WeakString(strconv.Itoa(http.StatusPermanentRedirect)), - Headers: http.Header{"Location": []string{"{http.request.orig_uri.path}/"}}, - } - redirRoute := caddyhttp.Route{ - MatcherSetsRaw: []caddy.ModuleMap{redirMatcherSet}, - HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(redirHandler, "handler", "static_response", nil)}, - } - - routes = append(routes, redirRoute) - } - - // route to rewrite to PHP index file - rewriteMatcherSet := caddy.ModuleMap{ - "file": h.JSON(fileserver.MatchFile{ - TryFiles: tryFiles, - TryPolicy: tryPolicy, - SplitPath: extensions, - }), - } - rewriteHandler := rewrite.Rewrite{ - URI: "{http.matchers.file.relative}", - } - rewriteRoute := caddyhttp.Route{ - MatcherSetsRaw: []caddy.ModuleMap{rewriteMatcherSet}, - HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(rewriteHandler, "handler", "rewrite", nil)}, - } - - routes = append(routes, rewriteRoute) - } - - // route to actually pass requests to PHP files; - // match only requests that are for PHP files - var pathList []string - for _, ext := range extensions { - pathList = append(pathList, "*"+ext) - } - phpMatcherSet := caddy.ModuleMap{ - "path": h.JSON(pathList), - } - - // create the PHP route which is - // conditional on matching PHP files - phpRoute := caddyhttp.Route{ - MatcherSetsRaw: []caddy.ModuleMap{phpMatcherSet}, - HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(phpsrv, "handler", "php", nil)}, - } - routes = append(routes, phpRoute) - - // create the file server route - if !disableFsrv { - fileRoute := caddyhttp.Route{ - MatcherSetsRaw: []caddy.ModuleMap{}, - HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(fsrv, "handler", "file_server", nil)}, - } - routes = append(routes, fileRoute) - } - - subroute := caddyhttp.Subroute{ - Routes: routes, - } - - // the user's matcher is a prerequisite for ours, so - // wrap ours in a subroute and return that - if userMatcherSet != nil { - return []httpcaddyfile.ConfigValue{ - { - Class: "route", - Value: caddyhttp.Route{ - MatcherSetsRaw: []caddy.ModuleMap{userMatcherSet}, - HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(subroute, "handler", "subroute", nil)}, - }, - }, - }, nil - } - - // otherwise, return the literal subroute instead of - // individual routes, to ensure they stay together and - // are treated as a single unit, without necessarily - // creating an actual subroute in the output - return []httpcaddyfile.ConfigValue{ - { - Class: "route", - Value: subroute, - }, - }, nil -} - -// get the routing logic for index workers -// serve non-php file or fallback to index worker -func getIndexWorkerSubroute(h httpcaddyfile.Helper, phpsrv FrankenPHPModule, fsrv caddy.Module) caddyhttp.Subroute { - return caddyhttp.Subroute{ - Routes: caddyhttp.RouteList{ - caddyhttp.Route{ - MatcherSetsRaw: []caddy.ModuleMap{ - caddy.ModuleMap{ - "file": h.JSON(fileserver.MatchFile{ - TryFiles: []string{"{http.request.uri.path}"}, - Root: phpsrv.Root, - }), - "not": h.JSON(caddyhttp.MatchNot{ - MatcherSetsRaw: []caddy.ModuleMap{ - { - "path": h.JSON(caddyhttp.MatchPath{"*.php"}), - }, - }, - }), - }, - }, - HandlersRaw: []json.RawMessage{ - caddyconfig.JSONModuleObject(fsrv, "handler", "file_server", nil), - }, - }, - caddyhttp.Route{ - MatcherSetsRaw: []caddy.ModuleMap{}, - HandlersRaw: []json.RawMessage{ - caddyconfig.JSONModuleObject(phpsrv, "handler", "php", nil), - }, - }, - }, - } -} -// without file_server, all requests go to the worker -func getIndexWorkerWithoutFileServer(h httpcaddyfile.Helper, phpsrv FrankenPHPModule) caddyhttp.Subroute { - return caddyhttp.Subroute{ - Routes: caddyhttp.RouteList{ - caddyhttp.Route{ - MatcherSetsRaw: []caddy.ModuleMap{}, - HandlersRaw: []json.RawMessage{ - caddyconfig.JSONModuleObject(phpsrv, "handler", "php", nil), - }, - }, - }, - } + httpcaddyfile.RegisterDirective("php_worker", parsePhpWorkerDirective) + httpcaddyfile.RegisterDirectiveOrder("php_worker", "before", "file_server") } // return a nice error message diff --git a/caddy/frankenphpapp.go b/caddy/frankenphpapp.go new file mode 100644 index 0000000000..bc0491b106 --- /dev/null +++ b/caddy/frankenphpapp.go @@ -0,0 +1,250 @@ +package caddy + +import ( + "errors" + "fmt" + "log/slog" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" + "github.com/dunglas/frankenphp" + "github.com/dunglas/frankenphp/internal/fastabs" +) + +/* +FrankenPHPApp represents the global 'frankenphp' directive in the Caddyfile +'frankenphp' is responsible for starting up the global PHP instance and all threads + + { + frankenphp { + num_threads 20 + } + } +*/ +type FrankenPHPApp struct { + // NumThreads sets the number of PHP threads to start. Default: 2x the number of available CPUs. + NumThreads int `json:"num_threads,omitempty"` + // MaxThreads limits how many threads can be started at runtime. Default 2x NumThreads + MaxThreads int `json:"max_threads,omitempty"` + // Workers configures the worker scripts to start. + Workers []workerConfig `json:"workers,omitempty"` + // Overwrites the default php ini configuration + PhpIni map[string]string `json:"php_ini,omitempty"` + // The maximum amount of time a request may be stalled waiting for a thread + MaxWaitTime time.Duration `json:"max_wait_time,omitempty"` + + metrics frankenphp.Metrics + logger *slog.Logger +} + +var iniError = errors.New("'php_ini' must be in the format: php_ini \"\" \"\"") + +// CaddyModule returns the Caddy module information. +func (f FrankenPHPApp) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "frankenphp", + New: func() caddy.Module { return &f }, + } +} + +// Provision sets up the module. +func (f *FrankenPHPApp) Provision(ctx caddy.Context) error { + f.logger = ctx.Slogger() + + if httpApp, err := ctx.AppIfConfigured("http"); err == nil { + if httpApp.(*caddyhttp.App).Metrics != nil { + f.metrics = frankenphp.NewPrometheusMetrics(ctx.GetMetricsRegistry()) + } + } else { + // if the http module is not configured (this should never happen) then collect the metrics by default + f.metrics = frankenphp.NewPrometheusMetrics(ctx.GetMetricsRegistry()) + } + + return nil +} + +func (f *FrankenPHPApp) Start() error { + repl := caddy.NewReplacer() + + opts := []frankenphp.Option{ + frankenphp.WithNumThreads(f.NumThreads), + frankenphp.WithMaxThreads(f.MaxThreads), + frankenphp.WithLogger(f.logger), + frankenphp.WithMetrics(f.metrics), + frankenphp.WithPhpIni(f.PhpIni), + frankenphp.WithMaxWaitTime(f.MaxWaitTime), + } + // Add workers from FrankenPHPApp and FrankenPHPModule configurations + // f.Workers may have been set by JSON config, so keep them separate + for _, w := range append(f.Workers, moduleWorkerConfigs...) { + opts = append(opts, frankenphp.WithWorkers(w.Name, repl.ReplaceKnown(w.FileName, ""), w.Num, w.Env, w.Watch)) + } + + frankenphp.Shutdown() + if err := frankenphp.Init(opts...); err != nil { + return err + } + + return nil +} + +func (f *FrankenPHPApp) Stop() error { + f.logger.Info("FrankenPHP stopped 🐘") + + // attempt a graceful shutdown if caddy is exiting + // note: Exiting() is currently marked as 'experimental' + // https://github.com/caddyserver/caddy/blob/e76405d55058b0a3e5ba222b44b5ef00516116aa/caddy.go#L810 + if caddy.Exiting() { + frankenphp.DrainWorkers() + } + + // reset the configuration so it doesn't bleed into later tests + f.Workers = nil + f.NumThreads = 0 + f.MaxWaitTime = 0 + moduleWorkerConfigs = nil + + return nil +} + +// UnmarshalCaddyfile implements caddyfile.Unmarshaler. +func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + moduleWorkerConfigs = []workerConfig{} + for d.Next() { + for d.NextBlock(0) { + // when adding a new directive, also update the allowedDirectives error message + switch d.Val() { + case "num_threads": + if !d.NextArg() { + return d.ArgErr() + } + + v, err := strconv.ParseUint(d.Val(), 10, 32) + if err != nil { + return err + } + + f.NumThreads = int(v) + case "max_threads": + if !d.NextArg() { + return d.ArgErr() + } + + if d.Val() == "auto" { + f.MaxThreads = -1 + continue + } + + v, err := strconv.ParseUint(d.Val(), 10, 32) + if err != nil { + return err + } + + f.MaxThreads = int(v) + case "max_wait_time": + if !d.NextArg() { + return d.ArgErr() + } + + v, err := time.ParseDuration(d.Val()) + if err != nil { + return errors.New("max_wait_time must be a valid duration (example: 10s)") + } + + f.MaxWaitTime = v + case "php_ini": + parseIniLine := func(d *caddyfile.Dispenser) error { + key := d.Val() + if !d.NextArg() { + return iniError + } + if f.PhpIni == nil { + f.PhpIni = make(map[string]string) + } + f.PhpIni[key] = d.Val() + if d.NextArg() { + return iniError + } + + return nil + } + + isBlock := false + for d.NextBlock(1) { + isBlock = true + err := parseIniLine(d) + if err != nil { + return err + } + } + + if !isBlock { + if !d.NextArg() { + return iniError + } + err := parseIniLine(d) + if err != nil { + return err + } + } + + case "worker": + wc, err := parseWorkerConfig(d) + if err != nil { + return err + } + if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) { + wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName) + } + if wc.Name == "" { + // let worker initialization validate if the FileName is valid or not + name, _ := fastabs.FastAbs(wc.FileName) + if name == "" { + name = wc.FileName + } + wc.Name = name + } + if strings.HasPrefix(wc.Name, "m#") { + return fmt.Errorf(`global worker names must not start with "m#": %q`, wc.Name) + } + // check for duplicate workers + for _, existingWorker := range f.Workers { + if existingWorker.FileName == wc.FileName { + return fmt.Errorf("global workers must not have duplicate filenames: %q", wc.FileName) + } + } + + f.Workers = append(f.Workers, wc) + default: + allowedDirectives := "num_threads, max_threads, php_ini, worker, max_wait_time" + return wrongSubDirectiveError("frankenphp", allowedDirectives, d.Val()) + } + } + } + + if f.MaxThreads > 0 && f.NumThreads > 0 && f.MaxThreads < f.NumThreads { + return errors.New("'max_threads' must be greater than or equal to 'num_threads'") + } + + return nil +} + +func parseFrankenPhpDirective(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) { + app := &FrankenPHPApp{} + if err := app.UnmarshalCaddyfile(d); err != nil { + return nil, err + } + + // tell Caddyfile adapter that this is the JSON for an app + return httpcaddyfile.App{ + Name: "frankenphp", + Value: caddyconfig.JSON(app, nil), + }, nil +} diff --git a/caddy/frankenphpmodule.go b/caddy/frankenphpmodule.go new file mode 100644 index 0000000000..98892f42a0 --- /dev/null +++ b/caddy/frankenphpmodule.go @@ -0,0 +1,796 @@ +package caddy + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "path/filepath" + "strconv" + "strings" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" + "github.com/caddyserver/caddy/v2/modules/caddyhttp/fileserver" + "github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite" + "github.com/dunglas/frankenphp" + "github.com/dunglas/frankenphp/internal/fastabs" +) + +/* +FrankenPHPModule represents the 'php_server' and 'php' directives in the Caddyfile +they are responsible for forwarding requests to FrankenPHP via 'ServeHTTP' + + example.com { + php_server { + root /var/www/html + } + } +*/ +type FrankenPHPModule struct { + // Root sets the root folder to the site. Default: `root` directive, or the path of the public directory of the embed app it exists. + Root string `json:"root,omitempty"` + // SplitPath sets the substrings for splitting the URI into two parts. The first matching substring will be used to split the "path info" from the path. The first piece is suffixed with the matching substring and will be assumed as the actual resource (CGI script) name. The second piece will be set to PATH_INFO for the CGI script to use. Default: `.php`. + SplitPath []string `json:"split_path,omitempty"` + // ResolveRootSymlink enables resolving the `root` directory to its actual value by evaluating a symbolic link, if one exists. + ResolveRootSymlink *bool `json:"resolve_root_symlink,omitempty"` + // Env sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. + Env map[string]string `json:"env,omitempty"` + // Workers configures the worker scripts to start. + Workers []workerConfig `json:"workers,omitempty"` + + resolvedDocumentRoot string + preparedEnv frankenphp.PreparedEnv + preparedEnvNeedsReplacement bool + logger *slog.Logger +} + +// CaddyModule returns the Caddy module information. +func (FrankenPHPModule) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "http.handlers.php", + New: func() caddy.Module { return new(FrankenPHPModule) }, + } +} + +// Provision sets up the module. +func (f *FrankenPHPModule) Provision(ctx caddy.Context) error { + f.logger = ctx.Slogger() + + if f.Root == "" { + if frankenphp.EmbeddedAppPath == "" { + f.Root = "{http.vars.root}" + } else { + rrs := false + f.Root = filepath.Join(frankenphp.EmbeddedAppPath, defaultDocumentRoot) + f.ResolveRootSymlink = &rrs + } + } else { + if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(f.Root) { + f.Root = filepath.Join(frankenphp.EmbeddedAppPath, f.Root) + } + } + + if len(f.SplitPath) == 0 { + f.SplitPath = []string{".php"} + } + + if f.ResolveRootSymlink == nil { + rrs := true + f.ResolveRootSymlink = &rrs + } + + if !needReplacement(f.Root) { + root, err := fastabs.FastAbs(f.Root) + if err != nil { + return fmt.Errorf("unable to make the root path absolute: %w", err) + } + f.resolvedDocumentRoot = root + + if *f.ResolveRootSymlink { + root, err := filepath.EvalSymlinks(root) + if err != nil { + return fmt.Errorf("unable to resolve root symlink: %w", err) + } + + f.resolvedDocumentRoot = root + } + } + + // copy the prepared env to all module workers + if f.Env != nil { + for _, wc := range f.Workers { + if wc.Env == nil { + wc.Env = make(map[string]string) + } + for k, v := range f.Env { + // Only set if not already defined in the worker + if _, exists := wc.Env[k]; !exists { + wc.Env[k] = v + } + } + } + } + + if f.preparedEnv == nil { + f.preparedEnv = frankenphp.PrepareEnv(f.Env) + + for _, e := range f.preparedEnv { + if needReplacement(e) { + f.preparedEnvNeedsReplacement = true + + break + } + } + } + + return nil +} + +// needReplacement checks if a string contains placeholders. +func needReplacement(s string) bool { + return strings.Contains(s, "{") || strings.Contains(s, "}") +} + +// ServeHTTP implements caddyhttp.MiddlewareHandler. +// TODO: Expose TLS versions as env vars, as Apache's mod_ssl: https://github.com/caddyserver/caddy/blob/master/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go#L298 +func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ caddyhttp.Handler) error { + origReq := r.Context().Value(caddyhttp.OriginalRequestCtxKey).(http.Request) + repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + + var documentRootOption frankenphp.RequestOption + var documentRoot string + if f.resolvedDocumentRoot == "" { + documentRoot = repl.ReplaceKnown(f.Root, "") + if documentRoot == "" && frankenphp.EmbeddedAppPath != "" { + documentRoot = frankenphp.EmbeddedAppPath + } + documentRootOption = frankenphp.WithRequestDocumentRoot(documentRoot, *f.ResolveRootSymlink) + } else { + documentRoot = f.resolvedDocumentRoot + documentRootOption = frankenphp.WithRequestResolvedDocumentRoot(documentRoot) + } + + env := f.preparedEnv + if f.preparedEnvNeedsReplacement { + env = make(frankenphp.PreparedEnv, len(f.Env)) + for k, v := range f.preparedEnv { + env[k] = repl.ReplaceKnown(v, "") + } + } + + workerName := "" + // check if the request should be handled by a module worker + for _, w := range f.Workers { + // always fall back to an index worker + if w.IsIndex { + workerName = w.Name + break + } + if absPath, _ := fastabs.FastAbs(documentRoot + "/" + r.URL.Path); w.FileName == absPath { + workerName = w.Name + break + } + } + + fr, err := frankenphp.NewRequestWithContext( + r, + documentRootOption, + frankenphp.WithRequestSplitPath(f.SplitPath), + frankenphp.WithRequestPreparedEnv(env), + frankenphp.WithOriginalRequest(&origReq), + frankenphp.WithWorkerName(workerName), + ) + + if err = frankenphp.ServeHTTP(w, fr); err != nil { + return caddyhttp.Error(http.StatusInternalServerError, err) + } + + return nil +} + +func generateUniqueModuleWorkerName(filepath string) string { + var i uint + name := "m#" + filepath + +outer: + for { + for _, wc := range moduleWorkerConfigs { + if wc.Name == name { + name = fmt.Sprintf("m#%s_%d", filepath, i) + i++ + + continue outer + } + } + + return name + } +} + +// UnmarshalCaddyfile implements caddyfile.Unmarshaler. +func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + for d.Next() { + for d.NextBlock(0) { + directive := d.Val() + switch directive { + case "root": + if !d.NextArg() { + return d.ArgErr() + } + f.Root = d.Val() + + case "split": + f.SplitPath = d.RemainingArgs() + if len(f.SplitPath) == 0 { + return d.ArgErr() + } + + case "env": + args := d.RemainingArgs() + if len(args) != 2 { + return d.ArgErr() + } + if f.Env == nil { + f.Env = make(map[string]string) + f.preparedEnv = make(frankenphp.PreparedEnv) + } + f.Env[args[0]] = args[1] + f.preparedEnv[args[0]+"\x00"] = args[1] + + case "resolve_root_symlink": + if !d.NextArg() { + continue + } + v, err := strconv.ParseBool(d.Val()) + if err != nil { + return err + } + if d.NextArg() { + return d.ArgErr() + } + f.ResolveRootSymlink = &v + + // register the worker if 'index' is a worker file + case "index": + if !d.NextArg() { + return d.ArgErr() + } + + if d.Val() == "worker" { + wc, err := parseModuleWorker(d, f) + if err != nil { + return err + } + wc.IsIndex = true + f.Workers = append(f.Workers, wc) + moduleWorkerConfigs = append(moduleWorkerConfigs, wc) + } + + case "worker": + wc, err := parseModuleWorker(d, f) + if err != nil { + return err + } + f.Workers = append(f.Workers, wc) + moduleWorkerConfigs = append(moduleWorkerConfigs, wc) + + default: + allowedDirectives := "root, split, env, resolve_root_symlink, worker, index" + return wrongSubDirectiveError("php or php_server", allowedDirectives, d.Val()) + } + } + } + + return nil +} + +// parse a worker inside a php or php_server directive +func parseModuleWorker(d *caddyfile.Dispenser, f *FrankenPHPModule) (workerConfig, error) { + wc, err := parseWorkerConfig(d) + if err != nil { + return wc, err + } + + // if the worker path is relative, make it absolute + // either with the php_server Root or with the WD + if !filepath.IsAbs(wc.FileName) { + if f.Root != "" { + wc.FileName = filepath.Join(f.Root, wc.FileName) + } + wc.FileName, err = fastabs.FastAbs(wc.FileName) + if err != nil { + return wc, err + } + } + + if f.Env != nil { + if wc.Env == nil { + wc.Env = make(map[string]string) + } + for k, v := range f.Env { + // Only set if not already defined in the worker + if _, exists := wc.Env[k]; !exists { + wc.Env[k] = v + } + } + } + + if wc.Name == "" { + wc.Name = generateUniqueModuleWorkerName(wc.FileName) + } + if !strings.HasPrefix(wc.Name, "m#") { + wc.Name = "m#" + wc.Name + } + + // Check if a worker with this filename already exists in this module + for _, existingWorker := range f.Workers { + if existingWorker.FileName == wc.FileName { + return wc, fmt.Errorf(`workers in a single "php_server" block must not have duplicate filenames: %q`, wc.FileName) + } + } + // Check if a worker with this name and a different environment or filename already exists + for _, existingWorker := range moduleWorkerConfigs { + if existingWorker.Name == wc.Name { + return wc, fmt.Errorf("workers must not have duplicate names: %q", wc.Name) + } + } + + return wc, nil +} + +// parsePhpDirective unmarshals tokens from h into a new Middleware. +func parsePhpDirective(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { + m := &FrankenPHPModule{} + err := m.UnmarshalCaddyfile(h.Dispenser) + + return m, err +} + +// parsePhpServerDirective parses the php_server directive, which has a similar syntax +// to the php_fastcgi directive. A line such as this: +// +// php_server +// +// is equivalent to a route consisting of: +// +// # Add trailing slash for directory requests +// @canonicalPath { +// file {path}/index.php +// not path */ +// } +// redir @canonicalPath {path}/ 308 +// +// # If the requested file does not exist, try index files +// @indexFiles file { +// try_files {path} {path}/index.php index.php +// split_path .php +// } +// rewrite @indexFiles {http.matchers.file.relative} +// +// # FrankenPHP! +// @phpFiles path *.php +// php @phpFiles +// file_server +// +// parsePhpServerDirective is freely inspired from the php_fastgci directive of the Caddy server (Apache License 2.0, Matthew Holt and The Caddy Authors) +func parsePhpServerDirective(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) { + if !h.Next() { + return nil, h.ArgErr() + } + + // set up FrankenPHP + phpsrv := FrankenPHPModule{} + + // set up file server + fsrv := fileserver.FileServer{} + disableFsrv := false + useWorkerAsIndex := false + + // set up the set of file extensions allowed to execute PHP code + extensions := []string{".php"} + + // set the default index file for the try_files rewrites + indexFile := "index.php" + + // set up for explicitly overriding try_files + var tryFiles []string + + // if the user specified a matcher token, use that + // matcher in a route that wraps both of our routes; + // either way, strip the matcher token and pass + // the remaining tokens to the unmarshaler so that + // we can gain the rest of the directive syntax + userMatcherSet, err := h.ExtractMatcherSet() + if err != nil { + return nil, err + } + + // make a new dispenser from the remaining tokens so that we + // can reset the dispenser back to this point for the + // php unmarshaler to read from it as well + dispenser := h.NewFromNextSegment() + + // read the subdirectives that we allow as overrides to + // the php_server shortcut + // NOTE: we delete the tokens as we go so that the php + // unmarshal doesn't see these subdirectives which it cannot handle + for dispenser.Next() { + for dispenser.NextBlock(0) { + // ignore any sub-subdirectives that might + // have the same name somewhere within + // the php passthrough tokens + if dispenser.Nesting() != 1 { + continue + } + + // parse the php_server subdirectives + switch dispenser.Val() { + case "root": + if !dispenser.NextArg() { + return nil, dispenser.ArgErr() + } + phpsrv.Root = dispenser.Val() + fsrv.Root = phpsrv.Root + dispenser.DeleteN(2) + + case "split": + extensions = dispenser.RemainingArgs() + dispenser.DeleteN(len(extensions) + 1) + if len(extensions) == 0 { + return nil, dispenser.ArgErr() + } + + case "index": + args := dispenser.RemainingArgs() + if len(args) != 1 { + return nil, dispenser.ArgErr() + } + indexFile = args[0] + if args[0] == "worker" { + useWorkerAsIndex = true + } + + case "try_files": + args := dispenser.RemainingArgs() + dispenser.DeleteN(len(args) + 1) + if len(args) < 1 { + return nil, dispenser.ArgErr() + } + tryFiles = args + + case "file_server": + args := dispenser.RemainingArgs() + dispenser.DeleteN(len(args) + 1) + if len(args) < 1 || args[0] != "off" { + return nil, dispenser.ArgErr() + } + disableFsrv = true + } + } + } + + if frankenphp.EmbeddedAppPath != "" { + if phpsrv.Root == "" { + phpsrv.Root = filepath.Join(frankenphp.EmbeddedAppPath, defaultDocumentRoot) + fsrv.Root = phpsrv.Root + rrs := false + phpsrv.ResolveRootSymlink = &rrs + } else if filepath.IsLocal(fsrv.Root) { + phpsrv.Root = filepath.Join(frankenphp.EmbeddedAppPath, phpsrv.Root) + fsrv.Root = phpsrv.Root + } + } + + // set up a route list that we'll append to + routes := caddyhttp.RouteList{} + + // set the list of allowed path segments on which to split + phpsrv.SplitPath = extensions + + // reset the dispenser after we're done so that the frankenphp + // unmarshaler can read it from the start + dispenser.Reset() + // the rest of the config is specified by the user + // using the php directive syntax + dispenser.Next() // consume the directive name + err = phpsrv.UnmarshalCaddyfile(dispenser) + + if err != nil { + return nil, err + } + + if useWorkerAsIndex { + if len(tryFiles) > 0 || len(extensions) != 1 { + caddy.Log().Warn("'try_files' and 'split_path' are inconsequental with index workers") + } + + if disableFsrv { + return []httpcaddyfile.ConfigValue{ + { + Class: "route", + Value: getIndexWorkerWithoutFileServer(h, phpsrv), + }, + }, nil + } + + return []httpcaddyfile.ConfigValue{ + { + Class: "route", + Value: getIndexWorkerSubroute(h, phpsrv, fsrv), + }, + }, nil + } + + // if the index is turned off, we skip the redirect and try_files + if indexFile != "off" { + dirRedir := false + dirIndex := "{http.request.uri.path}/" + indexFile + tryPolicy := "first_exist_fallback" + + // if tryFiles wasn't overridden, use a reasonable default + if len(tryFiles) == 0 { + if disableFsrv { + tryFiles = []string{dirIndex, indexFile} + } else { + tryFiles = []string{"{http.request.uri.path}", dirIndex, indexFile} + } + + dirRedir = true + } else { + if !strings.HasSuffix(tryFiles[len(tryFiles)-1], ".php") { + // use first_exist strategy if the last file is not a PHP file + tryPolicy = "" + } + + for _, tf := range tryFiles { + if tf == dirIndex { + dirRedir = true + + break + } + } + } + + // route to redirect to canonical path if index PHP file + if dirRedir { + redirMatcherSet := caddy.ModuleMap{ + "file": h.JSON(fileserver.MatchFile{ + TryFiles: []string{dirIndex}, + }), + "not": h.JSON(caddyhttp.MatchNot{ + MatcherSetsRaw: []caddy.ModuleMap{ + { + "path": h.JSON(caddyhttp.MatchPath{"*/"}), + }, + }, + }), + } + redirHandler := caddyhttp.StaticResponse{ + StatusCode: caddyhttp.WeakString(strconv.Itoa(http.StatusPermanentRedirect)), + Headers: http.Header{"Location": []string{"{http.request.orig_uri.path}/"}}, + } + redirRoute := caddyhttp.Route{ + MatcherSetsRaw: []caddy.ModuleMap{redirMatcherSet}, + HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(redirHandler, "handler", "static_response", nil)}, + } + + routes = append(routes, redirRoute) + } + + // route to rewrite to PHP index file + rewriteMatcherSet := caddy.ModuleMap{ + "file": h.JSON(fileserver.MatchFile{ + TryFiles: tryFiles, + TryPolicy: tryPolicy, + SplitPath: extensions, + }), + } + rewriteHandler := rewrite.Rewrite{ + URI: "{http.matchers.file.relative}", + } + rewriteRoute := caddyhttp.Route{ + MatcherSetsRaw: []caddy.ModuleMap{rewriteMatcherSet}, + HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(rewriteHandler, "handler", "rewrite", nil)}, + } + + routes = append(routes, rewriteRoute) + } + + // route to actually pass requests to PHP files; + // match only requests that are for PHP files + var pathList []string + for _, ext := range extensions { + pathList = append(pathList, "*"+ext) + } + phpMatcherSet := caddy.ModuleMap{ + "path": h.JSON(pathList), + } + + // create the PHP route which is + // conditional on matching PHP files + phpRoute := caddyhttp.Route{ + MatcherSetsRaw: []caddy.ModuleMap{phpMatcherSet}, + HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(phpsrv, "handler", "php", nil)}, + } + routes = append(routes, phpRoute) + + // create the file server route + if !disableFsrv { + fileRoute := caddyhttp.Route{ + MatcherSetsRaw: []caddy.ModuleMap{}, + HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(fsrv, "handler", "file_server", nil)}, + } + routes = append(routes, fileRoute) + } + + subroute := caddyhttp.Subroute{ + Routes: routes, + } + + // the user's matcher is a prerequisite for ours, so + // wrap ours in a subroute and return that + if userMatcherSet != nil { + return []httpcaddyfile.ConfigValue{ + { + Class: "route", + Value: caddyhttp.Route{ + MatcherSetsRaw: []caddy.ModuleMap{userMatcherSet}, + HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(subroute, "handler", "subroute", nil)}, + }, + }, + }, nil + } + + // otherwise, return the literal subroute instead of + // individual routes, to ensure they stay together and + // are treated as a single unit, without necessarily + // creating an actual subroute in the output + return []httpcaddyfile.ConfigValue{ + { + Class: "route", + Value: subroute, + }, + }, nil +} + +func parsePhpWorkerDirective(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) { + if !h.Next() { + return nil, h.ArgErr() + } + + frankenphpModule := FrankenPHPModule{} + fsrv := fileserver.FileServer{} + disableFsrv := false + + // make a new dispenser from the remaining tokens so that we + // can reset the dispenser back to this point for the + // php unmarshaler to read from it as well + dispenser := h.NewFromNextSegment() + + // read the subdirectives that we allow as overrides to + // the php_server shortcut + // NOTE: we delete the tokens as we go so that the php + // unmarshal doesn't see these subdirectives which it cannot handle + for dispenser.Next() { + for dispenser.NextBlock(0) { + // ignore any sub-subdirectives that might + // have the same name somewhere within + // the php passthrough tokens + if dispenser.Nesting() != 1 { + continue + } + + // parse the php_server subdirectives + switch dispenser.Val() { + case "root": + if !dispenser.NextArg() { + return nil, dispenser.ArgErr() + } + frankenphpModule.Root = dispenser.Val() + fsrv.Root = frankenphpModule.Root + dispenser.DeleteN(2) + case "file_server": + args := dispenser.RemainingArgs() + dispenser.DeleteN(len(args) + 1) + if len(args) < 1 || args[0] != "off" { + return nil, dispenser.ArgErr() + } + disableFsrv = true + } + } + } + + if frankenphp.EmbeddedAppPath != "" { + if frankenphpModule.Root == "" { + frankenphpModule.Root = filepath.Join(frankenphp.EmbeddedAppPath, defaultDocumentRoot) + fsrv.Root = frankenphpModule.Root + rrs := false + frankenphpModule.ResolveRootSymlink = &rrs + } else if filepath.IsLocal(fsrv.Root) { + frankenphpModule.Root = filepath.Join(frankenphp.EmbeddedAppPath, frankenphpModule.Root) + fsrv.Root = frankenphpModule.Root + } + } + + dispenser.Reset() + dispenser.Next() // consume the directive name + wc, err := parseModuleWorker(dispenser, &frankenphpModule) + wc.IsIndex = true + frankenphpModule.Workers = append(frankenphpModule.Workers, wc) + moduleWorkerConfigs = append(moduleWorkerConfigs, wc) + + if err != nil { + return nil, err + } + + if disableFsrv { + return []httpcaddyfile.ConfigValue{ + { + Class: "route", + Value: getIndexWorkerWithoutFileServer(h, frankenphpModule), + }, + }, nil + } + + return []httpcaddyfile.ConfigValue{ + { + Class: "route", + Value: getIndexWorkerSubroute(h, frankenphpModule, fsrv), + }, + }, nil +} + +// get the routing logic for index workers +// serve non-php file or fallback to index worker +func getIndexWorkerSubroute(h httpcaddyfile.Helper, frankenphpModule FrankenPHPModule, fsrv caddy.Module) caddyhttp.Subroute { + return caddyhttp.Subroute{ + Routes: caddyhttp.RouteList{ + caddyhttp.Route{ + MatcherSetsRaw: []caddy.ModuleMap{ + caddy.ModuleMap{ + "file": h.JSON(fileserver.MatchFile{ + TryFiles: []string{"{http.request.uri.path}"}, + Root: frankenphpModule.Root, + }), + "not": h.JSON(caddyhttp.MatchNot{ + MatcherSetsRaw: []caddy.ModuleMap{ + { + "path": h.JSON(caddyhttp.MatchPath{"*.php"}), + }, + }, + }), + }, + }, + HandlersRaw: []json.RawMessage{ + caddyconfig.JSONModuleObject(fsrv, "handler", "file_server", nil), + }, + }, + caddyhttp.Route{ + MatcherSetsRaw: []caddy.ModuleMap{}, + HandlersRaw: []json.RawMessage{ + caddyconfig.JSONModuleObject(frankenphpModule, "handler", "php", nil), + }, + }, + }, + } +} + + + +// without file_server, all requests go to the worker +func getIndexWorkerWithoutFileServer(h httpcaddyfile.Helper, frankenphpModule FrankenPHPModule) caddyhttp.Subroute { + return caddyhttp.Subroute{ + Routes: caddyhttp.RouteList{ + caddyhttp.Route{ + MatcherSetsRaw: []caddy.ModuleMap{}, + HandlersRaw: []json.RawMessage{ + caddyconfig.JSONModuleObject(frankenphpModule, "handler", "php", nil), + }, + }, + }, + } +} diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go new file mode 100644 index 0000000000..f0620fd6d2 --- /dev/null +++ b/caddy/workerconfig.go @@ -0,0 +1,116 @@ +package caddy + +import ( + "errors" + "path/filepath" + "strconv" + + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "github.com/dunglas/frankenphp" +) + +/* +workerConfig represents the 'worker' directive in the Caddyfile +'worker' can appear in the 'frankenphp', 'php_server' and 'php' directives + + frankenphp { + worker { + name "my-worker" + file "my-worker.php" + } + } +*/ +type workerConfig struct { + // Name for the worker. Default: the filename for FrankenPHPApp workers, always prefixed with "m#" for FrankenPHPModule workers. + Name string `json:"name,omitempty"` + // FileName sets the path to the worker script. + FileName string `json:"file_name,omitempty"` + // Num sets the number of workers to start. + Num int `json:"num,omitempty"` + // Env sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. + Env map[string]string `json:"env,omitempty"` + // Directories to watch for file changes + Watch []string `json:"watch,omitempty"` + // IsIndex determines weather the worker is the first_exist_fallback + IsIndex bool `json:"is_index,omitempty"` +} + +func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) { + wc := workerConfig{} + if d.NextArg() { + wc.FileName = d.Val() + } + + if d.NextArg() { + if d.Val() == "watch" { + wc.Watch = append(wc.Watch, defaultWatchPattern) + } else { + v, err := strconv.ParseUint(d.Val(), 10, 32) + if err != nil { + return wc, err + } + + wc.Num = int(v) + } + } + + if d.NextArg() { + return wc, errors.New(`FrankenPHP: too many "worker" arguments: ` + d.Val()) + } + + for d.NextBlock(1) { + v := d.Val() + switch v { + case "name": + if !d.NextArg() { + return wc, d.ArgErr() + } + wc.Name = d.Val() + case "file": + if !d.NextArg() { + return wc, d.ArgErr() + } + wc.FileName = d.Val() + case "num": + if !d.NextArg() { + return wc, d.ArgErr() + } + + v, err := strconv.ParseUint(d.Val(), 10, 32) + if err != nil { + return wc, err + } + + wc.Num = int(v) + case "env": + args := d.RemainingArgs() + if len(args) != 2 { + return wc, d.ArgErr() + } + if wc.Env == nil { + wc.Env = make(map[string]string) + } + wc.Env[args[0]] = args[1] + case "watch": + if !d.NextArg() { + // the default if the watch directory is left empty: + wc.Watch = append(wc.Watch, defaultWatchPattern) + } else { + wc.Watch = append(wc.Watch, d.Val()) + } + default: + allowedDirectives := "name, file, num, env, watch" + return wc, wrongSubDirectiveError("worker", allowedDirectives, v) + } + } + + if wc.FileName == "" { + return wc, errors.New(`the "file" argument must be specified`) + } + + if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) { + wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName) + } + + return wc, nil +} From 842ef96bff2132d51955bcfe637a343df863d8cf Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 11 May 2025 20:47:03 +0200 Subject: [PATCH 06/17] Allow both 'php_worker' and 'index worker' --- caddy/caddy_test.go | 24 +++++++------------- caddy/frankenphpapp.go | 2 +- caddy/frankenphpmodule.go | 47 +++++++++++++++++++-------------------- caddy/workerconfig.go | 4 ++-- 4 files changed, 34 insertions(+), 43 deletions(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index bbd457a970..ba65109dd8 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -246,22 +246,18 @@ func TestTwoIndexModuleWorkers(t *testing.T) { http://localhost:`+testPort+` { route { - php { + php_worker { + file_server off root ../testdata/files - index worker { - file `+indexFileName+` - num 2 - } + file `+indexFileName+` + num 2 } } } http://localhost:`+testPortTwo+` { route { - php { - root ../testdata/files - index worker `+indexFileName+` 2 - } + php_worker `+indexFileName+` 2 } } `, "caddyfile") @@ -281,7 +277,6 @@ func TestTwoIndexModuleWorkers(t *testing.T) { func TestIndexWorkerWithFileServer(t *testing.T) { tester := caddytest.NewTester(t) - indexFileName, _ := fastabs.FastAbs("../testdata/index.php") tester.InitServer(` { @@ -295,13 +290,10 @@ func TestIndexWorkerWithFileServer(t *testing.T) { http://localhost:`+testPort+` { route { - root ../testdata/files - php_server { + php_worker { root ../testdata/files - index worker { - file `+indexFileName+` - num 2 - } + file ../index.php + num 2 } } } diff --git a/caddy/frankenphpapp.go b/caddy/frankenphpapp.go index bc0491b106..085f622764 100644 --- a/caddy/frankenphpapp.go +++ b/caddy/frankenphpapp.go @@ -196,7 +196,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } case "worker": - wc, err := parseWorkerConfig(d) + wc, err := parseWorkerConfig(d, 1) if err != nil { return err } diff --git a/caddy/frankenphpmodule.go b/caddy/frankenphpmodule.go index 98892f42a0..fac1c009bc 100644 --- a/caddy/frankenphpmodule.go +++ b/caddy/frankenphpmodule.go @@ -261,7 +261,7 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } if d.Val() == "worker" { - wc, err := parseModuleWorker(d, f) + wc, err := parseModuleWorker(d, f, 1) if err != nil { return err } @@ -271,7 +271,7 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } case "worker": - wc, err := parseModuleWorker(d, f) + wc, err := parseModuleWorker(d, f, 1) if err != nil { return err } @@ -289,8 +289,8 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } // parse a worker inside a php or php_server directive -func parseModuleWorker(d *caddyfile.Dispenser, f *FrankenPHPModule) (workerConfig, error) { - wc, err := parseWorkerConfig(d) +func parseModuleWorker(d *caddyfile.Dispenser, f *FrankenPHPModule, nestingLevel int) (workerConfig, error) { + wc, err := parseWorkerConfig(d, nestingLevel) if err != nil { return wc, err } @@ -716,32 +716,33 @@ func parsePhpWorkerDirective(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValu } } + // reset the dispenser and treat it like a worker config dispenser.Reset() - dispenser.Next() // consume the directive name - wc, err := parseModuleWorker(dispenser, &frankenphpModule) + dispenser.Next() + wc, err := parseModuleWorker(dispenser, &frankenphpModule, 0) wc.IsIndex = true - frankenphpModule.Workers = append(frankenphpModule.Workers, wc) - moduleWorkerConfigs = append(moduleWorkerConfigs, wc) + frankenphpModule.Workers = append(frankenphpModule.Workers, wc) + moduleWorkerConfigs = append(moduleWorkerConfigs, wc) if err != nil { return nil, err } if disableFsrv { - return []httpcaddyfile.ConfigValue{ - { - Class: "route", - Value: getIndexWorkerWithoutFileServer(h, frankenphpModule), - }, - }, nil - } - - return []httpcaddyfile.ConfigValue{ - { - Class: "route", - Value: getIndexWorkerSubroute(h, frankenphpModule, fsrv), - }, - }, nil + return []httpcaddyfile.ConfigValue{ + { + Class: "route", + Value: getIndexWorkerWithoutFileServer(h, frankenphpModule), + }, + }, nil + } + + return []httpcaddyfile.ConfigValue{ + { + Class: "route", + Value: getIndexWorkerSubroute(h, frankenphpModule, fsrv), + }, + }, nil } // get the routing logic for index workers @@ -779,8 +780,6 @@ func getIndexWorkerSubroute(h httpcaddyfile.Helper, frankenphpModule FrankenPHPM } } - - // without file_server, all requests go to the worker func getIndexWorkerWithoutFileServer(h httpcaddyfile.Helper, frankenphpModule FrankenPHPModule) caddyhttp.Subroute { return caddyhttp.Subroute{ diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index f0620fd6d2..ea9766f21e 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -35,7 +35,7 @@ type workerConfig struct { IsIndex bool `json:"is_index,omitempty"` } -func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) { +func parseWorkerConfig(d *caddyfile.Dispenser, nestingLevel int) (workerConfig, error) { wc := workerConfig{} if d.NextArg() { wc.FileName = d.Val() @@ -58,7 +58,7 @@ func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) { return wc, errors.New(`FrankenPHP: too many "worker" arguments: ` + d.Val()) } - for d.NextBlock(1) { + for d.NextBlock(nestingLevel) { v := d.Val() switch v { case "name": From 7eba1db97b597925e7acbe6daf6d2a27ebb8cc3d Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 11 May 2025 20:47:14 +0200 Subject: [PATCH 07/17] Allow both 'php_worker' and 'index worker' --- caddy/caddy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/caddy/caddy.go b/caddy/caddy.go index 969a6c11f4..d2a9ac37ab 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -35,7 +35,7 @@ func init() { httpcaddyfile.RegisterDirectiveOrder("php_server", "before", "file_server") httpcaddyfile.RegisterDirective("php_worker", parsePhpWorkerDirective) - httpcaddyfile.RegisterDirectiveOrder("php_worker", "before", "file_server") + httpcaddyfile.RegisterDirectiveOrder("php_worker", "before", "file_server") } // return a nice error message From e14f24e3e98493bcd3c5b40c1d119724d73a859d Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 11 May 2025 20:54:35 +0200 Subject: [PATCH 08/17] Removes 'index worker' --- caddy/frankenphpmodule.go | 40 +++++---------------------------------- 1 file changed, 5 insertions(+), 35 deletions(-) diff --git a/caddy/frankenphpmodule.go b/caddy/frankenphpmodule.go index fac1c009bc..2e023264cc 100644 --- a/caddy/frankenphpmodule.go +++ b/caddy/frankenphpmodule.go @@ -388,7 +388,6 @@ func parsePhpServerDirective(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValu // set up file server fsrv := fileserver.FileServer{} disableFsrv := false - useWorkerAsIndex := false // set up the set of file extensions allowed to execute PHP code extensions := []string{".php"} @@ -450,9 +449,6 @@ func parsePhpServerDirective(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValu return nil, dispenser.ArgErr() } indexFile = args[0] - if args[0] == "worker" { - useWorkerAsIndex = true - } case "try_files": args := dispenser.RemainingArgs() @@ -503,28 +499,6 @@ func parsePhpServerDirective(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValu return nil, err } - if useWorkerAsIndex { - if len(tryFiles) > 0 || len(extensions) != 1 { - caddy.Log().Warn("'try_files' and 'split_path' are inconsequental with index workers") - } - - if disableFsrv { - return []httpcaddyfile.ConfigValue{ - { - Class: "route", - Value: getIndexWorkerWithoutFileServer(h, phpsrv), - }, - }, nil - } - - return []httpcaddyfile.ConfigValue{ - { - Class: "route", - Value: getIndexWorkerSubroute(h, phpsrv, fsrv), - }, - }, nil - } - // if the index is turned off, we skip the redirect and try_files if indexFile != "off" { dirRedir := false @@ -657,6 +631,7 @@ func parsePhpServerDirective(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValu }, nil } +// parse the 'php_worker' shorthand func parsePhpWorkerDirective(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) { if !h.Next() { return nil, h.ArgErr() @@ -666,15 +641,9 @@ func parsePhpWorkerDirective(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValu fsrv := fileserver.FileServer{} disableFsrv := false - // make a new dispenser from the remaining tokens so that we - // can reset the dispenser back to this point for the - // php unmarshaler to read from it as well + // this block is similar to 'php_server' + // but only 'root' and 'file_server' are allowed dispenser := h.NewFromNextSegment() - - // read the subdirectives that we allow as overrides to - // the php_server shortcut - // NOTE: we delete the tokens as we go so that the php - // unmarshal doesn't see these subdirectives which it cannot handle for dispenser.Next() { for dispenser.NextBlock(0) { // ignore any sub-subdirectives that might @@ -704,6 +673,7 @@ func parsePhpWorkerDirective(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValu } } + // fix the root for an embdedded app if frankenphp.EmbeddedAppPath != "" { if frankenphpModule.Root == "" { frankenphpModule.Root = filepath.Join(frankenphp.EmbeddedAppPath, defaultDocumentRoot) @@ -745,7 +715,7 @@ func parsePhpWorkerDirective(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValu }, nil } -// get the routing logic for index workers +// get the routing for php_worker // serve non-php file or fallback to index worker func getIndexWorkerSubroute(h httpcaddyfile.Helper, frankenphpModule FrankenPHPModule, fsrv caddy.Module) caddyhttp.Subroute { return caddyhttp.Subroute{ From dffbe40413708f70360c6724f6e1e8b281bea321 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 11 May 2025 21:19:16 +0200 Subject: [PATCH 09/17] refactoring. --- caddy/caddy_test.go | 4 ++-- caddy/config_test.go | 32 +------------------------------- caddy/frankenphpmodule.go | 26 +++++--------------------- caddy/workerconfig.go | 4 ++-- 4 files changed, 10 insertions(+), 56 deletions(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index ba65109dd8..287d441e74 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -227,7 +227,7 @@ func TestNamedModuleWorkers(t *testing.T) { wg.Wait() } -func TestTwoIndexModuleWorkers(t *testing.T) { +func TestTwoPhpWorkerModules(t *testing.T) { var wg sync.WaitGroup testPortNum, _ := strconv.Atoi(testPort) testPortTwo := strconv.Itoa(testPortNum + 1) @@ -275,7 +275,7 @@ func TestTwoIndexModuleWorkers(t *testing.T) { wg.Wait() } -func TestIndexWorkerWithFileServer(t *testing.T) { +func TestPhpWorkerWithFileServer(t *testing.T) { tester := caddytest.NewTester(t) tester.InitServer(` diff --git a/caddy/config_test.go b/caddy/config_test.go index 9050ede02d..10e35485b5 100644 --- a/caddy/config_test.go +++ b/caddy/config_test.go @@ -268,7 +268,7 @@ func TestModuleWorkerWithCustomName(t *testing.T) { // Verify that the worker was added to the module require.Len(t, module.Workers, 1, "Expected one worker to be added to the module") require.Equal(t, fastTestAbs("../testdata/worker-with-env.php"), module.Workers[0].FileName, "Worker should have the correct filename") - require.False(t, module.Workers[0].IsIndex, "module worker should not be a index worker") + require.False(t, module.Workers[0].IsFallback, "module worker should not be a fallback worker") // Verify that the worker was added to moduleWorkerConfigs with the m# prefix require.Equal(t, "m#custom-worker-name", module.Workers[0].Name, "Worker should have the custom name") @@ -276,36 +276,6 @@ func TestModuleWorkerWithCustomName(t *testing.T) { resetModuleWorkers() } -func TestModuleIndexWorker(t *testing.T) { - // Create a test configuration with a custom worker name - configWithCustomName := ` - { - php { - index worker { - file ../testdata/worker-with-env.php - num 1 - } - } - }` - - // Parse the configuration - d := caddyfile.NewTestDispenser(configWithCustomName) - module := &FrankenPHPModule{} - - // Unmarshal the configuration - err := module.UnmarshalCaddyfile(d) - - // Verify that no error was returned - require.NoError(t, err, "Expected no error when configuring a worker with a custom name") - - // Verify that the worker was added to the module - require.Len(t, module.Workers, 1, "Expected one worker to be added to the module") - require.Len(t, moduleWorkerConfigs, 1, "Expected one worker to be added to the global workers") - require.True(t, module.Workers[0].IsIndex, "module worker should be index worker") - - resetModuleWorkers() -} - func fastTestAbs(path string) string { abs, _ := fastabs.FastAbs(path) return abs diff --git a/caddy/frankenphpmodule.go b/caddy/frankenphpmodule.go index 2e023264cc..1b29103359 100644 --- a/caddy/frankenphpmodule.go +++ b/caddy/frankenphpmodule.go @@ -166,7 +166,7 @@ func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c // check if the request should be handled by a module worker for _, w := range f.Workers { // always fall back to an index worker - if w.IsIndex { + if w.IsFallback { workerName = w.Name break } @@ -254,22 +254,6 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } f.ResolveRootSymlink = &v - // register the worker if 'index' is a worker file - case "index": - if !d.NextArg() { - return d.ArgErr() - } - - if d.Val() == "worker" { - wc, err := parseModuleWorker(d, f, 1) - if err != nil { - return err - } - wc.IsIndex = true - f.Workers = append(f.Workers, wc) - moduleWorkerConfigs = append(moduleWorkerConfigs, wc) - } - case "worker": wc, err := parseModuleWorker(d, f, 1) if err != nil { @@ -279,7 +263,7 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { moduleWorkerConfigs = append(moduleWorkerConfigs, wc) default: - allowedDirectives := "root, split, env, resolve_root_symlink, worker, index" + allowedDirectives := "root, split, env, resolve_root_symlink, worker" return wrongSubDirectiveError("php or php_server", allowedDirectives, d.Val()) } } @@ -690,7 +674,7 @@ func parsePhpWorkerDirective(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValu dispenser.Reset() dispenser.Next() wc, err := parseModuleWorker(dispenser, &frankenphpModule, 0) - wc.IsIndex = true + wc.IsFallback = true frankenphpModule.Workers = append(frankenphpModule.Workers, wc) moduleWorkerConfigs = append(moduleWorkerConfigs, wc) @@ -702,7 +686,7 @@ func parsePhpWorkerDirective(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValu return []httpcaddyfile.ConfigValue{ { Class: "route", - Value: getIndexWorkerWithoutFileServer(h, frankenphpModule), + Value: getIndexWorkerWithoutFileServer(frankenphpModule), }, }, nil } @@ -751,7 +735,7 @@ func getIndexWorkerSubroute(h httpcaddyfile.Helper, frankenphpModule FrankenPHPM } // without file_server, all requests go to the worker -func getIndexWorkerWithoutFileServer(h httpcaddyfile.Helper, frankenphpModule FrankenPHPModule) caddyhttp.Subroute { +func getIndexWorkerWithoutFileServer(frankenphpModule FrankenPHPModule) caddyhttp.Subroute { return caddyhttp.Subroute{ Routes: caddyhttp.RouteList{ caddyhttp.Route{ diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index ea9766f21e..e049ffe6b2 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -31,8 +31,8 @@ type workerConfig struct { Env map[string]string `json:"env,omitempty"` // Directories to watch for file changes Watch []string `json:"watch,omitempty"` - // IsIndex determines weather the worker is the first_exist_fallback - IsIndex bool `json:"is_index,omitempty"` + // IsFallback determines weather to always fall back to the worker in a module + IsFallback bool `json:"is_fallback,omitempty"` } func parseWorkerConfig(d *caddyfile.Dispenser, nestingLevel int) (workerConfig, error) { From fb3f6de4cd71babc8c0c90cce643e75cf3962d64 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 11 May 2025 21:46:05 +0200 Subject: [PATCH 10/17] trigger push --- caddy/frankenphpmodule.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/caddy/frankenphpmodule.go b/caddy/frankenphpmodule.go index 1b29103359..50bc79949d 100644 --- a/caddy/frankenphpmodule.go +++ b/caddy/frankenphpmodule.go @@ -21,7 +21,7 @@ import ( ) /* -FrankenPHPModule represents the 'php_server' and 'php' directives in the Caddyfile +FrankenPHPModule represents the 'php_server', 'php_worker' and 'php' directives in the Caddyfile they are responsible for forwarding requests to FrankenPHP via 'ServeHTTP' example.com { From ca7c32eb8c87c5356f530ee3c42a4fd1c2b16853 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 11 May 2025 21:47:04 +0200 Subject: [PATCH 11/17] trigger From ca87040bf86f00c6b281b16e5fd7771a299a8e0b Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 11 May 2025 21:54:30 +0200 Subject: [PATCH 12/17] Fixes linting. --- caddy/caddy_test.go | 16 ++++++++-------- testdata/files/.gitignore | 2 +- testdata/files/hello.json | 1 - 3 files changed, 9 insertions(+), 10 deletions(-) delete mode 100644 testdata/files/hello.json diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 287d441e74..acf7dc5ccb 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -250,16 +250,16 @@ func TestTwoPhpWorkerModules(t *testing.T) { file_server off root ../testdata/files file `+indexFileName+` - num 2 + num 2 } } } http://localhost:`+testPortTwo+` { - route { - php_worker `+indexFileName+` 2 - } - } + route { + php_worker `+indexFileName+` 2 + } + } `, "caddyfile") nbRequests := 10 @@ -293,7 +293,7 @@ func TestPhpWorkerWithFileServer(t *testing.T) { php_worker { root ../testdata/files file ../index.php - num 2 + num 2 } } } @@ -315,9 +315,9 @@ func TestPhpWorkerWithFileServer(t *testing.T) { // should respond with the file_server tester.AssertGetResponse( - "http://localhost:"+testPort+"/hello.json", + "http://localhost:"+testPort+"/hello.txt", http.StatusOK, - "{\"Hello\": \"World\"}", + "Hello World", ) // should always respond with the index worker on other PHP files diff --git a/testdata/files/.gitignore b/testdata/files/.gitignore index 2211df63dd..4871fd5275 100644 --- a/testdata/files/.gitignore +++ b/testdata/files/.gitignore @@ -1 +1 @@ -*.txt +test.txt diff --git a/testdata/files/hello.json b/testdata/files/hello.json deleted file mode 100644 index 2e57bd1eb6..0000000000 --- a/testdata/files/hello.json +++ /dev/null @@ -1 +0,0 @@ -{"Hello": "World"} \ No newline at end of file From 62cf017946a7bcb96b15c62f51438d4a93f5fff8 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 11 May 2025 21:55:56 +0200 Subject: [PATCH 13/17] Fixes linting. --- testdata/files/hello.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 testdata/files/hello.txt diff --git a/testdata/files/hello.txt b/testdata/files/hello.txt new file mode 100644 index 0000000000..5e1c309dae --- /dev/null +++ b/testdata/files/hello.txt @@ -0,0 +1 @@ +Hello World \ No newline at end of file From 423d18b6d9b72bb53377b930ffb09b2dee899e65 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 12 May 2025 21:49:32 +0200 Subject: [PATCH 14/17] Fixes merge conflict. --- caddy/frankenphpapp.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/caddy/frankenphpapp.go b/caddy/frankenphpapp.go index 085f622764..f82dfbcba8 100644 --- a/caddy/frankenphpapp.go +++ b/caddy/frankenphpapp.go @@ -230,7 +230,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } if f.MaxThreads > 0 && f.NumThreads > 0 && f.MaxThreads < f.NumThreads { - return errors.New("'max_threads' must be greater than or equal to 'num_threads'") + return errors.New(`"max_threads"" must be greater than or equal to "num_threads"`) } return nil From 1ff5ebf05bce82d959036140658cf37b06c34d5d Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 12 May 2025 21:55:42 +0200 Subject: [PATCH 15/17] Adds tests. --- caddy/caddy_test.go | 73 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index acf7dc5ccb..03a0487727 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -245,20 +245,16 @@ func TestTwoPhpWorkerModules(t *testing.T) { } http://localhost:`+testPort+` { - route { - php_worker { - file_server off - root ../testdata/files - file `+indexFileName+` - num 2 - } + php_worker { + file_server off + root ../testdata/files + file `+indexFileName+` + num 2 } } http://localhost:`+testPortTwo+` { - route { - php_worker `+indexFileName+` 2 - } + php_worker `+indexFileName+` 2 } `, "caddyfile") @@ -289,12 +285,10 @@ func TestPhpWorkerWithFileServer(t *testing.T) { } http://localhost:`+testPort+` { - route { - php_worker { - root ../testdata/files - file ../index.php - num 2 - } + php_worker { + root ../testdata/files + file ../index.php + num 2 } } `, "caddyfile") @@ -322,7 +316,52 @@ func TestPhpWorkerWithFileServer(t *testing.T) { // should always respond with the index worker on other PHP files tester.AssertGetResponse( - "http://localhost:"+testPort+"/index.php?i=3", + "http://localhost:"+testPort+"/hello.php", + http.StatusOK, + "I am by birth a Genevese (3)", + ) +} + +func TestPhpWorkerWithoutFileServer(t *testing.T) { + tester := caddytest.NewTester(t) + + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + + frankenphp { + num_threads 3 + } + } + + http://localhost:`+testPort+` { + php_worker { + root ../testdata/files + file ../index.php + file_server off + num 2 + } + } + `, "caddyfile") + + // should respond with the index worker file on random path + tester.AssertGetResponse( + "http://localhost:"+testPort+"/test123?i=1", + http.StatusOK, + "I am by birth a Genevese (1)", + ) + + // should respond with the index worker on file path + tester.AssertGetResponse( + "http://localhost:"+testPort+"/hello.txt?i=2", + http.StatusOK, + "I am by birth a Genevese (2)", + ) + + // should always respond with the index worker on other PHP files + tester.AssertGetResponse( + "http://localhost:"+testPort+"/hello.php?i=3", http.StatusOK, "I am by birth a Genevese (3)", ) From 0b878305562520104a93b27dd03c7970c5e1fcdd Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 12 May 2025 22:04:21 +0200 Subject: [PATCH 16/17] Fixes test. --- caddy/caddy_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 03a0487727..623dc197cd 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -316,7 +316,7 @@ func TestPhpWorkerWithFileServer(t *testing.T) { // should always respond with the index worker on other PHP files tester.AssertGetResponse( - "http://localhost:"+testPort+"/hello.php", + "http://localhost:"+testPort+"/hello.php?i=3", http.StatusOK, "I am by birth a Genevese (3)", ) From bde74b26aa357efb584efcdafd9b00920ad29c07 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 12 May 2025 22:07:36 +0200 Subject: [PATCH 17/17] improves test. --- caddy/caddy_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 623dc197cd..ebde716f3b 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -232,7 +232,7 @@ func TestTwoPhpWorkerModules(t *testing.T) { testPortNum, _ := strconv.Atoi(testPort) testPortTwo := strconv.Itoa(testPortNum + 1) tester := caddytest.NewTester(t) - indexFileName, _ := fastabs.FastAbs("../testdata/index.php") + indexFileName, _ := fastabs.FastAbs("../testdata/worker-with-env.php") tester.InitServer(` { @@ -247,6 +247,7 @@ func TestTwoPhpWorkerModules(t *testing.T) { http://localhost:`+testPort+` { php_worker { file_server off + env APP_ENV test root ../testdata/files file `+indexFileName+` num 2 @@ -262,9 +263,8 @@ func TestTwoPhpWorkerModules(t *testing.T) { wg.Add(nbRequests) for i := 0; i < nbRequests; i++ { go func(i int) { - num := strconv.Itoa(i) - tester.AssertGetResponse("http://localhost:"+testPort+"/some-path?i="+num, http.StatusOK, "I am by birth a Genevese ("+num+")") - tester.AssertGetResponse("http://localhost:"+testPortTwo+"/other-path?i="+num, http.StatusOK, "I am by birth a Genevese ("+num+")") + tester.AssertGetResponse("http://localhost:"+testPort+"/some-path", http.StatusOK, "Worker has APP_ENV=test") + tester.AssertGetResponse("http://localhost:"+testPortTwo+"/other-path", http.StatusOK, "Worker has APP_ENV=") wg.Done() }(i) }