feat: add a woker mode (#1)

* refactor: better memory management

* wip

* tmp

* introduce a go-like api

* upgraded to PHP 8.2

* Fix thread safety issues

* fix tests

* wip

* refactor worker

* worker prototype

* fix populate env

* session

* improve tests

* fix Caddy tests

* refactor
This commit is contained in:
Kévin Dunglas
2022-05-18 11:52:24 +02:00
committed by GitHub
parent 0d1ff2c438
commit 7d81fa51fe
25 changed files with 1816 additions and 748 deletions

View File

@@ -1,6 +1,6 @@
FROM golang
ARG PHP_VERSION=8.0.11
ARG PHP_VERSION=8.1.5
# Sury doesn't provide ZTS builds for now
#RUN apt-get update && \
@@ -8,7 +8,7 @@ ARG PHP_VERSION=8.0.11
# wget -O /etc/apt/trusted.gpg.d/php.gpg https://packages.sury.org/php/apt.gpg && \
# sh -c 'echo "deb https://packages.sury.org/php/ $(lsb_release -sc) main" > /etc/apt/sources.list.d/php.list' && \
# apt-get update && \
# apt-get -y --no-install-recommends install php8.0-dev && \
# apt-get -y --no-install-recommends install php8.1-dev && \
# apt-get -y remove apt-transport-https lsb-release && \
# apt-get clean all
#ENV CGO_CFLAGS="-I /usr/include/php/20200930 -I /usr/include/php/20200930/Zend -I /usr/include/php/20200930/TSRM -I /usr/include/php/20200930/main -I /usr/include/php/20200930/sapi/embed"
@@ -32,7 +32,7 @@ WORKDIR /go/src/app
COPY . .
RUN go get -d -v ./...
#RUN go build -v
RUN go build -v
#RUN cd cmd/frankenphp && go install -v ./...
#CMD ["frankenphp"]

View File

@@ -15,7 +15,7 @@ docker build -t frankenphp .
#### Install PHP
Most distributions doesn't provide packages containing ZTS builds of PHP.
Most distributions don't provide packages containing ZTS builds of PHP.
Because the Go HTTP server uses goroutines, a ZTS build is needed.
Start by [downloading the latest version of PHP](https://www.php.net/downloads.php),
@@ -24,42 +24,31 @@ then follow the instructions according to your operating system.
##### Linux
```
./configure --enable-embed --enable-zts
make
./configure \
--enable-embed=static \
--enable-zts
make -j6
make install
```
##### Mac
The instructions to build on Mac and Linux are similar.
However, on Mac, you have to use the [Homebrew](https://brew.sh/) package manager to install `libiconv` and `bison`.
You also need to slightly tweak the configuration.
```
brew install libiconv
brew install libiconv bison
echo 'export PATH="/opt/homebrew/opt/bison/bin:$PATH"' >> ~/.zshrc
./configure \
--enable-zts \
--enable-embed=static \
--enable-zts \
--with-iconv=/opt/homebrew/opt/libiconv/ \
--without-pcre-jit
make
make -j6
make install
```
Then, you also need to build a Mac-compatible PHP shared library.
As the standard PHP distribution doesn't provide one, you need to do a few extra steps:
Start by adding those lines at the end of the `Makefile`:
```
libs/libphp.dylib: $(PHP_GLOBAL_OBJS) $(PHP_SAPI_OBJS)
$(LIBTOOL) --mode=link $(CC) -dynamiclib $(LIBPHP_CFLAGS) $(CFLAGS_CLEAN) $(EXTRA_CFLAGS) -rpath $(phptempdir) $(EXTRA_LDFLAGS) $(LDFLAGS) $(PHP_RPATHS) $(PHP_GLOBAL_OBJS) $(PHP_SAPI_OBJS) $(EXTRA_LIBS) $(ZEND_EXTRA_LIBS) -o $@
-@$(LIBTOOL) --silent --mode=install cp $@ $(phptempdir)/$@ >/dev/null 2>&1
```
Then run:
```
make libs/libphp.dylib
sudo cp libs/libphp.dylib /usr/local/lib/
```
#### Compile the Go App
```

View File

@@ -50,6 +50,7 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {
f.logger = ctx.Logger(f)
_, _, err := php.LoadOrNew("php", func() (caddy.Destructor, error) {
frankenphp.Startup()
return &phpDestructor{}, nil
})
if err != nil {
@@ -80,7 +81,7 @@ func (f FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, next
documentRoot := repl.ReplaceKnown(f.Root, "")
fr := frankenphp.NewRequestWithContext(r, documentRoot)
fc := fr.Context().Value(frankenphp.FrankenPHPContextKey).(*frankenphp.FrankenPHPContext)
fc, _ := frankenphp.FromContext(fr.Context())
fc.ResolveRootSymlink = f.ResolveRootSymlink
fc.SplitPath = f.SplitPath

View File

@@ -31,7 +31,7 @@ func TestPHP(t *testing.T) {
}
rewrite @indexFiles {http.matchers.file.relative}
# Proxy PHP files to the FastCGI responder
# Handle PHP files with FrankenPHP
@phpFiles path *.php
php @phpFiles

View File

@@ -1,112 +1,136 @@
module github.com/dunglas/frankenphp/caddy
go 1.17
go 1.18
replace github.com/dunglas/frankenphp => ../
require (
github.com/caddyserver/caddy/v2 v2.4.5
github.com/caddyserver/caddy/v2 v2.5.1
github.com/dunglas/frankenphp v0.0.0-00010101000000-000000000000
go.uber.org/zap v1.19.1
go.uber.org/zap v1.21.0
)
require (
filippo.io/edwards25519 v1.0.0-rc.1 // indirect
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
github.com/BurntSushi/toml v1.0.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.1.1 // indirect
github.com/Masterminds/sprig/v3 v3.2.2 // indirect
github.com/alecthomas/chroma v0.9.2 // indirect
github.com/alecthomas/chroma v0.10.0 // indirect
github.com/antlr/antlr4 v0.0.0-20200503195918-621b933c7a7f // indirect
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/caddyserver/certmagic v0.14.5 // indirect
github.com/caddyserver/certmagic v0.16.1 // indirect
github.com/cenkalti/backoff/v4 v4.1.2 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.1.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/cheekybits/genny v1.0.0 // indirect
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect
github.com/dgraph-io/badger v1.6.2 // indirect
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
github.com/dgraph-io/ristretto v0.0.4-0.20200906165740-41ebdbffecfd // indirect
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
github.com/dlclark/regexp2 v1.4.0 // indirect
github.com/dustin/go-humanize v1.0.1-0.20200219035652-afde56e7acac // indirect
github.com/fsnotify/fsnotify v1.4.9 // indirect
github.com/felixge/httpsnoop v1.0.2 // indirect
github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/go-chi/chi v4.1.2+incompatible // indirect
github.com/go-kit/kit v0.10.0 // indirect
github.com/go-logfmt/logfmt v0.5.0 // indirect
github.com/go-logfmt/logfmt v0.5.1 // indirect
github.com/go-logr/logr v1.2.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.3 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/cel-go v0.7.3 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/huandu/xstrings v1.3.1 // indirect
github.com/imdario/mergo v0.3.11 // indirect
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a // indirect
github.com/klauspost/compress v1.13.4 // indirect
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.10.1 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.2.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
github.com/jackc/pgtype v1.9.0 // indirect
github.com/jackc/pgx/v4 v4.14.0 // indirect
github.com/klauspost/compress v1.15.0 // indirect
github.com/klauspost/cpuid/v2 v2.0.12 // indirect
github.com/libdns/libdns v0.2.1 // indirect
github.com/lucas-clemente/quic-go v0.23.0 // indirect
github.com/lunixbochs/vtclean v1.0.0 // indirect
github.com/manifoldco/promptui v0.8.0 // indirect
github.com/lucas-clemente/quic-go v0.26.0 // indirect
github.com/manifoldco/promptui v0.9.0 // indirect
github.com/marten-seemann/qpack v0.2.1 // indirect
github.com/marten-seemann/qtls-go1-16 v0.1.4 // indirect
github.com/marten-seemann/qtls-go1-17 v0.1.0 // indirect
github.com/marten-seemann/qtls-go1-16 v0.1.5 // indirect
github.com/marten-seemann/qtls-go1-17 v0.1.1 // indirect
github.com/marten-seemann/qtls-go1-18 v0.1.1 // indirect
github.com/mattn/go-colorable v0.1.8 // indirect
github.com/mattn/go-isatty v0.0.13 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mholt/acmez v1.0.0 // indirect
github.com/micromdm/scep/v2 v2.0.0 // indirect
github.com/miekg/dns v1.1.42 // indirect
github.com/mitchellh/copystructure v1.0.0 // indirect
github.com/mitchellh/reflectwalk v1.0.1 // indirect
github.com/naoina/go-stringutil v0.1.0 // indirect
github.com/naoina/toml v0.1.1 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/mholt/acmez v1.0.2 // indirect
github.com/micromdm/scep/v2 v2.1.0 // indirect
github.com/miekg/dns v1.1.49 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/nxadm/tail v1.4.8 // indirect
github.com/onsi/ginkgo v1.16.4 // indirect
github.com/onsi/ginkgo v1.16.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.11.0 // indirect
github.com/prometheus/client_golang v1.12.2 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.26.0 // indirect
github.com/prometheus/procfs v0.6.0 // indirect
github.com/prometheus/common v0.34.0 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/rs/xid v1.2.1 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/samfoo/ansi v0.0.0-20160124022901-b6bd2ded7189 // indirect
github.com/shopspring/decimal v1.2.0 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/sirupsen/logrus v1.7.0 // indirect
github.com/smallstep/certificates v0.16.4 // indirect
github.com/smallstep/cli v0.16.1 // indirect
github.com/smallstep/nosql v0.3.8 // indirect
github.com/smallstep/truststore v0.9.6 // indirect
github.com/spf13/cast v1.3.1 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/slackhq/nebula v1.5.2 // indirect
github.com/smallstep/certificates v0.19.0 // indirect
github.com/smallstep/cli v0.18.0 // indirect
github.com/smallstep/nosql v0.4.0 // indirect
github.com/smallstep/truststore v0.11.0 // indirect
github.com/spf13/cast v1.4.1 // indirect
github.com/stoewer/go-strcase v1.2.0 // indirect
github.com/tailscale/tscert v0.0.0-20220125204807-4509a5fbaf74 // indirect
github.com/urfave/cli v1.22.5 // indirect
github.com/yuin/goldmark v1.4.0 // indirect
github.com/yuin/goldmark-highlighting v0.0.0-20210516132338-9216f9c5aa01 // indirect
github.com/yuin/goldmark v1.4.8 // indirect
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 // indirect
go.etcd.io/bbolt v1.3.6 // indirect
go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1 // indirect
go.step.sm/cli-utils v0.4.1 // indirect
go.step.sm/crypto v0.9.0 // indirect
go.step.sm/linkedca v0.0.0-20210611183751-27424aae8d25 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e // indirect
golang.org/x/mod v0.4.2 // indirect
golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
golang.org/x/term v0.0.0-20210503060354-a79de5458b56 // indirect
golang.org/x/text v0.3.6 // indirect
golang.org/x/tools v0.1.5 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08 // indirect
google.golang.org/grpc v1.38.0 // indirect
google.golang.org/protobuf v1.27.1 // indirect
go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.29.0 // indirect
go.opentelemetry.io/otel v1.4.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.4.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.4.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.4.0 // indirect
go.opentelemetry.io/otel/internal/metric v0.27.0 // indirect
go.opentelemetry.io/otel/metric v0.27.0 // indirect
go.opentelemetry.io/otel/sdk v1.4.0 // indirect
go.opentelemetry.io/otel/trace v1.4.0 // indirect
go.opentelemetry.io/proto/otlp v0.12.0 // indirect
go.step.sm/cli-utils v0.7.0 // indirect
go.step.sm/crypto v0.16.1 // indirect
go.step.sm/linkedca v0.15.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.8.0 // indirect
golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898 // indirect
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 // indirect
golang.org/x/net v0.0.0-20220517181318-183a9ca12b87 // indirect
golang.org/x/sys v0.0.0-20220517195934-5e4e11fc645e // indirect
golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect
golang.org/x/text v0.3.8-0.20211004125949-5bd84dd9b33b // indirect
golang.org/x/tools v0.1.10 // indirect
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df // indirect
google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf // indirect
google.golang.org/grpc v1.44.0 // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
gopkg.in/square/go-jose.v2 v2.5.1 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
howett.net/plist v0.0.0-20181124034731-591f970eefbb // indirect
howett.net/plist v1.0.0 // indirect
)

File diff suppressed because it is too large Load Diff

273
cgi.go Normal file
View File

@@ -0,0 +1,273 @@
package frankenphp
import (
"crypto/tls"
"net"
"net/http"
"path/filepath"
"strings"
)
// populateEnv returns a set of CGI environment variables for the request.
//
// TODO: handle this case https://github.com/caddyserver/caddy/issues/3718
// Inspired by https://github.com/caddyserver/caddy/blob/master/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
func populateEnv(request *http.Request) error {
fc, ok := FromContext(request.Context())
if !ok {
panic("not a FrankenPHP request")
}
if fc.populated {
return nil
}
_, addrOk := fc.Env["REMOTE_ADDR"]
_, portOk := fc.Env["REMOTE_PORT"]
if !addrOk || !portOk {
// Separate remote IP and port; more lenient than net.SplitHostPort
var ip, port string
if idx := strings.LastIndex(request.RemoteAddr, ":"); idx > -1 {
ip = request.RemoteAddr[:idx]
port = request.RemoteAddr[idx+1:]
} else {
ip = request.RemoteAddr
}
// Remove [] from IPv6 addresses
ip = strings.Replace(ip, "[", "", 1)
ip = strings.Replace(ip, "]", "", 1)
if _, ok := fc.Env["REMOTE_ADDR"]; !ok {
fc.Env["REMOTE_ADDR"] = ip
}
if _, ok := fc.Env["REMOTE_HOST"]; !ok {
fc.Env["REMOTE_HOST"] = ip // For speed, remote host lookups disabled
}
if _, ok := fc.Env["REMOTE_PORT"]; !ok {
fc.Env["REMOTE_PORT"] = port
}
}
if _, ok := fc.Env["DOCUMENT_ROOT"]; !ok {
// make sure file root is absolute
root, err := filepath.Abs(fc.DocumentRoot)
if err != nil {
return err
}
if fc.ResolveRootSymlink {
if root, err = filepath.EvalSymlinks(root); err != nil {
return err
}
}
fc.Env["DOCUMENT_ROOT"] = root
}
fpath := request.URL.Path
scriptName := fpath
docURI := fpath
// split "actual path" from "path info" if configured
if splitPos := splitPos(fc, fpath); splitPos > -1 {
docURI = fpath[:splitPos]
fc.Env["PATH_INFO"] = fpath[splitPos:]
// Strip PATH_INFO from SCRIPT_NAME
scriptName = strings.TrimSuffix(scriptName, fc.Env["PATH_INFO"])
}
// SCRIPT_FILENAME is the absolute path of SCRIPT_NAME
scriptFilename := sanitizedPathJoin(fc.Env["DOCUMENT_ROOT"], scriptName)
// Ensure the SCRIPT_NAME has a leading slash for compliance with RFC3875
// Info: https://tools.ietf.org/html/rfc3875#section-4.1.13
if scriptName != "" && !strings.HasPrefix(scriptName, "/") {
scriptName = "/" + scriptName
}
if _, ok := fc.Env["DOCUMENT_URI"]; !ok {
fc.Env["DOCUMENT_URI"] = docURI
}
if _, ok := fc.Env["SCRIPT_FILENAME"]; !ok {
fc.Env["SCRIPT_FILENAME"] = scriptFilename
}
if _, ok := fc.Env["SCRIPT_NAME"]; !ok {
fc.Env["SCRIPT_NAME"] = scriptName
}
if _, ok := fc.Env["REQUEST_SCHEME"]; !ok {
if request.TLS == nil {
fc.Env["REQUEST_SCHEME"] = "http"
} else {
fc.Env["REQUEST_SCHEME"] = "https"
}
}
if request.TLS != nil {
if _, ok := fc.Env["HTTPS"]; !ok {
fc.Env["HTTPS"] = "on"
}
// and pass the protocol details in a manner compatible with apache's mod_ssl
// (which is why these have a SSL_ prefix and not TLS_).
_, sslProtocolOk := fc.Env["SSL_PROTOCOL"]
v, versionOk := tlsProtocolStrings[request.TLS.Version]
if !sslProtocolOk && versionOk {
fc.Env["SSL_PROTOCOL"] = v
}
}
_, serverNameOk := fc.Env["SERVER_NAME"]
_, serverPortOk := fc.Env["SERVER_PORT"]
if !serverNameOk || !serverPortOk {
reqHost, reqPort, err := net.SplitHostPort(request.Host)
if err == nil {
if !serverNameOk {
fc.Env["SERVER_NAME"] = reqHost
}
// compliance with the CGI specification requires that
// SERVER_PORT should only exist if it's a valid numeric value.
// Info: https://www.ietf.org/rfc/rfc3875 Page 18
if !serverPortOk && reqPort != "" {
fc.Env["SERVER_PORT"] = reqPort
}
} else if !serverNameOk {
// whatever, just assume there was no port
fc.Env["SERVER_NAME"] = request.Host
}
}
// Variables defined in CGI 1.1 spec
// Some variables are unused but cleared explicitly to prevent
// the parent environment from interfering.
// We never override an entry previously set
if _, ok := fc.Env["REMOTE_IDENT"]; !ok {
fc.Env["REMOTE_IDENT"] = "" // Not used
}
if _, ok := fc.Env["AUTH_TYPE"]; !ok {
fc.Env["AUTH_TYPE"] = "" // Not used
}
if _, ok := fc.Env["CONTENT_LENGTH"]; !ok {
fc.Env["CONTENT_LENGTH"] = request.Header.Get("Content-Length")
}
if _, ok := fc.Env["CONTENT_TYPE"]; !ok {
fc.Env["CONTENT_TYPE"] = request.Header.Get("Content-Type")
}
if _, ok := fc.Env["GATEWAY_INTERFACE"]; !ok {
fc.Env["GATEWAY_INTERFACE"] = "CGI/1.1"
}
if _, ok := fc.Env["QUERY_STRING"]; !ok {
fc.Env["QUERY_STRING"] = request.URL.RawQuery
}
if _, ok := fc.Env["QUERY_STRING"]; !ok {
fc.Env["QUERY_STRING"] = request.URL.RawQuery
}
if _, ok := fc.Env["REQUEST_METHOD"]; !ok {
fc.Env["REQUEST_METHOD"] = request.Method
}
if _, ok := fc.Env["SERVER_PROTOCOL"]; !ok {
fc.Env["SERVER_PROTOCOL"] = request.Proto
}
if _, ok := fc.Env["SERVER_SOFTWARE"]; !ok {
fc.Env["SERVER_SOFTWARE"] = "FrankenPHP"
}
if _, ok := fc.Env["HTTP_HOST"]; !ok {
fc.Env["HTTP_HOST"] = request.Host // added here, since not always part of headers
}
if _, ok := fc.Env["REQUEST_URI"]; !ok {
fc.Env["REQUEST_URI"] = request.URL.RequestURI()
}
// compliance with the CGI specification requires that
// PATH_TRANSLATED should only exist if PATH_INFO is defined.
// Info: https://www.ietf.org/rfc/rfc3875 Page 14
if fc.Env["PATH_INFO"] != "" {
fc.Env["PATH_TRANSLATED"] = sanitizedPathJoin(fc.Env["DOCUMENT_ROOT"], fc.Env["PATH_INFO"]) // Info: http://www.oreilly.com/openbook/cgi/ch02_04.html
}
// Add all HTTP headers to env variables
for field, val := range request.Header {
k := "HTTP_" + headerNameReplacer.Replace(strings.ToUpper(field))
if _, ok := fc.Env[k]; !ok {
fc.Env[k] = strings.Join(val, ", ")
}
}
if _, ok := fc.Env["REMOTE_USER"]; !ok {
var (
authUser string
ok bool
)
authUser, fc.authPassword, ok = request.BasicAuth()
if ok {
fc.Env["REMOTE_USER"] = authUser
}
}
fc.populated = true
return nil
}
// splitPos returns the index where path should
// be split based on SplitPath.
//
// Adapted from https://github.com/caddyserver/caddy/blob/master/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
// Copyright 2015 Matthew Holt and The Caddy Authors
func splitPos(fc *FrankenPHPContext, path string) int {
if len(fc.SplitPath) == 0 {
return 0
}
lowerPath := strings.ToLower(path)
for _, split := range fc.SplitPath {
if idx := strings.Index(lowerPath, strings.ToLower(split)); idx > -1 {
return idx + len(split)
}
}
return -1
}
// Map of supported protocols to Apache ssl_mod format
// Note that these are slightly different from SupportedProtocols in caddytls/config.go
var tlsProtocolStrings = map[uint16]string{
tls.VersionTLS10: "TLSv1",
tls.VersionTLS11: "TLSv1.1",
tls.VersionTLS12: "TLSv1.2",
tls.VersionTLS13: "TLSv1.3",
}
var headerNameReplacer = strings.NewReplacer(" ", "_", "-", "_")
// SanitizedPathJoin performs filepath.Join(root, reqPath) that
// is safe against directory traversal attacks. It uses logic
// similar to that in the Go standard library, specifically
// in the implementation of http.Dir. The root is assumed to
// be a trusted path, but reqPath is not; and the output will
// never be outside of root. The resulting path can be used
// with the local file system.
//
// Adapted from https://github.com/caddyserver/caddy/blob/master/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
// Copyright 2015 Matthew Holt and The Caddy Authors
func sanitizedPathJoin(root, reqPath string) string {
if root == "" {
root = "."
}
path := filepath.Join(root, filepath.Clean("/"+reqPath))
// filepath.Join also cleans the path, and cleaning strips
// the trailing slash, so we need to re-add it afterwards.
// if the length is 1, then it's a path to the root,
// and that should return ".", so we don't append the separator.
if strings.HasSuffix(reqPath, "/") && len(reqPath) > 1 {
path += separator
}
return path
}
const separator = string(filepath.Separator)

View File

@@ -5,19 +5,356 @@
#include "_cgo_export.h"
#include "php.h"
#include "SAPI.h"
#include "ext/standard/head.h"
#include "ext/session/php_session.h"
#include "php_main.h"
#include "php_variables.h"
#include "php_output.h"
#include "Zend/zend_alloc.h"
// Helper functions copied from the PHP source code
#include "php.h"
#include "SAPI.h"
// main/php_variables.c
static zend_always_inline void php_register_variable_quick(const char *name, size_t name_len, zval *val, HashTable *ht)
{
zend_string *key = zend_string_init_interned(name, name_len, 0);
zend_hash_update_ind(ht, key, val);
zend_string_release_ex(key, 0);
}
static inline void php_register_server_variables(void)
{
zval tmp;
zval *arr = &PG(http_globals)[TRACK_VARS_SERVER];
HashTable *ht;
zval_ptr_dtor_nogc(arr);
array_init(arr);
/* Server variables */
if (sapi_module.register_server_variables) {
sapi_module.register_server_variables(arr);
}
ht = Z_ARRVAL_P(arr);
/* PHP Authentication support */
if (SG(request_info).auth_user) {
ZVAL_STRING(&tmp, SG(request_info).auth_user);
php_register_variable_quick("PHP_AUTH_USER", sizeof("PHP_AUTH_USER")-1, &tmp, ht);
}
if (SG(request_info).auth_password) {
ZVAL_STRING(&tmp, SG(request_info).auth_password);
php_register_variable_quick("PHP_AUTH_PW", sizeof("PHP_AUTH_PW")-1, &tmp, ht);
}
if (SG(request_info).auth_digest) {
ZVAL_STRING(&tmp, SG(request_info).auth_digest);
php_register_variable_quick("PHP_AUTH_DIGEST", sizeof("PHP_AUTH_DIGEST")-1, &tmp, ht);
}
/* store request init time */
ZVAL_DOUBLE(&tmp, sapi_get_request_time());
php_register_variable_quick("REQUEST_TIME_FLOAT", sizeof("REQUEST_TIME_FLOAT")-1, &tmp, ht);
ZVAL_LONG(&tmp, zend_dval_to_lval(Z_DVAL(tmp)));
php_register_variable_quick("REQUEST_TIME", sizeof("REQUEST_TIME")-1, &tmp, ht);
}
// ext/session/php_session.c
/* Initialized in MINIT, readonly otherwise. */
static int my_module_number = 0;
/* Dispatched by RINIT and by php_session_destroy */
static inline void php_rinit_session_globals(void) /* {{{ */
{
/* Do NOT init PS(mod_user_names) here! */
/* TODO: These could be moved to MINIT and removed. These should be initialized by php_rshutdown_session_globals() always when execution is finished. */
PS(id) = NULL;
PS(session_status) = php_session_none;
PS(in_save_handler) = 0;
PS(set_handler) = 0;
PS(mod_data) = NULL;
PS(mod_user_is_open) = 0;
PS(define_sid) = 1;
PS(session_vars) = NULL;
PS(module_number) = my_module_number;
ZVAL_UNDEF(&PS(http_session_vars));
}
/* }}} */
/* Dispatched by RSHUTDOWN and by php_session_destroy */
static inline void php_rshutdown_session_globals(void) /* {{{ */
{
/* Do NOT destroy PS(mod_user_names) here! */
if (!Z_ISUNDEF(PS(http_session_vars))) {
zval_ptr_dtor(&PS(http_session_vars));
ZVAL_UNDEF(&PS(http_session_vars));
}
if (PS(mod_data) || PS(mod_user_implemented)) {
zend_try {
PS(mod)->s_close(&PS(mod_data));
} zend_end_try();
}
if (PS(id)) {
zend_string_release_ex(PS(id), 0);
PS(id) = NULL;
}
if (PS(session_vars)) {
zend_string_release_ex(PS(session_vars), 0);
PS(session_vars) = NULL;
}
/* User save handlers may end up directly here by misuse, bugs in user script, etc. */
/* Set session status to prevent error while restoring save handler INI value. */
PS(session_status) = php_session_none;
}
/* }}} */
// End of copied functions
typedef struct frankenphp_server_context {
uintptr_t response_writer;
uintptr_t request;
uintptr_t requests_chan;
char *worker_filename;
char *cookie_data;
} frankenphp_server_context;
ZEND_BEGIN_ARG_INFO_EX(arginfo_frankenphp_handle_request, 0, 0, 1)
ZEND_ARG_CALLABLE_INFO(false, handler, false)
ZEND_END_ARG_INFO()
PHP_FUNCTION(frankenphp_handle_request) {
zend_fcall_info fci;
zend_fcall_info_cache fcc;
if (zend_parse_parameters(ZEND_NUM_ARGS(), "f", &fci, &fcc) == FAILURE) {
RETURN_THROWS();
}
frankenphp_server_context *ctx = SG(server_context);
uintptr_t request = go_frankenphp_worker_handle_request_start(ctx->requests_chan);
if (!request) {
RETURN_FALSE;
}
// Call the PHP func
zval retval = {0};
fci.size = sizeof fci;
fci.retval = &retval;
zend_call_function(&fci, &fcc);
php_session_flush(1);
go_frankenphp_worker_handle_request_end(request);
// Adapted from php_request_shutdown
zend_try {
php_output_end_all();
} zend_end_try();
zend_try {
php_output_deactivate();
} zend_end_try();
php_rshutdown_session_globals();
php_rinit_session_globals();
RETURN_TRUE;
}
// Adapted from php_request_startup()
int frankenphp_worker_reset_server_context() {
int retval = SUCCESS;
zend_try {
//PG(in_error_log) = 0;
//PG(during_request_startup) = 1;
php_output_activate();
/* initialize global variables */
//PG(modules_activated) = 0;
PG(header_is_being_sent) = 0;
PG(connection_status) = PHP_CONNECTION_NORMAL;
//PG(in_user_include) = 0;
// Keep the current execution context
//zend_activate();
sapi_activate();
#ifdef ZEND_SIGNALS
//zend_signal_activate();
#endif
if (PG(max_input_time) == -1) {
zend_set_timeout(EG(timeout_seconds), 1);
} else {
zend_set_timeout(PG(max_input_time), 1);
}
/* Disable realpath cache if an open_basedir is set */
//if (PG(open_basedir) && *PG(open_basedir)) {
// CWDG(realpath_cache_size_limit) = 0;
//}
if (PG(expose_php)) {
sapi_add_header(SAPI_PHP_VERSION_HEADER, sizeof(SAPI_PHP_VERSION_HEADER)-1, 1);
}
if (PG(output_handler) && PG(output_handler)[0]) {
zval oh;
ZVAL_STRING(&oh, PG(output_handler));
php_output_start_user(&oh, 0, PHP_OUTPUT_HANDLER_STDFLAGS);
zval_ptr_dtor(&oh);
} else if (PG(output_buffering)) {
php_output_start_user(NULL, PG(output_buffering) > 1 ? PG(output_buffering) : 0, PHP_OUTPUT_HANDLER_STDFLAGS);
} else if (PG(implicit_flush)) {
php_output_set_implicit_flush(1);
}
/* We turn this off in php_execute_script() */
/* PG(during_request_startup) = 0; */
php_register_server_variables();
zend_hash_update(&EG(symbol_table), ZSTR_KNOWN(ZEND_STR_AUTOGLOBAL_SERVER), &PG(http_globals)[TRACK_VARS_SERVER]);
Z_ADDREF(PG(http_globals)[TRACK_VARS_SERVER]);
php_hash_environment();
} zend_catch {
retval = FAILURE;
} zend_end_try();
return retval;
}
static const zend_function_entry frankenphp_ext_functions[] = {
PHP_FE(frankenphp_handle_request, arginfo_frankenphp_handle_request)
PHP_FE_END
};
static zend_module_entry frankenphp_module = {
STANDARD_MODULE_HEADER,
"frankenphp",
frankenphp_ext_functions, /* function table */
NULL, /* initialization */
NULL, /* shutdown */
NULL, /* request initialization */
NULL, /* request shutdown */
NULL, /* information */
"dev",
STANDARD_MODULE_PROPERTIES
};
uintptr_t frankenphp_clean_server_context() {
frankenphp_server_context *ctx = SG(server_context);
if (ctx == NULL) return 0;
free(SG(request_info.auth_password));
SG(request_info.auth_password) = NULL;
free(SG(request_info.auth_user));
SG(request_info.auth_user) = NULL;
free((char *) SG(request_info.request_method));
SG(request_info.request_method) = NULL;
free(SG(request_info.query_string));
SG(request_info.query_string) = NULL;
free((char *) SG(request_info.content_type));
SG(request_info.content_type) = NULL;
free(SG(request_info.path_translated));
SG(request_info.path_translated) = NULL;
free(SG(request_info.request_uri));
SG(request_info.request_uri) = NULL;
return ctx->request;
}
uintptr_t frankenphp_request_shutdown()
{
php_request_shutdown((void *) 0);
frankenphp_server_context *ctx = SG(server_context);
free(ctx->cookie_data);
((frankenphp_server_context*) SG(server_context))->cookie_data = NULL;
uintptr_t rh = frankenphp_clean_server_context();
free(ctx);
SG(server_context) = NULL;
return rh;
}
// set worker to 0 if not in worker mode
int frankenphp_create_server_context(uintptr_t requests_chan, char* worker_filename)
{
frankenphp_server_context *ctx;
(void) ts_resource(0);
// todo: use a pool
ctx = malloc(sizeof(frankenphp_server_context));
if (ctx == NULL) return FAILURE;
ctx->request = 0;
ctx->requests_chan = requests_chan;
ctx->worker_filename = worker_filename;
ctx->cookie_data = NULL;
SG(server_context) = ctx;
return SUCCESS;
}
void frankenphp_update_server_context(
uintptr_t request,
const char *request_method,
char *query_string,
zend_long content_length,
char *path_translated,
char *request_uri,
const char *content_type,
char *auth_user,
char *auth_password,
int proto_num
) {
frankenphp_server_context *ctx = SG(server_context);
ctx->request = request;
SG(request_info).auth_password = auth_password;
SG(request_info).auth_user = auth_user;
SG(request_info).request_method = request_method;
SG(request_info).query_string = query_string;
SG(request_info).content_type = content_type;
SG(request_info).content_length = content_length;
SG(request_info).path_translated = path_translated;
SG(request_info).request_uri = request_uri;
SG(request_info).proto_num = proto_num;
}
static int frankenphp_startup(sapi_module_struct *sapi_module)
{
return php_module_startup(sapi_module, NULL, 0);
return php_module_startup(sapi_module, &frankenphp_module, 1);
}
static int frankenphp_deactivate(void)
@@ -30,7 +367,9 @@ static size_t frankenphp_ub_write(const char *str, size_t str_length)
{
frankenphp_server_context* ctx = SG(server_context);
return go_ub_write(ctx->response_writer, (char *) str, str_length);
if (ctx->request == 0) return 0; // TODO: write on stdout?
return go_ub_write(ctx->request, (char *) str, str_length);
}
static int frankenphp_send_headers(sapi_headers_struct *sapi_headers)
@@ -44,9 +383,11 @@ static int frankenphp_send_headers(sapi_headers_struct *sapi_headers)
int status;
frankenphp_server_context* ctx = SG(server_context);
if (ctx->request == 0) return SAPI_HEADER_SEND_FAILED;
h = zend_llist_get_first_ex(&sapi_headers->headers, &pos);
while (h) {
go_add_header(ctx->response_writer, h->header, h->header_len);
go_add_header(ctx->request, h->header, h->header_len);
h = zend_llist_get_next_ex(&sapi_headers->headers, &pos);
}
@@ -57,7 +398,7 @@ static int frankenphp_send_headers(sapi_headers_struct *sapi_headers)
status = atoi((SG(sapi_headers).http_status_line) + 9);
}
go_write_header(ctx->response_writer, status);
go_write_header(ctx->request, status);
return SAPI_HEADER_SENT_SUCCESSFULLY;
}
@@ -66,12 +407,17 @@ static size_t frankenphp_read_post(char *buffer, size_t count_bytes)
{
frankenphp_server_context* ctx = SG(server_context);
if (ctx->request == 0) return 0;
return go_read_post(ctx->request, buffer, count_bytes);
}
static char* frankenphp_read_cookies(void)
{
frankenphp_server_context* ctx = SG(server_context);
if (ctx->request == 0) return "";
ctx->cookie_data = go_read_cookies(ctx->request);
return ctx->cookie_data;
@@ -82,6 +428,12 @@ static void frankenphp_register_variables(zval *track_vars_array)
// https://www.php.net/manual/en/reserved.variables.server.php
frankenphp_server_context* ctx = SG(server_context);
if (ctx->request == 0 && ctx->worker_filename != NULL) {
// todo: also register PHP_SELF etc
php_register_variable_safe("SCRIPT_FILENAME", ctx->worker_filename, strlen(ctx->worker_filename), track_vars_array);
}
// todo: import or not environment variables set in the parent process?
//php_import_environment_variables(track_vars_array);
go_register_variables(ctx->request, track_vars_array);
@@ -92,7 +444,7 @@ static void frankenphp_log_message(const char *message, int syslog_type_int)
// TODO: call Go logger
}
sapi_module_struct frankenphp_module = {
sapi_module_struct frankenphp_sapi_module = {
"frankenphp", /* name */
"FrankenPHP", /* pretty name */
@@ -131,13 +483,9 @@ int frankenphp_init() {
php_tsrm_startup();
zend_signal_startup();
sapi_startup(&frankenphp_module);
sapi_startup(&frankenphp_sapi_module);
if (frankenphp_module.startup(&frankenphp_module) == FAILURE) {
return FAILURE;
}
return SUCCESS;
return frankenphp_sapi_module.startup(&frankenphp_sapi_module);
}
void frankenphp_shutdown()
@@ -147,55 +495,20 @@ void frankenphp_shutdown()
tsrm_shutdown();
}
int frankenphp_request_startup(
uintptr_t response_writer,
uintptr_t request,
const char *request_method,
char *query_string,
zend_long content_length,
char *path_translated,
char *request_uri,
const char *content_type,
char *auth_user,
char *auth_password,
int proto_num
) {
frankenphp_server_context *ctx;
(void) ts_resource(0);
ctx = emalloc(sizeof(frankenphp_server_context));
if (ctx == NULL) {
return FAILURE;
int frankenphp_request_startup()
{
if (php_request_startup() == SUCCESS) {
return SUCCESS;
}
ctx->response_writer = response_writer;
ctx->request = request;
fprintf(stderr, "problem in php_request_startup\n");
SG(server_context) = ctx;
php_request_shutdown((void *) 0);
frankenphp_server_context *ctx = SG(server_context);
SG(server_context) = NULL;
free(ctx);
SG(request_info).request_method = request_method;
SG(request_info).query_string = query_string;
SG(request_info).content_length = content_length;
SG(request_info).path_translated = path_translated;
SG(request_info).request_uri = request_uri;
SG(request_info).content_type = content_type;
if (auth_user != NULL)
SG(request_info).auth_user = estrdup(auth_user);
if (auth_password != NULL)
SG(request_info).auth_password = estrdup(auth_password);
SG(request_info).proto_num = proto_num;
if (php_request_startup() == FAILURE) {
php_request_shutdown(NULL);
SG(server_context) = NULL;
free(ctx);
return FAILURE;
}
return SUCCESS;
return FAILURE;
}
int frankenphp_execute_script(const char* file_name)
@@ -208,17 +521,8 @@ int frankenphp_execute_script(const char* file_name)
zend_first_try {
status = php_execute_script(&file_handle);
} zend_catch {
/* int exit_status = EG(exit_status); */ \
/* int exit_status = EG(exit_status); */
} zend_end_try();
return status;
}
void frankenphp_request_shutdown()
{
frankenphp_server_context *ctx = SG(server_context);
php_request_shutdown(NULL);
if (ctx->cookie_data != NULL) free(ctx->cookie_data);
efree(ctx);
SG(server_context) = NULL;
}

View File

@@ -2,7 +2,7 @@ package frankenphp
// #cgo CFLAGS: -Wall -Wno-unused-variable
// #cgo CFLAGS: -I/usr/local/include/php -I/usr/local/include/php/Zend -I/usr/local/include/php/TSRM -I/usr/local/include/php/main
// #cgo LDFLAGS: -L/usr/local/lib -lphp
// #cgo LDFLAGS: -L/usr/local/lib -L/opt/homebrew/opt/libiconv/lib -L/usr/lib -lphp -lxml2 -liconv -lresolv -lsqlite3
// #include <stdlib.h>
// #include <stdint.h>
// #include "php_variables.h"
@@ -10,13 +10,11 @@ package frankenphp
import "C"
import (
"context"
"crypto/tls"
"fmt"
"io"
"log"
"net"
"net/http"
"path/filepath"
"runtime"
"runtime/cgo"
"strconv"
"strings"
@@ -26,9 +24,13 @@ import (
var started int32
type ContextKey string
type key int
const FrankenPHPContextKey ContextKey = "frankenphp"
var contextKey key
func init() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
}
// FrankenPHP executes PHP scripts.
type FrankenPHPContext struct {
@@ -56,10 +58,16 @@ type FrankenPHPContext struct {
// CGI-like environment variables that will be available in $_SERVER.
// This map is populated automatically, exisiting key are never replaced.
Env map[string]string
populated bool
authPassword string
responseWriter http.ResponseWriter
done chan interface{}
}
func NewRequestWithContext(r *http.Request, documentRoot string) *http.Request {
ctx := context.WithValue(r.Context(), FrankenPHPContextKey, &FrankenPHPContext{
ctx := context.WithValue(r.Context(), contextKey, &FrankenPHPContext{
DocumentRoot: documentRoot,
SplitPath: []string{".php"},
Env: make(map[string]string),
@@ -68,67 +76,62 @@ func NewRequestWithContext(r *http.Request, documentRoot string) *http.Request {
return r.WithContext(ctx)
}
func FromContext(ctx context.Context) (fctx *FrankenPHPContext, ok bool) {
fctx, ok = ctx.Value(contextKey).(*FrankenPHPContext)
return
}
// Startup starts the PHP engine.
// Startup and Shutdown must be called in the same goroutine (ideally in the main function).
func Startup() error {
if atomic.LoadInt32(&started) > 0 {
return nil
}
atomic.StoreInt32(&started, 1)
runtime.LockOSThread()
if C.frankenphp_init() < 0 {
return fmt.Errorf(`ZTS is not enabled, recompile PHP using the "--enable-zts" configuration option`)
}
atomic.StoreInt32(&started, 1)
return nil
}
// Shutdown stops the PHP engine.
// Shutdown and Startup must be called in the same goroutine (ideally in the main function).
func Shutdown() {
if atomic.LoadInt32(&started) < 1 {
return
}
atomic.StoreInt32(&started, 0)
C.frankenphp_shutdown()
atomic.StoreInt32(&started, 0)
}
func ExecuteScript(responseWriter http.ResponseWriter, request *http.Request) error {
if atomic.LoadInt32(&started) < 1 {
if err := Startup(); err != nil {
return err
}
}
authPassword, err := populateEnv(request)
if err != nil {
func updateServerContext(request *http.Request) error {
if err := populateEnv(request); err != nil {
return err
}
fc := request.Context().Value(FrankenPHPContextKey).(*FrankenPHPContext)
fc, ok := FromContext(request.Context())
if !ok {
panic("not a FrankenPHP request")
}
var cAuthUser, cAuthPassword *C.char
if authPassword != "" {
cAuthPassword = C.CString(authPassword)
defer C.free(unsafe.Pointer(cAuthPassword))
if fc.authPassword != "" {
cAuthPassword = C.CString(fc.authPassword)
}
if authUser := fc.Env["REMOTE_USER"]; authUser != "" {
cAuthUser = C.CString(authUser)
defer C.free(unsafe.Pointer(cAuthUser))
}
wh := cgo.NewHandle(responseWriter)
defer wh.Delete()
rh := cgo.NewHandle(request)
defer rh.Delete()
cMethod := C.CString(request.Method)
defer C.free(unsafe.Pointer(cMethod))
cQueryString := C.CString(request.URL.RawQuery)
defer C.free(unsafe.Pointer(cQueryString))
contentLengthStr := request.Header.Get("Content-Length")
contentLength := 0
if contentLengthStr != "" {
@@ -139,20 +142,16 @@ func ExecuteScript(responseWriter http.ResponseWriter, request *http.Request) er
var cContentType *C.char
if contentType != "" {
cContentType = C.CString(contentType)
defer C.free(unsafe.Pointer(cContentType))
}
var cPathTranslated *C.char
if pathTranslated := fc.Env["PATH_TRANSLATED"]; pathTranslated != "" {
cPathTranslated = C.CString(pathTranslated)
defer C.free(unsafe.Pointer(cPathTranslated))
}
cRequestUri := C.CString(request.URL.RequestURI())
defer C.free(unsafe.Pointer(cRequestUri))
if C.frankenphp_request_startup(
C.uintptr_t(wh),
C.frankenphp_update_server_context(
C.uintptr_t(rh),
cMethod,
@@ -164,289 +163,77 @@ func ExecuteScript(responseWriter http.ResponseWriter, request *http.Request) er
cAuthUser,
cAuthPassword,
C.int(request.ProtoMajor*1000+request.ProtoMinor),
) < 0 {
return fmt.Errorf("error during PHP request startup")
}
cFileName := C.CString(fc.Env["SCRIPT_FILENAME"])
defer C.free(unsafe.Pointer(cFileName))
C.frankenphp_execute_script(cFileName)
C.frankenphp_request_shutdown()
)
return nil
}
// buildEnv returns a set of CGI environment variables for the request.
//
// TODO: handle this case https://github.com/caddyserver/caddy/issues/3718
// Inspired by https://github.com/caddyserver/caddy/blob/master/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
func populateEnv(request *http.Request) (authPassword string, err error) {
fc := request.Context().Value(FrankenPHPContextKey).(*FrankenPHPContext)
_, addrOk := fc.Env["REMOTE_ADDR"]
_, portOk := fc.Env["REMOTE_PORT"]
if !addrOk || !portOk {
// Separate remote IP and port; more lenient than net.SplitHostPort
var ip, port string
if idx := strings.LastIndex(request.RemoteAddr, ":"); idx > -1 {
ip = request.RemoteAddr[:idx]
port = request.RemoteAddr[idx+1:]
} else {
ip = request.RemoteAddr
}
// Remove [] from IPv6 addresses
ip = strings.Replace(ip, "[", "", 1)
ip = strings.Replace(ip, "]", "", 1)
if _, ok := fc.Env["REMOTE_ADDR"]; !ok {
fc.Env["REMOTE_ADDR"] = ip
}
if _, ok := fc.Env["REMOTE_HOST"]; !ok {
fc.Env["REMOTE_HOST"] = ip // For speed, remote host lookups disabled
}
if _, ok := fc.Env["REMOTE_PORT"]; !ok {
fc.Env["REMOTE_PORT"] = port
}
func ExecuteScript(responseWriter http.ResponseWriter, request *http.Request) error {
if atomic.LoadInt32(&started) < 1 {
panic("FrankenPHP isn't started, call frankenphp.Startup()")
}
if _, ok := fc.Env["DOCUMENT_ROOT"]; !ok {
// make sure file root is absolute
root, err := filepath.Abs(fc.DocumentRoot)
if err != nil {
return "", err
}
runtime.LockOSThread()
// todo: check if it's ok or not to call runtime.UnlockOSThread() to reuse this thread
if fc.ResolveRootSymlink {
if root, err = filepath.EvalSymlinks(root); err != nil {
return "", err
}
}
fc.Env["DOCUMENT_ROOT"] = root
if C.frankenphp_create_server_context(0, nil) < 0 {
return fmt.Errorf("error during request context creation")
}
fpath := request.URL.Path
scriptName := fpath
docURI := fpath
// split "actual path" from "path info" if configured
if splitPos := splitPos(fc, fpath); splitPos > -1 {
docURI = fpath[:splitPos]
fc.Env["PATH_INFO"] = fpath[splitPos:]
// Strip PATH_INFO from SCRIPT_NAME
scriptName = strings.TrimSuffix(scriptName, fc.Env["PATH_INFO"])
if err := updateServerContext(request); err != nil {
return err
}
// SCRIPT_FILENAME is the absolute path of SCRIPT_NAME
scriptFilename := sanitizedPathJoin(fc.Env["DOCUMENT_ROOT"], scriptName)
// Ensure the SCRIPT_NAME has a leading slash for compliance with RFC3875
// Info: https://tools.ietf.org/html/rfc3875#section-4.1.13
if scriptName != "" && !strings.HasPrefix(scriptName, "/") {
scriptName = "/" + scriptName
if C.frankenphp_request_startup() < 0 {
return fmt.Errorf("error during PHP request startup")
}
if _, ok := fc.Env["DOCUMENT_URI"]; !ok {
fc.Env["DOCUMENT_URI"] = docURI
}
if _, ok := fc.Env["SCRIPT_FILENAME"]; !ok {
fc.Env["SCRIPT_FILENAME"] = scriptFilename
}
if _, ok := fc.Env["SCRIPT_NAME"]; !ok {
fc.Env["SCRIPT_NAME"] = scriptName
fc := request.Context().Value(contextKey).(*FrankenPHPContext)
fc.responseWriter = responseWriter
cFileName := C.CString(fc.Env["SCRIPT_FILENAME"])
defer C.free(unsafe.Pointer(cFileName))
if C.frankenphp_execute_script(cFileName) < 0 {
return fmt.Errorf("error during PHP script execution")
}
if _, ok := fc.Env["REQUEST_SCHEME"]; !ok {
if request.TLS == nil {
fc.Env["REQUEST_SCHEME"] = "http"
} else {
fc.Env["REQUEST_SCHEME"] = "https"
}
}
rh := C.frankenphp_clean_server_context()
C.frankenphp_request_shutdown()
if request.TLS != nil {
if _, ok := fc.Env["HTTPS"]; !ok {
fc.Env["HTTPS"] = "on"
}
cgo.Handle(rh).Delete()
// and pass the protocol details in a manner compatible with apache's mod_ssl
// (which is why these have a SSL_ prefix and not TLS_).
_, sslProtocolOk := fc.Env["SSL_PROTOCOL"]
v, versionOk := tlsProtocolStrings[request.TLS.Version]
if !sslProtocolOk && versionOk {
fc.Env["SSL_PROTOCOL"] = v
}
}
_, serverNameOk := fc.Env["SERVER_NAME"]
_, serverPortOk := fc.Env["SERVER_PORT"]
if !serverNameOk || !serverPortOk {
reqHost, reqPort, err := net.SplitHostPort(request.Host)
if err == nil {
if !serverNameOk {
fc.Env["SERVER_NAME"] = reqHost
}
// compliance with the CGI specification requires that
// SERVER_PORT should only exist if it's a valid numeric value.
// Info: https://www.ietf.org/rfc/rfc3875 Page 18
if !serverPortOk && reqPort != "" {
fc.Env["SERVER_PORT"] = reqPort
}
} else if !serverNameOk {
// whatever, just assume there was no port
fc.Env["SERVER_NAME"] = request.Host
}
}
// Variables defined in CGI 1.1 spec
// Some variables are unused but cleared explicitly to prevent
// the parent environment from interfering.
// We never override an entry previously set
if _, ok := fc.Env["REMOTE_IDENT"]; !ok {
fc.Env["REMOTE_IDENT"] = "" // Not used
}
if _, ok := fc.Env["AUTH_TYPE"]; !ok {
fc.Env["AUTH_TYPE"] = "" // Not used
}
if _, ok := fc.Env["CONTENT_LENGTH"]; !ok {
fc.Env["CONTENT_LENGTH"] = request.Header.Get("Content-Length")
}
if _, ok := fc.Env["CONTENT_TYPE"]; !ok {
fc.Env["CONTENT_TYPE"] = request.Header.Get("Content-Type")
}
if _, ok := fc.Env["GATEWAY_INTERFACE"]; !ok {
fc.Env["GATEWAY_INTERFACE"] = "CGI/1.1"
}
if _, ok := fc.Env["QUERY_STRING"]; !ok {
fc.Env["QUERY_STRING"] = request.URL.RawQuery
}
if _, ok := fc.Env["QUERY_STRING"]; !ok {
fc.Env["QUERY_STRING"] = request.URL.RawQuery
}
if _, ok := fc.Env["REQUEST_METHOD"]; !ok {
fc.Env["REQUEST_METHOD"] = request.Method
}
if _, ok := fc.Env["SERVER_PROTOCOL"]; !ok {
fc.Env["SERVER_PROTOCOL"] = request.Proto
}
if _, ok := fc.Env["SERVER_SOFTWARE"]; !ok {
fc.Env["SERVER_SOFTWARE"] = "FrankenPHP"
}
if _, ok := fc.Env["HTTP_HOST"]; !ok {
fc.Env["HTTP_HOST"] = request.Host // added here, since not always part of headers
}
if _, ok := fc.Env["REQUEST_URI"]; !ok {
fc.Env["REQUEST_URI"] = request.URL.RequestURI()
}
// compliance with the CGI specification requires that
// PATH_TRANSLATED should only exist if PATH_INFO is defined.
// Info: https://www.ietf.org/rfc/rfc3875 Page 14
if fc.Env["PATH_INFO"] != "" {
fc.Env["PATH_TRANSLATED"] = sanitizedPathJoin(fc.Env["DOCUMENT_ROOT"], fc.Env["PATH_INFO"]) // Info: http://www.oreilly.com/openbook/cgi/ch02_04.html
}
// Add all HTTP headers to env variables
for field, val := range request.Header {
k := "HTTP_" + headerNameReplacer.Replace(strings.ToUpper(field))
if _, ok := fc.Env[k]; !ok {
fc.Env[k] = strings.Join(val, ", ")
}
}
if _, ok := fc.Env["REMOTE_USER"]; !ok {
var (
authUser string
ok bool
)
authUser, authPassword, ok = request.BasicAuth()
if ok {
fc.Env["REMOTE_USER"] = authUser
}
}
return authPassword, nil
return nil
}
// splitPos returns the index where path should
// be split based on SplitPath.
//
// Adapted from https://github.com/caddyserver/caddy/blob/master/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
// Copyright 2015 Matthew Holt and The Caddy Authors
func splitPos(fc *FrankenPHPContext, path string) int {
if len(fc.SplitPath) == 0 {
return 0
}
lowerPath := strings.ToLower(path)
for _, split := range fc.SplitPath {
if idx := strings.Index(lowerPath, strings.ToLower(split)); idx > -1 {
return idx + len(split)
}
}
return -1
}
// Map of supported protocols to Apache ssl_mod format
// Note that these are slightly different from SupportedProtocols in caddytls/config.go
var tlsProtocolStrings = map[uint16]string{
tls.VersionTLS10: "TLSv1",
tls.VersionTLS11: "TLSv1.1",
tls.VersionTLS12: "TLSv1.2",
tls.VersionTLS13: "TLSv1.3",
}
var headerNameReplacer = strings.NewReplacer(" ", "_", "-", "_")
// SanitizedPathJoin performs filepath.Join(root, reqPath) that
// is safe against directory traversal attacks. It uses logic
// similar to that in the Go standard library, specifically
// in the implementation of http.Dir. The root is assumed to
// be a trusted path, but reqPath is not; and the output will
// never be outside of root. The resulting path can be used
// with the local file system.
//
// Adapted from https://github.com/caddyserver/caddy/blob/master/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
// Copyright 2015 Matthew Holt and The Caddy Authors
func sanitizedPathJoin(root, reqPath string) string {
if root == "" {
root = "."
}
path := filepath.Join(root, filepath.Clean("/"+reqPath))
// filepath.Join also cleans the path, and cleaning strips
// the trailing slash, so we need to re-add it afterwards.
// if the length is 1, then it's a path to the root,
// and that should return ".", so we don't append the separator.
if strings.HasSuffix(reqPath, "/") && len(reqPath) > 1 {
path += separator
}
return path
}
const separator = string(filepath.Separator)
//export go_ub_write
func go_ub_write(wh C.uintptr_t, cString *C.char, length C.int) C.size_t {
w := cgo.Handle(wh).Value().(http.ResponseWriter)
i, _ := w.Write([]byte(C.GoStringN(cString, length)))
func go_ub_write(rh C.uintptr_t, cString *C.char, length C.int) C.size_t {
r := cgo.Handle(rh).Value().(*http.Request)
fc := r.Context().Value(contextKey).(*FrankenPHPContext)
i, _ := fc.responseWriter.Write([]byte(C.GoStringN(cString, length)))
return C.size_t(i)
}
//export go_register_variables
func go_register_variables(rh C.uintptr_t, trackVarsArray *C.zval) {
r := cgo.Handle(rh).Value().(*http.Request)
for k, v := range r.Context().Value(FrankenPHPContextKey).(*FrankenPHPContext).Env {
var env map[string]string
if rh == 0 {
// Worker mode, waiting for a request, initialize some useful variables
env = map[string]string{"FRANKENPHP_WORKER": "1"}
} else {
r := cgo.Handle(rh).Value().(*http.Request)
env = r.Context().Value(contextKey).(*FrankenPHPContext).Env
}
env[fmt.Sprintf("REQUEST_%d", rh)] = "on"
for k, v := range env {
ck := C.CString(k)
cv := C.CString(v)
C.php_register_variable_safe(ck, cv, C.size_t(len(v)), trackVarsArray)
C.php_register_variable(ck, cv, trackVarsArray)
C.free(unsafe.Pointer(ck))
C.free(unsafe.Pointer(cv))
@@ -454,8 +241,9 @@ func go_register_variables(rh C.uintptr_t, trackVarsArray *C.zval) {
}
//export go_add_header
func go_add_header(wh C.uintptr_t, cString *C.char, length C.int) {
w := cgo.Handle(wh).Value().(http.ResponseWriter)
func go_add_header(rh C.uintptr_t, cString *C.char, length C.int) {
r := cgo.Handle(rh).Value().(*http.Request)
fc := r.Context().Value(contextKey).(*FrankenPHPContext)
parts := strings.SplitN(C.GoStringN(cString, length), ": ", 2)
if len(parts) != 2 {
@@ -464,13 +252,15 @@ func go_add_header(wh C.uintptr_t, cString *C.char, length C.int) {
return
}
w.Header().Add(parts[0], parts[1])
fc.responseWriter.Header().Add(parts[0], parts[1])
}
//export go_write_header
func go_write_header(wh C.uintptr_t, status C.int) {
w := cgo.Handle(wh).Value().(http.ResponseWriter)
w.WriteHeader(int(status))
func go_write_header(rh C.uintptr_t, status C.int) {
r := cgo.Handle(rh).Value().(*http.Request)
fc := r.Context().Value(contextKey).(*FrankenPHPContext)
fc.responseWriter.WriteHeader(int(status))
}
//export go_read_post
@@ -484,6 +274,7 @@ func go_read_post(rh C.uintptr_t, cBuf *C.char, countBytes C.size_t) C.size_t {
}
if readBytes != 0 {
// todo: memory leak?
C.memcpy(unsafe.Pointer(cBuf), unsafe.Pointer(&p[0]), C.size_t(readBytes))
}

View File

@@ -6,8 +6,8 @@
int frankenphp_init();
void frankenphp_shutdown();
int frankenphp_request_startup(
uintptr_t response_writer,
int frankenphp_create_server_context(uintptr_t requests_chan, char *worker_filename);
void frankenphp_update_server_context(
uintptr_t request,
const char *request_method,
@@ -20,8 +20,10 @@ int frankenphp_request_startup(
char *auth_password,
int proto_num
);
void frankenphp_request_shutdown();
int frankenphp_execute_script(const char* file_name);
int frankenphp_worker_reset_server_context();
uintptr_t frankenphp_clean_server_context();
int frankenphp_request_startup();
int frankenphp_execute_script(const char *file_name);
uintptr_t frankenphp_request_shutdown();
#endif

View File

@@ -4,6 +4,7 @@ import (
"context"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
@@ -14,177 +15,292 @@ import (
"github.com/stretchr/testify/assert"
)
func TestStartup(t *testing.T) {
defer frankenphp.Shutdown()
assert.Nil(t, frankenphp.Startup())
}
func setRequestContext(t *testing.T, r *http.Request) *http.Request {
t.Helper()
cwd, _ := os.Getwd()
return frankenphp.NewRequestWithContext(r, cwd+"/testdata/")
}
func TestHelloWorld(t *testing.T) {
defer frankenphp.Shutdown()
func createTestHandler(t *testing.T, workerScript string) (shutdown func(), handler func(http.ResponseWriter, *http.Request), iterations int) {
assert.Nil(t, frankenphp.Startup())
handler := func(w http.ResponseWriter, r *http.Request) {
assert.Nil(t, frankenphp.ExecuteScript(w, setRequestContext(t, r)))
if workerScript == "" {
iterations = 1
} else {
iterations = 2
cwd, _ := os.Getwd()
frankenphp.StartWorkers(cwd+"/testdata/"+workerScript, 1)
}
req := httptest.NewRequest("GET", "http://example.com/index.php", nil)
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, "I am by birth a Genevese", string(body))
}
func TestServerVariable(t *testing.T) {
defer frankenphp.Shutdown()
handler := func(w http.ResponseWriter, r *http.Request) {
assert.Nil(t, frankenphp.ExecuteScript(w, setRequestContext(t, r)))
shutdown = func() {
if workerScript != "" {
frankenphp.StopWorkers()
}
frankenphp.Shutdown()
}
req := httptest.NewRequest("GET", "http://example.com/server-variable.php?foo=a&bar=b#hash", nil)
req.SetBasicAuth("kevin", "password")
w := httptest.NewRecorder()
handler(w, req)
handler = func(w http.ResponseWriter, r *http.Request) {
var err error
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
req := setRequestContext(t, r)
if workerScript == "" {
err = frankenphp.ExecuteScript(w, req)
} else {
err = frankenphp.WorkerHandleRequest(w, req)
}
strBody := string(body)
assert.Contains(t, strBody, "[REMOTE_HOST]")
assert.Contains(t, strBody, "[REMOTE_USER] => kevin")
assert.Contains(t, strBody, "[PHP_AUTH_USER] => kevin")
assert.Contains(t, strBody, "[PHP_AUTH_PW] => password")
assert.Contains(t, strBody, "[HTTP_AUTHORIZATION] => Basic a2V2aW46cGFzc3dvcmQ=")
assert.Contains(t, strBody, "[DOCUMENT_ROOT]")
assert.Contains(t, strBody, "[CONTENT_TYPE]")
assert.Contains(t, strBody, "[QUERY_STRING] => foo=a&bar=b#hash")
assert.Contains(t, strBody, "[REQUEST_URI] => /server-variable.php?foo=a&bar=b#hash")
assert.Contains(t, strBody, "[SCRIPT_NAME]")
assert.Contains(t, strBody, "[CONTENT_LENGTH]")
assert.Contains(t, strBody, "[REMOTE_ADDR]")
assert.Contains(t, strBody, "[REMOTE_PORT]")
assert.Contains(t, strBody, "[REQUEST_SCHEME] => http")
assert.Contains(t, strBody, "[DOCUMENT_URI]")
assert.Contains(t, strBody, "[AUTH_TYPE]")
assert.Contains(t, strBody, "[REMOTE_IDENT]")
assert.Contains(t, strBody, "[REQUEST_METHOD] => GET")
assert.Contains(t, strBody, "[SERVER_NAME] => example.com")
assert.Contains(t, strBody, "[SERVER_PROTOCOL] => HTTP/1.1")
assert.Contains(t, strBody, "[SCRIPT_FILENAME]")
assert.Contains(t, strBody, "[SERVER_SOFTWARE] => FrankenPHP")
assert.Contains(t, strBody, "[REQUEST_TIME_FLOAT]")
assert.Contains(t, strBody, "[REQUEST_TIME]")
assert.Contains(t, strBody, "[REQUEST_TIME]")
}
func TestPathInfo(t *testing.T) {
defer frankenphp.Shutdown()
handler := func(w http.ResponseWriter, r *http.Request) {
rewriteRequest := setRequestContext(t, r.Clone(context.TODO()))
rewriteRequest.URL.Path = "/server-variable.php/pathinfo"
rewriteRequest.Context().Value(frankenphp.FrankenPHPContextKey).(*frankenphp.FrankenPHPContext).Env["REQUEST_URI"] = r.URL.RequestURI()
assert.Nil(t, frankenphp.ExecuteScript(w, rewriteRequest))
assert.Nil(t, err)
}
req := httptest.NewRequest("GET", "http://example.com/pathinfo", nil)
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
strBody := string(body)
assert.Contains(t, strBody, "[PATH_INFO] => /pathinfo")
assert.Contains(t, strBody, "[REQUEST_URI] => /pathinfo")
assert.Contains(t, strBody, "[PATH_TRANSLATED] =>")
assert.Contains(t, strBody, "[SCRIPT_NAME] => /server-variable.php")
return
}
func TestHeaders(t *testing.T) {
func TestStartup(t *testing.T) {
defer frankenphp.Shutdown()
assert.Nil(t, frankenphp.Startup())
frankenphp.Shutdown()
handler := func(w http.ResponseWriter, r *http.Request) {
assert.Nil(t, frankenphp.ExecuteScript(w, setRequestContext(t, r)))
}
req := httptest.NewRequest("GET", "http://example.com/headers.php", nil)
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, "Hello", string(body))
assert.Equal(t, 201, resp.StatusCode)
assert.Equal(t, "bar", resp.Header.Get("Foo"))
assert.Equal(t, "bar2", resp.Header.Get("Foo2"))
assert.Nil(t, frankenphp.Startup())
}
func TestInput(t *testing.T) {
defer frankenphp.Shutdown()
func TestHelloWorld_module(t *testing.T) { testHelloWorld(t, "") }
func TestHelloWorld_worker(t *testing.T) { testHelloWorld(t, "index.php") }
func testHelloWorld(t *testing.T, scriptName string) {
shutdown, handler, iterations := createTestHandler(t, scriptName)
defer shutdown()
handler := func(w http.ResponseWriter, r *http.Request) {
assert.Nil(t, frankenphp.ExecuteScript(w, setRequestContext(t, r)))
for i := 0; i < iterations; i++ {
req := httptest.NewRequest("GET", "http://example.com/index.php", nil)
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, "I am by birth a Genevese", string(body))
}
req := httptest.NewRequest("POST", "http://example.com/input.php", strings.NewReader("post data"))
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, "post data", string(body))
assert.Equal(t, "bar", resp.Header.Get("Foo"))
}
func TestPostSuperGlobals(t *testing.T) {
defer frankenphp.Shutdown()
func TestServerVariable_module(t *testing.T) { testServerVariable(t, "") }
func TestServerVariable_worker(t *testing.T) {
testServerVariable(t, "server-variable.php")
}
func testServerVariable(t *testing.T, scriptName string) {
shutdown, handler, iterations := createTestHandler(t, scriptName)
defer shutdown()
handler := func(w http.ResponseWriter, r *http.Request) {
assert.Nil(t, frankenphp.ExecuteScript(w, setRequestContext(t, r)))
for i := 0; i < iterations; i++ {
req := httptest.NewRequest("GET", "http://example.com/server-variable.php?foo=a&bar=b#hash", nil)
req.SetBasicAuth("kevin", "password")
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
strBody := string(body)
assert.Contains(t, strBody, "[REMOTE_HOST]")
assert.Contains(t, strBody, "[REMOTE_USER] => kevin")
assert.Contains(t, strBody, "[PHP_AUTH_USER] => kevin")
assert.Contains(t, strBody, "[PHP_AUTH_PW] => password")
assert.Contains(t, strBody, "[HTTP_AUTHORIZATION] => Basic a2V2aW46cGFzc3dvcmQ=")
assert.Contains(t, strBody, "[DOCUMENT_ROOT]")
assert.Contains(t, strBody, "[CONTENT_TYPE]")
assert.Contains(t, strBody, "[QUERY_STRING] => foo=a&bar=b#hash")
assert.Contains(t, strBody, "[REQUEST_URI] => /server-variable.php?foo=a&bar=b#hash")
assert.Contains(t, strBody, "[SCRIPT_NAME]")
assert.Contains(t, strBody, "[CONTENT_LENGTH]")
assert.Contains(t, strBody, "[REMOTE_ADDR]")
assert.Contains(t, strBody, "[REMOTE_PORT]")
assert.Contains(t, strBody, "[REQUEST_SCHEME] => http")
assert.Contains(t, strBody, "[DOCUMENT_URI]")
assert.Contains(t, strBody, "[AUTH_TYPE]")
assert.Contains(t, strBody, "[REMOTE_IDENT]")
assert.Contains(t, strBody, "[REQUEST_METHOD] => GET")
assert.Contains(t, strBody, "[SERVER_NAME] => example.com")
assert.Contains(t, strBody, "[SERVER_PROTOCOL] => HTTP/1.1")
assert.Contains(t, strBody, "[SCRIPT_FILENAME]")
assert.Contains(t, strBody, "[SERVER_SOFTWARE] => FrankenPHP")
assert.Contains(t, strBody, "[REQUEST_TIME_FLOAT]")
assert.Contains(t, strBody, "[REQUEST_TIME]")
assert.Contains(t, strBody, "[REQUEST_TIME]")
}
formData := url.Values{"baz": {"bat"}}
req := httptest.NewRequest("POST", "http://example.com/super-globals.php?foo=bar", strings.NewReader(formData.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "'foo' => 'bar'")
assert.Contains(t, string(body), "'baz' => 'bat'")
}
func TestCookies(t *testing.T) {
defer frankenphp.Shutdown()
handler := func(w http.ResponseWriter, r *http.Request) {
assert.Nil(t, frankenphp.ExecuteScript(w, setRequestContext(t, r)))
}
req := httptest.NewRequest("GET", "http://example.com/cookies.php", nil)
req.AddCookie(&http.Cookie{Name: "foo", Value: "bar"})
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "'foo' => 'bar'")
func TestPathInfo_module(t *testing.T) { testPathInfo(t, "") }
func TestPathInfo_worker(t *testing.T) {
testPathInfo(t, "server-variable.php")
}
func testPathInfo(t *testing.T, scriptName string) {
shutdown, _, iterations := createTestHandler(t, scriptName)
defer shutdown()
for i := 0; i < iterations; i++ {
handler := func(w http.ResponseWriter, r *http.Request) {
rewriteRequest := setRequestContext(t, r.Clone(context.TODO()))
rewriteRequest.URL.Path = "/server-variable.php/pathinfo"
fc, _ := frankenphp.FromContext(rewriteRequest.Context())
fc.Env["REQUEST_URI"] = r.URL.RequestURI()
if scriptName == "" {
assert.Nil(t, frankenphp.ExecuteScript(w, rewriteRequest))
} else {
assert.Nil(t, frankenphp.WorkerHandleRequest(w, rewriteRequest))
}
}
req := httptest.NewRequest("GET", "http://example.com/pathinfo", nil)
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
strBody := string(body)
assert.Contains(t, strBody, "[PATH_INFO] => /pathinfo")
assert.Contains(t, strBody, "[REQUEST_URI] => /pathinfo")
assert.Contains(t, strBody, "[PATH_TRANSLATED] =>")
assert.Contains(t, strBody, "[SCRIPT_NAME] => /server-variable.php")
}
}
func TestHeaders_module(t *testing.T) { testHeaders(t, "") }
func TestHeaders_worker(t *testing.T) { testHeaders(t, "headers.php") }
func testHeaders(t *testing.T, scriptName string) {
shutdown, handler, iterations := createTestHandler(t, scriptName)
defer shutdown()
for i := 0; i < iterations; i++ {
req := httptest.NewRequest("GET", "http://example.com/headers.php", nil)
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, "Hello", string(body))
assert.Equal(t, 201, resp.StatusCode)
assert.Equal(t, "bar", resp.Header.Get("Foo"))
assert.Equal(t, "bar2", resp.Header.Get("Foo2"))
}
}
func TestInput_module(t *testing.T) { testInput(t, "") }
func TestInput_worker(t *testing.T) { testInput(t, "input.php") }
func testInput(t *testing.T, scriptName string) {
shutdown, handler, iterations := createTestHandler(t, scriptName)
defer shutdown()
for i := 0; i < iterations; i++ {
req := httptest.NewRequest("POST", "http://example.com/input.php", strings.NewReader("post data"))
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, "post data", string(body))
assert.Equal(t, "bar", resp.Header.Get("Foo"))
}
}
func TestPostSuperGlobals_module(t *testing.T) { testPostSuperGlobals(t, "") }
func TestPostSuperGlobals_worker(t *testing.T) { testPostSuperGlobals(t, "super-globals.php") }
func testPostSuperGlobals(t *testing.T, scriptName string) {
shutdown, handler, iterations := createTestHandler(t, scriptName)
defer shutdown()
for i := 0; i < iterations; i++ {
formData := url.Values{"baz": {"bat"}}
req := httptest.NewRequest("POST", "http://example.com/super-globals.php?foo=bar", strings.NewReader(formData.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "'foo' => 'bar'")
assert.Contains(t, string(body), "'baz' => 'bat'")
}
}
func TestCookies_module(t *testing.T) { testCookies(t, "") }
func TestCookies_worker(t *testing.T) { testCookies(t, "cookies.php") }
func testCookies(t *testing.T, scriptName string) {
shutdown, handler, iterations := createTestHandler(t, scriptName)
defer shutdown()
for i := 0; i < iterations; i++ {
req := httptest.NewRequest("GET", "http://example.com/cookies.php", nil)
req.AddCookie(&http.Cookie{Name: "foo", Value: "bar"})
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "'foo' => 'bar'")
}
}
func TestSession_module(t *testing.T) { testSession(t, "") }
func TestSession_worker(t *testing.T) {
testSession(t, "session.php")
}
func testSession(t *testing.T, scriptName string) {
shutdown, handler, iterations := createTestHandler(t, scriptName)
defer shutdown()
ts := httptest.NewServer(http.HandlerFunc(handler))
defer ts.Close()
for i := 0; i < iterations; i++ {
jar, err := cookiejar.New(&cookiejar.Options{})
if err != nil {
panic(err)
}
client := &http.Client{Jar: jar}
resp1, err := client.Get(ts.URL + "/session.php")
if err != nil {
panic(err)
}
body1, _ := io.ReadAll(resp1.Body)
t.Log(string(body1))
assert.Equal(t, "Count: 0\n", string(body1))
resp2, err := client.Get(ts.URL + "/session.php")
if err != nil {
panic(err)
}
body2, _ := io.ReadAll(resp2.Body)
assert.Equal(t, "Count: 1\n", string(body2))
}
}
func TestPhpInfo_module(t *testing.T) { testPhpInfo(t, "") }
func TestPhpInfo_worker(t *testing.T) { testPhpInfo(t, "phpinfo.php") }
func testPhpInfo(t *testing.T, scriptName string) {
shutdown, handler, iterations := createTestHandler(t, scriptName)
defer shutdown()
for i := 0; i < iterations; i++ {
req := httptest.NewRequest("GET", "http://example.com/phpinfo.php", nil)
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "frankenphp")
}
}

6
go.mod
View File

@@ -1,8 +1,8 @@
module github.com/dunglas/frankenphp
go 1.17
go 1.18
require github.com/stretchr/testify v1.7.0
require github.com/stretchr/testify v1.7.1
require (
github.com/davecgh/go-spew v1.1.1 // indirect
@@ -10,5 +10,5 @@ require (
github.com/kr/text v0.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
gopkg.in/yaml.v3 v3.0.0-20220512140231-539c8e751b99 // indirect
)

8
go.sum
View File

@@ -11,11 +11,11 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20220512140231-539c8e751b99 h1:dbuHpmKjkDzSOMKAWl10QNlgaZUd3V1q99xc81tt2Kc=
gopkg.in/yaml.v3 v3.0.0-20220512140231-539c8e751b99/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

9
testdata/_executor.php vendored Normal file
View File

@@ -0,0 +1,9 @@
<?php
$fn = require $_SERVER['SCRIPT_FILENAME'];
if (!isset($_SERVER['FRANKENPHP_WORKER'])) {
$fn();
return;
}
while (frankenphp_handle_request($fn)) {}

View File

@@ -1,3 +1,7 @@
<?php
echo var_export($_COOKIE);
require_once __DIR__.'/_executor.php';
return function () {
echo var_export($_COOKIE);
};

12
testdata/headers.php vendored
View File

@@ -1,7 +1,11 @@
<?php
header('Foo: bar');
header('Foo2: bar2');
http_response_code(201);
require_once __DIR__.'/_executor.php';
echo 'Hello';
return function () {
header('Foo: bar');
header('Foo2: bar2');
http_response_code(201);
echo 'Hello';
};

5
testdata/index.php vendored
View File

@@ -1,4 +1,7 @@
<?php
echo "I am by birth a Genevese";
require_once __DIR__.'/_executor.php';
return function () {
echo "I am by birth a Genevese";
};

8
testdata/input.php vendored
View File

@@ -1,5 +1,9 @@
<?php
header('Foo: bar');
require_once __DIR__.'/_executor.php';
echo file_get_contents('php://input');
return function () {
header('Foo: bar');
echo file_get_contents('php://input');
};

7
testdata/phpinfo.php vendored Normal file
View File

@@ -0,0 +1,7 @@
<?php
require_once __DIR__.'/_executor.php';
return function () {
phpinfo();
};

View File

@@ -1,3 +1,7 @@
<?php
echo print_r($_SERVER);
require_once __DIR__.'/_executor.php';
return function () {
echo print_r($_SERVER);
};

15
testdata/session.php vendored Normal file
View File

@@ -0,0 +1,15 @@
<?php
require_once __DIR__.'/_executor.php';
return function () {
session_start();
if (isset($_SESSION['count'])) {
$_SESSION['count']++;
} else {
$_SESSION['count'] = 0;
}
echo 'Count: '.$_SESSION['count'].PHP_EOL;
};

View File

@@ -1,5 +1,9 @@
<?php
var_export($_GET);
var_export($_POST);
var_export($_SERVER['CONTENT_TYPE']);
require_once __DIR__.'/_executor.php';
return function () {
var_export($_GET);
var_export($_POST);
var_export($_SERVER);
};

14
testdata/worker.php vendored Normal file
View File

@@ -0,0 +1,14 @@
<?php
$i = 0;
do {
$ok = frankenphp_handle_request(function () use ($i): void {
echo sprintf("Requests handled: %d (request time: %s)\n", $i, $_SERVER['REQUEST_TIME_FLOAT']);
var_export($_GET);
var_export($_POST);
var_export($_SERVER);
});
$i++;
} while ($ok);

142
worker.go Normal file
View File

@@ -0,0 +1,142 @@
package frankenphp
// #include <stdlib.h>
// #include "frankenphp.h"
import "C"
import (
"fmt"
"log"
"net/http"
"runtime"
"runtime/cgo"
"sync"
"sync/atomic"
"unsafe"
)
var requestsChans sync.Map // map[fileName]cgo.NewHandle(chan *http.Request)
var workersWaitGroup sync.WaitGroup
func WorkerHandleRequest(responseWriter http.ResponseWriter, request *http.Request) error {
if atomic.LoadInt32(&started) < 1 {
panic("FrankenPHP isn't started, call frankenphp.Startup()")
}
if err := populateEnv(request); err != nil {
return err
}
fc, _ := FromContext(request.Context())
v, ok := requestsChans.Load(fc.Env["SCRIPT_FILENAME"])
if !ok {
panic(fmt.Errorf("No worker started for script %s", fc.Env["SCRIPT_FILENAME"]))
}
rch := v.(cgo.Handle)
rc := rch.Value().(chan *http.Request)
fc.responseWriter = responseWriter
fc.done = make(chan interface{})
fc.Env["FRANKENPHP_WORKER"] = "1"
rc <- request
<-fc.done
return nil
}
func StartWorkers(fileName string, nbWorkers int) {
if _, ok := requestsChans.Load(fileName); ok {
panic(fmt.Errorf("workers %q: already started", fileName))
}
rc := make(chan *http.Request)
rch := cgo.NewHandle(rc)
requestsChans.Store(fileName, rch)
for i := 0; i < nbWorkers; i++ {
newWorker(fileName, rch)
}
}
func StopWorkers() {
requestsChans.Range(func(k, v any) bool {
close(v.(cgo.Handle).Value().(chan *http.Request))
requestsChans.Delete(k)
return true
})
workersWaitGroup.Wait()
}
func newWorker(fileName string, requestsChanHandle cgo.Handle) {
go func() {
workersWaitGroup.Add(1)
runtime.LockOSThread()
cFileName := C.CString(fileName)
defer C.free(unsafe.Pointer(cFileName))
if C.frankenphp_create_server_context(C.uintptr_t(requestsChanHandle), cFileName) < 0 {
panic(fmt.Errorf("error during request context creation"))
}
if C.frankenphp_request_startup() < 0 {
panic("error during PHP request startup")
}
log.Printf("new worker started: %q", fileName)
if C.frankenphp_execute_script(cFileName) < 0 {
panic("error during PHP script execution")
}
C.frankenphp_request_shutdown()
workersWaitGroup.Done()
}()
}
//export go_frankenphp_worker_handle_request_start
func go_frankenphp_worker_handle_request_start(rch C.uintptr_t) C.uintptr_t {
rc := cgo.Handle(rch).Value().(chan *http.Request)
log.Print("worker: waiting for request")
r, ok := <-rc
if !ok {
// channel closed, server is shutting down
log.Print("worker: shutting down")
return 0
}
log.Printf("worker: handling request %#v", r)
fc := r.Context().Value(contextKey).(*FrankenPHPContext)
if fc == nil || fc.responseWriter == nil {
panic("worker: not a valid worker request")
}
if err := updateServerContext(r); err != nil {
// Unexpected error
log.Print(err)
return 0
}
C.frankenphp_worker_reset_server_context()
return C.uintptr_t(cgo.NewHandle(r))
}
//export go_frankenphp_worker_handle_request_end
func go_frankenphp_worker_handle_request_end(rh C.uintptr_t) {
rHandle := cgo.Handle(rh)
r := rHandle.Value().(*http.Request)
fc := r.Context().Value(contextKey).(*FrankenPHPContext)
C.frankenphp_clean_server_context()
cgo.Handle(rh).Delete()
close(fc.done)
}

42
worker_test.go Normal file
View File

@@ -0,0 +1,42 @@
package frankenphp_test
import (
"fmt"
"io"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestWorker(t *testing.T) {
shutdown, handler, iterations := createTestHandler(t, "worker.php")
defer shutdown()
for i := 0; i < iterations; i++ {
formData := url.Values{"baz": {"bat"}}
req := httptest.NewRequest("POST", "http://example.com/worker.php?foo=bar", strings.NewReader(formData.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), fmt.Sprintf("Requests handled: %d", i*2))
formData2 := url.Values{"baz2": {"bat2"}}
req2 := httptest.NewRequest("POST", "http://example.com/worker.php?foo2=bar2", strings.NewReader(formData2.Encode()))
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w2 := httptest.NewRecorder()
handler(w2, req2)
resp2 := w2.Result()
body2, _ := io.ReadAll(resp2.Body)
assert.Contains(t, string(body2), fmt.Sprintf("Requests handled: %d", i*2+1))
}
}