diff --git a/config/config.go b/config/config.go index 7c3c0bc..4b94387 100644 --- a/config/config.go +++ b/config/config.go @@ -80,6 +80,9 @@ type Config struct { // ListenAddr for the demo site to listen on. Eg, ":443". ListenAddr string + // DebugAddr is the listen address for metrics and pprof + DebugAddr string + // Sites is a list of sites to host. Sites []Site diff --git a/config/config_test.go b/config/config_test.go index 75e7b03..848adf8 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -14,6 +14,7 @@ func TestLoadConfig(t *testing.T) { t.Parallel() expected := config.Config{ ListenAddr: "localhost:8443", + DebugAddr: "localhost:9876", Sites: []config.Site{ { diff --git a/config/testdata/test.json b/config/testdata/test.json index e14ef96..a2b10bc 100644 --- a/config/testdata/test.json +++ b/config/testdata/test.json @@ -1,5 +1,6 @@ { "listenAddr": "localhost:8443", + "debugAddr": "localhost:9876", "sites": [ { diff --git a/docker-compose.yml b/docker-compose.yml index 0361a31..e358ab6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: - pebble ports: - "5001:5001" + - "9001:9001" networks: testnet: aliases: diff --git a/go.mod b/go.mod index be11b78..202db70 100644 --- a/go.mod +++ b/go.mod @@ -4,18 +4,27 @@ go 1.25.0 require ( github.com/go-acme/lego/v4 v4.33.0 - golang.org/x/crypto/x509roots/fallback v0.0.0-20260311141749-982eaa62dfb7 + github.com/prometheus/client_golang v1.23.2 + golang.org/x/crypto/x509roots/fallback v0.0.0-20260323153451-8400f4a93807 ) require ( + github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect - github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/miekg/dns v1.1.72 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - golang.org/x/tools v0.41.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.43.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index a8ade9f..21644cc 100644 --- a/go.sum +++ b/go.sum @@ -1,34 +1,58 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-acme/lego/v4 v4.33.0 h1:2KrRKieG+VczT4zvVXKAY7Tp/S3XLvh/QImofBALRAM= github.com/go-acme/lego/v4 v4.33.0/go.mod h1:lI2fZNdgeM/ymf9xQ9YKbgZm6MeDuf91UrohMQE4DhI= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/crypto/x509roots/fallback v0.0.0-20260311141749-982eaa62dfb7 h1:uX5F+sUnmp3J14eF1J92KuF4wi4GP4lnnEbFfwuNVVU= -golang.org/x/crypto/x509roots/fallback v0.0.0-20260311141749-982eaa62dfb7/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260323153451-8400f4a93807 h1:sQVhWLXbNsa8CTzHOX3IHc7C4Q2JyxI5AweuMQZ/5H0= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260323153451-8400f4a93807/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/integration/integration_test.go b/integration/integration_test.go index d688927..622f584 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -174,10 +174,40 @@ func waitRenew(root []byte) error { } } +// checkMetrics verifies the debug server's /metrics endpoint is reachable +// and exposes Go runtime metrics. +func checkMetrics() error { + resp, err := http.Get("http://localhost:9001/metrics") + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if !bytes.Contains(body, []byte("go_goroutines")) { + return fmt.Errorf("metrics body missing go_goroutines: %s", string(body)) + } + + return nil +} + // TestIntegration verifies that test-certs-site is working properly. // It assumes that test-certs-site and pebble are running with the configurations // in this directory. func TestPebbleIntegration(t *testing.T) { + err := checkMetrics() + if err != nil { + t.Fatal(err) + } + root, err := getPebbleRoot() if err != nil { t.Fatal(err) diff --git a/integration/test-certs-site-config.json b/integration/test-certs-site-config.json index ff3c2ff..843d599 100644 --- a/integration/test-certs-site-config.json +++ b/integration/test-certs-site-config.json @@ -1,5 +1,6 @@ { "listenAddr": ":5001", + "debugAddr": ":9001", "sites": [ { "issuerCN": "Pebble Root CA", diff --git a/main.go b/main.go index 85f606f..02016b7 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "github.com/letsencrypt/test-certs-site/config" "github.com/letsencrypt/test-certs-site/scheduler" "github.com/letsencrypt/test-certs-site/server" + "github.com/letsencrypt/test-certs-site/stats" "github.com/letsencrypt/test-certs-site/storage" _ "golang.org/x/crypto/x509roots/fallback" // Include fallback roots for talking to ACME server @@ -60,6 +61,8 @@ func run(args []string) error { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() + registry := stats.New(ctx, cfg.DebugAddr) + schedule := scheduler.New(ctx) err = acme.New(cfg, store, schedule, certManager) @@ -67,7 +70,7 @@ func run(args []string) error { return err } - return server.Run(ctx, cfg, certManager.GetCertificate) + return server.Run(ctx, cfg, registry, certManager.GetCertificate) } func main() { diff --git a/server/page.go b/server/page.go index 9d0d95c..9a44809 100644 --- a/server/page.go +++ b/server/page.go @@ -9,6 +9,10 @@ import ( "os" "strings" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/letsencrypt/test-certs-site/config" ) @@ -29,7 +33,7 @@ type info struct { State string } -func newHandler(cfg *config.Config) (handler, error) { +func newHandler(cfg *config.Config, registry prometheus.Registerer) (http.HandlerFunc, error) { domains := make(map[string]info) for _, site := range cfg.Sites { @@ -49,19 +53,27 @@ func newHandler(cfg *config.Config) (handler, error) { html, err := loadTemplate(cfg.HTMLTemplate, htmlTemplate) if err != nil { - return handler{}, err + return nil, err } text, err := loadTemplate(cfg.TextTemplate, textTemplate) if err != nil { - return handler{}, err + return nil, err } - return handler{ + counter := promauto.With(registry).NewCounterVec( + prometheus.CounterOpts{ + Name: "http_requests_total", + Help: "HTTP Requests by status", + }, + []string{"code"}, + ) + + return promhttp.InstrumentHandlerCounter(counter, handler{ htmlTemplate: html, textTemplate: text, domains: domains, - }, nil + }), nil } func loadTemplate(filename string, defaultTemplate string) (*template.Template, error) { diff --git a/server/page_test.go b/server/page_test.go index bcb0140..bfed56d 100644 --- a/server/page_test.go +++ b/server/page_test.go @@ -27,7 +27,7 @@ func TestNewHandler(t *testing.T) { }, } - defaultHandler, err := newHandler(&testCfg) + defaultHandler, err := newHandler(&testCfg, nil) if err != nil { t.Fatal(err) } @@ -62,14 +62,14 @@ func TestNewHandler(t *testing.T) { testCfg.TextTemplate = testTextTmpl testCfg.HTMLTemplate = testHTMLTmpl - customHandler, err := newHandler(&testCfg) + customHandler, err := newHandler(&testCfg, nil) if err != nil { t.Fatal(err) } tests := []struct { domain string - handler handler + handler http.HandlerFunc url string bodyHas []string }{ diff --git a/server/server.go b/server/server.go index 1afccc1..e8c0b5e 100644 --- a/server/server.go +++ b/server/server.go @@ -8,6 +8,8 @@ import ( "net/http" "time" + "github.com/prometheus/client_golang/prometheus" + "github.com/letsencrypt/test-certs-site/config" ) @@ -17,11 +19,11 @@ import ( type GetCertificateFunc func(info *tls.ClientHelloInfo) (*tls.Certificate, error) // Run the server, until the context is canceled. -func Run(ctx context.Context, cfg *config.Config, getCert GetCertificateFunc) error { +func Run(ctx context.Context, cfg *config.Config, registry prometheus.Registerer, getCert GetCertificateFunc) error { // We want http requests to time out relatively quickly, as this server shouldn't be doing much. const timeout = 5 * time.Second - handler, err := newHandler(cfg) + handler, err := newHandler(cfg, registry) if err != nil { return err } diff --git a/stats/stats.go b/stats/stats.go new file mode 100644 index 0000000..34a15f2 --- /dev/null +++ b/stats/stats.go @@ -0,0 +1,71 @@ +// Package stats provides a debug HTTP server exposing pprof and Prometheus metrics. +package stats + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/pprof" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/collectors/version" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// New creates a Prometheus registry and listens on debugAddr if non-empty +// debugAddr exposes /metrics and pprof +func New(ctx context.Context, debugAddr string) *prometheus.Registry { + registry := prometheus.NewRegistry() + + registry.MustRegister(collectors.NewGoCollector()) + registry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) + registry.MustRegister(version.NewCollector("test_certs_site")) + + if debugAddr == "" { + slog.Info("No debug listen address specified") + + return registry + } + + mux := http.NewServeMux() + mux.Handle("/debug/pprof/", http.HandlerFunc(pprof.Index)) + mux.Handle("/debug/pprof/profile", http.HandlerFunc(pprof.Profile)) + mux.Handle("/debug/pprof/symbol", http.HandlerFunc(pprof.Symbol)) + mux.Handle("/debug/pprof/trace", http.HandlerFunc(pprof.Trace)) + mux.Handle("/debug/pprof/goroutine", pprof.Handler("goroutine")) + mux.Handle("/debug/pprof/block", pprof.Handler("block")) + mux.Handle("/debug/pprof/heap", pprof.Handler("heap")) + mux.Handle("/debug/pprof/mutex", pprof.Handler("mutex")) + mux.Handle("/debug/pprof/threadcreate", pprof.Handler("threadcreate")) + mux.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) + + srv := http.Server{ + Addr: debugAddr, + Handler: mux, + ReadTimeout: time.Minute, + } + + // Wait for a signal to shut down the server. + go func() { + <-ctx.Done() + + err := srv.Shutdown(context.WithoutCancel(ctx)) + if err != nil { + slog.Error("debug server error", slog.String("error", err.Error())) + } + }() + + go func() { + slog.Info("Debug server listening", slog.String("debugAddr", debugAddr)) + + err := srv.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("debug server exited", slog.String("error", err.Error())) + } + }() + + return registry +}