From 47c7cbb041654fc8e29f3520e59fbb87ffab47bb Mon Sep 17 00:00:00 2001 From: Keegan Carruthers-Smith Date: Mon, 11 May 2026 16:24:46 +0200 Subject: [PATCH 1/5] feat/webserver: support serving over unix sockets zoekt-webserver could proxy to the indexserver over a socket, but the public webserver itself only bound TCP addresses. That made deployments with nginx-to-socket upstreams require an extra TCP hop even though Go's HTTP server can serve the same mux on a Unix listener.\n\nAdd a dedicated -listen_unix flag that swaps the listener while preserving the existing TCP default, watchdog health checks, graceful shutdown, and TLS handling. Amp-Thread-ID: https://ampcode.com/threads/T-019e1765-d2ff-756b-a3ba-28594ca68956 Co-authored-by: Amp --- cmd/zoekt-webserver/main.go | 70 ++++++++++++++++++++--- cmd/zoekt-webserver/main_test.go | 96 ++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 9 deletions(-) create mode 100644 cmd/zoekt-webserver/main_test.go diff --git a/cmd/zoekt-webserver/main.go b/cmd/zoekt-webserver/main.go index d9e5c5773..cde0fbbb8 100644 --- a/cmd/zoekt-webserver/main.go +++ b/cmd/zoekt-webserver/main.go @@ -131,6 +131,7 @@ func main() { logRefresh := flag.Duration("log_refresh", 24*time.Hour, "if using --log_dir, start writing a new file this often.") listen := flag.String("listen", ":6070", "listen on this address.") + listenUnix := flag.String("listen_unix", "", "listen on this Unix socket path instead of TCP") indexDir := flag.String("index", index.DefaultDir, "set index directory to use") html := flag.Bool("html", true, "enable HTML interface") enableRPC := flag.Bool("rpc", false, "enable go/net RPC") @@ -278,10 +279,18 @@ func main() { if *sslCert != "" || *sslKey != "" { watchdogAddr = "https://" + *listen } + watchdogUnixSocket := "" + if *listenUnix != "" { + watchdogUnixSocket = *listenUnix + watchdogAddr = "http://unix" + if *sslCert != "" || *sslKey != "" { + watchdogAddr = "https://unix" + } + } watchdogAddr += "/healthz" if watchdogErrCount > 0 && watchdogTick > 0 { - go watchdog(watchdogTick, watchdogErrCount, watchdogAddr) + go watchdog(watchdogTick, watchdogErrCount, watchdogAddr, watchdogUnixSocket) } else { log.Println("watchdog disabled") } @@ -300,13 +309,8 @@ func main() { } go func() { - sglog.Scoped("server").Info("starting server", sglog.Stringp("address", listen)) - var err error - if *sslCert != "" || *sslKey != "" { - err = srv.ListenAndServeTLS(*sslCert, *sslKey) - } else { - err = srv.ListenAndServe() - } + sglog.Scoped("server").Info("starting server", sglog.Stringp("address", listen), sglog.Stringp("unixSocket", listenUnix)) + err := serveHTTP(srv, *listenUnix, *sslCert, *sslKey) if err != http.ErrServerClosed { // Fatal otherwise shutdownOnSignal will block @@ -328,6 +332,48 @@ func main() { } } +func serveHTTP(srv *http.Server, unixSocket, sslCert, sslKey string) error { + if unixSocket != "" { + l, err := listenUnixSocket(unixSocket) + if err != nil { + return err + } + defer os.Remove(unixSocket) + + if sslCert != "" || sslKey != "" { + return srv.ServeTLS(l, sslCert, sslKey) + } + return srv.Serve(l) + } + + if sslCert != "" || sslKey != "" { + return srv.ListenAndServeTLS(sslCert, sslKey) + } + return srv.ListenAndServe() +} + +func listenUnixSocket(socket string) (net.Listener, error) { + // We cannot bind a socket to an existing pathname. + if err := os.Remove(socket); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("error removing socket file: %s", socket) + } + + l, err := net.Listen("unix", socket) + if err != nil { + return nil, fmt.Errorf("failed to listen on socket %s: %w", socket, err) + } + + // nginx and zoekt-webserver often run as different users. Make the socket + // broadly connectable like the indexserver socket used by this binary's + // reverse proxy support. + if err := os.Chmod(socket, 0o777); err != nil { + _ = l.Close() + return nil, fmt.Errorf("failed to change permission of socket %s: %w", socket, err) + } + + return l, nil +} + // addProxyHandler adds a handler to "mux" that proxies all requests with base // /indexserver to "socket". func addProxyHandler(mux *http.ServeMux, socket string) { @@ -424,10 +470,16 @@ func watchdogOnce(ctx context.Context, client *http.Client, addr string) error { return nil } -func watchdog(dt time.Duration, maxErrCount int, addr string) { +func watchdog(dt time.Duration, maxErrCount int, addr, unixSocket string) { tr := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } + if unixSocket != "" { + tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", unixSocket) + } + } client := &http.Client{ Transport: tr, } diff --git a/cmd/zoekt-webserver/main_test.go b/cmd/zoekt-webserver/main_test.go new file mode 100644 index 000000000..b622274a8 --- /dev/null +++ b/cmd/zoekt-webserver/main_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "os" + "path/filepath" + "testing" + "time" +) + +func TestServeHTTPUnixSocket(t *testing.T) { + socket := filepath.Join(t.TempDir(), "zoekt.sock") + if err := os.WriteFile(socket, []byte("stale"), 0o600); err != nil { + t.Fatal(err) + } + + srv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/healthz" { + http.NotFound(w, r) + return + } + _, _ = io.WriteString(w, "ok") + }), + } + + errCh := make(chan error, 1) + go func() { + errCh <- serveHTTP(srv, socket, "", "") + }() + + client := &http.Client{ + Timeout: time.Second, + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", socket) + }, + }, + } + + var resp *http.Response + var err error + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + resp, err = client.Get("http://unix/healthz") + if err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + if err != nil { + t.Fatalf("GET over unix socket failed: %v", err) + } + + if resp.StatusCode != http.StatusOK { + t.Fatalf("got status %d, want %d", resp.StatusCode, http.StatusOK) + } + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + t.Fatal(err) + } + if string(body) != "ok" { + t.Fatalf("got body %q, want %q", string(body), "ok") + } + if mode := socketMode(t, socket); mode != 0o777 { + t.Fatalf("got socket mode %o, want 777", mode) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + t.Fatal(err) + } + + if err := <-errCh; !errors.Is(err, http.ErrServerClosed) { + t.Fatalf("serveHTTP returned %v, want %v", err, http.ErrServerClosed) + } + if _, err := os.Stat(socket); !os.IsNotExist(err) { + t.Fatalf("socket was not removed after shutdown: %v", err) + } +} + +func socketMode(t *testing.T, socket string) os.FileMode { + t.Helper() + fi, err := os.Stat(socket) + if err != nil { + t.Fatal(err) + } + return fi.Mode().Perm() +} From 1b8158ebf11e79416f43db2cdab6f07029be3958 Mon Sep 17 00:00:00 2001 From: Keegan Carruthers-Smith Date: Mon, 11 May 2026 16:33:13 +0200 Subject: [PATCH 2/5] fix/webserver: keep unix socket serving plain HTTP Unix domain socket support is intended for local proxy handoff, where adding TLS inside the socket only makes the new path harder to reason about without improving the deployment story. Rejecting TLS flags in this mode keeps the watchdog and server setup aligned with the simple HTTP-over-socket behavior. Amp-Thread-ID: https://ampcode.com/threads/T-019e1772-2eba-7270-8871-f9ebf6b45a56 Co-authored-by: Amp --- cmd/zoekt-webserver/main.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/cmd/zoekt-webserver/main.go b/cmd/zoekt-webserver/main.go index cde0fbbb8..ae9213c8a 100644 --- a/cmd/zoekt-webserver/main.go +++ b/cmd/zoekt-webserver/main.go @@ -183,6 +183,9 @@ func main() { // caller to divert stderr output if necessary. go divertLogs(*logDir, *logRefresh) } + if *listenUnix != "" && (*sslCert != "" || *sslKey != "") { + log.Fatal("-listen_unix cannot be combined with -ssl_cert or -ssl_key") + } // Tune GOMAXPROCS to match Linux container CPU quota. _, _ = maxprocs.Set() @@ -283,9 +286,6 @@ func main() { if *listenUnix != "" { watchdogUnixSocket = *listenUnix watchdogAddr = "http://unix" - if *sslCert != "" || *sslKey != "" { - watchdogAddr = "https://unix" - } } watchdogAddr += "/healthz" @@ -339,10 +339,6 @@ func serveHTTP(srv *http.Server, unixSocket, sslCert, sslKey string) error { return err } defer os.Remove(unixSocket) - - if sslCert != "" || sslKey != "" { - return srv.ServeTLS(l, sslCert, sslKey) - } return srv.Serve(l) } From 63110a7a7f882fc9691b0c9e3e11d2129d5f9304 Mon Sep 17 00:00:00 2001 From: Keegan Carruthers-Smith Date: Mon, 11 May 2026 16:37:12 +0200 Subject: [PATCH 3/5] fix/webserver: wait for unix socket cleanup The socket file cleanup lives in the serving goroutine, so main needs to let that goroutine finish after shutdown. Waiting for the server result preserves the existing fatal behavior for serve errors while ensuring the Unix socket path is removed before process exit. Amp-Thread-ID: https://ampcode.com/threads/T-019e1772-2eba-7270-8871-f9ebf6b45a56 Co-authored-by: Amp --- cmd/zoekt-webserver/main.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/zoekt-webserver/main.go b/cmd/zoekt-webserver/main.go index ae9213c8a..9663c93ab 100644 --- a/cmd/zoekt-webserver/main.go +++ b/cmd/zoekt-webserver/main.go @@ -308,6 +308,7 @@ func main() { Handler: handler, } + serveErrCh := make(chan error, 1) go func() { sglog.Scoped("server").Info("starting server", sglog.Stringp("address", listen), sglog.Stringp("unixSocket", listenUnix)) err := serveHTTP(srv, *listenUnix, *sslCert, *sslKey) @@ -316,6 +317,7 @@ func main() { // Fatal otherwise shutdownOnSignal will block log.Fatalf("ListenAndServe: %v", err) } + serveErrCh <- err }() if s.RPC { @@ -330,6 +332,7 @@ func main() { log.Fatalf("http.Server.Shutdown: %v", err) } } + <-serveErrCh } func serveHTTP(srv *http.Server, unixSocket, sslCert, sslKey string) error { From 8f2db002adfdeda6568c071d329c8a0d3728e7e5 Mon Sep 17 00:00:00 2001 From: Keegan Carruthers-Smith Date: Mon, 11 May 2026 16:46:09 +0200 Subject: [PATCH 4/5] fix/webserver: rely on unix listener unlink Go tracks whether it created a Unix socket path and unlinks it when the listener closes. Leaving a second Remove behind can race with a replacement process that has already rebound the same path, so rely on the listener's ownership-aware cleanup instead. Amp-Thread-ID: https://ampcode.com/threads/T-019e1772-2eba-7270-8871-f9ebf6b45a56 Co-authored-by: Amp --- cmd/zoekt-webserver/main.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/zoekt-webserver/main.go b/cmd/zoekt-webserver/main.go index 9663c93ab..1973d1796 100644 --- a/cmd/zoekt-webserver/main.go +++ b/cmd/zoekt-webserver/main.go @@ -341,7 +341,6 @@ func serveHTTP(srv *http.Server, unixSocket, sslCert, sslKey string) error { if err != nil { return err } - defer os.Remove(unixSocket) return srv.Serve(l) } From 96354d86311acbdb762123e824afc304c4ef872f Mon Sep 17 00:00:00 2001 From: Keegan Carruthers-Smith Date: Mon, 11 May 2026 16:56:40 +0200 Subject: [PATCH 5/5] fix/webserver: log the active listen endpoint The startup message should describe the endpoint actually used by the webserver. Moving the log into serveHTTP keeps the TCP and Unix socket paths from reporting unrelated configuration values together, which makes the log less ambiguous when Unix sockets are enabled. Amp-Thread-ID: https://ampcode.com/threads/T-019e1788-6ac1-744f-8b2d-c987f9132c24 Co-authored-by: Amp --- cmd/zoekt-webserver/main.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/zoekt-webserver/main.go b/cmd/zoekt-webserver/main.go index 1973d1796..79c82337f 100644 --- a/cmd/zoekt-webserver/main.go +++ b/cmd/zoekt-webserver/main.go @@ -310,7 +310,6 @@ func main() { serveErrCh := make(chan error, 1) go func() { - sglog.Scoped("server").Info("starting server", sglog.Stringp("address", listen), sglog.Stringp("unixSocket", listenUnix)) err := serveHTTP(srv, *listenUnix, *sslCert, *sslKey) if err != http.ErrServerClosed { @@ -336,14 +335,17 @@ func main() { } func serveHTTP(srv *http.Server, unixSocket, sslCert, sslKey string) error { + logger := sglog.Scoped("server") if unixSocket != "" { l, err := listenUnixSocket(unixSocket) if err != nil { return err } + logger.Info("starting server", sglog.String("unixSocket", unixSocket)) return srv.Serve(l) } + logger.Info("starting server", sglog.String("address", srv.Addr)) if sslCert != "" || sslKey != "" { return srv.ListenAndServeTLS(sslCert, sslKey) }