Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
84cd988
Adds 'match' configuration
AlliBalliBaba Jun 9, 2025
dbd66ec
test
AlliBalliBaba Jun 9, 2025
1e8bef1
Adds Caddy's matcher.
AlliBalliBaba Jun 10, 2025
fdbee0f
Adds no-fileserver test.
AlliBalliBaba Jun 11, 2025
b383af9
Prevents duplicate path calculations and optimizes worker access.
AlliBalliBaba Jun 11, 2025
9b7ef4d
trigger
AlliBalliBaba Jun 11, 2025
09d8da8
Changes worker->match to match->worker
AlliBalliBaba Jun 11, 2025
6acc01e
Adjusts tests.
AlliBalliBaba Jun 11, 2025
b93f261
formatting
AlliBalliBaba Jun 11, 2025
1393be5
Resets implementation to worker->match
AlliBalliBaba Jun 13, 2025
ff4d18c
Provisions match path rules.
AlliBalliBaba Jun 13, 2025
608d175
Allows matching multiple paths
AlliBalliBaba Jun 13, 2025
8cda5d9
Fixes var
AlliBalliBaba Jun 13, 2025
b9021fd
Formatting.
AlliBalliBaba Jun 13, 2025
245ad76
refactoring.
AlliBalliBaba Jun 14, 2025
9ec9560
Adds 'match' configuration
AlliBalliBaba Jun 9, 2025
4047b3f
test
AlliBalliBaba Jun 9, 2025
03faa72
Adds Caddy's matcher.
AlliBalliBaba Jun 10, 2025
081327e
Adds no-fileserver test.
AlliBalliBaba Jun 11, 2025
b5a1f76
Prevents duplicate path calculations and optimizes worker access.
AlliBalliBaba Jun 11, 2025
6de13d4
trigger
AlliBalliBaba Jun 11, 2025
b9adf63
Changes worker->match to match->worker
AlliBalliBaba Jun 11, 2025
847f4d5
Adjusts tests.
AlliBalliBaba Jun 11, 2025
9b92725
formatting
AlliBalliBaba Jun 11, 2025
570069b
Resets implementation to worker->match
AlliBalliBaba Jun 13, 2025
6dd533d
Provisions match path rules.
AlliBalliBaba Jun 13, 2025
5a1ecf2
Allows matching multiple paths
AlliBalliBaba Jun 13, 2025
ab26c9e
Fixes var
AlliBalliBaba Jun 13, 2025
cfeaf07
Formatting.
AlliBalliBaba Jun 13, 2025
6062f02
refactoring.
AlliBalliBaba Jun 14, 2025
e295a68
Merge branch 'main' into feat/worker-matching
AlliBalliBaba Jun 25, 2025
ef304a7
Merge branch 'feat/worker-matching' of https://github.com/dunglas/fra…
AlliBalliBaba Jun 25, 2025
c9cc0c5
Update frankenphp.go
AlliBalliBaba Jun 25, 2025
6367456
Update caddy/workerconfig.go
AlliBalliBaba Jun 25, 2025
771099c
Update caddy/workerconfig.go
AlliBalliBaba Jun 25, 2025
16d7877
Update caddy/module.go
AlliBalliBaba Jun 25, 2025
94ffc39
Update caddy/module.go
AlliBalliBaba Jun 25, 2025
1da86cb
Merge branch 'feat/worker-matching' of https://github.com/dunglas/fra…
AlliBalliBaba Jun 25, 2025
0fcb920
Fixes suggestion
AlliBalliBaba Jun 25, 2025
d3d8405
Refactoring.
AlliBalliBaba Jun 25, 2025
23606e6
Adds 'match' configuration
AlliBalliBaba Jun 9, 2025
226cafe
test
AlliBalliBaba Jun 9, 2025
ea9a9fe
Adds Caddy's matcher.
AlliBalliBaba Jun 10, 2025
db66a76
Adds no-fileserver test.
AlliBalliBaba Jun 11, 2025
e634358
Prevents duplicate path calculations and optimizes worker access.
AlliBalliBaba Jun 11, 2025
30427d3
trigger
AlliBalliBaba Jun 11, 2025
1ed4504
Changes worker->match to match->worker
AlliBalliBaba Jun 11, 2025
36820c3
Adjusts tests.
AlliBalliBaba Jun 11, 2025
6833933
formatting
AlliBalliBaba Jun 11, 2025
1ecb44c
Resets implementation to worker->match
AlliBalliBaba Jun 13, 2025
da23e7c
Provisions match path rules.
AlliBalliBaba Jun 13, 2025
d3fb25d
Allows matching multiple paths
AlliBalliBaba Jun 13, 2025
6e5350e
Fixes var
AlliBalliBaba Jun 13, 2025
a95faf4
Formatting.
AlliBalliBaba Jun 13, 2025
b8ad01a
refactoring.
AlliBalliBaba Jun 14, 2025
eb9b02a
Adds docs.
AlliBalliBaba Jun 28, 2025
3b8c80a
Merge branch 'feat/worker-matching' of https://github.com/dunglas/fra…
AlliBalliBaba Jun 28, 2025
48bb34c
Fixes merge removal.
AlliBalliBaba Jun 28, 2025
3927399
Update config.md
AlliBalliBaba Jun 29, 2025
ee2d4ac
Merge branch 'main' into feat/worker-matching
AlliBalliBaba Jun 30, 2025
949295b
go fmt.
AlliBalliBaba Jun 30, 2025
4adfa38
Merge branch 'feat/worker-matching' of https://github.com/dunglas/fra…
AlliBalliBaba Jun 30, 2025
fd18aa4
Adds line ending to static.txt and fixes tests.
AlliBalliBaba Jun 30, 2025
a11c17d
Trigger CI
dunglas Jun 30, 2025
a9a1589
fix Markdown CS
dunglas Jun 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions caddy/caddy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1316,3 +1316,112 @@ func TestWorkerRestart(t *testing.T) {
"frankenphp_worker_restarts",
))
}

func TestWorkerMatchDirective(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`
{
skip_install_trust
admin localhost:2999
}

http://localhost:`+testPort+` {
php_server {
root ../testdata/files
worker {
file ../worker-with-counter.php
match /matched-path*
num 1
}
}
}
`, "caddyfile")

// worker is outside of public directory, match anyways
tester.AssertGetResponse("http://localhost:"+testPort+"/matched-path", http.StatusOK, "requests:1")
tester.AssertGetResponse("http://localhost:"+testPort+"/matched-path/anywhere", http.StatusOK, "requests:2")

// 404 on unmatched paths
tester.AssertGetResponse("http://localhost:"+testPort+"/elsewhere", http.StatusNotFound, "")

// static file will be served by the fileserver
expectedFileResponse, err := os.ReadFile("../testdata/files/static.txt")
require.NoError(t, err, "static.txt file must be readable for this test")
tester.AssertGetResponse("http://localhost:"+testPort+"/static.txt", http.StatusOK, string(expectedFileResponse))
}

func TestWorkerMatchDirectiveWithMultipleWorkers(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`
{
skip_install_trust
admin localhost:2999
}
http://localhost:`+testPort+` {
php_server {
root ../testdata
worker {
file worker-with-counter.php
match /counter/*
num 1
}
worker {
file index.php
match /index/*
num 1
}
}
}
`, "caddyfile")

// match 2 workers respectively (in the public directory)
tester.AssertGetResponse("http://localhost:"+testPort+"/counter/sub-path", http.StatusOK, "requests:1")
tester.AssertGetResponse("http://localhost:"+testPort+"/index/sub-path", http.StatusOK, "I am by birth a Genevese (i not set)")

// static file will be served by the fileserver
expectedFileResponse, err := os.ReadFile("../testdata/files/static.txt")
require.NoError(t, err, "static.txt file must be readable for this test")
tester.AssertGetResponse("http://localhost:"+testPort+"/files/static.txt", http.StatusOK, string(expectedFileResponse))

// 404 if the request falls through
tester.AssertGetResponse("http://localhost:"+testPort+"/not-matched", http.StatusNotFound, "")

// serve php file directly as fallback
tester.AssertGetResponse("http://localhost:"+testPort+"/hello.php", http.StatusOK, "Hello from PHP")

// serve worker file directly as fallback
tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, "I am by birth a Genevese (i not set)")
}

func TestWorkerMatchDirectiveWithoutFileServer(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`
{
skip_install_trust
admin localhost:2999
}

http://localhost:`+testPort+` {
route {
php_server {
index off
file_server off
root ../testdata/files
worker {
file ../worker-with-counter.php
match /some-path
}
}

respond "Request falls through" 404
}
}
`, "caddyfile")

// find the worker at some-path
tester.AssertGetResponse("http://localhost:"+testPort+"/some-path", http.StatusOK, "requests:1")

// do not find the file at static.txt
// the request should completely fall through the php_server module
tester.AssertGetResponse("http://localhost:"+testPort+"/static.txt", http.StatusNotFound, "Request falls through")
}
2 changes: 1 addition & 1 deletion caddy/config_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package caddy
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the change here? Invisible char?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a BOM?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be a line ending, was the result of go fmt

package caddy

import (
"testing"
Expand Down
139 changes: 86 additions & 53 deletions caddy/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {
return fmt.Errorf(`expected ctx.App("frankenphp") to return *FrankenPHPApp, got nil`)
}

for i, wc := range f.Workers {

// make the file path absolute from the public directory
// this can only be done if the root is definied inside php_server
if !filepath.IsAbs(wc.FileName) && f.Root != "" {
wc.FileName = filepath.Join(f.Root, wc.FileName)
}
Comment on lines +76 to +78
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a potential subtle bug when the root is "" and the worker path is not an absolute path.

Also, the comment above doesn't match the code :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm yeah this code was already there, I just moved it from Parsing to Provisioning. I think the root being empty means that it is defined outside of the php_server directive, so we cannot make the path absolute at this point. At least that's what I assume @henderkes

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see the bug here - when root is "" the if statement is not entered and the worker path will later be resolved in the current working directory, just like with global workers.

There's no way to resolve it to the root defined outside the php_server block, because workers are started before the first request.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this is the currently expected behavior. A bit unfortunate, but it's not possible to always determine the root at provisioning.

I guess it would also be possible to first try from the public path and if there is no script, try from the working directory, something for a future PR.


// Inherit environment variables from the parent php_server directive
if f.Env != nil {
wc.inheritEnv(f.Env)
}
f.Workers[i] = wc
}

workers, err := fapp.addModuleWorkers(f.Workers...)
if err != nil {
return err
Expand Down Expand Up @@ -161,12 +176,11 @@ 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 {
if w.matchesPath(r, documentRoot) {
workerName = w.Name
break
}
}

Expand Down Expand Up @@ -230,12 +244,11 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
f.ResolveRootSymlink = &v

case "worker":
for d.NextBlock(1) {
}
for d.NextArg() {
wc, err := parseWorkerConfig(d)
if err != nil {
return err
}
// Skip "worker" blocks in the first pass
continue
f.Workers = append(f.Workers, wc)

default:
allowedDirectives := "root, split, env, resolve_root_symlink, worker"
Expand All @@ -244,43 +257,13 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
}
}

// Second pass: Parse only "worker" blocks
d.Reset()
for d.Next() {
for d.NextBlock(0) {
if d.Val() == "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)
}

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
}
}
}

// 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)
}
}

f.Workers = append(f.Workers, wc)
}
// Check if a worker with this filename already exists in this module
fileNames := make(map[string]struct{}, len(f.Workers))
for _, w := range f.Workers {
if _, ok := fileNames[w.FileName]; ok {
return fmt.Errorf(`workers in a single "php_server" block must not have duplicate filenames: %q`, w.FileName)
}
fileNames[w.FileName] = struct{}{}
}

return nil
Expand Down Expand Up @@ -418,6 +401,13 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
// 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
if err := phpsrv.UnmarshalCaddyfile(dispenser); err != nil {
return nil, err
}

if frankenphp.EmbeddedAppPath != "" {
if phpsrv.Root == "" {
phpsrv.Root = filepath.Join(frankenphp.EmbeddedAppPath, defaultDocumentRoot)
Expand All @@ -433,6 +423,9 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
// set up a route list that we'll append to
routes := caddyhttp.RouteList{}

// prepend routes from the 'worker match *' directives
routes = prependWorkerRoutes(routes, h, phpsrv, fsrv, disableFsrv)

// set the list of allowed path segments on which to split
phpsrv.SplitPath = extensions

Expand Down Expand Up @@ -521,15 +514,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{
Expand Down Expand Up @@ -576,3 +560,52 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
},
}, nil
}

// workers can also match a path without being in the public directory
// in this case we need to prepend the worker routes to the existing routes
func prependWorkerRoutes(routes caddyhttp.RouteList, h httpcaddyfile.Helper, f FrankenPHPModule, fsrv caddy.Module, disableFsrv bool) caddyhttp.RouteList {
allWorkerMatches := caddyhttp.MatchPath{}
for _, w := range f.Workers {
for _, path := range w.MatchPath {
allWorkerMatches = append(allWorkerMatches, path)
}
}

if len(allWorkerMatches) == 0 {
return routes
}

// if there are match patterns, we need to check for files beforehand
if !disableFsrv {
routes = append(routes, caddyhttp.Route{
MatcherSetsRaw: []caddy.ModuleMap{
caddy.ModuleMap{
"file": h.JSON(fileserver.MatchFile{
TryFiles: []string{"{http.request.uri.path}"},
Root: f.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),
},
})
}

// forward matching routes to the PHP handler
routes = append(routes, caddyhttp.Route{
MatcherSetsRaw: []caddy.ModuleMap{
caddy.ModuleMap{"path": h.JSON(allWorkerMatches)},
},
HandlersRaw: []json.RawMessage{
caddyconfig.JSONModuleObject(f, "handler", "php", nil),
},
})

return routes
}
40 changes: 39 additions & 1 deletion caddy/workerconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ package caddy

import (
"errors"
"net/http"
"path/filepath"
"strconv"

"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/dunglas/frankenphp"
"github.com/dunglas/frankenphp/internal/fastabs"
)

// workerConfig represents the "worker" directive in the Caddyfile
Expand All @@ -29,6 +33,8 @@ type workerConfig struct {
Env map[string]string `json:"env,omitempty"`
// Directories to watch for file changes
Watch []string `json:"watch,omitempty"`
// The path to match against the worker
MatchPath []string `json:"match_path,omitempty"`
// MaxConsecutiveFailures sets the maximum number of consecutive failures before panicking (defaults to 6, set to -1 to never panick)
MaxConsecutiveFailures int `json:"max_consecutive_failures,omitempty"`
}
Expand Down Expand Up @@ -96,6 +102,12 @@ func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) {
} else {
wc.Watch = append(wc.Watch, d.Val())
}
case "match":
// provision the path so it's identical to Caddy match rules
// see: https://github.com/caddyserver/caddy/blob/master/modules/caddyhttp/matchers.go
caddyMatchPath := (caddyhttp.MatchPath)(d.RemainingArgs())
caddyMatchPath.Provision(caddy.Context{})
wc.MatchPath = ([]string)(caddyMatchPath)
case "max_consecutive_failures":
if !d.NextArg() {
return wc, d.ArgErr()
Expand All @@ -111,7 +123,7 @@ func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) {

wc.MaxConsecutiveFailures = int(v)
default:
allowedDirectives := "name, file, num, env, watch, max_consecutive_failures"
allowedDirectives := "name, file, num, env, watch, match, max_consecutive_failures"
return wc, wrongSubDirectiveError("worker", allowedDirectives, v)
}
}
Expand All @@ -126,3 +138,29 @@ func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) {

return wc, nil
}

func (wc workerConfig) inheritEnv(env map[string]string) {
if wc.Env == nil {
wc.Env = make(map[string]string, len(env))
}
for k, v := range env {
// do not overwrite existing environment variables
if _, exists := wc.Env[k]; !exists {
wc.Env[k] = v
}
}
}

func (wc workerConfig) matchesPath(r *http.Request, documentRoot string) bool {

// try to match against a pattern if one is assigned
if len(wc.MatchPath) != 0 {
return (caddyhttp.MatchPath)(wc.MatchPath).Match(r)
}

// if there is no pattern, try to match against the actual path (in the public directory)
fullScriptPath, _ := fastabs.FastAbs(documentRoot + "/" + r.URL.Path)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this work with symlinks (see #1637)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the fallback to the previous logic, so if there was a bug with symlinks before, then it probably will still exist. In general, documentRoot should be the symlink resolved document root though.

Copy link
Contributor Author

@AlliBalliBaba AlliBalliBaba Jun 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When using the new matching logic though, it should always work, regardless of symlinks.

absFileName, _ := fastabs.FastAbs(wc.FileName)

return fullScriptPath == absFileName
}
Loading
Loading