diff --git a/cmd/main.go b/cmd/main.go index af4d2ce..488ed2c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -3,7 +3,6 @@ package cmd import ( "context" "crypto/tls" - "errors" "fmt" "net" "net/http" @@ -52,17 +51,6 @@ func Serve(ctx *cli.Context) error { listeningAddress := fmt.Sprintf("%s:%s", ctx.String("host"), ctx.String("port")) enableHTTPServer := ctx.Bool("enable-http-server") - acmeAPI := ctx.String("acme-api-endpoint") - acmeMail := ctx.String("acme-email") - acmeUseRateLimits := ctx.Bool("acme-use-rate-limits") - acmeAcceptTerms := ctx.Bool("acme-accept-terms") - acmeEabKID := ctx.String("acme-eab-kid") - acmeEabHmac := ctx.String("acme-eab-hmac") - dnsProvider := ctx.String("dns-provider") - if (!acmeAcceptTerms || dnsProvider == "") && acmeAPI != "https://acme.mock.directory" { - return errors.New("you must set $ACME_ACCEPT_TERMS and $DNS_PROVIDER, unless $ACME_API is set to https://acme.mock.directory") - } - allowedCorsDomains := AllowedCorsDomains if rawDomain != "" { allowedCorsDomains = append(allowedCorsDomains, rawDomain) @@ -94,6 +82,15 @@ func Serve(ctx *cli.Context) error { return fmt.Errorf("could not create new gitea client: %v", err) } + acmeClient, err := createAcmeClient(ctx, enableHTTPServer, challengeCache) + if err != nil { + return err + } + + if err := certificates.SetupCertificates(mainDomainSuffix, acmeClient, certDB); err != nil { + return err + } + // Create handler based on settings httpsHandler := handler.Handler(mainDomainSuffix, rawDomain, giteaClient, @@ -112,24 +109,14 @@ func Serve(ctx *cli.Context) error { listener = tls.NewListener(listener, certificates.TLSConfig(mainDomainSuffix, giteaClient, - dnsProvider, - acmeUseRateLimits, + acmeClient, keyCache, challengeCache, dnsLookupCache, canonicalDomainCache, certDB)) - acmeConfig, err := certificates.SetupAcmeConfig(acmeAPI, acmeMail, acmeEabHmac, acmeEabKID, acmeAcceptTerms) - if err != nil { - return err - } - - if err := certificates.SetupCertificates(mainDomainSuffix, dnsProvider, acmeConfig, acmeUseRateLimits, enableHTTPServer, challengeCache, certDB); err != nil { - return err - } - interval := 12 * time.Hour certMaintainCtx, cancelCertMaintain := context.WithCancel(context.Background()) defer cancelCertMaintain() - go certificates.MaintainCertDB(certMaintainCtx, interval, mainDomainSuffix, dnsProvider, acmeUseRateLimits, certDB) + go certificates.MaintainCertDB(certMaintainCtx, interval, acmeClient, mainDomainSuffix, certDB) if enableHTTPServer { go func() { diff --git a/cmd/setup.go b/cmd/setup.go index 3d1d6ee..a25b2d5 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -1,14 +1,19 @@ package cmd import ( + "errors" "fmt" "github.com/rs/zerolog/log" "github.com/urfave/cli/v2" + "codeberg.org/codeberg/pages/server/cache" + "codeberg.org/codeberg/pages/server/certificates" "codeberg.org/codeberg/pages/server/database" ) +var ErrAcmeMissConfig = errors.New("ACME client has wrong config") + func openCertDB(ctx *cli.Context) (certDB database.CertDB, closeFn func(), err error) { if ctx.String("db-type") != "" { log.Trace().Msg("use xorm mode") @@ -43,3 +48,30 @@ The simplest way is, to use './pages certs migrate' and set environment var DB_T return certDB, closeFn, nil } + +func createAcmeClient(ctx *cli.Context, enableHTTPServer bool, challengeCache cache.SetGetKey) (*certificates.AcmeClient, error) { + acmeAPI := ctx.String("acme-api-endpoint") + acmeMail := ctx.String("acme-email") + acmeEabHmac := ctx.String("acme-eab-hmac") + acmeEabKID := ctx.String("acme-eab-kid") + acmeAcceptTerms := ctx.Bool("acme-accept-terms") + dnsProvider := ctx.String("dns-provider") + acmeUseRateLimits := ctx.Bool("acme-use-rate-limits") + + // check config + if (!acmeAcceptTerms || dnsProvider == "") && acmeAPI != "https://acme.mock.directory" { + return nil, fmt.Errorf("%w: you must set $ACME_ACCEPT_TERMS and $DNS_PROVIDER, unless $ACME_API is set to https://acme.mock.directory", ErrAcmeMissConfig) + } + + return certificates.NewAcmeClient( + acmeAPI, + acmeMail, + acmeEabHmac, + acmeEabKID, + dnsProvider, + acmeAcceptTerms, + enableHTTPServer, + acmeUseRateLimits, + challengeCache, + ) +} diff --git a/server/certificates/acme_client.go b/server/certificates/acme_client.go new file mode 100644 index 0000000..2dbaaf4 --- /dev/null +++ b/server/certificates/acme_client.go @@ -0,0 +1,97 @@ +package certificates + +import ( + "sync" + "time" + + "codeberg.org/codeberg/pages/server/cache" + "github.com/go-acme/lego/v4/lego" + "github.com/go-acme/lego/v4/providers/dns" + "github.com/reugn/equalizer" + "github.com/rs/zerolog/log" +) + +type AcmeClient struct { + legoClient *lego.Client + mainDomainLegoClient *lego.Client + + dnsProvider string + + obtainLocks sync.Map + + acmeUseRateLimits bool + + // limiter + acmeClientOrderLimit *equalizer.TokenBucket + acmeClientRequestLimit *equalizer.TokenBucket + acmeClientFailLimit *equalizer.TokenBucket + acmeClientCertificateLimitPerUser map[string]*equalizer.TokenBucket +} + +func NewAcmeClient(acmeAPI, acmeMail, acmeEabHmac, acmeEabKID, dnsProvider string, acmeAcceptTerms, enableHTTPServer, acmeUseRateLimits bool, challengeCache cache.SetGetKey) (*AcmeClient, error) { + acmeConfig, err := SetupAcmeConfig(acmeAPI, acmeMail, acmeEabHmac, acmeEabKID, acmeAcceptTerms) + if err != nil { + return nil, err + } + + acmeClient, err := lego.NewClient(acmeConfig) + if err != nil { + log.Fatal().Err(err).Msg("Can't create ACME client, continuing with mock certs only") + } else { + err = acmeClient.Challenge.SetTLSALPN01Provider(AcmeTLSChallengeProvider{challengeCache}) + if err != nil { + log.Error().Err(err).Msg("Can't create TLS-ALPN-01 provider") + } + if enableHTTPServer { + err = acmeClient.Challenge.SetHTTP01Provider(AcmeHTTPChallengeProvider{challengeCache}) + if err != nil { + log.Error().Err(err).Msg("Can't create HTTP-01 provider") + } + } + } + + mainDomainAcmeClient, err := lego.NewClient(acmeConfig) + if err != nil { + log.Error().Err(err).Msg("Can't create ACME client, continuing with mock certs only") + } else { + if dnsProvider == "" { + // using mock server, don't use wildcard certs + err := mainDomainAcmeClient.Challenge.SetTLSALPN01Provider(AcmeTLSChallengeProvider{challengeCache}) + if err != nil { + log.Error().Err(err).Msg("Can't create TLS-ALPN-01 provider") + } + } else { + provider, err := dns.NewDNSChallengeProviderByName(dnsProvider) + if err != nil { + log.Error().Err(err).Msg("Can't create DNS Challenge provider") + } + err = mainDomainAcmeClient.Challenge.SetDNS01Provider(provider) + if err != nil { + log.Error().Err(err).Msg("Can't create DNS-01 provider") + } + } + } + + return &AcmeClient{ + legoClient: acmeClient, + mainDomainLegoClient: mainDomainAcmeClient, + + dnsProvider: dnsProvider, + + acmeUseRateLimits: acmeUseRateLimits, + + obtainLocks: sync.Map{}, + + // limiter + + // rate limit is 300 / 3 hours, we want 200 / 2 hours but to refill more often, so that's 25 new domains every 15 minutes + // TODO: when this is used a lot, we probably have to think of a somewhat better solution? + acmeClientOrderLimit: equalizer.NewTokenBucket(25, 15*time.Minute), + // rate limit is 20 / second, we want 5 / second (especially as one cert takes at least two requests) + acmeClientRequestLimit: equalizer.NewTokenBucket(5, 1*time.Second), + // rate limit is 5 / hour https://letsencrypt.org/docs/failed-validation-limit/ + acmeClientFailLimit: equalizer.NewTokenBucket(5, 1*time.Hour), + // checkUserLimit() use this to rate als per user + acmeClientCertificateLimitPerUser: map[string]*equalizer.TokenBucket{}, + }, nil +} diff --git a/server/certificates/certificates.go b/server/certificates/certificates.go index 555539e..a84d083 100644 --- a/server/certificates/certificates.go +++ b/server/certificates/certificates.go @@ -13,7 +13,6 @@ import ( "os" "strconv" "strings" - "sync" "time" "github.com/go-acme/lego/v4/certcrypto" @@ -21,7 +20,6 @@ import ( "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/tlsalpn01" "github.com/go-acme/lego/v4/lego" - "github.com/go-acme/lego/v4/providers/dns" "github.com/go-acme/lego/v4/registration" "github.com/reugn/equalizer" "github.com/rs/zerolog/log" @@ -36,8 +34,7 @@ import ( // TLSConfig returns the configuration for generating, serving and cleaning up Let's Encrypt certificates. func TLSConfig(mainDomainSuffix string, giteaClient *gitea.Client, - dnsProvider string, - acmeUseRateLimits bool, + acmeClient *AcmeClient, keyCache, challengeCache, dnsLookupCache, canonicalDomainCache cache.SetGetKey, certDB database.CertDB, ) *tls.Config { @@ -100,7 +97,7 @@ func TLSConfig(mainDomainSuffix string, var tlsCertificate *tls.Certificate var err error - if tlsCertificate, err = retrieveCertFromDB(sni, mainDomainSuffix, dnsProvider, acmeUseRateLimits, certDB); err != nil { + if tlsCertificate, err = acmeClient.retrieveCertFromDB(sni, mainDomainSuffix, false, certDB); err != nil { // request a new certificate if strings.EqualFold(sni, mainDomainSuffix) { return nil, errors.New("won't request certificate for main domain, something really bad has happened") @@ -110,7 +107,7 @@ func TLSConfig(mainDomainSuffix string, return nil, fmt.Errorf("won't request certificate for %q", sni) } - tlsCertificate, err = obtainCert(acmeClient, []string{sni}, nil, targetOwner, dnsProvider, mainDomainSuffix, acmeUseRateLimits, certDB) + tlsCertificate, err = acmeClient.obtainCert(acmeClient.legoClient, []string{sni}, nil, targetOwner, false, mainDomainSuffix, certDB) if err != nil { return nil, err } @@ -141,34 +138,21 @@ func TLSConfig(mainDomainSuffix string, } } -func checkUserLimit(user string) error { - userLimit, ok := acmeClientCertificateLimitPerUser[user] +var ErrUserRateLimitExceeded = errors.New("rate limit exceeded: 10 certificates per user per 24 hours") + +func (c *AcmeClient) checkUserLimit(user string) error { + userLimit, ok := c.acmeClientCertificateLimitPerUser[user] if !ok { - // Each Codeberg user can only add 10 new domains per day. + // Each user can only add 10 new domains per day. userLimit = equalizer.NewTokenBucket(10, time.Hour*24) - acmeClientCertificateLimitPerUser[user] = userLimit + c.acmeClientCertificateLimitPerUser[user] = userLimit } if !userLimit.Ask() { - return errors.New("rate limit exceeded: 10 certificates per user per 24 hours") + return fmt.Errorf("user '%s' error: %w", user, ErrUserRateLimitExceeded) } return nil } -var ( - acmeClient, mainDomainAcmeClient *lego.Client - acmeClientCertificateLimitPerUser = map[string]*equalizer.TokenBucket{} -) - -// rate limit is 300 / 3 hours, we want 200 / 2 hours but to refill more often, so that's 25 new domains every 15 minutes -// TODO: when this is used a lot, we probably have to think of a somewhat better solution? -var acmeClientOrderLimit = equalizer.NewTokenBucket(25, 15*time.Minute) - -// rate limit is 20 / second, we want 5 / second (especially as one cert takes at least two requests) -var acmeClientRequestLimit = equalizer.NewTokenBucket(5, 1*time.Second) - -// rate limit is 5 / hour https://letsencrypt.org/docs/failed-validation-limit/ -var acmeClientFailLimit = equalizer.NewTokenBucket(5, 1*time.Hour) - type AcmeTLSChallengeProvider struct { challengeCache cache.SetGetKey } @@ -201,7 +185,7 @@ func (a AcmeHTTPChallengeProvider) CleanUp(domain, token, _ string) error { return nil } -func retrieveCertFromDB(sni, mainDomainSuffix, dnsProvider string, acmeUseRateLimits bool, certDB database.CertDB) (*tls.Certificate, error) { +func (c *AcmeClient) retrieveCertFromDB(sni, mainDomainSuffix string, useDnsProvider bool, certDB database.CertDB) (*tls.Certificate, error) { // parse certificate from database res, err := certDB.Get(sni) if err != nil { @@ -235,7 +219,7 @@ func retrieveCertFromDB(sni, mainDomainSuffix, dnsProvider string, acmeUseRateLi // TODO: make a queue ? go (func() { res.CSR = nil // acme client doesn't like CSR to be set - if _, err := obtainCert(acmeClient, []string{sni}, res, "", dnsProvider, mainDomainSuffix, acmeUseRateLimits, certDB); err != nil { + if _, err := c.obtainCert(c.legoClient, []string{sni}, res, "", useDnsProvider, mainDomainSuffix, certDB); err != nil { log.Error().Msgf("Couldn't renew certificate for %s: %v", sni, err) } })() @@ -245,28 +229,26 @@ func retrieveCertFromDB(sni, mainDomainSuffix, dnsProvider string, acmeUseRateLi return &tlsCertificate, nil } -var obtainLocks = sync.Map{} - -func obtainCert(acmeClient *lego.Client, domains []string, renew *certificate.Resource, user, dnsProvider, mainDomainSuffix string, acmeUseRateLimits bool, keyDatabase database.CertDB) (*tls.Certificate, error) { +func (c *AcmeClient) obtainCert(acmeClient *lego.Client, domains []string, renew *certificate.Resource, user string, useDnsProvider bool, mainDomainSuffix string, keyDatabase database.CertDB) (*tls.Certificate, error) { name := strings.TrimPrefix(domains[0], "*") - if dnsProvider == "" && len(domains[0]) > 0 && domains[0][0] == '*' { + if useDnsProvider && len(domains[0]) > 0 && domains[0][0] == '*' { domains = domains[1:] } // lock to avoid simultaneous requests - _, working := obtainLocks.LoadOrStore(name, struct{}{}) + _, working := c.obtainLocks.LoadOrStore(name, struct{}{}) if working { for working { time.Sleep(100 * time.Millisecond) - _, working = obtainLocks.Load(name) + _, working = c.obtainLocks.Load(name) } - cert, err := retrieveCertFromDB(name, mainDomainSuffix, dnsProvider, acmeUseRateLimits, keyDatabase) + cert, err := c.retrieveCertFromDB(name, mainDomainSuffix, useDnsProvider, keyDatabase) if err != nil { return nil, fmt.Errorf("certificate failed in synchronous request: %w", err) } return cert, nil } - defer obtainLocks.Delete(name) + defer c.obtainLocks.Delete(name) if acmeClient == nil { return mockCert(domains[0], "ACME client uninitialized. This is a server error, please report!", mainDomainSuffix, keyDatabase) @@ -276,29 +258,29 @@ func obtainCert(acmeClient *lego.Client, domains []string, renew *certificate.Re var res *certificate.Resource var err error if renew != nil && renew.CertURL != "" { - if acmeUseRateLimits { - acmeClientRequestLimit.Take() + if c.acmeUseRateLimits { + c.acmeClientRequestLimit.Take() } log.Debug().Msgf("Renewing certificate for: %v", domains) res, err = acmeClient.Certificate.Renew(*renew, true, false, "") if err != nil { log.Error().Err(err).Msgf("Couldn't renew certificate for %v, trying to request a new one", domains) - if acmeUseRateLimits { - acmeClientFailLimit.Take() + if c.acmeUseRateLimits { + c.acmeClientFailLimit.Take() } res = nil } } if res == nil { if user != "" { - if err := checkUserLimit(user); err != nil { + if err := c.checkUserLimit(user); err != nil { return nil, err } } - if acmeUseRateLimits { - acmeClientOrderLimit.Take() - acmeClientRequestLimit.Take() + if c.acmeUseRateLimits { + c.acmeClientOrderLimit.Take() + c.acmeClientRequestLimit.Take() } log.Debug().Msgf("Re-requesting new certificate for %v", domains) res, err = acmeClient.Certificate.Obtain(certificate.ObtainRequest{ @@ -306,8 +288,8 @@ func obtainCert(acmeClient *lego.Client, domains []string, renew *certificate.Re Bundle: true, MustStaple: false, }) - if acmeUseRateLimits && err != nil { - acmeClientFailLimit.Take() + if c.acmeUseRateLimits && err != nil { + c.acmeClientFailLimit.Take() } } if err != nil { @@ -432,53 +414,15 @@ func SetupAcmeConfig(acmeAPI, acmeMail, acmeEabHmac, acmeEabKID string, acmeAcce return myAcmeConfig, nil } -func SetupCertificates(mainDomainSuffix, dnsProvider string, acmeConfig *lego.Config, acmeUseRateLimits, enableHTTPServer bool, challengeCache cache.SetGetKey, certDB database.CertDB) error { +func SetupCertificates(mainDomainSuffix string, acmeClient *AcmeClient, certDB database.CertDB) error { // getting main cert before ACME account so that we can fail here without hitting rate limits mainCertBytes, err := certDB.Get(mainDomainSuffix) if err != nil && !errors.Is(err, database.ErrNotFound) { return fmt.Errorf("cert database is not working: %w", err) } - acmeClient, err = lego.NewClient(acmeConfig) - if err != nil { - log.Fatal().Err(err).Msg("Can't create ACME client, continuing with mock certs only") - } else { - err = acmeClient.Challenge.SetTLSALPN01Provider(AcmeTLSChallengeProvider{challengeCache}) - if err != nil { - log.Error().Err(err).Msg("Can't create TLS-ALPN-01 provider") - } - if enableHTTPServer { - err = acmeClient.Challenge.SetHTTP01Provider(AcmeHTTPChallengeProvider{challengeCache}) - if err != nil { - log.Error().Err(err).Msg("Can't create HTTP-01 provider") - } - } - } - - mainDomainAcmeClient, err = lego.NewClient(acmeConfig) - if err != nil { - log.Error().Err(err).Msg("Can't create ACME client, continuing with mock certs only") - } else { - if dnsProvider == "" { - // using mock server, don't use wildcard certs - err := mainDomainAcmeClient.Challenge.SetTLSALPN01Provider(AcmeTLSChallengeProvider{challengeCache}) - if err != nil { - log.Error().Err(err).Msg("Can't create TLS-ALPN-01 provider") - } - } else { - provider, err := dns.NewDNSChallengeProviderByName(dnsProvider) - if err != nil { - log.Error().Err(err).Msg("Can't create DNS Challenge provider") - } - err = mainDomainAcmeClient.Challenge.SetDNS01Provider(provider) - if err != nil { - log.Error().Err(err).Msg("Can't create DNS-01 provider") - } - } - } - if mainCertBytes == nil { - _, err = obtainCert(mainDomainAcmeClient, []string{"*" + mainDomainSuffix, mainDomainSuffix[1:]}, nil, "", dnsProvider, mainDomainSuffix, acmeUseRateLimits, certDB) + _, err = acmeClient.obtainCert(acmeClient.mainDomainLegoClient, []string{"*" + mainDomainSuffix, mainDomainSuffix[1:]}, nil, "", true, mainDomainSuffix, certDB) if err != nil { log.Error().Err(err).Msg("Couldn't renew main domain certificate, continuing with mock certs only") } @@ -487,7 +431,7 @@ func SetupCertificates(mainDomainSuffix, dnsProvider string, acmeConfig *lego.Co return nil } -func MaintainCertDB(ctx context.Context, interval time.Duration, mainDomainSuffix, dnsProvider string, acmeUseRateLimits bool, certDB database.CertDB) { +func MaintainCertDB(ctx context.Context, interval time.Duration, acmeClient *AcmeClient, mainDomainSuffix string, certDB database.CertDB) { for { // delete expired certs that will be invalid until next clean up threshold := time.Now().Add(interval) @@ -533,7 +477,7 @@ func MaintainCertDB(ctx context.Context, interval time.Duration, mainDomainSuffi } else if tlsCertificates[0].NotAfter.Before(time.Now().Add(30 * 24 * time.Hour)) { // renew main certificate 30 days before it expires go (func() { - _, err = obtainCert(mainDomainAcmeClient, []string{"*" + mainDomainSuffix, mainDomainSuffix[1:]}, res, "", dnsProvider, mainDomainSuffix, acmeUseRateLimits, certDB) + _, err = acmeClient.obtainCert(acmeClient.mainDomainLegoClient, []string{"*" + mainDomainSuffix, mainDomainSuffix[1:]}, res, "", true, mainDomainSuffix, certDB) if err != nil { log.Error().Err(err).Msg("Couldn't renew certificate for main domain") }