diff --git a/docs/ai.md b/docs/ai.md index 5410fa90..506224e6 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -46,7 +46,7 @@ These tools only read, so most clients run them without asking: | Tool | What it does | | --- | --- | | `status` | Whether Flock runs, the default PHP and Node.js versions, and any problems | -| `site_info` | A site's URLs, folder, HTTPS, PHP and Node.js versions (and where they come from), and its server | +| `site_info` | A site's URLs, folder, HTTPS, PHP and Node.js versions (and where they come from), its server, and its Reverb server with the `.env` lines it needs | | `php_extensions` | The PHP extensions a site requires or uses, and whether Flock's PHP has each | | `list_sites` | Every site with its URL, folder and PHP version | | `list_services` | Databases and other services: installed, running, their port and the `.env` settings to connect | @@ -66,7 +66,7 @@ These tools change things, so clients ask you first: | `service` | Install, start or stop a service | | `install_version` | Install a PHP or Node.js version | | `link_site` | Serve a project as a new site | -| `update_site` | Turn HTTPS on or off, isolate, unisolate, pin or unpin a version, add a domain, switch between FrankenPHP (with or without Octane) and PHP-FPM, or restart it | +| `update_site` | Turn HTTPS on or off, isolate, unisolate, pin or unpin a version, add a domain, switch between FrankenPHP (with or without Octane) and PHP-FPM, run or stop its Reverb server, or restart it | | `set_debugging` | Turn Xdebug or dump capture on or off | | `create_database` | Create a database in PostgreSQL, MySQL or MariaDB | diff --git a/docs/cli.md b/docs/cli.md index 7d055698..dda361de 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -57,10 +57,14 @@ This page lists the `flock` commands. The other pages show where to find each ac | `flock frankenphp` | Show the current site's server and the installed FrankenPHP builds | | `flock frankenphp on [site] [--octane\|--classic]` | Serve a site with FrankenPHP, optionally in Laravel Octane's worker mode | | `flock frankenphp off [site]` | Serve a site with PHP-FPM again (php-cgi on Windows) | -| `flock frankenphp restart [site]` | Restart a site's FrankenPHP, e.g. to load changed code into Octane's workers | +| `flock frankenphp restart [site]` | Restart a site's FrankenPHP (and its Reverb server), e.g. to load changed code into Octane's workers | | `flock frankenphp list` | List FrankenPHP builds, installed and available | | `flock frankenphp install ` | Install or update FrankenPHP for a PHP version (`--from ` for a local package) | | `flock frankenphp uninstall ` | Remove FrankenPHP for a PHP version | +| `flock reverb [site]` | Show a site's [Reverb](php.md#laravel-reverb) server and the `.env` lines its app needs | +| `flock reverb on [site]` | Run a Laravel app's Reverb server, reached at `wss://.test/app` | +| `flock reverb off [site]` | Stop a site's Reverb server | +| `flock reverb restart [site]` | Restart a site's Reverb server (and its FrankenPHP), e.g. to load changed code | ## Services diff --git a/docs/introduction.md b/docs/introduction.md index 8bac2456..ed410ea6 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -14,7 +14,7 @@ Flock is a free, open-source local development environment for Laravel on **Wind Flock is a single `flock` binary that runs as a small background service (the *daemon*): -- An embedded [Caddy](https://caddyserver.com) web server listens on ports 80 and 443 and passes PHP requests to PHP-FPM (macOS and Linux) or a pool of `php-cgi` workers (Windows). A site can also run on [FrankenPHP](php.md#frankenphp), with Laravel Octane's worker mode if you like. +- An embedded [Caddy](https://caddyserver.com) web server listens on ports 80 and 443 and passes PHP requests to PHP-FPM (macOS and Linux) or a pool of `php-cgi` workers (Windows). A site can also run on [FrankenPHP](php.md#frankenphp), with Laravel Octane's worker mode if you like. For a Laravel app, Flock can run its [Reverb](php.md#laravel-reverb) server, reached at the site's own address. - An embedded DNS server answers for `*.test`, so new sites work immediately without editing your hosts file. - A local certificate authority issues HTTPS certificates. Its root is restricted to your local TLD, so it can't be used to impersonate real websites. - Small **shims** for `php`, `composer`, `node`, `npm`, `npx` and the database clients sit on your PATH and run the version that the current project uses. diff --git a/docs/php.md b/docs/php.md index e6368f36..f59cc7f2 100644 --- a/docs/php.md +++ b/docs/php.md @@ -132,6 +132,40 @@ flock frankenphp install 8.4 # install or update the build for a PHP vers Run them without a site name inside the project folder. ::: +## Laravel Reverb + +Flock can run a Laravel app's [Reverb](https://reverb.laravel.com) server for its site. Browsers reach it at the site's own address, `wss://acme.test/app`, through Flock's web server, so there's no port to open and no separate certificate. Flock runs `php artisan reverb:start` in the project with the site's PHP version, on a loopback port of its own. The project needs Laravel Reverb (`php artisan install:broadcasting --reverb`). + +Only Reverb's traffic goes to it: WebSocket connections under `/app/` and the signed requests Laravel broadcasts through under `/apps/`. Every other request on those paths stays the app's, so a panel at `/app` keeps working. + +The app has to send its broadcasts and its Echo client to the site rather than to Reverb's own port. Flock shows the `.env` lines that do this, but doesn't change the project's `.env` itself: + +```env +REVERB_HOST=acme.test +REVERB_PORT=443 +REVERB_SCHEME=https +``` + +On a site served over HTTP only, they are `REVERB_PORT=80` and `REVERB_SCHEME=http`. `install:broadcasting --reverb` writes the `VITE_REVERB_*` variables as references to these, so the Echo client follows them. Rebuild the front end (`npm run build`, or restart `npm run dev`) after changing them. + +Restart the Reverb server to load changed code. A restart also restarts the site's FrankenPHP if the site runs on it, and `flock frankenphp restart` restarts Reverb too. Reverb's output goes to the `reverb-acme` [log](logs.md). + +:::app +Reverb isn't in the app yet. Use the CLI commands below, or ask an [AI agent](ai.md) to turn it on: Flock's `update_site` tool runs and stops it. +::: + +:::cli +```bash +flock reverb on acme # run the site's Reverb server; prints the .env lines +flock reverb acme # its state and the .env lines +flock reverb restart acme # load changed code +flock reverb off acme # stop it +flock logs reverb-acme # its output +``` + +Run them without a site name inside the project folder. `flock sites` marks a site with Reverb on as `+ Reverb`. +::: + ## Composer `composer` is included and runs with the PHP version of the current project, so `composer install` always matches the version the browser uses. If the project's PHP version isn't installed yet, `php` and `composer` offer to install it the first time you run them. diff --git a/internal/api/api.go b/internal/api/api.go index 87f21899..a64e3cda 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -144,6 +144,8 @@ type SiteStatus struct { // FrankenPHP is the state of the site's FrankenPHP process, when Server is "frankenphp". Until // it runs, the site is served through FastCGI. FrankenPHP *SiteFrankenPHP `json:"frankenphp,omitempty"` + // Reverb is the state of the site's Laravel Reverb server, when it's turned on. + Reverb *SiteReverb `json:"reverb,omitempty"` // Boost is whether the project uses Laravel Boost and has Flock's guideline (see package boost): // "" (no Boost), "missing", "current", "outdated" or "custom". Boost string `json:"boost,omitempty"` @@ -170,6 +172,22 @@ type SiteFrankenPHP struct { Log string `json:"log"` // name for /v1/logs/services/{name} } +// Reverb states of a site. +const ( + ReverbRunning = "running" + ReverbFailed = "failed" // see Problem + ReverbUnavailable = "unavailable" // the project has no Laravel Reverb; see Problem +) + +// SiteReverb is a site's Laravel Reverb server. +type SiteReverb struct { + State string `json:"state"` + Port int `json:"port"` // loopback port the web server proxies to + Env []string `json:"env,omitempty"` // the app's .env lines for reaching it through the web server + Problem string `json:"problem,omitempty"` + Log string `json:"log"` // name for /v1/logs/services/{name} +} + // SiteDomain is one of a site's extra domains. type SiteDomain struct { Entry string `json:"entry"` // as stored and passed to the API: "tenant1" or "my-app-2.test" @@ -227,7 +245,7 @@ type Backend interface { FrankenPHP() *frankenphp.Manager // Binaries is the package manifest of the configured channel (nil when there is none). Binaries() *binaries.Catalog - // RestartSite restarts a FrankenPHP site's process, e.g. so Octane's workers load changed code. + // RestartSite restarts a site's FrankenPHP and Reverb processes, e.g. so they load changed code. RestartSite(name string) error // Shares runs the sites' tunnels (nil when unavailable); StartShare starts one after checking // that the site can be shared, with the app's debug mode on for visitors when debug is set. @@ -331,10 +349,11 @@ func (c *Client) Status(ctx context.Context) (Status, error) { return status, err } -// Reload asks the daemon to apply config.json and returns the resulting status. +// Reload asks the daemon to apply config.json and returns the resulting status. It may start a +// site's FrankenPHP or Reverb and wait for it (Octane up to 20 s), so it gets UpdateSite's minute. func (c *Client) Reload(ctx context.Context) (Status, error) { var status Status - err := c.do(ctx, http.MethodPost, "/v1/reload", &status) + err := c.timeout(time.Minute).do(ctx, http.MethodPost, "/v1/reload", &status) return status, err } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 11f9f002..a5b5e2ed 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -424,6 +424,43 @@ func TestSitesSwitchToFrankenPHPAndBack(t *testing.T) { } } +func TestSitesTurnReverbOnAndOff(t *testing.T) { + root := t.TempDir() + b := &fakeBackend{root: root} + srv := serve(t, b, "") + app, plain := t.TempDir(), t.TempDir() + for _, file := range []string{filepath.Join(app, "artisan"), filepath.Join(app, "vendor", "laravel", "reverb", "composer.json")} { + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + } + cfg := b.config(t) + cfg.Upsert(config.Site{Name: "app", Path: app}) + cfg.Upsert(config.Site{Name: "plain", Path: plain}) + if err := cfg.Save(root); err != nil { + t.Fatal(err) + } + + if resp, body := call(t, "PATCH", srv.URL+"/v1/sites/app", "", `{"reverb":true}`); resp.StatusCode != http.StatusOK { + t.Fatalf("on: %d %s", resp.StatusCode, body) + } + if site, _ := b.config(t).FindSite("app"); !site.Reverb || site.ReverbPort != config.FirstSitePort { + t.Fatalf("site: %+v", site) + } + if resp, body := call(t, "PATCH", srv.URL+"/v1/sites/app", "", `{"reverb":false}`); resp.StatusCode != http.StatusOK { + t.Fatalf("off: %d %s", resp.StatusCode, body) + } + if site, _ := b.config(t).FindSite("app"); site.Reverb || site.ReverbPort != config.FirstSitePort { + t.Fatalf("site: %+v", site) + } + if resp, body := call(t, "PATCH", srv.URL+"/v1/sites/plain", "", `{"reverb":true}`); resp.StatusCode != http.StatusBadRequest || !strings.Contains(body, "install:broadcasting --reverb") { + t.Fatalf("without Reverb: %d %s", resp.StatusCode, body) + } +} + // jsonString quotes s for a JSON body (Windows paths have backslashes). func jsonString(s string) string { b, _ := json.Marshal(s) diff --git a/internal/api/configapi.go b/internal/api/configapi.go index 479f1188..46bc9953 100644 --- a/internal/api/configapi.go +++ b/internal/api/configapi.go @@ -21,6 +21,7 @@ import ( "flock/internal/php" "flock/internal/ports" "flock/internal/project" + "flock/internal/reverb" ) // Service is one background service as shown in the Services settings. @@ -243,6 +244,7 @@ func registerConfig(mux *http.ServeMux, b Backend) { // Server is "fastcgi" or "frankenphp"; Worker "" (classic) or "octane" (FrankenPHP's worker mode). Server *string `json:"server"` Worker *string `json:"worker"` + Reverb *bool `json:"reverb"` // Root is the document root on this machine, relative to the project ("" lets the // project's .flockrc or detection decide again). Root *string `json:"root"` @@ -296,6 +298,12 @@ func registerConfig(mux *http.ServeMux, b Backend) { return err } } + if body.Reverb != nil { + if *body.Reverb && !reverb.Installed(site.Path) { + return invalid("Laravel Reverb isn't installed in %s — run `php artisan install:broadcasting --reverb`", site.Path) + } + cfg.SetReverb(&site, *body.Reverb) + } // A parked site becomes linked so its settings can be stored. site.Parked = false cfg.Upsert(site) @@ -520,6 +528,7 @@ type SiteChange struct { Node *string `json:"node,omitempty"` Server *string `json:"server,omitempty"` Worker *string `json:"worker,omitempty"` + Reverb *bool `json:"reverb,omitempty"` Root *string `json:"root,omitempty"` } @@ -555,7 +564,8 @@ func (c *Client) LinkSite(ctx context.Context, dir, name string) (Status, error) return out, err } -// UpdateSite changes a site's settings. Switching servers can take a while (FrankenPHP starts). +// UpdateSite changes a site's settings. Switching servers or turning on Reverb can take a while +// (the process starts). func (c *Client) UpdateSite(ctx context.Context, name string, change SiteChange) (Status, error) { var out Status err := c.timeout(time.Minute).send(ctx, http.MethodPatch, "/v1/sites/"+url.PathEscape(name), change, &out) diff --git a/internal/cli/frankenphp.go b/internal/cli/frankenphp.go index f2a58386..ce7af2f5 100644 --- a/internal/cli/frankenphp.go +++ b/internal/cli/frankenphp.go @@ -133,7 +133,7 @@ first time a site needs it. php.ini is the regular PHP's (see ` + "`flock php li restart := &cobra.Command{ Use: "restart [site]", - Short: "Restart a site's FrankenPHP (Octane's workers load changed code)", + Short: "Restart a site's FrankenPHP and Reverb (Octane's workers load changed code)", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { s, err := load() diff --git a/internal/cli/reverb.go b/internal/cli/reverb.go new file mode 100644 index 00000000..be9ab581 --- /dev/null +++ b/internal/cli/reverb.go @@ -0,0 +1,128 @@ +package cli + +import ( + "errors" + "strings" + + "github.com/spf13/cobra" + + "flock/internal/api" + "flock/internal/reverb" +) + +func newReverbCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "reverb", + Short: "Run a Laravel app's Reverb server, reachable at wss://.test/app", + Long: `Flock runs ` + "`php artisan reverb:start`" + ` for the site with its PHP version, on a loopback port, and its +web server sends the site's WebSocket connections (/app/...) and broadcasts (/apps/...) there. +Browsers connect to the site's own address, over HTTPS when the site is secured; the app's .env +needs the REVERB_HOST, REVERB_PORT and REVERB_SCHEME lines ` + "`flock reverb`" + ` shows. + + flock reverb [site] show the site's Reverb server and its .env lines + flock reverb on [site] run it (the project needs ` + "`php artisan install:broadcasting --reverb`" + `) + flock reverb off [site] stop it + flock reverb restart [site] restart it, e.g. to load changed code`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + s, err := load() + if err != nil { + return err + } + site, err := siteArg(s, args) + if err != nil { + return err + } + return showReverb(cmd, s, site.Name) + }, + } + + toggle := func(on bool) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + s, err := load() + if err != nil { + return err + } + site, err := siteArg(s, args) + if err != nil { + return err + } + if on && !reverb.Installed(site.Path) { + return errors.New("Laravel Reverb isn't installed in " + site.Path + " — run `php artisan install:broadcasting --reverb` first") + } + s.cfg.SetReverb(&site, on) + s.cfg.Upsert(site) + if err := s.commit(cmd.Context(), cmd.ErrOrStderr()); err != nil { + return err + } + if !on { + cmd.Printf("stopped %s's Reverb server\n", site.Name) + return nil + } + // commit has already said that a stopped daemon applies the change when it starts. + if err := showReverb(cmd, s, site.Name); !errors.Is(err, api.ErrDaemonNotRunning) { + return err + } + return nil + } + } + on := &cobra.Command{Use: "on [site]", Short: "Run a site's Reverb server", Args: cobra.MaximumNArgs(1), RunE: toggle(true)} + off := &cobra.Command{Use: "off [site]", Short: "Stop a site's Reverb server", Args: cobra.MaximumNArgs(1), RunE: toggle(false)} + + restart := &cobra.Command{ + Use: "restart [site]", + Short: "Restart a site's Reverb server (and its FrankenPHP), e.g. to load changed code", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + s, err := load() + if err != nil { + return err + } + site, err := siteArg(s, args) + if err != nil { + return err + } + if !site.Reverb { + return errors.New("Reverb is off for " + site.Name + " — `flock reverb on` runs it") + } + st, err := s.client().RestartSite(cmd.Context(), site.Name) + if err != nil { + return err + } + printReverb(cmd, st, site.Name) + return nil + }, + } + + cmd.AddCommand(on, off, restart) + return cmd +} + +// showReverb prints the site's Reverb server as the daemon reports it. +func showReverb(cmd *cobra.Command, s *state, name string) error { + st, err := s.client().Status(cmd.Context()) + if err != nil { + return err + } + printReverb(cmd, st, name) + return nil +} + +func printReverb(cmd *cobra.Command, st api.Status, name string) { + for _, site := range st.Sites { + if site.Name != name { + continue + } + r := site.Reverb + switch { + case r == nil: + cmd.Printf("Reverb is off for %s — `flock reverb on` runs it\n", name) + return + case r.State == api.ReverbRunning: + cmd.Printf("%s's Reverb server is running, log: `flock logs %s`\n", name, r.Log) + default: + cmd.Printf("%s's Reverb server isn't running: %s\n", name, r.Problem) + } + cmd.Printf("in the app's .env:\n %s\n", strings.Join(r.Env, "\n ")) + } +} diff --git a/internal/cli/reverb_test.go b/internal/cli/reverb_test.go new file mode 100644 index 00000000..3d514d97 --- /dev/null +++ b/internal/cli/reverb_test.go @@ -0,0 +1,30 @@ +package cli + +import ( + "errors" + "io" + "testing" + + "flock/internal/api" + "flock/internal/config" +) + +func TestReverbStatusSaysWhenTheDaemonIsNotRunning(t *testing.T) { + root := t.TempDir() + t.Setenv("FLOCK_HOME", root) + cfg := config.Default() + cfg.Upsert(config.Site{Name: "app", Path: t.TempDir(), Reverb: true, ReverbPort: 21000}) + if err := cfg.Save(root); err != nil { + t.Fatal(err) + } + + cmd := newReverbCommand() + cmd.SetArgs([]string{"app"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + // It used to exit successfully without printing anything. + if err := cmd.Execute(); !errors.Is(err, api.ErrDaemonNotRunning) { + t.Fatalf("err = %v, want %v", err, api.ErrDaemonNotRunning) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index acc4e133..4c66ae6f 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -28,7 +28,7 @@ func NewRootCommand() *cobra.Command { newSitesCommand(), newLinkCommand(), newUnlinkCommand(), newParkCommand(), newUnparkCommand(), newSecureCommand(true), newSecureCommand(false), newDomainCommand(), newShareCommand(), newUseCommand(), newIsolateCommand(), newUnisolateCommand(), newIsolatedCommand(), newPinCommand(), newUnpinCommand(), newRootCommand(), - newPHPCommand(), newNodeCommand(), newServicesCommand(), newXdebugCommand(), newDebugCommand(), newFrankenPHPCommand(), newDumpsCommand(), newEventsCommand(), newMailCommand(), + newPHPCommand(), newNodeCommand(), newServicesCommand(), newXdebugCommand(), newDebugCommand(), newFrankenPHPCommand(), newReverbCommand(), newDumpsCommand(), newEventsCommand(), newMailCommand(), newExecCommand(), newMCPCommand(), newBoostCommand(), newChannelCommand(), newExtCommand(), ) diff --git a/internal/cli/status.go b/internal/cli/status.go index 113c816b..a6cb8c6c 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -128,6 +128,9 @@ func newSitesCommand() *cobra.Command { version += " (Octane)" } } + if site.Reverb != nil { + version += " + Reverb" + } fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", site.Name, version, url, rootColumn(docRootOf(site)), site.Path) } return w.Flush() diff --git a/internal/config/config.go b/internal/config/config.go index e730a0ab..9bbdcf79 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -131,6 +131,10 @@ type Site struct { // It is kept while the site runs on FastCGI, for switching back. Worker string `json:"worker,omitempty"` Port int `json:"port,omitempty"` // FrankenPHP's loopback port, assigned by SetServer + // Reverb runs the app's Laravel Reverb server on ReverbPort, and the web server sends the site's + // WebSocket and broadcast requests there. The port is kept while Reverb is off. + Reverb bool `json:"reverb,omitempty"` + ReverbPort int `json:"reverb_port,omitempty"` } // Servers and worker modes of a site (see Site.Server and Site.Worker). @@ -140,13 +144,17 @@ const ( WorkerOctane = "octane" ) -// FirstSitePort is where the loopback ports of FrankenPHP sites start. +// FirstSitePort is where the loopback ports of sites' FrankenPHP and Reverb processes start. const FirstSitePort = 21000 // SitePortID is how UsedPorts names the port of a FrankenPHP site: "frankenphp-", which is // also the name of its process and log. func SitePortID(site string) string { return "frankenphp-" + site } +// ReverbPortID is how UsedPorts names the port of a site's Reverb server: "reverb-", which +// is also the name of its process and log. +func ReverbPortID(site string) string { return "reverb-" + site } + // FrankenPHP reports whether the site runs on FrankenPHP. func (s Site) FrankenPHP() bool { return s.Server == ServerFrankenPHP } @@ -165,33 +173,46 @@ func (c *Config) SetServer(site *Site, server, worker string) error { return fmt.Errorf("unknown worker mode %q (octane, or empty for classic)", worker) } site.Server, site.Worker, site.Parked = server, worker, false - if server == ServerFrankenPHP && (site.Port == 0 || c.portTaken(site.Port, site.Name)) { - site.Port = c.nextSitePort(site.Name) + if server == ServerFrankenPHP { + reverb := 0 + if site.Reverb { + reverb = site.ReverbPort + } + site.Port = c.sitePort(site.Port, SitePortID(site.Name), reverb) } return nil } -// portTaken reports whether a service or another site uses port. -func (c *Config) portTaken(port int, site string) bool { - if _, used := c.UsedPorts("")[port]; used { - for _, s := range c.Sites { - if s.Name == site && s.Port == port && s.FrankenPHP() { - return false - } +// SetReverb turns the site's Reverb server on or off, giving it a loopback port the first time. +// The site becomes linked so the setting can be stored; the caller saves it with Upsert. +func (c *Config) SetReverb(site *Site, on bool) { + site.Reverb, site.Parked = on, false + if on { + frankenphp := 0 + if site.FrankenPHP() { + frankenphp = site.Port } - return true + site.ReverbPort = c.sitePort(site.ReverbPort, ReverbPortID(site.Name), frankenphp) } - return false } -func (c *Config) nextSitePort(site string) int { - port := FirstSitePort - for c.portTaken(port, site) { - port++ +// sitePort keeps port for the process id when nothing else uses it, or finds the first free one. +// sibling is the site's other process's port, which may not be saved yet. +func (c *Config) sitePort(port int, id string, sibling int) int { + if port != 0 && port != sibling && !c.portTaken(port, id) { + return port + } + for port = FirstSitePort; port == sibling || c.portTaken(port, id); port++ { } return port } +// portTaken reports whether a service or a process other than id uses port. +func (c *Config) portTaken(port int, id string) bool { + other, used := c.UsedPorts("")[port] + return used && other != id +} + // Default returns the config used when none exists yet. It has no default PHP and Node.js: the // daemon installs the newest stable ones on the first run and makes them the defaults then. func Default() *Config { @@ -245,6 +266,9 @@ func (c *Config) UsedPorts(except string) map[int]string { if s.FrankenPHP() && s.Port != 0 { used[s.Port] = SitePortID(s.Name) } + if s.Reverb && s.ReverbPort != 0 { + used[s.ReverbPort] = ReverbPortID(s.Name) + } } return used } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 38bca3e3..9615b96b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -112,6 +112,33 @@ func TestSetServerAssignsStablePortsAndValidates(t *testing.T) { t.Errorf("UsedPorts names b's port %q", got) } + // Reverb gets a port of its own, past b's, and keeps it while off. + cfg.SetReverb(&a, true) + if !a.Reverb || a.ReverbPort != FirstSitePort+3 { + t.Fatalf("reverb: %+v", a) + } + cfg.Upsert(a) + if got := cfg.UsedPorts("")[FirstSitePort+3]; got != "reverb-a" { + t.Errorf("UsedPorts names a's Reverb port %q", got) + } + cfg.SetReverb(&a, false) + cfg.Upsert(a) + if _, used := cfg.UsedPorts("")[FirstSitePort+3]; used || a.ReverbPort != FirstSitePort+3 { + t.Errorf("Reverb off: %+v", a) + } + cfg.SetReverb(&a, true) + if a.ReverbPort != FirstSitePort+3 { + t.Errorf("Reverb on again: %+v", a) + } + cfg.Upsert(a) + + // Both switched on before either is saved: the ports still differ. + c := Site{Name: "c", Path: "/srv/c"} + cfg.SetReverb(&c, true) + if err := cfg.SetServer(&c, ServerFrankenPHP, ""); err != nil || c.ReverbPort != FirstSitePort+4 || c.Port != FirstSitePort+5 { + t.Fatalf("c: %+v, %v", c, err) + } + if err := cfg.SetServer(&a, "swoole", ""); err == nil { t.Error("unknown server accepted") } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index a4d76a64..69046311 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -75,7 +75,8 @@ type daemon struct { php *php.Manager // binaries is the manifest of prebuilt packages for the configured channel, shared by the managers. binaries *binaries.Catalog - // frankenphp installs FrankenPHP builds; restarts counts RestartSite calls per site (see frankenphp.Site.Restart). + // frankenphp installs FrankenPHP builds; restarts counts RestartSite calls per site (see + // frankenphp.Site.Restart and reverb.Site.Restart). frankenphp *frankenphp.Manager restarts map[string]int // pg manages the PostgreSQL versions, catalog the other services (MySQL, Valkey, Meilisearch, ...). @@ -628,6 +629,7 @@ func (d *daemon) reload() error { // Its names get a page that says what's missing instead of the site (see web.Waiting). w := d.waitingFor(site, phpVersion, domain, aliases) status.Problem = waitingProblem(w) + status.Reverb = reverbBlocked(cfg, site, scheme, status.Problem) waiting = append(waiting, w) domains = append(append(domains, domain), served...) statuses = append(statuses, status) @@ -645,6 +647,7 @@ func (d *daemon) reload() error { if root.State == project.RootMissing { status.Problem = rootProblem(root) } + status.Reverb = reverbBlocked(cfg, site, scheme, "the site has no usable document root") rootless = append(rootless, r) status.URL = scheme + "://" + domain + portSuffix(scheme, cfg) domains = append(append(domains, domain), served...) @@ -681,6 +684,27 @@ func (d *daemon) reload() error { return err } } + if site.Reverb { + spec, rs := d.reverbSite(cfg, site, version, scheme, extraEnv) + if spec != nil { + restarted := d.procs.Ensure(*spec) + keep[spec.Name] = true + ws.Reverb = fmt.Sprintf("127.0.0.1:%d", site.ReverbPort) + wait := 300 * time.Millisecond + if restarted { + wait = 10 * time.Second // Reverb boots the app first + } + if php.WaitReady([]string{ws.Reverb}, wait) { + rs.State = api.ReverbRunning + } else { + rs.State, rs.Problem = api.ReverbFailed, fmt.Sprintf("Reverb did not start — see `flock logs %s`", spec.Name) + warnings = append(warnings, fmt.Sprintf("reverb: %s did not start — see `flock logs %s`", site.Name, spec.Name)) + } + } else if w := reverbPreflightWarning(site.Name, rs); w != "" { + warnings = append(warnings, w) + } + status.Reverb = &rs + } if cfg.Xdebug && cfg.XdebugBreakpoints { breakpoints[site.Path] = d.breakpoints.Has(site.Path) ws.Debugging = breakpoints[site.Path] && len(ws.DebugFPM) > 0 @@ -896,6 +920,9 @@ func serviceName(id string) string { case "postgres": return "PostgreSQL" } + if site, ok := strings.CutPrefix(id, "reverb-"); ok { + return site + "'s Reverb server" + } for _, k := range catalog.Kinds(runtime.GOOS) { if k.ID == id { return k.Name @@ -1016,12 +1043,29 @@ func (d *daemon) ReleasePHP(minor string) { d.reloadMu.Lock() defer d.reloadMu.Unlock() + d.mu.Lock() + cfg := d.cfg + d.mu.Unlock() + d.procs.Retain(notUsingPHP(cfg, minor, d.procs.Running())) +} + +// notUsingPHP marks which of the running processes don't run PHP minor: the dump server, the +// version's pools and the Reverb servers of the sites on it do. +func notUsingPHP(cfg *config.Config, minor string, running []string) map[string]bool { + // A site's Reverb server is a php.exe of the site's version, named after the site. + reverbs := map[string]bool{} + if cfg != nil { + for _, site := range cfg.AllSites() { + if site.Reverb && (minor == "" || project.PHPFor(cfg, &site, site.Path).Version == minor) { + reverbs[config.ReverbPortID(site.Name)] = true + } + } + } keep := map[string]bool{} - for _, name := range d.procs.Running() { - usesPHP := name == "dump-server" || strings.HasPrefix(name, "php"+minor) - keep[name] = !usesPHP + for _, name := range running { + keep[name] = !(name == "dump-server" || strings.HasPrefix(name, "php"+minor) || reverbs[name]) } - d.procs.Retain(keep) + return keep } // Services implements api.Backend. diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 2da28704..6fdc2311 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -161,6 +161,28 @@ func TestReloadReportsSitesWithMissingPHP(t *testing.T) { } } +func TestReloadSaysWhyAnEnabledReverbServerCantRun(t *testing.T) { + root, cfg := testRoot(t, nil) + client := startDaemon(t, root) + + cfg.Upsert(config.Site{Name: "app", Path: t.TempDir(), PHP: "7.0", Reverb: true, ReverbPort: freePort(t)}) + if err := cfg.Save(root); err != nil { + t.Fatal(err) + } + + status, err := client.Reload(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(status.Sites) != 1 { + t.Fatalf("unexpected sites: %+v", status.Sites) + } + // Without it `flock reverb` read the missing status as "Reverb is off" and suggested turning it on. + if r := status.Sites[0].Reverb; r == nil || r.State != api.ReverbUnavailable || r.Problem != "PHP 7.0 is not installed" { + t.Fatalf("reverb status: %+v", r) + } +} + func TestTheFirstRunWaitsForTheDefaultsAndSaysSo(t *testing.T) { root, cfg := testRoot(t, func(cfg *config.Config) { cfg.DefaultPHP, cfg.DefaultNode = "", "" }) cfg.Upsert(config.Site{Name: "app", Path: t.TempDir()}) diff --git a/internal/daemon/frankenphp.go b/internal/daemon/frankenphp.go index 610c2c02..04fb91cf 100644 --- a/internal/daemon/frankenphp.go +++ b/internal/daemon/frankenphp.go @@ -74,8 +74,8 @@ func (d *daemon) frankenPHPSite(cfg *config.Config, site config.Site, minor, doc return fp } -// RestartSite implements api.Backend: it restarts a FrankenPHP site's process, e.g. so Octane's -// workers load changed code. +// RestartSite implements api.Backend: it restarts a site's FrankenPHP and Reverb processes, e.g. so +// Octane's workers or Reverb load changed code. func (d *daemon) RestartSite(name string) error { d.mu.Lock() cfg := d.cfg @@ -87,8 +87,8 @@ func (d *daemon) RestartSite(name string) error { if !ok { return fmt.Errorf("no site named %s", name) } - if !site.FrankenPHP() { - return fmt.Errorf("%s doesn't run on FrankenPHP", name) + if !site.FrankenPHP() && !site.Reverb { + return fmt.Errorf("%s runs neither FrankenPHP nor Reverb", name) } d.mu.Lock() d.restarts[name]++ diff --git a/internal/daemon/reverb.go b/internal/daemon/reverb.go new file mode 100644 index 00000000..f27b9f03 --- /dev/null +++ b/internal/daemon/reverb.go @@ -0,0 +1,66 @@ +package daemon + +import ( + "fmt" + + "flock/internal/api" + "flock/internal/config" + "flock/internal/ports" + "flock/internal/reverb" + "flock/internal/supervisor" +) + +// reverbStatus is a site's Reverb server as status reports it, before its state is known. +func reverbStatus(cfg *config.Config, site config.Site, scheme string) api.SiteReverb { + port := cfg.Ports.HTTP + if scheme == "https" { + port = cfg.Ports.HTTPS + } + return api.SiteReverb{Port: site.ReverbPort, Log: config.ReverbPortID(site.Name), Env: reverb.Env(cfg.Domain(site), scheme, port)} +} + +// reverbBlocked is the status of an enabled Reverb server that can't run because the site itself +// can't be served (no PHP, no document root), or nil when Reverb is off. +func reverbBlocked(cfg *config.Config, site config.Site, scheme, problem string) *api.SiteReverb { + if !site.Reverb { + return nil + } + st := reverbStatus(cfg, site, scheme) + st.State, st.Problem = api.ReverbUnavailable, problem + return &st +} + +// reverbPreflightWarning is the warning for a Reverb server that couldn't be started at all (its +// port is busy, say), or "". Warnings make the daemon retry its reload, so Reverb starts once the +// port is free; without one an otherwise healthy daemon never tried again. +func reverbPreflightWarning(site string, st api.SiteReverb) string { + if st.State != api.ReverbFailed { + return "" + } + return fmt.Sprintf("reverb: %s: %s", site, st.Problem) +} + +// reverbSite prepares a site's Reverb server: its process, when it can run, and its state. +func (d *daemon) reverbSite(cfg *config.Config, site config.Site, minor, scheme string, extraEnv []string) (*supervisor.Spec, api.SiteReverb) { + name := config.ReverbPortID(site.Name) + st := reverbStatus(cfg, site, scheme) + fail := func(state, format string, args ...any) (*supervisor.Spec, api.SiteReverb) { + st.State, st.Problem = state, fmt.Sprintf(format, args...) + return nil, st + } + + if !reverb.Installed(site.Path) { + return fail(api.ReverbUnavailable, "Laravel Reverb isn't installed in %s — run `php artisan install:broadcasting --reverb`", site.Path) + } + if other, taken := cfg.UsedPorts("")[site.ReverbPort]; site.ReverbPort == 0 || taken && other != name { + return fail(api.ReverbFailed, "port %d is also configured for %s — turn Reverb off and on again for a new port", site.ReverbPort, serviceName(other)) + } + d.mu.Lock() + restart := d.restarts[site.Name] + d.mu.Unlock() + spec := reverb.Spec(d.root, reverb.Site{Name: site.Name, Path: site.Path, Minor: minor, Port: site.ReverbPort, Env: extraEnv, Restart: restart}) + if !d.procUp(spec.Name) && !ports.Free("127.0.0.1", site.ReverbPort) { + return fail(api.ReverbFailed, "%s", ports.Describe(site.ReverbPort)) + } + return &spec, st +} diff --git a/internal/daemon/reverb_test.go b/internal/daemon/reverb_test.go new file mode 100644 index 00000000..aad6ed57 --- /dev/null +++ b/internal/daemon/reverb_test.go @@ -0,0 +1,126 @@ +package daemon + +import ( + "maps" + "net" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "flock/internal/api" + "flock/internal/config" + "flock/internal/supervisor" +) + +func TestReverbSiteRunsReverbStartOrSaysWhyNot(t *testing.T) { + d := &daemon{root: t.TempDir(), procs: supervisor.New(), restarts: map[string]int{"app": 2}} + cfg := config.Default() + cfg.Ports.HTTPS = 8443 + app := t.TempDir() + cfg.Upsert(config.Site{Name: "app", Path: app, Secured: true, Reverb: true, ReverbPort: freePort(t)}) + site, _ := cfg.FindSite("app") + + // Not a Laravel app with Reverb yet. + if spec, st := d.reverbSite(cfg, site, "8.4", "https", nil); spec != nil || st.State != api.ReverbUnavailable || !strings.Contains(st.Problem, "install:broadcasting --reverb") { + t.Fatalf("without Reverb: %v %+v", spec, st) + } + + for _, file := range []string{"artisan", filepath.Join("vendor", "laravel", "reverb", "composer.json")} { + _ = os.MkdirAll(filepath.Dir(filepath.Join(app, file)), 0o755) + if err := os.WriteFile(filepath.Join(app, file), nil, 0o644); err != nil { + t.Fatal(err) + } + } + spec, st := d.reverbSite(cfg, site, "8.4", "https", []string{"VAR_DUMPER_FORMAT=server"}) + if spec == nil || st.Problem != "" || st.Log != "reverb-app" || st.Port != site.ReverbPort { + t.Fatalf("with Reverb: %v %+v", spec, st) + } + if want := []string{"REVERB_HOST=app.test", "REVERB_PORT=8443", "REVERB_SCHEME=https"}; !slices.Equal(st.Env, want) { + t.Errorf(".env lines: %v", st.Env) + } + args := strings.Join(spec.Args, " ") + if !strings.HasPrefix(args, "artisan reverb:start --host=127.0.0.1 --port=") || !strings.Contains(args, "--hostname=127.0.0.1") || spec.Dir != app { + t.Errorf("command: %s in %s", args, spec.Dir) + } + if !slices.Contains(spec.Env, "VAR_DUMPER_FORMAT=server") || !slices.Contains(spec.Env, "FLOCK_RESTART=2") || + !slices.ContainsFunc(spec.Env, func(e string) bool { return strings.HasPrefix(e, "FLOCK_PHP_VERSION=") }) { + t.Errorf("env: %v", spec.Env) + } + + // Another site's FrankenPHP configured on the same port. + cfg.Upsert(config.Site{Name: "other", Path: t.TempDir(), Server: config.ServerFrankenPHP, Port: site.ReverbPort}) + if spec, st := d.reverbSite(cfg, site, "8.4", "https", nil); spec != nil || st.State != api.ReverbFailed || !strings.Contains(st.Problem, "also configured") { + t.Fatalf("port conflict: %v %+v", spec, st) + } +} + +func TestReleasingAPHPVersionStopsTheReverbServersOnIt(t *testing.T) { + cfg := config.Default() + cfg.DefaultPHP = "8.4" + cfg.Upsert(config.Site{Name: "app", Path: t.TempDir(), Reverb: true}) + cfg.Upsert(config.Site{Name: "old", Path: t.TempDir(), PHP: "8.3", Reverb: true}) + cfg.Upsert(config.Site{Name: "off", Path: t.TempDir()}) + + keep := notUsingPHP(cfg, "8.4", []string{"php8.4", "php8.4-xdebug", "php8.3", "dump-server", "reverb-app", "reverb-old", "frankenphp-off"}) + + want := map[string]bool{"php8.4": false, "php8.4-xdebug": false, "php8.3": true, "dump-server": false, + "reverb-app": false, "reverb-old": true, "frankenphp-off": true} + if !maps.Equal(keep, want) { + t.Errorf("keep = %v, want %v", keep, want) + } + + // "" releases every version. + keep = notUsingPHP(cfg, "", []string{"php8.4", "php8.3", "dump-server", "reverb-app", "reverb-old", "frankenphp-off"}) + want = map[string]bool{"php8.4": false, "php8.3": false, "dump-server": false, "reverb-app": false, "reverb-old": false, "frankenphp-off": true} + if !maps.Equal(keep, want) { + t.Errorf("keep all = %v, want %v", keep, want) + } +} + +func TestAnEnabledReverbServerSaysWhyItCantRun(t *testing.T) { + cfg := config.Default() + cfg.Upsert(config.Site{Name: "app", Path: t.TempDir(), Reverb: true, ReverbPort: 21000}) + cfg.Upsert(config.Site{Name: "plain", Path: t.TempDir()}) + app, _ := cfg.FindSite("app") + plain, _ := cfg.FindSite("plain") + + st := reverbBlocked(cfg, app, "http", "PHP 8.4 is not installed") + if st == nil || st.State != api.ReverbUnavailable || st.Problem != "PHP 8.4 is not installed" || st.Log != "reverb-app" || len(st.Env) != 3 { + t.Fatalf("blocked: %+v", st) + } + if reverbBlocked(cfg, plain, "http", "anything") != nil { + t.Error("a site with Reverb off has no Reverb status") + } +} + +func TestABusyReverbPortIsAWarningSoTheReloadIsRetried(t *testing.T) { + d := &daemon{root: t.TempDir(), procs: supervisor.New(), restarts: map[string]int{}} + cfg := config.Default() + app := t.TempDir() + for _, file := range []string{"artisan", filepath.Join("vendor", "laravel", "reverb", "composer.json")} { + _ = os.MkdirAll(filepath.Dir(filepath.Join(app, file)), 0o755) + if err := os.WriteFile(filepath.Join(app, file), nil, 0o644); err != nil { + t.Fatal(err) + } + } + busy, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer busy.Close() + cfg.Upsert(config.Site{Name: "app", Path: app, Reverb: true, ReverbPort: busy.Addr().(*net.TCPAddr).Port}) + site, _ := cfg.FindSite("app") + + spec, st := d.reverbSite(cfg, site, "8.4", "http", nil) + if spec != nil || st.State != api.ReverbFailed { + t.Fatalf("busy port: %v %+v", spec, st) + } + if w := reverbPreflightWarning("app", st); !strings.HasPrefix(w, "reverb: app: ") { + t.Errorf("warning = %q", w) + } + if w := reverbPreflightWarning("app", api.SiteReverb{State: api.ReverbUnavailable, Problem: "not installed"}); w != "" { + t.Errorf("a project without Reverb isn't a warning: %q", w) + } +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 8a4a6dbd..c672772c 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -112,6 +112,7 @@ func (s *server) instructions(ctx context.Context) string { } b.WriteString(`- Flock always serves its sites: don't run "php artisan serve" or another web server. Use site_info for the URL. +- Flock runs a Laravel app's Reverb server when update_site's reverb_on turned it on: then don't run "php artisan reverb:start". - Run php, composer, node and npm as usual: Flock's shims pick the versions the project uses. - When code uses a PHP extension (imagick, intl, ...), require it in composer.json ("composer require ext-:*"). php_extensions shows what the project requires and what Flock's PHP has. - Mail the app sends is caught by Flock, not delivered: read it with list_mail and read_mail. diff --git a/internal/mcpserver/tools_read.go b/internal/mcpserver/tools_read.go index 8f31ca589..f30fb6e0 100644 --- a/internal/mcpserver/tools_read.go +++ b/internal/mcpserver/tools_read.go @@ -200,7 +200,11 @@ type siteInfo struct { Octane bool `json:"octane_installed,omitempty"` Parked bool `json:"parked,omitempty"` Problem string `json:"problem,omitempty"` - Notice string `json:"notice,omitempty"` + // Reverb is the state of the site's Laravel Reverb server when it's on; ReverbEnv the .env lines + // that send the app's broadcasts and its Echo client through Flock (wss://.test/app). + Reverb string `json:"reverb_state,omitempty"` + ReverbEnv []string `json:"reverb_env,omitempty"` + Notice string `json:"notice,omitempty"` } func siteInfoOf(st api.SiteStatus) siteInfo { @@ -235,6 +239,12 @@ func siteInfoOf(st api.SiteStatus) siteInfo { info.Problem = st.FrankenPHP.Problem } } + if st.Reverb != nil { + info.Reverb, info.ReverbEnv = st.Reverb.State, st.Reverb.Env + if info.Problem == "" { + info.Problem = st.Reverb.Problem + } + } return info } diff --git a/internal/mcpserver/tools_write.go b/internal/mcpserver/tools_write.go index e39584ae..aed64fff 100644 --- a/internal/mcpserver/tools_write.go +++ b/internal/mcpserver/tools_write.go @@ -72,10 +72,11 @@ func (s *server) addWriteTools(srv *mcp.Server) { "working on the project gets it); pin without a version pins the one the site uses now.\n" + "- add_domain (domain): an extra domain, a subdomain (\"tenant1\" → tenant1..test) or an alias (\"my-app-2.test\").\n" + "- set_server (server, worker): run PHP through FastCGI (PHP-FPM) or FrankenPHP, optionally in Octane's worker mode.\n" + - "- restart: restart a FrankenPHP site's process, e.g. so Octane workers load changed code.", + "- reverb_on / reverb_off: run or stop the Laravel app's Reverb server, reached at wss://.test/app; site_info shows the .env lines it needs.\n" + + "- restart: restart a site's FrankenPHP and Reverb processes, e.g. so Octane workers or Reverb load changed code.", Annotations: additive(false), InputSchema: schema[updateSiteIn](map[string][]any{ - "action": {"secure", "unsecure", "isolate", "unisolate", "pin", "unpin", "add_domain", "set_server", "restart"}, + "action": {"secure", "unsecure", "isolate", "unisolate", "pin", "unpin", "add_domain", "set_server", "reverb_on", "reverb_off", "restart"}, "tool": {"php", "node"}, "server": {"fastcgi", "frankenphp"}, "worker": {"classic", "octane"}, @@ -582,6 +583,10 @@ func (s *server) updateSite(ctx context.Context, _ *mcp.CallToolRequest, in upda } st, err = s.Daemon.UpdateSite(ctx, site.Name, change) msg = fmt.Sprintf("%s now runs on %s.", site.Name, in.Server) + case "reverb_on", "reverb_off": + on := in.Action == "reverb_on" + st, err = s.Daemon.UpdateSite(ctx, site.Name, api.SiteChange{Reverb: &on}) + msg = fmt.Sprintf("Reverb is %s for %s.", map[bool]string{true: "on", false: "off"}[on], site.Name) case "restart": st, err = s.Daemon.RestartSite(ctx, site.Name) msg = fmt.Sprintf("Restarted %s.", site.Name) diff --git a/internal/reverb/reverb.go b/internal/reverb/reverb.go new file mode 100644 index 00000000..990127fd --- /dev/null +++ b/internal/reverb/reverb.go @@ -0,0 +1,64 @@ +// Package reverb runs a site's Laravel Reverb server behind Flock's web server, which sends it the +// site's WebSocket connections and the HTTP API Laravel broadcasts through, so browsers reach it at +// wss://.test/app like the rest of the site. +package reverb + +import ( + "os" + "path/filepath" + "strconv" + "syscall" + + "flock/internal/config" + "flock/internal/php" + "flock/internal/supervisor" +) + +// Site is a site whose Reverb server runs. +type Site struct { + Name string + Path string // the project + Minor string // the PHP version + Port int // loopback port the web server proxies to + Env []string + // Restart is bumped to restart the process, e.g. so it loads changed code. + Restart int +} + +// Installed reports whether a project is a Laravel app with Laravel Reverb installed. +func Installed(path string) bool { + return exists(filepath.Join(path, "artisan")) && exists(filepath.Join(path, "vendor", "laravel", "reverb", "composer.json")) +} + +// Spec is the site's `php artisan reverb:start`. The PHP version must be installed. +func Spec(root string, s Site) supervisor.Spec { + name := config.ReverbPortID(s.Name) + // php.Env rather than CLIEnv: a server has no use for pcov. FLOCK_RESTART and FLOCK_PHP_VERSION + // change the spec when the counter changes or a patch update is installed, so the supervisor + // restarts the process. + env := append(php.Env(root, s.Minor), s.Env...) + env = append(env, "FLOCK_RESTART="+strconv.Itoa(s.Restart), "FLOCK_PHP_VERSION="+php.FullVersion(root, s.Minor)) + return supervisor.Spec{ + Name: name, + Path: php.Binary(root, s.Minor), + // --hostname: given the site's name (REVERB_HOST), Reverb serves TLS itself when Herd or + // Valet has a certificate for it, but the web server speaks plain HTTP to it. + Args: []string{"artisan", "reverb:start", "--host=127.0.0.1", "--port=" + strconv.Itoa(s.Port), "--hostname=127.0.0.1", "--no-interaction"}, + Env: env, + Dir: s.Path, + LogFile: filepath.Join(root, "log", name+".log"), + StopSignal: syscall.SIGTERM, + } +} + +// Env is what the app's .env needs for its broadcasts and its Echo client to go through the web +// server: the site's host, and the scheme and port it is served on. install:broadcasting's +// VITE_REVERB_* variables refer to these. +func Env(host, scheme string, port int) []string { + return []string{"REVERB_HOST=" + host, "REVERB_PORT=" + strconv.Itoa(port), "REVERB_SCHEME=" + scheme} +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/internal/web/serve_test.go b/internal/web/serve_test.go index 8564cdaf..0f57eab1 100644 --- a/internal/web/serve_test.go +++ b/internal/web/serve_test.go @@ -324,3 +324,111 @@ func TestAnAliasUnderAnotherSiteWinsOverItsSubdomains(t *testing.T) { t.Errorf("http redirect: %v %+v", err, resp) } } + +// TestSendsReverbRequestsToReverb runs the embedded Caddy in front of a stand-in Reverb server: a +// WebSocket connection over HTTPS and a signed broadcast reach it, the app's own pages under +// /app and /apps don't. +func TestSendsReverbRequestsToReverb(t *testing.T) { + dir, err := os.MkdirTemp("", "flock-web-test-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + if err := os.WriteFile(filepath.Join(dir, "index.php"), []byte(", as pusher-js opens it. + conn, err := tls.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.HTTPSPort), tlsConfig) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + fmt.Fprint(conn, "GET /app/key?protocol=7&client=js HTTP/1.1\r\nHost: chat.test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n"+ + "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n") + head := make([]byte, 512) + n, err := conn.Read(head) + if err != nil || !strings.HasPrefix(string(head[:n]), "HTTP/1.1 101") { + t.Fatalf("WebSocket handshake: %v %q", err, head[:n]) + } + fmt.Fprint(conn, "ping") + if n, err = io.ReadFull(conn, head[:4]); err != nil || string(head[:n]) != "ping" { + t.Fatalf("through the WebSocket: %v %q", err, head[:n]) + } + + client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool}, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + _, port, _ := net.SplitHostPort(addr) + return (&net.Dialer{}).DialContext(ctx, network, "127.0.0.1:"+port) + }, + ForceAttemptHTTP2: true, + }} + for path, want := range map[string]string{ + "/apps/1/events?auth_key=k&auth_timestamp=1&auth_signature=s": "reverb /apps/1/events", // a broadcast + "/app/key?protocol=7": "reverb /app/key", // a WebSocket over HTTP/2 has no Upgrade header + "/app/dashboard": "", // the app's pages: PHP, a 502 here + "/apps/list": "", + } { + resp, err := client.Post(base+path, "application/json", strings.NewReader("{}")) + if err != nil { + t.Errorf("%s: %v", path, err) + continue + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if want == "" && resp.StatusCode != http.StatusBadGateway || want != "" && string(body) != want { + t.Errorf("%s: got %d %q", path, resp.StatusCode, body) + } + } +} diff --git a/internal/web/web.go b/internal/web/web.go index fc3c23bc..15f4e4ba 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -35,6 +35,9 @@ type Site struct { // Proxy is the loopback address of the site's own FrankenPHP ("127.0.0.1:"). When set, requests // go there instead of to FPM; those that ask for Xdebug still go to DebugFPM. Proxy string + // Reverb is the loopback address of the site's Laravel Reverb server, which gets the site's + // WebSocket connections and broadcasts (see reverbRequest). + Reverb string // CertFile and KeyFile (from certs.SiteCert, covering Domain and *.Domain) are set when the site // is secured. Deeper subdomains and aliases get certificates from Config.Issue. CertFile string @@ -115,6 +118,9 @@ func Caddyfile(cfg Config) string { persist_config off auto_https disable_certs skip_install_trust + # abort comes after handle by default, so a site's handle blocks (Reverb, Xdebug) would serve + # remote clients before flock_site rejects them. + order abort first http_port %d https_port %d storage file_system %s @@ -230,8 +236,18 @@ func unlinked(cfg Config) string { const xdebugRequest = `{cookie.XDEBUG_SESSION} != "" || {cookie.XDEBUG_TRIGGER} != "" || ` + `{query.XDEBUG_SESSION} != "" || {query.XDEBUG_SESSION_START} != "" || {query.XDEBUG_TRIGGER} != ""` +// reverbRequest matches the Pusher protocol's requests on /app/* and /apps/*: WebSocket connections +// (an Upgrade header; ?protocol= also covers WebSockets over HTTP/2, which have none) and the signed +// HTTP API Laravel broadcasts through. Other requests there stay the app's, e.g. a panel at /app. +const reverbRequest = `{http.request.header.Upgrade} != "" || {query.protocol} != "" || {query.auth_signature} != ""` + func app(s Site) string { root := fmt.Sprintf("\troot * %s\n", quote(s.Root)) + if s.Reverb != "" { + // Before the other handle blocks, which keep their order (none has a path matcher alone). + root += fmt.Sprintf("\t@reverb {\n\t\tpath /app/* /apps/*\n\t\texpression `%s`\n\t}\n\thandle @reverb {\n%s\t}\n", + reverbRequest, proxy(s.Reverb, "\t\t")) + } serve := func(indent string) string { return php(s.FPM, indent) } if s.Proxy != "" { serve = func(indent string) string { return proxy(s.Proxy, indent) } @@ -246,8 +262,8 @@ func app(s Site) string { xdebugRequest, php(s.DebugFPM, "\t\t"), serve("\t\t")) } -// proxy sends every request to a FrankenPHP process, which serves the static files too. The Host -// header passes unchanged, so the app sees its own domain. +// proxy sends requests to a site's own process: FrankenPHP, which serves the static files too, or +// Reverb. The Host header passes unchanged, so the app sees its own domain. func proxy(upstream, indent string) string { return fmt.Sprintf("%sreverse_proxy %s\n", indent, quote(upstream)) } diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 7e6ca184..c7048081 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -1,6 +1,7 @@ package web import ( + "encoding/json" "strings" "testing" @@ -48,6 +49,56 @@ func TestCaddyfileRoutesSitesAndSubdomains(t *testing.T) { } } +func TestRemoteClientsAreRejectedBeforeAnyHandleBlock(t *testing.T) { + cfg := testConfig() + cfg.Sites = []Site{{Domain: "chat.test", Root: "/code/chat/public", FPM: []string{"unix//run/php84.sock"}, + DebugFPM: []string{"unix//run/php84-xdebug.sock"}, Reverb: "127.0.0.1:21001"}} + out, _, err := caddyconfig.GetAdapter("caddyfile").Adapt([]byte(Caddyfile(cfg)), nil) + if err != nil { + t.Fatal(err) + } + var doc any + if err := json.Unmarshal(out, &doc); err != nil { + t.Fatal(err) + } + + // In every route list, the abort (a static_response matching client_ip) comes before the subroutes. + checked := 0 + var walk func(v any) + walk = func(v any) { + switch v := v.(type) { + case map[string]any: + if routes, ok := v["routes"].([]any); ok { + subroute := -1 + for i, r := range routes { + b, _ := json.Marshal(r) + s := string(b) + if subroute < 0 && strings.Contains(s, `"handler":"subroute"`) { + subroute = i + } + if strings.Contains(s, `"client_ip"`) && strings.Contains(s, `"handler":"static_response"`) && !strings.Contains(s, `"handler":"subroute"`) { + checked++ + if subroute >= 0 { + t.Errorf("abort after a handle block in:\n%s", out) + } + } + } + } + for _, child := range v { + walk(child) + } + case []any: + for _, child := range v { + walk(child) + } + } + } + walk(doc) + if checked == 0 { + t.Fatalf("no abort found in:\n%s", out) + } +} + func TestWithBindQualifiesListenAddresses(t *testing.T) { in := []byte(`{"apps":{"http":{"servers":{"srv0":{"listen":[":443"]}}}}}`) @@ -130,3 +181,29 @@ func TestCaddyfileProxiesFrankenPHPSitesButNotTheirXdebugRequests(t *testing.T) } } } + +func TestCaddyfileSendsReverbRequestsFirst(t *testing.T) { + cfg := testConfig() + cfg.Sites = []Site{ + {Domain: "chat.test", Root: "/code/chat/public", FPM: []string{"unix//run/php84.sock"}, Reverb: "127.0.0.1:21001", DebugFPM: []string{"unix//run/php84-xdebug.sock"}}, + {Domain: "octane.test", Root: "/code/octane/public", Proxy: "127.0.0.1:21002", Reverb: "127.0.0.1:21003"}, + } + + cf := Caddyfile(cfg) + json, _, err := caddyconfig.GetAdapter("caddyfile").Adapt([]byte(cf), nil) + if err != nil { + t.Fatalf("%v\n%s", err, cf) + } + for _, want := range []string{`"dial":"127.0.0.1:21001"`, `"dial":"127.0.0.1:21003"`, `"/apps/*"`, `query.auth_signature`} { + if !strings.Contains(string(json), want) { + t.Errorf("missing %s in:\n%s", want, json) + } + } + // Xdebug's handle block would win if it came first. + if strings.Index(cf, "handle @reverb") > strings.Index(cf, "handle @xdebug") { + t.Errorf("Reverb's route comes after Xdebug's:\n%s", cf) + } + if strings.Contains(Caddyfile(testConfig()), "@reverb") { + t.Error("a site without Reverb has its route") + } +}