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>
This commit is contained in:
Kévin Dunglas
2023-10-03 15:44:55 +02:00
committed by GitHub
parent 69416cc0d6
commit 2d91a606fd
6 changed files with 353 additions and 46 deletions

View File

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

View File

@@ -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)

View File

@@ -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")
}

View File

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

View File

@@ -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
testdata/hello.txt vendored Normal file
View File

@@ -0,0 +1 @@
Hello