From 83e2740d68ec11904dabf3b867f32661c20da560 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 20:32:04 +0000 Subject: [PATCH] Add HTTP health endpoint --- http_server.go | 85 +++++++++++++++++++++++++++++++++++++++++++++++++ service_test.go | 46 ++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 http_server.go diff --git a/http_server.go b/http_server.go new file mode 100644 index 0000000..47098fb --- /dev/null +++ b/http_server.go @@ -0,0 +1,85 @@ +package dns + +import ( + "context" + "encoding/json" + "net" + "net/http" + "strconv" + "time" +) + +// HTTPServer owns the health endpoint listener and server. +type HTTPServer struct { + listener net.Listener + server *http.Server +} + +func (server *HTTPServer) Address() string { + if server == nil || server.listener == nil { + return "" + } + return server.listener.Addr().String() +} + +func (server *HTTPServer) Close() error { + if server == nil { + return nil + } + shutdownContext, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if server.server != nil { + if err := server.server.Shutdown(shutdownContext); err != nil { + _ = server.server.Close() + return err + } + return nil + } + + if server.listener != nil { + return server.listener.Close() + } + return nil +} + +// ServeHTTPHealth starts a minimal HTTP server exposing GET /health. +// +// service.ServeHTTPHealth("127.0.0.1", 5554) +func (service *Service) ServeHTTPHealth(bind string, port int) (*HTTPServer, error) { + if bind == "" { + bind = "127.0.0.1" + } + + address := net.JoinHostPort(bind, strconv.Itoa(port)) + listener, err := net.Listen("tcp", address) + if err != nil { + return nil, err + } + + mux := http.NewServeMux() + mux.HandleFunc("/health", func(responseWriter http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + responseWriter.WriteHeader(http.StatusMethodNotAllowed) + return + } + + responseWriter.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(responseWriter).Encode(service.Health()) + }) + + server := &http.Server{ + Handler: mux, + } + + httpServer := &HTTPServer{ + listener: listener, + server: server, + } + + go func() { + _ = server.Serve(listener) + }() + + return httpServer, nil +} diff --git a/service_test.go b/service_test.go index bb4dc65..d015c40 100644 --- a/service_test.go +++ b/service_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "io" "net/http" "net/http/httptest" "strings" @@ -300,6 +301,51 @@ func TestServiceHealthUsesChainTreeRootAfterDiscovery(t *testing.T) { } } +func TestServiceServeHTTPHealthReturnsJSON(t *testing.T) { + service := NewService(ServiceOptions{ + Records: map[string]NameRecords{ + "gateway.charon.lthn": { + A: []string{"10.10.10.10"}, + }, + }, + }) + + httpServer, err := service.ServeHTTPHealth("127.0.0.1", 0) + if err != nil { + t.Fatalf("expected health HTTP server to start: %v", err) + } + defer func() { + _ = httpServer.Close() + }() + + response, err := http.Get("http://" + httpServer.Address() + "/health") + if err != nil { + t.Fatalf("expected health endpoint to respond: %v", err) + } + defer func() { + _ = response.Body.Close() + }() + + if response.StatusCode != http.StatusOK { + t.Fatalf("unexpected health status: %d", response.StatusCode) + } + + var payload map[string]any + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatalf("expected health payload: %v", err) + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("expected health JSON: %v", err) + } + if payload["status"] != "ready" { + t.Fatalf("expected ready health status, got %#v", payload["status"]) + } + if payload["names_cached"] != float64(1) { + t.Fatalf("expected one cached name, got %#v", payload["names_cached"]) + } +} + func TestServiceDiscoverReplacesRecordsFromDiscoverer(t *testing.T) { records := []map[string]NameRecords{ { -- 2.45.3