Skip to content

Commit fb10b1e

Browse files
feat: worker matching (php#1646)
* Adds 'match' configuration * test * Adds Caddy's matcher. * Adds no-fileserver test. * Prevents duplicate path calculations and optimizes worker access. * trigger * Changes worker->match to match->worker * Adjusts tests. * formatting * Resets implementation to worker->match * Provisions match path rules. * Allows matching multiple paths * Fixes var * Formatting. * refactoring. * Adds 'match' configuration * test * Adds Caddy's matcher. * Adds no-fileserver test. * Prevents duplicate path calculations and optimizes worker access. * trigger * Changes worker->match to match->worker * Adjusts tests. * formatting * Resets implementation to worker->match * Provisions match path rules. * Allows matching multiple paths * Fixes var * Formatting. * refactoring. * Update frankenphp.go Co-authored-by: Kévin Dunglas <[email protected]> * Update caddy/workerconfig.go Co-authored-by: Kévin Dunglas <[email protected]> * Update caddy/workerconfig.go Co-authored-by: Kévin Dunglas <[email protected]> * Update caddy/module.go Co-authored-by: Kévin Dunglas <[email protected]> * Update caddy/module.go Co-authored-by: Kévin Dunglas <[email protected]> * Fixes suggestion * Refactoring. * Adds 'match' configuration * test * Adds Caddy's matcher. * Adds no-fileserver test. * Prevents duplicate path calculations and optimizes worker access. * trigger * Changes worker->match to match->worker * Adjusts tests. * formatting * Resets implementation to worker->match * Provisions match path rules. * Allows matching multiple paths * Fixes var * Formatting. * refactoring. * Adds docs. * Fixes merge removal. * Update config.md * go fmt. * Adds line ending to static.txt and fixes tests. * Trigger CI * fix Markdown CS --------- Co-authored-by: Alliballibaba <[email protected]> Co-authored-by: Kévin Dunglas <[email protected]>
1 parent 94c3fac commit fb10b1e

15 files changed

+332
-93
lines changed

caddy/caddy_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,3 +1316,112 @@ func TestWorkerRestart(t *testing.T) {
13161316
"frankenphp_worker_restarts",
13171317
))
13181318
}
1319+
1320+
func TestWorkerMatchDirective(t *testing.T) {
1321+
tester := caddytest.NewTester(t)
1322+
tester.InitServer(`
1323+
{
1324+
skip_install_trust
1325+
admin localhost:2999
1326+
}
1327+
1328+
http://localhost:`+testPort+` {
1329+
php_server {
1330+
root ../testdata/files
1331+
worker {
1332+
file ../worker-with-counter.php
1333+
match /matched-path*
1334+
num 1
1335+
}
1336+
}
1337+
}
1338+
`, "caddyfile")
1339+
1340+
// worker is outside of public directory, match anyways
1341+
tester.AssertGetResponse("http://localhost:"+testPort+"/matched-path", http.StatusOK, "requests:1")
1342+
tester.AssertGetResponse("http://localhost:"+testPort+"/matched-path/anywhere", http.StatusOK, "requests:2")
1343+
1344+
// 404 on unmatched paths
1345+
tester.AssertGetResponse("http://localhost:"+testPort+"/elsewhere", http.StatusNotFound, "")
1346+
1347+
// static file will be served by the fileserver
1348+
expectedFileResponse, err := os.ReadFile("../testdata/files/static.txt")
1349+
require.NoError(t, err, "static.txt file must be readable for this test")
1350+
tester.AssertGetResponse("http://localhost:"+testPort+"/static.txt", http.StatusOK, string(expectedFileResponse))
1351+
}
1352+
1353+
func TestWorkerMatchDirectiveWithMultipleWorkers(t *testing.T) {
1354+
tester := caddytest.NewTester(t)
1355+
tester.InitServer(`
1356+
{
1357+
skip_install_trust
1358+
admin localhost:2999
1359+
}
1360+
http://localhost:`+testPort+` {
1361+
php_server {
1362+
root ../testdata
1363+
worker {
1364+
file worker-with-counter.php
1365+
match /counter/*
1366+
num 1
1367+
}
1368+
worker {
1369+
file index.php
1370+
match /index/*
1371+
num 1
1372+
}
1373+
}
1374+
}
1375+
`, "caddyfile")
1376+
1377+
// match 2 workers respectively (in the public directory)
1378+
tester.AssertGetResponse("http://localhost:"+testPort+"/counter/sub-path", http.StatusOK, "requests:1")
1379+
tester.AssertGetResponse("http://localhost:"+testPort+"/index/sub-path", http.StatusOK, "I am by birth a Genevese (i not set)")
1380+
1381+
// static file will be served by the fileserver
1382+
expectedFileResponse, err := os.ReadFile("../testdata/files/static.txt")
1383+
require.NoError(t, err, "static.txt file must be readable for this test")
1384+
tester.AssertGetResponse("http://localhost:"+testPort+"/files/static.txt", http.StatusOK, string(expectedFileResponse))
1385+
1386+
// 404 if the request falls through
1387+
tester.AssertGetResponse("http://localhost:"+testPort+"/not-matched", http.StatusNotFound, "")
1388+
1389+
// serve php file directly as fallback
1390+
tester.AssertGetResponse("http://localhost:"+testPort+"/hello.php", http.StatusOK, "Hello from PHP")
1391+
1392+
// serve worker file directly as fallback
1393+
tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, "I am by birth a Genevese (i not set)")
1394+
}
1395+
1396+
func TestWorkerMatchDirectiveWithoutFileServer(t *testing.T) {
1397+
tester := caddytest.NewTester(t)
1398+
tester.InitServer(`
1399+
{
1400+
skip_install_trust
1401+
admin localhost:2999
1402+
}
1403+
1404+
http://localhost:`+testPort+` {
1405+
route {
1406+
php_server {
1407+
index off
1408+
file_server off
1409+
root ../testdata/files
1410+
worker {
1411+
file ../worker-with-counter.php
1412+
match /some-path
1413+
}
1414+
}
1415+
1416+
respond "Request falls through" 404
1417+
}
1418+
}
1419+
`, "caddyfile")
1420+
1421+
// find the worker at some-path
1422+
tester.AssertGetResponse("http://localhost:"+testPort+"/some-path", http.StatusOK, "requests:1")
1423+
1424+
// do not find the file at static.txt
1425+
// the request should completely fall through the php_server module
1426+
tester.AssertGetResponse("http://localhost:"+testPort+"/static.txt", http.StatusNotFound, "Request falls through")
1427+
}

caddy/config_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package caddy
1+
package caddy
22

33
import (
44
"testing"

caddy/module.go

Lines changed: 86 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,21 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {
6969
return fmt.Errorf(`expected ctx.App("frankenphp") to return *FrankenPHPApp, got nil`)
7070
}
7171

72+
for i, wc := range f.Workers {
73+
74+
// make the file path absolute from the public directory
75+
// this can only be done if the root is definied inside php_server
76+
if !filepath.IsAbs(wc.FileName) && f.Root != "" {
77+
wc.FileName = filepath.Join(f.Root, wc.FileName)
78+
}
79+
80+
// Inherit environment variables from the parent php_server directive
81+
if f.Env != nil {
82+
wc.inheritEnv(f.Env)
83+
}
84+
f.Workers[i] = wc
85+
}
86+
7287
workers, err := fapp.addModuleWorkers(f.Workers...)
7388
if err != nil {
7489
return err
@@ -161,12 +176,11 @@ func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c
161176
}
162177
}
163178

164-
fullScriptPath, _ := fastabs.FastAbs(documentRoot + "/" + r.URL.Path)
165-
166179
workerName := ""
167180
for _, w := range f.Workers {
168-
if p, _ := fastabs.FastAbs(w.FileName); p == fullScriptPath {
181+
if w.matchesPath(r, documentRoot) {
169182
workerName = w.Name
183+
break
170184
}
171185
}
172186

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

232246
case "worker":
233-
for d.NextBlock(1) {
234-
}
235-
for d.NextArg() {
247+
wc, err := parseWorkerConfig(d)
248+
if err != nil {
249+
return err
236250
}
237-
// Skip "worker" blocks in the first pass
238-
continue
251+
f.Workers = append(f.Workers, wc)
239252

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

247-
// Second pass: Parse only "worker" blocks
248-
d.Reset()
249-
for d.Next() {
250-
for d.NextBlock(0) {
251-
if d.Val() == "worker" {
252-
wc, err := parseWorkerConfig(d)
253-
if err != nil {
254-
return err
255-
}
256-
257-
// Inherit environment variables from the parent php_server directive
258-
if !filepath.IsAbs(wc.FileName) && f.Root != "" {
259-
wc.FileName = filepath.Join(f.Root, wc.FileName)
260-
}
261-
262-
if f.Env != nil {
263-
if wc.Env == nil {
264-
wc.Env = make(map[string]string)
265-
}
266-
for k, v := range f.Env {
267-
// Only set if not already defined in the worker
268-
if _, exists := wc.Env[k]; !exists {
269-
wc.Env[k] = v
270-
}
271-
}
272-
}
273-
274-
// Check if a worker with this filename already exists in this module
275-
for _, existingWorker := range f.Workers {
276-
if existingWorker.FileName == wc.FileName {
277-
return fmt.Errorf(`workers in a single "php_server" block must not have duplicate filenames: %q`, wc.FileName)
278-
}
279-
}
280-
281-
f.Workers = append(f.Workers, wc)
282-
}
260+
// Check if a worker with this filename already exists in this module
261+
fileNames := make(map[string]struct{}, len(f.Workers))
262+
for _, w := range f.Workers {
263+
if _, ok := fileNames[w.FileName]; ok {
264+
return fmt.Errorf(`workers in a single "php_server" block must not have duplicate filenames: %q`, w.FileName)
283265
}
266+
fileNames[w.FileName] = struct{}{}
284267
}
285268

286269
return nil
@@ -418,6 +401,13 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
418401
// unmarshaler can read it from the start
419402
dispenser.Reset()
420403

404+
// the rest of the config is specified by the user
405+
// using the php directive syntax
406+
dispenser.Next() // consume the directive name
407+
if err := phpsrv.UnmarshalCaddyfile(dispenser); err != nil {
408+
return nil, err
409+
}
410+
421411
if frankenphp.EmbeddedAppPath != "" {
422412
if phpsrv.Root == "" {
423413
phpsrv.Root = filepath.Join(frankenphp.EmbeddedAppPath, defaultDocumentRoot)
@@ -433,6 +423,9 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
433423
// set up a route list that we'll append to
434424
routes := caddyhttp.RouteList{}
435425

426+
// prepend routes from the 'worker match *' directives
427+
routes = prependWorkerRoutes(routes, h, phpsrv, fsrv, disableFsrv)
428+
436429
// set the list of allowed path segments on which to split
437430
phpsrv.SplitPath = extensions
438431

@@ -521,15 +514,6 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
521514
"path": h.JSON(pathList),
522515
}
523516

524-
// the rest of the config is specified by the user
525-
// using the php directive syntax
526-
dispenser.Next() // consume the directive name
527-
err = phpsrv.UnmarshalCaddyfile(dispenser)
528-
529-
if err != nil {
530-
return nil, err
531-
}
532-
533517
// create the PHP route which is
534518
// conditional on matching PHP files
535519
phpRoute := caddyhttp.Route{
@@ -576,3 +560,52 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
576560
},
577561
}, nil
578562
}
563+
564+
// workers can also match a path without being in the public directory
565+
// in this case we need to prepend the worker routes to the existing routes
566+
func prependWorkerRoutes(routes caddyhttp.RouteList, h httpcaddyfile.Helper, f FrankenPHPModule, fsrv caddy.Module, disableFsrv bool) caddyhttp.RouteList {
567+
allWorkerMatches := caddyhttp.MatchPath{}
568+
for _, w := range f.Workers {
569+
for _, path := range w.MatchPath {
570+
allWorkerMatches = append(allWorkerMatches, path)
571+
}
572+
}
573+
574+
if len(allWorkerMatches) == 0 {
575+
return routes
576+
}
577+
578+
// if there are match patterns, we need to check for files beforehand
579+
if !disableFsrv {
580+
routes = append(routes, caddyhttp.Route{
581+
MatcherSetsRaw: []caddy.ModuleMap{
582+
caddy.ModuleMap{
583+
"file": h.JSON(fileserver.MatchFile{
584+
TryFiles: []string{"{http.request.uri.path}"},
585+
Root: f.Root,
586+
}),
587+
"not": h.JSON(caddyhttp.MatchNot{
588+
MatcherSetsRaw: []caddy.ModuleMap{
589+
{"path": h.JSON(caddyhttp.MatchPath{"*.php"})},
590+
},
591+
}),
592+
},
593+
},
594+
HandlersRaw: []json.RawMessage{
595+
caddyconfig.JSONModuleObject(fsrv, "handler", "file_server", nil),
596+
},
597+
})
598+
}
599+
600+
// forward matching routes to the PHP handler
601+
routes = append(routes, caddyhttp.Route{
602+
MatcherSetsRaw: []caddy.ModuleMap{
603+
caddy.ModuleMap{"path": h.JSON(allWorkerMatches)},
604+
},
605+
HandlersRaw: []json.RawMessage{
606+
caddyconfig.JSONModuleObject(f, "handler", "php", nil),
607+
},
608+
})
609+
610+
return routes
611+
}

caddy/workerconfig.go

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ package caddy
22

33
import (
44
"errors"
5+
"net/http"
56
"path/filepath"
67
"strconv"
78

9+
"github.com/caddyserver/caddy/v2"
810
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
11+
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
912
"github.com/dunglas/frankenphp"
13+
"github.com/dunglas/frankenphp/internal/fastabs"
1014
)
1115

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

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

127139
return wc, nil
128140
}
141+
142+
func (wc workerConfig) inheritEnv(env map[string]string) {
143+
if wc.Env == nil {
144+
wc.Env = make(map[string]string, len(env))
145+
}
146+
for k, v := range env {
147+
// do not overwrite existing environment variables
148+
if _, exists := wc.Env[k]; !exists {
149+
wc.Env[k] = v
150+
}
151+
}
152+
}
153+
154+
func (wc workerConfig) matchesPath(r *http.Request, documentRoot string) bool {
155+
156+
// try to match against a pattern if one is assigned
157+
if len(wc.MatchPath) != 0 {
158+
return (caddyhttp.MatchPath)(wc.MatchPath).Match(r)
159+
}
160+
161+
// if there is no pattern, try to match against the actual path (in the public directory)
162+
fullScriptPath, _ := fastabs.FastAbs(documentRoot + "/" + r.URL.Path)
163+
absFileName, _ := fastabs.FastAbs(wc.FileName)
164+
165+
return fullScriptPath == absFileName
166+
}

0 commit comments

Comments
 (0)