From 2c41e11f2ff0565456f9b2d74c79d961266226c7 Mon Sep 17 00:00:00 2001 From: crapStone Date: Sun, 26 May 2024 20:05:46 +0000 Subject: [PATCH] Use hashicorp's LRU cache for DNS & certificates (#315) Taken from #301 Co-authored-by: Moritz Marquardt Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/315 --- flake.lock | 6 ++--- flake.nix | 1 + go.mod | 1 + go.sum | 2 ++ server/certificates/certificates.go | 34 +++++++++++++++++-------- server/dns/dns.go | 20 +++++++++------ server/handler/handler.go | 4 +-- server/handler/handler_custom_domain.go | 6 ++--- server/handler/handler_test.go | 2 +- server/startup.go | 7 ++--- 10 files changed, 50 insertions(+), 33 deletions(-) diff --git a/flake.lock b/flake.lock index b14ba48..c74fe33 100644 --- a/flake.lock +++ b/flake.lock @@ -19,11 +19,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1714030708, - "narHash": "sha256-JOGPOxa8N6ySzB7SQBsh0OVz+UXZriyahgvfNHMIY0Y=", + "lastModified": 1716715802, + "narHash": "sha256-usk0vE7VlxPX8jOavrtpOqphdfqEQpf9lgedlY/r66c=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "b0d52b31f7f4d80f8bf38f0253652125579c35ff", + "rev": "e2dd4e18cc1c7314e24154331bae07df76eb582f", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index f981ed1..61f3b55 100644 --- a/flake.nix +++ b/flake.nix @@ -16,6 +16,7 @@ gcc go gofumpt + golangci-lint gopls gotools go-tools diff --git a/go.mod b/go.mod index 518dff0..bb3a05a 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/creasty/defaults v1.7.0 github.com/go-acme/lego/v4 v4.5.3 github.com/go-sql-driver/mysql v1.6.0 + github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/joho/godotenv v1.4.0 github.com/lib/pq v1.10.7 github.com/mattn/go-sqlite3 v1.14.16 diff --git a/go.sum b/go.sum index ae24fc3..5875472 100644 --- a/go.sum +++ b/go.sum @@ -323,6 +323,8 @@ github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09 github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= diff --git a/server/certificates/certificates.go b/server/certificates/certificates.go index 67219dd..ff34775 100644 --- a/server/certificates/certificates.go +++ b/server/certificates/certificates.go @@ -14,6 +14,7 @@ import ( "github.com/go-acme/lego/v4/certificate" "github.com/go-acme/lego/v4/challenge/tlsalpn01" "github.com/go-acme/lego/v4/lego" + "github.com/hashicorp/golang-lru/v2/expirable" "github.com/reugn/equalizer" "github.com/rs/zerolog/log" @@ -31,11 +32,14 @@ func TLSConfig(mainDomainSuffix string, giteaClient *gitea.Client, acmeClient *AcmeClient, firstDefaultBranch string, - keyCache, challengeCache, dnsLookupCache, canonicalDomainCache cache.ICache, + challengeCache, canonicalDomainCache cache.ICache, certDB database.CertDB, noDNS01 bool, rawDomain string, ) *tls.Config { + // every cert is at most 24h in the cache and 7 days before expiry the cert is renewed + keyCache := expirable.NewLRU[string, *tls.Certificate](32, nil, 24*time.Hour) + return &tls.Config{ // check DNS name & get certificate from Let's Encrypt GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) { @@ -86,7 +90,7 @@ func TLSConfig(mainDomainSuffix string, } } else { var targetRepo, targetBranch string - targetOwner, targetRepo, targetBranch = dnsutils.GetTargetFromDNS(domain, mainDomainSuffix, firstDefaultBranch, dnsLookupCache) + targetOwner, targetRepo, targetBranch = dnsutils.GetTargetFromDNS(domain, mainDomainSuffix, firstDefaultBranch) if targetOwner == "" { // DNS not set up, return main certificate to redirect to the docs domain = mainDomainSuffix @@ -107,7 +111,7 @@ func TLSConfig(mainDomainSuffix string, if tlsCertificate, ok := keyCache.Get(domain); ok { // we can use an existing certificate object - return tlsCertificate.(*tls.Certificate), nil + return tlsCertificate, nil } var tlsCertificate *tls.Certificate @@ -132,9 +136,8 @@ func TLSConfig(mainDomainSuffix string, } } - if err := keyCache.Set(domain, tlsCertificate, 15*time.Minute); err != nil { - return nil, err - } + keyCache.Add(domain, tlsCertificate) + return tlsCertificate, nil }, NextProtos: []string{ @@ -186,11 +189,10 @@ func (c *AcmeClient) retrieveCertFromDB(sni, mainDomainSuffix string, useDnsProv // TODO: document & put into own function if !strings.EqualFold(sni, mainDomainSuffix) { - tlsCertificate.Leaf, err = x509.ParseCertificate(tlsCertificate.Certificate[0]) + tlsCertificate.Leaf, err = leaf(&tlsCertificate) if err != nil { - return nil, fmt.Errorf("error parsing leaf tlsCert: %w", err) + return nil, err } - // renew certificates 7 days before they expire if tlsCertificate.Leaf.NotAfter.Before(time.Now().Add(7 * 24 * time.Hour)) { // TODO: use ValidTill of custom cert struct @@ -291,6 +293,7 @@ func (c *AcmeClient) obtainCert(acmeClient *lego.Client, domains []string, renew } leaf, err := leaf(&tlsCertificate) if err == nil && leaf.NotAfter.After(time.Now()) { + tlsCertificate.Leaf = leaf // avoid sending a mock cert instead of a still valid cert, instead abuse CSR field to store time to try again at renew.CSR = []byte(strconv.FormatInt(time.Now().Add(6*time.Hour).Unix(), 10)) if err := keyDatabase.Put(name, renew); err != nil { @@ -388,11 +391,20 @@ func MaintainCertDB(ctx context.Context, interval time.Duration, acmeClient *Acm } } -// leaf returns the parsed leaf certificate, either from c.leaf or by parsing +// leaf returns the parsed leaf certificate, either from c.Leaf or by parsing // the corresponding c.Certificate[0]. +// After successfully parsing the cert c.Leaf gets set to the parsed cert. func leaf(c *tls.Certificate) (*x509.Certificate, error) { if c.Leaf != nil { return c.Leaf, nil } - return x509.ParseCertificate(c.Certificate[0]) + + leaf, err := x509.ParseCertificate(c.Certificate[0]) + if err != nil { + return nil, fmt.Errorf("tlsCert - failed to parse leaf: %w", err) + } + + c.Leaf = leaf + + return leaf, err } diff --git a/server/dns/dns.go b/server/dns/dns.go index 970f0c0..e29e42c 100644 --- a/server/dns/dns.go +++ b/server/dns/dns.go @@ -5,22 +5,26 @@ import ( "strings" "time" - "codeberg.org/codeberg/pages/server/cache" + "github.com/hashicorp/golang-lru/v2/expirable" ) -// lookupCacheTimeout specifies the timeout for the DNS lookup cache. -var lookupCacheTimeout = 15 * time.Minute +const ( + lookupCacheValidity = 30 * time.Second + defaultPagesRepo = "pages" +) -var defaultPagesRepo = "pages" +// TODO(#316): refactor to not use global variables +var lookupCache *expirable.LRU[string, string] = expirable.NewLRU[string, string](4096, nil, lookupCacheValidity) // GetTargetFromDNS searches for CNAME or TXT entries on the request domain ending with MainDomainSuffix. // If everything is fine, it returns the target data. -func GetTargetFromDNS(domain, mainDomainSuffix, firstDefaultBranch string, dnsLookupCache cache.ICache) (targetOwner, targetRepo, targetBranch string) { +func GetTargetFromDNS(domain, mainDomainSuffix, firstDefaultBranch string) (targetOwner, targetRepo, targetBranch string) { // Get CNAME or TXT var cname string var err error - if cachedName, ok := dnsLookupCache.Get(domain); ok { - cname = cachedName.(string) + + if entry, ok := lookupCache.Get(domain); ok { + cname = entry } else { cname, err = net.LookupCNAME(domain) cname = strings.TrimSuffix(cname, ".") @@ -38,7 +42,7 @@ func GetTargetFromDNS(domain, mainDomainSuffix, firstDefaultBranch string, dnsLo } } } - _ = dnsLookupCache.Set(domain, cname, lookupCacheTimeout) + _ = lookupCache.Add(domain, cname) } if cname == "" { return diff --git a/server/handler/handler.go b/server/handler/handler.go index ffc3400..c038c2d 100644 --- a/server/handler/handler.go +++ b/server/handler/handler.go @@ -23,7 +23,7 @@ const ( func Handler( cfg config.ServerConfig, giteaClient *gitea.Client, - dnsLookupCache, canonicalDomainCache, redirectsCache cache.ICache, + canonicalDomainCache, redirectsCache cache.ICache, ) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { log.Debug().Msg("\n----------------------------------------------------------") @@ -108,7 +108,7 @@ func Handler( trimmedHost, pathElements, cfg.PagesBranches[0], - dnsLookupCache, canonicalDomainCache, redirectsCache) + canonicalDomainCache, redirectsCache) } } } diff --git a/server/handler/handler_custom_domain.go b/server/handler/handler_custom_domain.go index 82953f9..852001a 100644 --- a/server/handler/handler_custom_domain.go +++ b/server/handler/handler_custom_domain.go @@ -19,10 +19,10 @@ func handleCustomDomain(log zerolog.Logger, ctx *context.Context, giteaClient *g trimmedHost string, pathElements []string, firstDefaultBranch string, - dnsLookupCache, canonicalDomainCache, redirectsCache cache.ICache, + canonicalDomainCache, redirectsCache cache.ICache, ) { // Serve pages from custom domains - targetOwner, targetRepo, targetBranch := dns.GetTargetFromDNS(trimmedHost, mainDomainSuffix, firstDefaultBranch, dnsLookupCache) + targetOwner, targetRepo, targetBranch := dns.GetTargetFromDNS(trimmedHost, mainDomainSuffix, firstDefaultBranch) if targetOwner == "" { html.ReturnErrorPage(ctx, "could not obtain repo owner from custom domain", @@ -53,7 +53,7 @@ func handleCustomDomain(log zerolog.Logger, ctx *context.Context, giteaClient *g return } else if canonicalDomain != trimmedHost { // only redirect if the target is also a codeberg page! - targetOwner, _, _ = dns.GetTargetFromDNS(strings.SplitN(canonicalDomain, "/", 2)[0], mainDomainSuffix, firstDefaultBranch, dnsLookupCache) + targetOwner, _, _ = dns.GetTargetFromDNS(strings.SplitN(canonicalDomain, "/", 2)[0], mainDomainSuffix, firstDefaultBranch) if targetOwner != "" { ctx.Redirect("https://"+canonicalDomain+"/"+targetOpt.TargetPath, http.StatusTemporaryRedirect) return diff --git a/server/handler/handler_test.go b/server/handler/handler_test.go index 0ae7962..765b3b1 100644 --- a/server/handler/handler_test.go +++ b/server/handler/handler_test.go @@ -29,7 +29,7 @@ func TestHandlerPerformance(t *testing.T) { AllowedCorsDomains: []string{"raw.codeberg.org", "fonts.codeberg.org", "design.codeberg.org"}, PagesBranches: []string{"pages"}, } - testHandler := Handler(serverCfg, giteaClient, cache.NewInMemoryCache(), cache.NewInMemoryCache(), cache.NewInMemoryCache()) + testHandler := Handler(serverCfg, giteaClient, cache.NewInMemoryCache(), cache.NewInMemoryCache()) testCase := func(uri string, status int) { t.Run(uri, func(t *testing.T) { diff --git a/server/startup.go b/server/startup.go index 95c3c5c..6642d83 100644 --- a/server/startup.go +++ b/server/startup.go @@ -66,12 +66,9 @@ func Serve(ctx *cli.Context) error { } defer closeFn() - keyCache := cache.NewInMemoryCache() challengeCache := cache.NewInMemoryCache() // canonicalDomainCache stores canonical domains canonicalDomainCache := cache.NewInMemoryCache() - // dnsLookupCache stores DNS lookups for custom domains - dnsLookupCache := cache.NewInMemoryCache() // redirectsCache stores redirects in _redirects files redirectsCache := cache.NewInMemoryCache() // clientResponseCache stores responses from the Gitea server @@ -104,7 +101,7 @@ func Serve(ctx *cli.Context) error { giteaClient, acmeClient, cfg.Server.PagesBranches[0], - keyCache, challengeCache, dnsLookupCache, canonicalDomainCache, + challengeCache, canonicalDomainCache, certDB, cfg.ACME.NoDNS01, cfg.Server.RawDomain, @@ -134,7 +131,7 @@ func Serve(ctx *cli.Context) error { } // Create ssl handler based on settings - sslHandler := handler.Handler(cfg.Server, giteaClient, dnsLookupCache, canonicalDomainCache, redirectsCache) + sslHandler := handler.Handler(cfg.Server, giteaClient, canonicalDomainCache, redirectsCache) // Start the ssl listener log.Info().Msgf("Start SSL server using TCP listener on %s", listener.Addr())