From 6405360d62ae86c6365d021d5a91149040bda72b Mon Sep 17 00:00:00 2001 From: MehdiY Date: Sat, 7 Nov 2026 13:22:24 +0100 Subject: [PATCH 1/4] feat(web): support per-site Caddy handlers --- docs/sites.md | 21 +++++++++++++++++++++ internal/daemon/daemon.go | 16 +++++++++++++++- internal/daemon/daemon_test.go | 18 ++++++++++++++++++ internal/web/serve_test.go | 24 +++++++++++++++++++++++- internal/web/web.go | 19 ++++++++++++++++--- internal/web/web_test.go | 31 +++++++++++++++++++++++++++++++ 6 files changed, 124 insertions(+), 5 deletions(-) diff --git a/docs/sites.md b/docs/sites.md index 33751c83..fafaaa96 100644 --- a/docs/sites.md +++ b/docs/sites.md @@ -95,6 +95,27 @@ 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 handlers + +Put a `.flock/Caddyfile` in a PHP project to add [HTTP handler directives](https://caddyserver.com/docs/caddyfile/directives) to the site. Flock imports the file into an ordered `route`, after its local-access guard, and reloads it when the file changes. Write handlers only, without a site address or surrounding braces. Site options such as `tls` and `handle_errors` aren't supported there. + +For example, this 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 +``` + +Keep production CORS policy in the application or production web server too: this file only configures the local site served by Flock. If the file isn't valid Caddy syntax, Flock keeps the previous web configuration and reports the error in its warnings. + ## Listing sites :::app diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index b683286c..16acc027 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -442,10 +442,24 @@ func (d *daemon) projectFiles() string { fmt.Fprintf(&b, "%s\n", file) } } + if file := caddyfile(site.Path); file != "" { + if info, err := os.Stat(file); err == nil { + fmt.Fprintf(&b, "%s %d %d\n", file, info.Size(), info.ModTime().UnixNano()) + } + } } return b.String() } +// caddyfile returns a site's optional project-specific Caddy directives file. +func caddyfile(project string) string { + file := filepath.Join(project, ".flock", "Caddyfile") + if info, err := os.Stat(file); err == nil && !info.IsDir() { + return file + } + return "" +} + // breakpointInterval is how often the sites' PhpStorm workspaces are checked for breakpoints. var breakpointInterval = time.Second @@ -686,7 +700,7 @@ func (d *daemon) reload() error { continue } - ws := web.Site{Domain: domain, Aliases: aliases, Root: root.Dir} + ws := web.Site{Domain: domain, Aliases: aliases, Root: root.Dir, Caddyfile: caddyfile(site.Path)} if cfg.Xdebug { ws.DebugFPM = debugPool(version) } diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 0645658e..b35ecac1 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -358,6 +358,24 @@ func TestFoldersInParkedPathsAreFollowed(t *testing.T) { waitFor(false) } +func TestProjectFilesFollowsCustomCaddyfile(t *testing.T) { + site := t.TempDir() + d := daemon{cfg: &config.Config{Sites: []config.Site{{Name: "app", Path: site}}}} + before := d.projectFiles() + + file := filepath.Join(site, ".flock", "Caddyfile") + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte("header X-Test yes\n"), 0o644); err != nil { + t.Fatal(err) + } + after := d.projectFiles() + if before == after || !strings.Contains(after, file) { + t.Fatalf("custom Caddyfile did not change the project fingerprint: before %q, after %q", before, after) + } +} + func TestGitWorktreesAreServedUnderTheirSite(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not installed") diff --git a/internal/web/serve_test.go b/internal/web/serve_test.go index e90b34be..5ac671c9 100644 --- a/internal/web/serve_test.go +++ b/internal/web/serve_test.go @@ -60,6 +60,14 @@ 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) + } pool := x509.NewCertPool() caPEM, _ := os.ReadFile(ca.CertPath()) pool.AppendCertsFromPEM(caPEM) @@ -79,7 +87,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, Caddyfile: custom, FPM: []string{"127.0.0.1:1"}, CertFile: certFile, KeyFile: keyFile}, {Domain: "plain.test", Root: dir, FPM: []string{"127.0.0.1:1"}}, }, Waiting: []Waiting{ @@ -123,6 +131,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 f4b033fd..b67a1498 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -27,7 +27,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) + // Caddyfile is an optional file of HTTP handler directives imported into the ordered route that + // serves the site. It is not imported into the HTTP-to-HTTPS redirect or Flock's unlinked page. + Caddyfile 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 @@ -146,13 +149,13 @@ func Caddyfile(cfg Config) string { hosts := hosts(name) if s.CertFile != "" { - fmt.Fprintf(&b, "\n%s {\n\timport flock_site\n%s%s%s}\n", addresses("https", hosts, cfg), tlsBlock, app(s), upstreamDown(s, cfg)) + fmt.Fprintf(&b, "\n%s {\n\timport flock_site\n%s%s%s%s}\n", addresses("https", hosts, cfg), tlsBlock, custom(s), app(s), upstreamDown(s, cfg)) fmt.Fprintf(&b, "\n%s {\n\timport flock_site\n\tredir https://{host}%s{uri} 308\n}\n", addresses("http", hosts, cfg), portSuffix(cfg.HTTPSPort, 443)) continue } - fmt.Fprintf(&b, "\n%s {\n\timport flock_site\n%s%s}\n", addresses("http", hosts, cfg), app(s), upstreamDown(s, cfg)) + fmt.Fprintf(&b, "\n%s {\n\timport flock_site\n%s%s%s}\n", addresses("http", hosts, cfg), custom(s), app(s), upstreamDown(s, cfg)) // Over HTTPS, a site that isn't secured names itself, so the page asks to secure it rather than link it. // A proxy says so too: the apps can't secure one yet, so the page offers only the command. vars := fmt.Sprintf("\tvars flock_site %s\n", quote(siteName(s.Domain, cfg.TLD))) @@ -271,6 +274,16 @@ func app(s Site) string { xdebugRequest, php(s.DebugFPM, "\t\t"), serve("\t\t")) } +func custom(s Site) string { + if s.Caddyfile == "" { + return "" + } + // A route preserves the written order. Without it Caddy's directive sorting could put a + // custom terminating handler before flock_site's abort and expose a site when macOS requires + // Flock to bind privileged ports on every interface. + return fmt.Sprintf("\troute {\n\t\tabort @remote\n\t\timport %s\n\t}\n", quote(s.Caddyfile)) +} + // proxy sends every request to a FrankenPHP process, which serves the static files too, or to a // proxied name's server. The Host header passes unchanged, so the app sees its own domain, and // WebSocket upgrades (a dev server's hot reload) pass through. diff --git a/internal/web/web_test.go b/internal/web/web_test.go index b6ef1c53..b2dccbbb 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -1,6 +1,8 @@ package web import ( + "os" + "path/filepath" "strings" "testing" @@ -48,6 +50,35 @@ func TestCaddyfileRoutesSitesAndSubdomains(t *testing.T) { } } +func TestCaddyfileImportsSiteDirectivesOnlyWhereTheSiteIsServed(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, ".flock", "Caddyfile") + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte("handle {\n\theader X-Flock-Custom yes\n\trespond custom 200\n}\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := testConfig() + cfg.Sites[1].Caddyfile = file + cf := Caddyfile(cfg) + if got := strings.Count(cf, "import "+quote(file)); got != 1 { + t.Fatalf("custom Caddyfile imported %d times, want once in the serving block:\n%s", got, cf) + } + want := "\troute {\n\t\tabort @remote\n\t\timport " + quote(file) + if !strings.Contains(cf, want) { + t.Fatalf("custom handlers are not guarded by an ordered remote abort:\n%s", cf) + } + json, _, err := caddyconfig.GetAdapter("caddyfile").Adapt([]byte(cf), nil) + if err != nil { + t.Fatalf("%v\n%s", err, cf) + } + if !strings.Contains(string(json), `"X-Flock-Custom"`) { + t.Fatalf("custom header missing from adapted config:\n%s", json) + } +} + func TestWithBindQualifiesListenAddresses(t *testing.T) { in := []byte(`{"apps":{"http":{"servers":{"srv0":{"listen":[":443"]}}}}}`) From d13a28a67dbdf03198e33725336827305bd802a2 Mon Sep 17 00:00:00 2001 From: MehdiY Date: Sat, 7 Nov 2026 18:56:58 +0100 Subject: [PATCH 2/4] fix(web): address custom handler review findings --- docs/sites.md | 2 +- internal/daemon/daemon.go | 9 +++- internal/web/serve_test.go | 103 ++++++++++++++++++++++++++++++++++++- internal/web/web.go | 49 +++++++++++------- internal/web/web_test.go | 17 +++--- 5 files changed, 152 insertions(+), 28 deletions(-) diff --git a/docs/sites.md b/docs/sites.md index fafaaa96..9fa87d9b 100644 --- a/docs/sites.md +++ b/docs/sites.md @@ -97,7 +97,7 @@ A proxy's name can't be a linked site's: Flock refuses it. A folder of the same ## Custom Caddy handlers -Put a `.flock/Caddyfile` in a PHP project to add [HTTP handler directives](https://caddyserver.com/docs/caddyfile/directives) to the site. Flock imports the file into an ordered `route`, after its local-access guard, and reloads it when the file changes. Write handlers only, without a site address or surrounding braces. Site options such as `tls` and `handle_errors` aren't supported there. +Put a `.flock/Caddyfile` in a PHP project to add [HTTP handler directives](https://caddyserver.com/docs/caddyfile/directives) to the site. Flock loads its handlers into an ordered `route`, after its local-access guard, and reloads them when the file changes. Write handlers only, without a site address or surrounding braces. Site options such as `tls` and `handle_errors` aren't supported there. For example, this allows a separate frontend to call the site and answers CORS preflight requests: diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 16acc027..c46f9b9d 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -700,7 +700,14 @@ func (d *daemon) reload() error { continue } - ws := web.Site{Domain: domain, Aliases: aliases, Root: root.Dir, Caddyfile: caddyfile(site.Path)} + ws := web.Site{Domain: domain, Aliases: aliases, Root: root.Dir} + if file := caddyfile(site.Path); file != "" { + data, readErr := os.ReadFile(file) + if readErr != nil { + return fmt.Errorf("read %s: %w", file, readErr) + } + ws.Handlers = string(data) + } if cfg.Xdebug { ws.DebugFPM = debugPool(version) } diff --git a/internal/web/serve_test.go b/internal/web/serve_test.go index 5ac671c9..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) { @@ -68,6 +165,10 @@ 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) @@ -87,7 +188,7 @@ respond @cors_preflight 204 return leaves.Get(ca, name) }, Sites: []Site{ - {Domain: "app.test", Aliases: []string{"app-2.test"}, Root: dir, Caddyfile: custom, 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{ diff --git a/internal/web/web.go b/internal/web/web.go index b67a1498..f124342e 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -27,10 +27,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 - // Caddyfile is an optional file of HTTP handler directives imported into the ordered route that - // serves the site. It is not imported into the HTTP-to-HTTPS redirect or Flock's unlinked page. - Caddyfile string - 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 @@ -149,13 +149,13 @@ func Caddyfile(cfg Config) string { hosts := hosts(name) if s.CertFile != "" { - fmt.Fprintf(&b, "\n%s {\n\timport flock_site\n%s%s%s%s}\n", addresses("https", hosts, cfg), tlsBlock, custom(s), app(s), upstreamDown(s, cfg)) + fmt.Fprintf(&b, "\n%s {\n\timport flock_site\n%s%s%s}\n", addresses("https", hosts, cfg), tlsBlock, app(s), upstreamDown(s, cfg)) fmt.Fprintf(&b, "\n%s {\n\timport flock_site\n\tredir https://{host}%s{uri} 308\n}\n", addresses("http", hosts, cfg), portSuffix(cfg.HTTPSPort, 443)) continue } - fmt.Fprintf(&b, "\n%s {\n\timport flock_site\n%s%s%s}\n", addresses("http", hosts, cfg), custom(s), app(s), upstreamDown(s, cfg)) + fmt.Fprintf(&b, "\n%s {\n\timport flock_site\n%s%s}\n", addresses("http", hosts, cfg), app(s), upstreamDown(s, cfg)) // Over HTTPS, a site that isn't secured names itself, so the page asks to secure it rather than link it. // A proxy says so too: the apps can't secure one yet, so the page offers only the command. vars := fmt.Sprintf("\tvars flock_site %s\n", quote(siteName(s.Domain, cfg.TLD))) @@ -256,32 +256,43 @@ 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 preserves the written order. The remote abort must run before any custom terminating + // handler, and the custom handlers must run before the Xdebug/default handle pair. + return "\troute {\n\t\tabort @remote\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 custom(s Site) string { - if s.Caddyfile == "" { +func handlers(caddy, indent string) string { + caddy = strings.TrimSpace(caddy) + if caddy == "" { return "" } - // A route preserves the written order. Without it Caddy's directive sorting could put a - // custom terminating handler before flock_site's abort and expose a site when macOS requires - // Flock to bind privileged ports on every interface. - return fmt.Sprintf("\troute {\n\t\tabort @remote\n\t\timport %s\n\t}\n", quote(s.Caddyfile)) + return indent + strings.ReplaceAll(caddy, "\n", "\n"+indent) + "\n" } // 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 b2dccbbb..30a25a32 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -50,8 +50,8 @@ func TestCaddyfileRoutesSitesAndSubdomains(t *testing.T) { } } -func TestCaddyfileImportsSiteDirectivesOnlyWhereTheSiteIsServed(t *testing.T) { - dir := t.TempDir() +func TestCaddyfileIncludesSiteHandlersFromBracketedProjectPath(t *testing.T) { + dir := filepath.Join(t.TempDir(), "app [local]") file := filepath.Join(dir, ".flock", "Caddyfile") if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { t.Fatal(err) @@ -61,12 +61,17 @@ func TestCaddyfileImportsSiteDirectivesOnlyWhereTheSiteIsServed(t *testing.T) { } cfg := testConfig() - cfg.Sites[1].Caddyfile = file + cfg.Sites[1].Root = dir + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + cfg.Sites[1].Handlers = string(data) cf := Caddyfile(cfg) - if got := strings.Count(cf, "import "+quote(file)); got != 1 { - t.Fatalf("custom Caddyfile imported %d times, want once in the serving block:\n%s", got, cf) + if strings.Contains(cf, "import "+quote(file)) { + t.Fatalf("custom Caddyfile path must not be passed through Caddy's glob import:\n%s", cf) } - want := "\troute {\n\t\tabort @remote\n\t\timport " + quote(file) + want := "\troute {\n\t\tabort @remote\n\t\troot * " + quote(dir) + "\n\t\thandle {" if !strings.Contains(cf, want) { t.Fatalf("custom handlers are not guarded by an ordered remote abort:\n%s", cf) } From 6c8235e565edf629ef52ef289da9a6cf89ea00bd Mon Sep 17 00:00:00 2001 From: MehdiY Date: Sat, 7 Nov 2026 23:56:38 +0100 Subject: [PATCH 3/4] refactor(web): use the project Caddyfile --- docs/sites.md | 8 +++++++- internal/daemon/daemon.go | 13 +++++++++++-- internal/daemon/daemon_test.go | 20 +++++++++++++++----- internal/web/web_test.go | 2 +- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/docs/sites.md b/docs/sites.md index 9fa87d9b..5ea016b6 100644 --- a/docs/sites.md +++ b/docs/sites.md @@ -97,7 +97,13 @@ A proxy's name can't be a linked site's: Flock refuses it. A folder of the same ## Custom Caddy handlers -Put a `.flock/Caddyfile` in a PHP project to add [HTTP handler directives](https://caddyserver.com/docs/caddyfile/directives) to the site. Flock loads its handlers into an ordered `route`, after its local-access guard, and reloads them when the file changes. Write handlers only, without a site address or surrounding braces. Site options such as `tls` and `handle_errors` aren't supported there. +Put a `Caddyfile` in a PHP project's root to add [HTTP handler directives](https://caddyserver.com/docs/caddyfile/directives) to the site. Flock loads its handlers into an ordered `route`, after its local-access guard, and reloads them when the file changes. Write handlers only, without a site address or surrounding braces. Site options such as `tls` and `handle_errors` aren't supported there. + +To use another file, set `FLOCK_CADDYFILE` in the project's `.env`. A relative path starts at the project root: + +```dotenv +FLOCK_CADDYFILE=ops/local.Caddyfile +``` For example, this allows a separate frontend to call the site and answers CORS preflight requests: diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index c46f9b9d..a8d17d65 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -451,9 +451,18 @@ func (d *daemon) projectFiles() string { return b.String() } -// caddyfile returns a site's optional project-specific Caddy directives file. +// caddyfile returns a site's optional project-specific Caddy directives file. It is Caddyfile in +// the project by default; FLOCK_CADDYFILE in .env may name another file, relative to the project. func caddyfile(project string) string { - file := filepath.Join(project, ".flock", "Caddyfile") + file := filepath.Join(project, "Caddyfile") + if env, err := worktree.ReadDotenv(filepath.Join(project, ".env")); err == nil { + if configured := env.Get("FLOCK_CADDYFILE"); configured != "" { + file = configured + if !filepath.IsAbs(file) { + file = filepath.Join(project, file) + } + } + } if info, err := os.Stat(file); err == nil && !info.IsDir() { return file } diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index b35ecac1..945bd36d 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -358,15 +358,12 @@ func TestFoldersInParkedPathsAreFollowed(t *testing.T) { waitFor(false) } -func TestProjectFilesFollowsCustomCaddyfile(t *testing.T) { +func TestProjectFilesFollowsRootOrConfiguredCaddyfile(t *testing.T) { site := t.TempDir() d := daemon{cfg: &config.Config{Sites: []config.Site{{Name: "app", Path: site}}}} before := d.projectFiles() - file := filepath.Join(site, ".flock", "Caddyfile") - if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { - t.Fatal(err) - } + file := filepath.Join(site, "Caddyfile") if err := os.WriteFile(file, []byte("header X-Test yes\n"), 0o644); err != nil { t.Fatal(err) } @@ -374,6 +371,19 @@ func TestProjectFilesFollowsCustomCaddyfile(t *testing.T) { if before == after || !strings.Contains(after, file) { t.Fatalf("custom Caddyfile did not change the project fingerprint: before %q, after %q", before, after) } + + configured := filepath.Join(site, "ops", "local.Caddyfile") + if err := os.MkdirAll(filepath.Dir(configured), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configured, []byte("header X-Configured yes\n"), 0o644); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(site, ".env"), "FLOCK_CADDYFILE='ops/local.Caddyfile'\n") + overridden := d.projectFiles() + if overridden == after || !strings.Contains(overridden, configured) || strings.Contains(overridden, file+" ") { + t.Fatalf("FLOCK_CADDYFILE did not select the configured file: %q", overridden) + } } func TestGitWorktreesAreServedUnderTheirSite(t *testing.T) { diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 30a25a32..fccdf4a6 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -52,7 +52,7 @@ func TestCaddyfileRoutesSitesAndSubdomains(t *testing.T) { func TestCaddyfileIncludesSiteHandlersFromBracketedProjectPath(t *testing.T) { dir := filepath.Join(t.TempDir(), "app [local]") - file := filepath.Join(dir, ".flock", "Caddyfile") + file := filepath.Join(dir, "Caddyfile") if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { t.Fatal(err) } From 749a56bdc89cab4108424a4111113af347129998 Mon Sep 17 00:00:00 2001 From: Lukas Frey Date: Sun, 8 Nov 2026 15:52:02 +0100 Subject: [PATCH 4/4] feat(web): name the site's Caddy directives in flock.yml Flock no longer reads a project's Caddyfile or FLOCK_CADDYFILE. A site opts in with caddyfile: in flock.yml, and flock.local.yml replaces it. A file that can't be used is a warning and leaves only that site without it; braces must balance and import isn't allowed. --- docs/configuration.md | 2 +- docs/sites.md | 16 +++---- internal/daemon/daemon.go | 55 +++++++++++++---------- internal/daemon/daemon_test.go | 55 ++++++++++++++++------- internal/project/caddy.go | 73 +++++++++++++++++++++++++++++++ internal/project/caddy_test.go | 80 ++++++++++++++++++++++++++++++++++ internal/web/web.go | 67 ++++++++++++++++++++++++++-- internal/web/web_test.go | 70 ++++++++++++++++++----------- internal/worktree/settings.go | 6 ++- 9 files changed, 346 insertions(+), 78 deletions(-) create mode 100644 internal/project/caddy.go create mode 100644 internal/project/caddy_test.go 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 5ea016b6..e803f8d3 100644 --- a/docs/sites.md +++ b/docs/sites.md @@ -95,17 +95,17 @@ 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 handlers +## Custom Caddy directives -Put a `Caddyfile` in a PHP project's root to add [HTTP handler directives](https://caddyserver.com/docs/caddyfile/directives) to the site. Flock loads its handlers into an ordered `route`, after its local-access guard, and reloads them when the file changes. Write handlers only, without a site address or surrounding braces. Site options such as `tls` and `handle_errors` aren't supported there. +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`: -To use another file, set `FLOCK_CADDYFILE` in the project's `.env`. A relative path starts at the project root: - -```dotenv -FLOCK_CADDYFILE=ops/local.Caddyfile +```yaml +caddyfile: flock.caddy ``` -For example, this allows a separate frontend to call the site and answers CORS preflight requests: +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 @@ -120,7 +120,7 @@ header { respond @cors_preflight 204 ``` -Keep production CORS policy in the application or production web server too: this file only configures the local site served by Flock. If the file isn't valid Caddy syntax, Flock keeps the previous web configuration and reports the error in its warnings. +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 diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index a8d17d65..84060fd0 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -442,7 +442,12 @@ func (d *daemon) projectFiles() string { fmt.Fprintf(&b, "%s\n", file) } } - if file := caddyfile(site.Path); 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()) } @@ -451,22 +456,29 @@ func (d *daemon) projectFiles() string { return b.String() } -// caddyfile returns a site's optional project-specific Caddy directives file. It is Caddyfile in -// the project by default; FLOCK_CADDYFILE in .env may name another file, relative to the project. -func caddyfile(project string) string { - file := filepath.Join(project, "Caddyfile") - if env, err := worktree.ReadDotenv(filepath.Join(project, ".env")); err == nil { - if configured := env.Get("FLOCK_CADDYFILE"); configured != "" { - file = configured - if !filepath.IsAbs(file) { - file = filepath.Join(project, file) - } - } +// 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 info, err := os.Stat(file); err == nil && !info.IsDir() { - return file + if file == "" { + return "", "" } - 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. @@ -710,13 +722,6 @@ func (d *daemon) reload() error { } ws := web.Site{Domain: domain, Aliases: aliases, Root: root.Dir} - if file := caddyfile(site.Path); file != "" { - data, readErr := os.ReadFile(file) - if readErr != nil { - return fmt.Errorf("read %s: %w", file, readErr) - } - ws.Handlers = string(data) - } if cfg.Xdebug { ws.DebugFPM = debugPool(version) } @@ -758,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 945bd36d..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,31 +359,55 @@ func TestFoldersInParkedPathsAreFollowed(t *testing.T) { waitFor(false) } -func TestProjectFilesFollowsRootOrConfiguredCaddyfile(t *testing.T) { +func TestProjectFilesFollowsTheConfiguredCaddyfile(t *testing.T) { site := t.TempDir() d := daemon{cfg: &config.Config{Sites: []config.Site{{Name: "app", Path: site}}}} before := d.projectFiles() - file := filepath.Join(site, "Caddyfile") - if err := os.WriteFile(file, []byte("header X-Test yes\n"), 0o644); err != nil { - t.Fatal(err) - } - after := d.projectFiles() - if before == after || !strings.Contains(after, file) { - t.Fatalf("custom Caddyfile did not change the project fingerprint: before %q, after %q", before, after) + // 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") } - configured := filepath.Join(site, "ops", "local.Caddyfile") + 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) } - if err := os.WriteFile(configured, []byte("header X-Configured yes\n"), 0o644); 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) } - writeFile(t, filepath.Join(site, ".env"), "FLOCK_CADDYFILE='ops/local.Caddyfile'\n") - overridden := d.projectFiles() - if overridden == after || !strings.Contains(overridden, configured) || strings.Contains(overridden, file+" ") { - t.Fatalf("FLOCK_CADDYFILE did not select the configured file: %q", overridden) +} + +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) } } 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/web.go b/internal/web/web.go index e9aba4f8..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" @@ -263,9 +266,9 @@ func app(s Site) string { return root(s, "\t") + dispatch(s, "\t") } - // A route preserves the written order. The remote abort must run before any custom terminating - // handler, and the custom handlers must run before the Xdebug/default handle pair. - return "\troute {\n\t\tabort @remote\n" + root(s, "\t\t") + handlers(s.Handlers, "\t\t") + dispatch(s, "\t\t") + "\t}\n" + // 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 { @@ -291,13 +294,69 @@ func dispatch(s Site, indent string) string { } func handlers(caddy, indent string) string { - caddy = strings.TrimSpace(caddy) + 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 // proxied name's server. The Host header passes unchanged, so the app sees its own domain, and // WebSocket upgrades (a dev server's hot reload) pass through. diff --git a/internal/web/web_test.go b/internal/web/web_test.go index fadbef46..179c2d8a 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -2,8 +2,6 @@ package web import ( "encoding/json" - "os" - "path/filepath" "strings" "testing" @@ -51,37 +49,57 @@ func TestCaddyfileRoutesSitesAndSubdomains(t *testing.T) { } } -func TestCaddyfileIncludesSiteHandlersFromBracketedProjectPath(t *testing.T) { - dir := filepath.Join(t.TempDir(), "app [local]") - file := filepath.Join(dir, "Caddyfile") - if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(file, []byte("handle {\n\theader X-Flock-Custom yes\n\trespond custom 200\n}\n"), 0o644); err != nil { - t.Fatal(err) - } - +func TestCaddyfileRunsSiteHandlersInOrderBeforePHP(t *testing.T) { cfg := testConfig() - cfg.Sites[1].Root = dir - data, err := os.ReadFile(file) - if err != nil { - t.Fatal(err) - } - cfg.Sites[1].Handlers = string(data) + 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) - if strings.Contains(cf, "import "+quote(file)) { - t.Fatalf("custom Caddyfile path must not be passed through Caddy's glob import:\n%s", cf) - } - want := "\troute {\n\t\tabort @remote\n\t\troot * " + quote(dir) + "\n\t\thandle {" + 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 guarded by an ordered remote abort:\n%s", cf) + 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) } - json, _, err := caddyconfig.GetAdapter("caddyfile").Adapt([]byte(cf), nil) + adapted, _, err := caddyconfig.GetAdapter("caddyfile").Adapt([]byte(cf), nil) if err != nil { t.Fatalf("%v\n%s", err, cf) } - if !strings.Contains(string(json), `"X-Flock-Custom"`) { - t.Fatalf("custom header missing from adapted config:\n%s", json) + 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) + } + }) } } 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.