php-server.go 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. package caddy
  2. import (
  3. "encoding/json"
  4. "log"
  5. "net/http"
  6. "os"
  7. "path/filepath"
  8. "strconv"
  9. "strings"
  10. "time"
  11. mercureModule "github.com/dunglas/mercure/caddy"
  12. "github.com/caddyserver/caddy/v2"
  13. "github.com/caddyserver/caddy/v2/caddyconfig"
  14. caddycmd "github.com/caddyserver/caddy/v2/cmd"
  15. "github.com/caddyserver/caddy/v2/modules/caddyhttp"
  16. "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
  17. "github.com/caddyserver/caddy/v2/modules/caddyhttp/fileserver"
  18. "github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite"
  19. "github.com/caddyserver/certmagic"
  20. "github.com/dunglas/frankenphp"
  21. "go.uber.org/zap"
  22. "github.com/spf13/cobra"
  23. )
  24. func init() {
  25. caddycmd.RegisterCommand(caddycmd.Command{
  26. Name: "php-server",
  27. Usage: "[--domain <example.com>] [--root <path>] [--listen <addr>] [--worker /path/to/worker.php<,nb-workers>] [--access-log] [--debug] [--no-compress] [--mercure]",
  28. Short: "Spins up a production-ready PHP server",
  29. Long: `
  30. A simple but production-ready PHP server. Useful for quick deployments,
  31. demos, and development.
  32. The listener's socket address can be customized with the --listen flag.
  33. If a domain name is specified with --domain, the default listener address
  34. will be changed to the HTTPS port and the server will use HTTPS. If using
  35. a public domain, ensure A/AAAA records are properly configured before
  36. using this option.
  37. For more advanced use cases, see https://github.com/dunglas/frankenphp/blob/main/docs/config.md`,
  38. CobraFunc: func(cmd *cobra.Command) {
  39. cmd.Flags().StringP("domain", "d", "", "Domain name at which to serve the files")
  40. cmd.Flags().StringP("root", "r", "", "The path to the root of the site")
  41. cmd.Flags().StringP("listen", "l", "", "The address to which to bind the listener")
  42. cmd.Flags().StringArrayP("worker", "w", []string{}, "Worker script")
  43. cmd.Flags().BoolP("access-log", "a", false, "Enable the access log")
  44. cmd.Flags().BoolP("debug", "v", false, "Enable verbose debug logs")
  45. cmd.Flags().BoolP("mercure", "m", false, "Enable the built-in Mercure.rocks hub")
  46. cmd.Flags().BoolP("no-compress", "", false, "Disable Zstandard, Brotli and Gzip compression")
  47. cmd.Flags().BoolP("admin", "", false, "Enable the admin API")
  48. cmd.RunE = caddycmd.WrapCommandFuncForCobra(cmdPHPServer)
  49. },
  50. })
  51. }
  52. // cmdPHPServer is freely inspired from the file-server command of the Caddy server (Apache License 2.0, Matthew Holt and The Caddy Authors)
  53. func cmdPHPServer(fs caddycmd.Flags) (int, error) {
  54. caddy.TrapSignals()
  55. domain := fs.String("domain")
  56. root := fs.String("root")
  57. listen := fs.String("listen")
  58. accessLog := fs.Bool("access-log")
  59. debug := fs.Bool("debug")
  60. compress := !fs.Bool("no-compress")
  61. mercure := fs.Bool("mercure")
  62. admin := fs.Bool("admin")
  63. workers, err := fs.GetStringArray("worker")
  64. if err != nil {
  65. panic(err)
  66. }
  67. var workersOption []workerConfig
  68. if len(workers) != 0 {
  69. workersOption = make([]workerConfig, 0, len(workers))
  70. for _, worker := range workers {
  71. parts := strings.SplitN(worker, ",", 2)
  72. if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(parts[0]) {
  73. parts[0] = filepath.Join(frankenphp.EmbeddedAppPath, parts[0])
  74. }
  75. var num int
  76. if len(parts) > 1 {
  77. num, _ = strconv.Atoi(parts[1])
  78. }
  79. workersOption = append(workersOption, workerConfig{FileName: parts[0], Num: num})
  80. }
  81. }
  82. if frankenphp.EmbeddedAppPath != "" {
  83. if _, err := os.Stat(filepath.Join(frankenphp.EmbeddedAppPath, "php.ini")); err == nil {
  84. iniScanDir := os.Getenv("PHP_INI_SCAN_DIR")
  85. if err := os.Setenv("PHP_INI_SCAN_DIR", iniScanDir+":"+frankenphp.EmbeddedAppPath); err != nil {
  86. return caddy.ExitCodeFailedStartup, err
  87. }
  88. }
  89. if _, err := os.Stat(filepath.Join(frankenphp.EmbeddedAppPath, "Caddyfile")); err == nil {
  90. config, _, err := caddycmd.LoadConfig(filepath.Join(frankenphp.EmbeddedAppPath, "Caddyfile"), "")
  91. if err != nil {
  92. return caddy.ExitCodeFailedStartup, err
  93. }
  94. if err = caddy.Load(config, true); err != nil {
  95. return caddy.ExitCodeFailedStartup, err
  96. }
  97. select {}
  98. }
  99. if root == "" {
  100. root = filepath.Join(frankenphp.EmbeddedAppPath, defaultDocumentRoot)
  101. } else if filepath.IsLocal(root) {
  102. root = filepath.Join(frankenphp.EmbeddedAppPath, root)
  103. }
  104. }
  105. const indexFile = "index.php"
  106. extensions := []string{"php"}
  107. tryFiles := []string{"{http.request.uri.path}", "{http.request.uri.path}/" + indexFile, indexFile}
  108. rrs := true
  109. phpHandler := FrankenPHPModule{
  110. Root: root,
  111. SplitPath: extensions,
  112. ResolveRootSymlink: &rrs,
  113. }
  114. // route to redirect to canonical path if index PHP file
  115. redirMatcherSet := caddy.ModuleMap{
  116. "file": caddyconfig.JSON(fileserver.MatchFile{
  117. Root: root,
  118. TryFiles: []string{"{http.request.uri.path}/" + indexFile},
  119. }, nil),
  120. "not": caddyconfig.JSON(caddyhttp.MatchNot{
  121. MatcherSetsRaw: []caddy.ModuleMap{
  122. {
  123. "path": caddyconfig.JSON(caddyhttp.MatchPath{"*/"}, nil),
  124. },
  125. },
  126. }, nil),
  127. }
  128. redirHandler := caddyhttp.StaticResponse{
  129. StatusCode: caddyhttp.WeakString(strconv.Itoa(http.StatusPermanentRedirect)),
  130. Headers: http.Header{"Location": []string{"{http.request.orig_uri.path}/"}},
  131. }
  132. redirRoute := caddyhttp.Route{
  133. MatcherSetsRaw: []caddy.ModuleMap{redirMatcherSet},
  134. HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(redirHandler, "handler", "static_response", nil)},
  135. }
  136. // route to rewrite to PHP index file
  137. rewriteMatcherSet := caddy.ModuleMap{
  138. "file": caddyconfig.JSON(fileserver.MatchFile{
  139. Root: root,
  140. TryFiles: tryFiles,
  141. SplitPath: extensions,
  142. }, nil),
  143. }
  144. rewriteHandler := rewrite.Rewrite{
  145. URI: "{http.matchers.file.relative}",
  146. }
  147. rewriteRoute := caddyhttp.Route{
  148. MatcherSetsRaw: []caddy.ModuleMap{rewriteMatcherSet},
  149. HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(rewriteHandler, "handler", "rewrite", nil)},
  150. }
  151. // route to actually pass requests to PHP files;
  152. // match only requests that are for PHP files
  153. pathList := []string{}
  154. for _, ext := range extensions {
  155. pathList = append(pathList, "*"+ext)
  156. }
  157. phpMatcherSet := caddy.ModuleMap{
  158. "path": caddyconfig.JSON(pathList, nil),
  159. }
  160. // create the PHP route which is
  161. // conditional on matching PHP files
  162. phpRoute := caddyhttp.Route{
  163. MatcherSetsRaw: []caddy.ModuleMap{phpMatcherSet},
  164. HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(phpHandler, "handler", "php", nil)},
  165. }
  166. fileRoute := caddyhttp.Route{
  167. MatcherSetsRaw: []caddy.ModuleMap{},
  168. HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(fileserver.FileServer{Root: root}, "handler", "file_server", nil)},
  169. }
  170. subroute := caddyhttp.Subroute{
  171. Routes: caddyhttp.RouteList{redirRoute, rewriteRoute, phpRoute, fileRoute},
  172. }
  173. if compress {
  174. gzip, err := caddy.GetModule("http.encoders.gzip")
  175. if err != nil {
  176. return caddy.ExitCodeFailedStartup, err
  177. }
  178. br, err := caddy.GetModule("http.encoders.br")
  179. if err != nil {
  180. return caddy.ExitCodeFailedStartup, err
  181. }
  182. zstd, err := caddy.GetModule("http.encoders.zstd")
  183. if err != nil {
  184. return caddy.ExitCodeFailedStartup, err
  185. }
  186. encodeRoute := caddyhttp.Route{
  187. MatcherSetsRaw: []caddy.ModuleMap{},
  188. HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(encode.Encode{
  189. EncodingsRaw: caddy.ModuleMap{
  190. "zstd": caddyconfig.JSON(zstd.New(), nil),
  191. "br": caddyconfig.JSON(br.New(), nil),
  192. "gzip": caddyconfig.JSON(gzip.New(), nil),
  193. },
  194. Prefer: []string{"zstd", "br", "gzip"},
  195. }, "handler", "encode", nil)},
  196. }
  197. subroute.Routes = append(caddyhttp.RouteList{encodeRoute}, subroute.Routes...)
  198. }
  199. if mercure {
  200. mercurePublisherJwtKey := os.Getenv("MERCURE_PUBLISHER_JWT_KEY")
  201. if mercurePublisherJwtKey == "" {
  202. panic(`The "MERCURE_PUBLISHER_JWT_KEY" environment variable must be set to use the Mercure.rocks hub`)
  203. }
  204. mercureSubscriberJwtKey := os.Getenv("MERCURE_SUBSCRIBER_JWT_KEY")
  205. if mercureSubscriberJwtKey == "" {
  206. panic(`The "MERCURE_SUBSCRIBER_JWT_KEY" environment variable must be set to use the Mercure.rocks hub`)
  207. }
  208. mercureRoute := caddyhttp.Route{
  209. HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(
  210. mercureModule.Mercure{
  211. PublisherJWT: mercureModule.JWTConfig{
  212. Alg: os.Getenv("MERCURE_PUBLISHER_JWT_ALG"),
  213. Key: mercurePublisherJwtKey,
  214. },
  215. SubscriberJWT: mercureModule.JWTConfig{
  216. Alg: os.Getenv("MERCURE_SUBSCRIBER_JWT_ALG"),
  217. Key: mercureSubscriberJwtKey,
  218. },
  219. },
  220. "handler",
  221. "mercure",
  222. nil,
  223. ),
  224. },
  225. }
  226. subroute.Routes = append(caddyhttp.RouteList{mercureRoute}, subroute.Routes...)
  227. }
  228. route := caddyhttp.Route{
  229. HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(subroute, "handler", "subroute", nil)},
  230. }
  231. if domain != "" {
  232. route.MatcherSetsRaw = []caddy.ModuleMap{
  233. {
  234. "host": caddyconfig.JSON(caddyhttp.MatchHost{domain}, nil),
  235. },
  236. }
  237. }
  238. server := &caddyhttp.Server{
  239. ReadHeaderTimeout: caddy.Duration(10 * time.Second),
  240. IdleTimeout: caddy.Duration(30 * time.Second),
  241. MaxHeaderBytes: 1024 * 10,
  242. Routes: caddyhttp.RouteList{route},
  243. }
  244. if listen == "" {
  245. if domain == "" {
  246. listen = ":80"
  247. } else {
  248. listen = ":" + strconv.Itoa(certmagic.HTTPSPort)
  249. }
  250. }
  251. server.Listen = []string{listen}
  252. if accessLog {
  253. server.Logs = &caddyhttp.ServerLogConfig{}
  254. }
  255. httpApp := caddyhttp.App{
  256. Servers: map[string]*caddyhttp.Server{"php": server},
  257. }
  258. var false bool
  259. cfg := &caddy.Config{
  260. Admin: &caddy.AdminConfig{
  261. Disabled: !admin,
  262. Config: &caddy.ConfigSettings{
  263. Persist: &false,
  264. },
  265. },
  266. AppsRaw: caddy.ModuleMap{
  267. "http": caddyconfig.JSON(httpApp, nil),
  268. "frankenphp": caddyconfig.JSON(FrankenPHPApp{Workers: workersOption}, nil),
  269. },
  270. }
  271. if debug {
  272. cfg.Logging = &caddy.Logging{
  273. Logs: map[string]*caddy.CustomLog{
  274. "default": {
  275. BaseLog: caddy.BaseLog{Level: zap.DebugLevel.CapitalString()},
  276. },
  277. },
  278. }
  279. }
  280. err = caddy.Run(cfg)
  281. if err != nil {
  282. return caddy.ExitCodeFailedStartup, err
  283. }
  284. log.Printf("Caddy serving PHP app on %s", listen)
  285. select {}
  286. }