Browse Source

feat(caddy): php_server simplified directive (#235)

* feat(caddy): php_server simplified directive

* fix typo

Co-authored-by: Matt Holt <mholt@users.noreply.github.com>

* fix

* cleanup

* Update config.md

Co-authored-by: Francis Lavoie <lavofr@gmail.com>

* feat: automatically serve static files

* file_server off

* fix tests

* fix config

* fix tests in Docker

* debug

* fix

---------

Co-authored-by: Matt Holt <mholt@users.noreply.github.com>
Co-authored-by: Francis Lavoie <lavofr@gmail.com>
Kévin Dunglas 1 year ago
parent
commit
2d91a606fd
6 changed files with 353 additions and 46 deletions
  1. 1 0
      .dockerignore
  2. 245 1
      caddy/caddy.go
  3. 50 0
      caddy/caddy_test.go
  4. 12 34
      caddy/frankenphp/Caddyfile
  5. 44 11
      docs/config.md
  6. 1 0
      testdata/hello.txt

+ 1 - 0
.dockerignore

@@ -8,3 +8,4 @@
 !**/*.c
 !**/*.h
 !testdata/*.php
+!testdata/*.txt

+ 245 - 1
caddy/caddy.go

@@ -4,6 +4,7 @@
 package caddy
 
 import (
+	"encoding/json"
 	"errors"
 	"net/http"
 	"strconv"
@@ -13,6 +14,8 @@ import (
 	"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"
 	"go.uber.org/zap"
 )
@@ -22,6 +25,7 @@ func init() {
 	caddy.RegisterModule(FrankenPHPModule{})
 	httpcaddyfile.RegisterGlobalOption("frankenphp", parseGlobalOption)
 	httpcaddyfile.RegisterHandlerDirective("php", parseCaddyfile)
+	httpcaddyfile.RegisterDirective("php_server", parsePhpServer)
 }
 
 type mainPHPinterpreterKeyType int
@@ -283,12 +287,252 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
 
 // parseCaddyfile unmarshals tokens from h into a new Middleware.
 func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
-	var m FrankenPHPModule
+	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
+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
+
+	// 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
+	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()
+				dispenser.DeleteN(len(args) + 1)
+				if len(args) != 1 {
+					return nil, dispenser.ArgErr()
+				}
+				indexFile = args[0]
+
+			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
+			}
+		}
+	}
+
+	// reset the dispenser after we're done so that the frankenphp
+	// unmarshaler can read it from the start
+	dispenser.Reset()
+
+	// 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
+
+	// if the index is turned off, we skip the redirect and try_files
+	if indexFile != "off" {
+		// route to redirect to canonical path if index PHP file
+		redirMatcherSet := caddy.ModuleMap{
+			"file": h.JSON(fileserver.MatchFile{
+				TryFiles: []string{"{http.request.uri.path}/" + indexFile},
+			}),
+			"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)},
+		}
+
+		// if tryFiles wasn't overridden, use a reasonable default
+		if len(tryFiles) == 0 {
+			tryFiles = []string{"{http.request.uri.path}", "{http.request.uri.path}/" + indexFile, indexFile}
+		}
+
+		// route to rewrite to PHP index file
+		rewriteMatcherSet := caddy.ModuleMap{
+			"file": h.JSON(fileserver.MatchFile{
+				TryFiles:  tryFiles,
+				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, redirRoute, rewriteRoute)
+	}
+
+	// route to actually pass requests to PHP files;
+	// match only requests that are for PHP files
+	pathList := []string{}
+	for _, ext := range extensions {
+		pathList = append(pathList, "*"+ext)
+	}
+	phpMatcherSet := caddy.ModuleMap{
+		"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{
+		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
+}
+
 // Interface guards
 var (
 	_ caddy.App                   = (*FrankenPHPApp)(nil)

+ 50 - 0
caddy/caddy_test.go

@@ -139,3 +139,53 @@ func TestEnv(t *testing.T) {
 
 	tester.AssertGetResponse("http://localhost:9080/env.php", http.StatusOK, "bazbar")
 }
+
+func TestPHPServerDirective(t *testing.T) {
+	tester := caddytest.NewTester(t)
+	tester.InitServer(`
+		{
+			skip_install_trust
+			admin localhost:2999
+			http_port 9080
+			https_port 9443
+
+			frankenphp
+			order php_server before reverse_proxy
+		}
+
+		localhost:9080 {
+			root * ../testdata
+			php_server
+		}
+		`, "caddyfile")
+
+	tester.AssertGetResponse("http://localhost:9080", http.StatusOK, "I am by birth a Genevese (i not set)")
+	tester.AssertGetResponse("http://localhost:9080/hello.txt", http.StatusOK, "Hello")
+	tester.AssertGetResponse("http://localhost:9080/not-found.txt", http.StatusOK, "I am by birth a Genevese (i not set)")
+}
+
+func TestPHPServerDirectiveDisableFileServer(t *testing.T) {
+	tester := caddytest.NewTester(t)
+	tester.InitServer(`
+		{
+			skip_install_trust
+			admin localhost:2999
+			http_port 9080
+			https_port 9443
+
+			frankenphp
+			order php_server before respond
+		}
+
+		localhost:9080 {
+			root * ../testdata
+			php_server {
+				file_server off
+			}
+			respond "Not found" 404
+		}
+		`, "caddyfile")
+
+	tester.AssertGetResponse("http://localhost:9080", http.StatusOK, "I am by birth a Genevese (i not set)")
+	tester.AssertGetResponse("http://localhost:9080/hello.txt", http.StatusNotFound, "Not found")
+}

+ 12 - 34
caddy/frankenphp/Caddyfile

@@ -5,26 +5,21 @@
 		#worker /path/to/your/worker.php
 		{$FRANKENPHP_CONFIG}
 	}
+	order php_server before reverse_proxy
 }
 
-{$SERVER_NAME:localhost}
-
-log {
-	# Redact the authorization query parameter that can be set by Mercure
-	format filter {
-		wrap console
-		fields {
-			uri query {
-				replace authorization REDACTED
+{$SERVER_NAME:localhost} {
+	log {
+		# Redact the authorization query parameter that can be set by Mercure
+		format filter {
+			wrap console
+			fields {
+				uri query {
+					replace authorization REDACTED
+				}
 			}
 		}
 	}
-}
-
-route {
-	# Healthcheck URL
-	skip_log /healthz
-	respond /healthz 200
 
 	root * public/
 	# Uncomment the following lines to enable Mercure and Vulcain modules
@@ -44,25 +39,8 @@ route {
 	#}
 	#vulcain
 
-	# Add trailing slash for directory requests
-	@canonicalPath {
-		file {path}/index.php
-		not path */
-	}
-	redir @canonicalPath {path}/ 308
+	{$SERVER_EXTRA_DIRECTIVES}
 
-	# 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
 	encode zstd gzip
-	file_server
-
-	respond 404
+	php_server
 }

+ 44 - 11
docs/config.md

@@ -19,25 +19,23 @@ RUN cp $PHP_INI_DIR/php.ini-production $PHP_INI_DIR/php.ini; \
 
 ## Caddy Directives
 
-To register the FrankenPHP executor, the `frankenphp` directive must be set in Caddy global options, then the `php` HTTP directive must be set under routes serving PHP scripts:
+To register the FrankenPHP executor, the `frankenphp` directive must be set in Caddy global options, then the `php_server` or the `php` HTTP directives must be set under routes serving PHP scripts.
 
-
-Then, you can use the `php` HTTP directive to execute PHP scripts:
+Minimal example:
 
 ```caddyfile
 {
+    # Enable FrankenPHP
     frankenphp
+    # Configure when the directive must be executed
+    order php_server before reverse_proxy
 }
 
 localhost {
-    route {
-        php {
-            root <directory> # Sets the root folder to the site. Default: `root` directive.
-            split_path <delim...> # 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`
-            resolve_root_symlink # Enables resolving the `root` directory to its actual value by evaluating a symbolic link, if one exists.
-            env <key> <value> # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables.
-        }
-    }
+    # Enable compression (optional)
+    encode zstd gzip
+    # Execute PHP files in the current directory and serve assets
+    php_server
 }
 ```
 
@@ -70,6 +68,41 @@ Alternatively, the short form of the `worker` directive can also be used:
 # ...
 ```
 
+Using the `php_server` directive is generaly what you need,
+but if you need full control, you can use the lower level `php` directive:
+
+Using the `php_server` directive is equivalent to this configuration:
+
+```caddyfile
+# 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
+```
+
+The `php_server` and the `php` directives have the following options:
+
+```caddyfile
+php_server [<matcher>] {
+    root <directory> # Sets the root folder to the site. Default: `root` directive.
+    split_path <delim...> # 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`
+    resolve_root_symlink # Enables resolving the `root` directory to its actual value by evaluating a symbolic link, if one exists.
+    env <key> <value> # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables.
+}
+```
+
 ## Environment Variables
 
 The following environment variables can be used to inject Caddy directives in the `Caddyfile` without modifying it:

+ 1 - 0
testdata/hello.txt

@@ -0,0 +1 @@
+Hello