diff --git a/docs/configuration.md b/docs/configuration.md index 70246ade..24abee77 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -7,7 +7,7 @@ Most settings are changed from the Flock app or the CLI. This page describes whe | File | Purpose | | --- | --- | | `.flockrc` | PHP (`php=8.5`) and optionally Node (`node=22`) versions for the project. Commit it. | -| `flock.yml` | How the project's [git worktrees](worktrees.md) are set up. Commit it; `flock.local.yml` next to it holds your machine's settings. | +| `flock.yml` | How the project's [git worktrees](worktrees.md) are set up, and the site's [Caddy directives](sites.md#custom-caddy-directives). Commit it; `flock.local.yml` next to it holds your machine's settings. | | `.nvmrc` / `.node-version` | Node version, read the same way as by nvm and other tools. Only version numbers count; aliases like `lts/*` are ignored. | Project files are looked up from the current directory upwards. See [Per-project versions](project-versions.md). diff --git a/docs/sites.md b/docs/sites.md index 33751c83..e803f8d3 100644 --- a/docs/sites.md +++ b/docs/sites.md @@ -95,6 +95,33 @@ Requests pass to the server with their `Host` header, so the app sees `softpack. A proxy's name can't be a linked site's: Flock refuses it. A folder of the same name in a project folder is served by the proxy instead, so `flock proxy softpack 3000` works for `~/Code/softpack`; `flock unproxy softpack` serves the folder again. +## Custom Caddy directives + +A PHP site can add its own [Caddy directives](https://caddyserver.com/docs/caddyfile/directives), for example CORS headers. Put them in a file and name it with `caddyfile:` in the project's `flock.yml`: + +```yaml +caddyfile: flock.caddy +``` + +A relative path starts at the folder of the `flock.yml`. To use a file only on your machine, set `caddyfile:` in `flock.local.yml` instead: it replaces the one in `flock.yml`, and `caddyfile: ""` turns it off. Without `caddyfile:`, Flock serves the site as usual and ignores any `Caddyfile` the project has. + +The file holds directives only, without a site address or the braces around them: Flock adds them to the site it serves, before PHP, and reloads them when the file changes. This file allows a separate frontend to call the site and answers CORS preflight requests: + +```caddyfile +@cors_preflight method OPTIONS + +header { + Access-Control-Allow-Origin "https://admin.example.test" + Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" + Access-Control-Allow-Headers "Content-Type, Authorization" + Vary "Origin" +} + +respond @cors_preflight 204 +``` + +Site options such as `tls` and `handle_errors` aren't supported, and neither is `import`. If Flock can't use the file, it serves the site without it and says why in its warnings. The directives only apply to the site on your machine, so keep the production CORS setup in the app or the production web server. + ## Listing sites :::app diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index b683286c..84060fd0 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -442,10 +442,45 @@ func (d *daemon) projectFiles() string { fmt.Fprintf(&b, "%s\n", file) } } + // The settings files and the Caddy directives they name (see siteHandlers). + files := project.SettingsFiles(cfg, site) + if file, _, _ := project.CaddyFile(cfg, site); file != "" { + files = append(files, file) + } + for _, file := range files { + if info, err := os.Stat(file); err == nil { + fmt.Fprintf(&b, "%s %d %d\n", file, info.Size(), info.ModTime().UnixNano()) + } + } } return b.String() } +// siteHandlers reads the Caddy directives a site's flock.yml or flock.local.yml names with +// `caddyfile:` (see project.CaddyFile) and checks them in ws, the site as it is served. A problem +// is a warning: the site is served without them, and the other sites aren't affected. +func siteHandlers(cfg *config.Config, site config.Site, ws web.Site) (handlers, problem string) { + file, from, err := project.CaddyFile(cfg, site) + if err != nil { + return "", fmt.Sprintf("caddyfile: %s: %v", site.Name, err) + } + if file == "" { + return "", "" + } + data, err := os.ReadFile(file) + if errors.Is(err, os.ErrNotExist) { + return "", fmt.Sprintf("caddyfile: %s: %s names %s, which doesn't exist", site.Name, from, file) + } + if err != nil { + return "", fmt.Sprintf("caddyfile: %s: %v", site.Name, err) + } + ws.Handlers = string(data) + if err := web.CheckHandlers(ws, file); err != nil { + return "", fmt.Sprintf("caddyfile: %s is served without its directives: %v", site.Name, err) + } + return ws.Handlers, "" +} + // breakpointInterval is how often the sites' PhpStorm workspaces are checked for breakpoints. var breakpointInterval = time.Second @@ -728,6 +763,12 @@ func (d *daemon) reload() error { status.URL = scheme + "://" + domain + portSuffix(scheme, cfg) shareables[site.Name] = shareable{root: ws.Root, version: version} + // Checked once the site is complete: its directives adapt in the block it is served with. + if handlers, problem := siteHandlers(cfg, site, ws); problem != "" { + warnings = append(warnings, problem) + } else { + ws.Handlers = handlers + } webSites = append(webSites, ws) domains = append(append(domains, domain), served...) statuses = append(statuses, status) diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 0645658e..fa6d8b46 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -19,6 +19,7 @@ import ( "flock/internal/binaries" "flock/internal/config" "flock/internal/paths" + "flock/internal/web" "flock/internal/worktree" ) @@ -358,6 +359,58 @@ func TestFoldersInParkedPathsAreFollowed(t *testing.T) { waitFor(false) } +func TestProjectFilesFollowsTheConfiguredCaddyfile(t *testing.T) { + site := t.TempDir() + d := daemon{cfg: &config.Config{Sites: []config.Site{{Name: "app", Path: site}}}} + before := d.projectFiles() + + // A project's own Caddyfile isn't Flock's. + writeFile(t, filepath.Join(site, "Caddyfile"), "example.com {\n}\n") + if d.projectFiles() != before { + t.Fatal("a Caddyfile nobody configured changed the project fingerprint") + } + + writeFile(t, filepath.Join(site, "flock.yml"), "caddyfile: ops/local.caddy\n") + named := d.projectFiles() + if named == before { + t.Fatal("flock.yml did not change the project fingerprint") + } + configured := filepath.Join(site, "ops", "local.caddy") + if err := os.MkdirAll(filepath.Dir(configured), 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, configured, "header X-Configured yes\n") + if created := d.projectFiles(); created == named || !strings.Contains(created, configured) { + t.Fatalf("creating the configured file did not change the project fingerprint: %q", created) + } +} + +func TestSiteHandlersProblemsLeaveTheSiteServedWithoutThem(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{} + site := config.Site{Name: "app", Path: dir} + ws := web.Site{Domain: "app.test", Root: dir, FPM: []string{"127.0.0.1:1"}} + + if handlers, problem := siteHandlers(cfg, site, ws); handlers != "" || problem != "" { + t.Fatalf("without settings: got %q, %q", handlers, problem) + } + + writeFile(t, filepath.Join(dir, "flock.yml"), "caddyfile: local.caddy\n") + if _, problem := siteHandlers(cfg, site, ws); !strings.Contains(problem, "doesn't exist") { + t.Fatalf("missing file: got %q", problem) + } + + writeFile(t, filepath.Join(dir, "local.caddy"), "header X-A b\n}\n:9999 {\n") + if handlers, problem := siteHandlers(cfg, site, ws); handlers != "" || !strings.Contains(problem, "local.caddy:2") { + t.Fatalf("breakout: got %q, %q", handlers, problem) + } + + writeFile(t, filepath.Join(dir, "local.caddy"), "header X-A b\n") + if handlers, problem := siteHandlers(cfg, site, ws); handlers != "header X-A b\n" || problem != "" { + t.Fatalf("valid file: got %q, %q", handlers, problem) + } +} + func TestGitWorktreesAreServedUnderTheirSite(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not installed") diff --git a/internal/project/caddy.go b/internal/project/caddy.go new file mode 100644 index 00000000..031806d7 --- /dev/null +++ b/internal/project/caddy.go @@ -0,0 +1,73 @@ +package project + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + + "flock/internal/config" +) + +// The project's Flock settings: ConfigFile is committed, LocalFile is this machine's and stays out +// of git. +const ( + ConfigFile = "flock.yml" + LocalFile = "flock.local.yml" +) + +// SettingsFiles are the flock.yml and flock.local.yml files that apply to a site, in order: later +// files win. A git worktree reads its own flock.yml (it comes with the branch), its site's +// flock.local.yml (a new worktree has none of its own yet), then its own flock.local.yml. +func SettingsFiles(cfg *config.Config, site config.Site) []string { + if site.IsWorktree() { + if parent, ok := cfg.FindSite(site.Parent); ok { + return []string{filepath.Join(site.Path, ConfigFile), filepath.Join(parent.Path, LocalFile), filepath.Join(site.Path, LocalFile)} + } + } + return []string{filepath.Join(site.Path, ConfigFile), filepath.Join(site.Path, LocalFile)} +} + +// CaddyFile is the file of Caddy directives a site's settings name with `caddyfile:`, and the +// settings file that names it; both are "" when none does. The last file that sets the key wins, +// so flock.local.yml replaces flock.yml, and an empty value turns it off. A relative path starts +// at the folder of the file that names it, ~/ at the home folder. +func CaddyFile(cfg *config.Config, site config.Site) (file, from string, err error) { + for _, path := range SettingsFiles(cfg, site) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return "", "", err + } + var settings struct { + Caddyfile *string `yaml:"caddyfile"` + } + if err := yaml.Unmarshal(data, &settings); err != nil { + return "", "", fmt.Errorf("%s: %w", path, err) + } + if settings.Caddyfile == nil { + continue + } + file, from = strings.TrimSpace(*settings.Caddyfile), path + switch { + case file == "": + case strings.HasPrefix(file, "~/") || strings.HasPrefix(file, `~\`): + home, err := os.UserHomeDir() + if err != nil { + return "", "", fmt.Errorf("%s: %w", path, err) + } + file = filepath.Join(home, file[2:]) + case !filepath.IsAbs(file): + file = filepath.Join(filepath.Dir(path), file) + } + } + if file == "" { + return "", "", nil + } + return file, from, nil +} diff --git a/internal/project/caddy_test.go b/internal/project/caddy_test.go new file mode 100644 index 00000000..a8300ea0 --- /dev/null +++ b/internal/project/caddy_test.go @@ -0,0 +1,80 @@ +package project + +import ( + "os" + "path/filepath" + "testing" + + "flock/internal/config" +) + +func writeSettings(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestCaddyFileIsOnlyWhatTheSettingsName(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{} + site := config.Site{Name: "app", Path: dir} + + // A Caddyfile in the project is the project's own, e.g. for production: Flock leaves it alone. + writeSettings(t, filepath.Join(dir, "Caddyfile"), "example.com {\n\tphp_server\n}\n") + if file, _, err := CaddyFile(cfg, site); err != nil || file != "" { + t.Fatalf("without settings: got %q, %v", file, err) + } + + writeSettings(t, filepath.Join(dir, ConfigFile), "caddyfile: ops/local.caddy\nworktrees:\n build: false\n") + file, from, err := CaddyFile(cfg, site) + if err != nil || file != filepath.Join(dir, "ops", "local.caddy") || from != filepath.Join(dir, ConfigFile) { + t.Fatalf("flock.yml: got %q from %q, %v", file, from, err) + } + + // flock.local.yml replaces flock.yml; an absolute path is used as it is. + other := filepath.Join(t.TempDir(), "mine.caddy") + writeSettings(t, filepath.Join(dir, LocalFile), "caddyfile: "+other+"\n") + if file, from, err = CaddyFile(cfg, site); err != nil || file != other || from != filepath.Join(dir, LocalFile) { + t.Fatalf("flock.local.yml: got %q from %q, %v", file, from, err) + } + + // An empty value turns the committed one off. + writeSettings(t, filepath.Join(dir, LocalFile), "caddyfile: \"\"\n") + if file, _, err = CaddyFile(cfg, site); err != nil || file != "" { + t.Fatalf("empty in flock.local.yml: got %q, %v", file, err) + } + + writeSettings(t, filepath.Join(dir, LocalFile), "caddyfile: [nope\n") + if _, _, err = CaddyFile(cfg, site); err == nil { + t.Fatal("broken flock.local.yml: no error") + } +} + +func TestCaddyFileOfAWorktreeFollowsItsSettingsFiles(t *testing.T) { + parent, wt := t.TempDir(), t.TempDir() + cfg := &config.Config{Sites: []config.Site{{Name: "shop", Path: parent}}} + site := config.Site{Name: "feature.shop", Path: wt, Parent: "shop"} + + // The site's flock.yml isn't the worktree's: the branch brings its own. + writeSettings(t, filepath.Join(parent, ConfigFile), "caddyfile: site.caddy\n") + if file, _, err := CaddyFile(cfg, site); err != nil || file != "" { + t.Fatalf("site's flock.yml: got %q, %v", file, err) + } + + writeSettings(t, filepath.Join(wt, ConfigFile), "caddyfile: branch.caddy\n") + if file, _, _ := CaddyFile(cfg, site); file != filepath.Join(wt, "branch.caddy") { + t.Fatalf("worktree's flock.yml: got %q", file) + } + + // The site's flock.local.yml applies to its worktrees, relative to the site. + writeSettings(t, filepath.Join(parent, LocalFile), "caddyfile: mine.caddy\n") + if file, _, _ := CaddyFile(cfg, site); file != filepath.Join(parent, "mine.caddy") { + t.Fatalf("site's flock.local.yml: got %q", file) + } + + writeSettings(t, filepath.Join(wt, LocalFile), "caddyfile: here.caddy\n") + if file, _, _ := CaddyFile(cfg, site); file != filepath.Join(wt, "here.caddy") { + t.Fatalf("worktree's flock.local.yml: got %q", file) + } +} diff --git a/internal/web/serve_test.go b/internal/web/serve_test.go index e90b34be..94fdbd85 100644 --- a/internal/web/serve_test.go +++ b/internal/web/serve_test.go @@ -39,6 +39,103 @@ func freePort(t *testing.T) int { return 0 } +func nonLoopbackIPv4(t *testing.T) string { + t.Helper() + interfaces, err := net.Interfaces() + if err != nil { + t.Fatal(err) + } + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + addresses, err := iface.Addrs() + if err != nil { + continue + } + for _, address := range addresses { + ip, _, err := net.ParseCIDR(address.String()) + if err == nil && ip.To4() != nil && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() { + return ip.String() + } + } + } + t.Skip("no non-loopback IPv4 address") + return "" +} + +// TestCustomHandlersCannotBypassRemoteGuard runs the adapted handler chain on a wildcard listener. +// Custom terminating handle and route directives work over loopback, but a connection through a +// non-loopback interface is aborted before either can answer. +func TestCustomHandlersCannotBypassRemoteGuard(t *testing.T) { + dir, err := os.MkdirTemp("", "flock-web-test-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + port := freePort(t) + cfg := Config{ + StorageDir: filepath.Join(dir, "caddy"), + LogFile: filepath.Join(dir, "web.log"), + HTTPPort: port, + HTTPSPort: freePort(t), + TLD: "test", + Sites: []Site{{ + Domain: "app.test", Root: dir, FPM: []string{"127.0.0.1:1"}, + Handlers: ` +handle /handled { + respond "handled" 200 +} +route { + @custom_route path /routed + respond @custom_route "routed" 200 +}`, + }}, + } + if err := Apply(cfg); err != nil { + t.Fatalf("%v\n%s", err, Caddyfile(cfg)) + } + t.Cleanup(func() { _ = Stop() }) + + client := func(target string) *http.Client { + return &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + DisableKeepAlives: true, + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(target, fmt.Sprint(port))) + }, + }, + } + } + url := func(path string) string { return fmt.Sprintf("http://app.test:%d%s", port, path) } + for path, want := range map[string]string{"/handled": "handled", "/routed": "routed"} { + resp, err := client("127.0.0.1").Get(url(path)) + if err != nil { + t.Fatalf("loopback %s: %v", path, err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK || string(body) != want { + t.Fatalf("loopback %s: got %d %q, want 200 %q", path, resp.StatusCode, body, want) + } + } + + remote := nonLoopbackIPv4(t) + address := net.JoinHostPort(remote, fmt.Sprint(port)) + conn, err := net.DialTimeout("tcp", address, 5*time.Second) + if err != nil { + t.Fatalf("wildcard listener is not reachable through %s: %v", address, err) + } + conn.Close() + for _, path := range []string{"/handled", "/routed"} { + if resp, err := client(remote).Get(url(path)); err == nil { + resp.Body.Close() + t.Fatalf("non-loopback request reached the custom handler at %s: %d", path, resp.StatusCode) + } + } +} + // TestServesEveryDepthAliasAndCatchAllOverTrustedHTTPS runs the embedded Caddy and makes real // TLS handshakes, verifying the certificates against the Flock CA alone. func TestServesEveryDepthAliasAndCatchAllOverTrustedHTTPS(t *testing.T) { @@ -60,6 +157,18 @@ func TestServesEveryDepthAliasAndCatchAllOverTrustedHTTPS(t *testing.T) { if err != nil { t.Fatal(err) } + custom := filepath.Join(dir, "site.Caddyfile") + if err := os.WriteFile(custom, []byte(` +@cors_preflight method OPTIONS +header Access-Control-Allow-Origin "https://admin.example.test" +respond @cors_preflight 204 +`), 0o644); err != nil { + t.Fatal(err) + } + customHandlers, err := os.ReadFile(custom) + if err != nil { + t.Fatal(err) + } pool := x509.NewCertPool() caPEM, _ := os.ReadFile(ca.CertPath()) pool.AppendCertsFromPEM(caPEM) @@ -79,7 +188,7 @@ func TestServesEveryDepthAliasAndCatchAllOverTrustedHTTPS(t *testing.T) { return leaves.Get(ca, name) }, Sites: []Site{ - {Domain: "app.test", Aliases: []string{"app-2.test"}, Root: dir, FPM: []string{"127.0.0.1:1"}, CertFile: certFile, KeyFile: keyFile}, + {Domain: "app.test", Aliases: []string{"app-2.test"}, Root: dir, Handlers: string(customHandlers), FPM: []string{"127.0.0.1:1"}, DebugFPM: []string{"127.0.0.1:2"}, CertFile: certFile, KeyFile: keyFile}, {Domain: "plain.test", Root: dir, FPM: []string{"127.0.0.1:1"}}, }, Waiting: []Waiting{ @@ -123,6 +232,20 @@ func TestServesEveryDepthAliasAndCatchAllOverTrustedHTTPS(t *testing.T) { } https := func(host string) string { return fmt.Sprintf("https://%s:%d/", host, cfg.HTTPSPort) } + preflight, err := http.NewRequest(http.MethodOptions, https("app.test"), nil) + if err != nil { + t.Fatal(err) + } + preflight.Header.Set("Origin", "https://admin.example.test") + resp, err := client.Do(preflight) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent || resp.Header.Get("Access-Control-Allow-Origin") != "https://admin.example.test" { + t.Errorf("custom Caddy directives: status %d, headers %v", resp.StatusCode, resp.Header) + } + // The site answers (with a 502, as nothing runs PHP here) on every depth and alias, and the // certificate verifies against the Flock CA alone. for _, host := range []string{"app.test", "t1.app.test", "a.b.app.test", "a.b.c.app.test", "a.b.c.d.app.test", diff --git a/internal/web/web.go b/internal/web/web.go index 61fda498..26498f92 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -8,14 +8,17 @@ package web import ( "crypto/tls" _ "embed" + "errors" "fmt" "os" "path/filepath" + "regexp" "strconv" "strings" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" _ "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" _ "github.com/caddyserver/caddy/v2/modules/standard" @@ -27,7 +30,10 @@ type Site struct { Domain string // e.g. watchtower.test Aliases []string // other names served like Domain, e.g. watchtower-2.test Root string // document root, e.g. /public - FPM []string // FastCGI upstreams: "unix/" (PHP-FPM) or "127.0.0.1:" (php-cgi workers) + // Handlers are optional Caddy HTTP handler directives included in the ordered route that serves + // the site. They are not included in the HTTP-to-HTTPS redirect or Flock's unlinked page. + Handlers string + FPM []string // FastCGI upstreams: "unix/" (PHP-FPM) or "127.0.0.1:" (php-cgi workers) // DebugFPM are the upstreams of the pool that loads Xdebug. Requests that ask for Xdebug go // there, and with Debugging (the project has breakpoints) every request does. DebugFPM []string @@ -256,22 +262,99 @@ const xdebugRequest = `{cookie.XDEBUG_SESSION} != "" || {cookie.XDEBUG_TRIGGER} `{query.XDEBUG_SESSION} != "" || {query.XDEBUG_SESSION_START} != "" || {query.XDEBUG_TRIGGER} != ""` func app(s Site) string { - root := "" - if s.Root != "" { - root = fmt.Sprintf("\troot * %s\n", quote(s.Root)) + if strings.TrimSpace(s.Handlers) == "" { + return root(s, "\t") + dispatch(s, "\t") } + + // A route keeps the written order, so the custom handlers run before the Xdebug/default handle + // pair. The remote abort still runs first: it is ordered before every directive, route too. + return "\troute {\n" + root(s, "\t\t") + handlers(s.Handlers, "\t\t") + dispatch(s, "\t\t") + "\t}\n" +} + +func root(s Site, indent string) string { + if s.Root == "" { + return "" + } + return fmt.Sprintf("%sroot * %s\n", indent, quote(s.Root)) +} + +func dispatch(s Site, indent string) string { serve := func(indent string) string { return php(s.FPM, indent) } if s.Proxy != "" { serve = func(indent string) string { return proxy(s.Proxy, indent) } } switch { case len(s.DebugFPM) == 0: - return root + serve("\t") + return serve(indent) case s.Debugging: - return root + php(s.DebugFPM, "\t") + return php(s.DebugFPM, indent) } - return root + fmt.Sprintf("\t@xdebug expression `%s`\n\thandle @xdebug {\n%s\t}\n\thandle {\n%s\t}\n", - xdebugRequest, php(s.DebugFPM, "\t\t"), serve("\t\t")) + return fmt.Sprintf("%[1]s@xdebug expression `%[2]s`\n%[1]shandle @xdebug {\n%[3]s%[1]s}\n%[1]shandle {\n%[4]s%[1]s}\n", + indent, xdebugRequest, php(s.DebugFPM, indent+"\t"), serve(indent+"\t")) +} + +func handlers(caddy, indent string) string { + caddy = strings.TrimSpace(strings.ReplaceAll(caddy, "\r\n", "\n")) + if caddy == "" { + return "" + } + return indent + strings.ReplaceAll(caddy, "\n", "\n"+indent) + "\n" +} + +// CheckHandlers reports whether s.Handlers can be served: the directives must stay inside the +// site's route (balanced braces), import nothing, and adapt in the site exactly as it is served, +// e.g. without a matcher named like one of Flock's. Errors name the lines as name:line. +func CheckHandlers(s Site, name string) error { + text := strings.ReplaceAll(s.Handlers, "\r\n", "\n") + tokens, err := caddyfile.Tokenize([]byte(text), name) + if err != nil { + return err + } + depth := 0 + for i, t := range tokens { + if t.Quoted() { + continue + } + switch { + case t.Text == "{": + depth++ + case t.Text == "}": + if depth--; depth < 0 { + return fmt.Errorf("%s:%d: } without a matching {", name, t.Line) + } + case t.Text == "import" && (i == 0 || tokens[i-1].Line+tokens[i-1].NumLineBreaks() < t.Line): + return fmt.Errorf("%s:%d: import isn't supported here", name, t.Line) + } + } + if depth > 0 { + return fmt.Errorf("%s: %d { without a matching }", name, depth) + } + + // Adapt the site on its own and point the error at the line of the file, not of the config. + const placeholder = "flock-site-handlers" + cf := Caddyfile(Config{StorageDir: "caddy", LogFile: "web.log", HTTPPort: 80, HTTPSPort: 443, TLD: "test", Sites: []Site{s}}) + first := strings.Index(cf, handlers(s.Handlers, "\t\t")) + if first < 0 { + return nil + } + trimmed := len(text) - len(strings.TrimLeft(text, " \t\n")) + offset := strings.Count(cf[:first], "\n") - strings.Count(text[:trimmed], "\n") + lines := strings.Count(strings.TrimRight(text, " \t\n"), "\n") + 1 + if _, _, err := caddyconfig.GetAdapter("caddyfile").Adapt([]byte(cf), map[string]any{"filename": placeholder}); err != nil { + at := regexp.MustCompile(`,? at ` + placeholder + `:(\d+)`) + message := at.ReplaceAllStringFunc(err.Error(), func(m string) string { + n, _ := strconv.Atoi(at.FindStringSubmatch(m)[1]) + if n -= offset; n < 1 || n > lines { + return "" + } + return fmt.Sprintf(", at %s:%d", name, n) + }) + if !strings.Contains(message, name) { + message = name + ": " + message + } + return errors.New(message) + } + return nil } // proxy sends every request to a FrankenPHP process, which serves the static files too, or to a diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 135186ea..179c2d8a 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -49,6 +49,60 @@ func TestCaddyfileRoutesSitesAndSubdomains(t *testing.T) { } } +func TestCaddyfileRunsSiteHandlersInOrderBeforePHP(t *testing.T) { + cfg := testConfig() + cfg.Sites[1].DebugFPM = []string{"unix//run/php84-xdebug.sock"} + cfg.Sites[1].Handlers = "handle /custom {\r\n\theader X-Flock-Custom yes\r\n\trespond custom 200\r\n}\r\n" + cf := Caddyfile(cfg) + want := "\troute {\n\t\troot * " + quote(cfg.Sites[1].Root) + "\n\t\thandle /custom {\n\t\t\theader X-Flock-Custom yes\n\t\t\trespond custom 200\n\t\t}\n\t\t@xdebug" + if !strings.Contains(cf, want) { + t.Fatalf("custom handlers are not in the route between the root and PHP:\n%s", cf) + } + if strings.Contains(cf, "\r") { + t.Fatalf("CRLF line endings kept:\n%q", cf) + } + adapted, _, err := caddyconfig.GetAdapter("caddyfile").Adapt([]byte(cf), nil) + if err != nil { + t.Fatalf("%v\n%s", err, cf) + } + if !strings.Contains(string(adapted), `"X-Flock-Custom"`) { + t.Fatalf("custom header missing from adapted config:\n%s", adapted) + } +} + +func TestCheckHandlers(t *testing.T) { + site := Site{Domain: "app.test", Root: "/code/app/public", FPM: []string{"unix//run/php85.sock"}, DebugFPM: []string{"unix//run/php85-xdebug.sock"}} + for _, tc := range []struct { + name, handlers, want string + }{ + {"cors", "@cors_preflight method OPTIONS\nheader Access-Control-Allow-Origin \"https://admin.example.test\"\nrespond @cors_preflight 204\n", ""}, + {"docs example", "@cors_preflight method OPTIONS\n\nheader {\n Access-Control-Allow-Origin \"https://admin.example.test\"\n Access-Control-Allow-Methods \"GET, POST, PUT, PATCH, DELETE, OPTIONS\"\n Access-Control-Allow-Headers \"Content-Type, Authorization\"\n Vary \"Origin\"\n}\n\nrespond @cors_preflight 204\n", ""}, + {"braces in quotes", "respond \"{ not a block\" 200\n", ""}, + {"placeholders", "header X-Host {host}\n", ""}, + {"breakout", "header X-A b\n}\n}\n:9999 {\n\tfile_server\n", "app.caddy:2: } without a matching {"}, + {"unclosed", "handle {\n\trespond x\n", "app.caddy: 1 { without a matching }"}, + {"import", "header X-A b\nimport /etc/caddy/*\n", "app.caddy:2: import isn't supported here"}, + {"unknown directive", "header X-A b\nfoo bar\n", "app.caddy: parsing caddyfile tokens for 'route': unrecognized directive: foo"}, + {"nested error", "\n\nheader X-A b\nrespond 204 {\n\tclose\n\tnope\n}\n", "unrecognized subdirective 'nope', at app.caddy:6"}, + {"Flock's matcher", "@xdebug path /x\nrespond @xdebug 200\n", "app.caddy"}, + } { + t.Run(tc.name, func(t *testing.T) { + s := site + s.Handlers = tc.handlers + err := CheckHandlers(s, "app.caddy") + switch { + case tc.want == "" && err != nil: + t.Fatalf("unexpected error: %v", err) + case tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)): + t.Fatalf("got %v, want an error with %q", err, tc.want) + } + if err != nil && strings.Contains(err.Error(), "flock-site-handlers") { + t.Fatalf("error names the generated config: %v", err) + } + }) + } +} + func TestWithBindQualifiesListenAddresses(t *testing.T) { in := []byte(`{"apps":{"http":{"servers":{"srv0":{"listen":[":443"]}}}}}`) diff --git a/internal/worktree/settings.go b/internal/worktree/settings.go index 20acb530..f50427f5 100644 --- a/internal/worktree/settings.go +++ b/internal/worktree/settings.go @@ -12,13 +12,15 @@ import ( "strings" "gopkg.in/yaml.v3" + + "flock/internal/project" ) // Project files with the `worktrees:` settings: ConfigFile is committed, LocalFile is this // machine's and stays out of git. const ( - ConfigFile = "flock.yml" - LocalFile = "flock.local.yml" + ConfigFile = project.ConfigFile + LocalFile = project.LocalFile ) // DefaultPath is where `flock worktree add` puts a worktree, relative to the site's folder.