diff --git a/.gitignore b/.gitignore index 8745935..3035107 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ build/ vendor/ pages certs.sqlite +.bash_history diff --git a/Justfile b/Justfile index 0db7845..9ee7eb3 100644 --- a/Justfile +++ b/Justfile @@ -50,3 +50,6 @@ integration: integration-run TEST: go test -race -tags 'integration {{TAGS}}' -run "^{{TEST}}$" codeberg.org/codeberg/pages/integration/... + +docker: + docker run --rm -it --user $(id -u) -v $(pwd):/work --workdir /work -e HOME=/work codeberg.org/6543/docker-images/golang_just diff --git a/cmd/flags.go b/cmd/flags.go index 8052421..5bc638b 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -8,11 +8,13 @@ var ( CertStorageFlags = []cli.Flag{ &cli.StringFlag{ Name: "db-type", + Usage: "Specify the database driver. Valid options are \"sqlite3\", \"mysql\" and \"postgres\". Read more at https://xorm.io", Value: "sqlite3", EnvVars: []string{"DB_TYPE"}, }, &cli.StringFlag{ Name: "db-conn", + Usage: "Specify the database connection. For \"sqlite3\" it's the filepath. Read more at https://go.dev/doc/tutorial/database-access", Value: "certs.sqlite", EnvVars: []string{"DB_CONN"}, }, @@ -87,15 +89,21 @@ var ( EnvVars: []string{"HOST"}, Value: "[::]", }, - &cli.StringFlag{ + &cli.UintFlag{ Name: "port", - Usage: "specifies port of listening address", - EnvVars: []string{"PORT"}, - Value: "443", + Usage: "specifies the https port to listen to ssl requests", + EnvVars: []string{"PORT", "HTTPS_PORT"}, + Value: 443, + }, + &cli.UintFlag{ + Name: "http-port", + Usage: "specifies the http port, you also have to enable http server via ENABLE_HTTP_SERVER=true", + EnvVars: []string{"HTTP_PORT"}, + Value: 80, }, &cli.BoolFlag{ - Name: "enable-http-server", - // TODO: desc + Name: "enable-http-server", + Usage: "start a http server to redirect to https and respond to http acme challenges", EnvVars: []string{"ENABLE_HTTP_SERVER"}, }, &cli.StringFlag{ @@ -125,23 +133,23 @@ var ( Value: true, }, &cli.BoolFlag{ - Name: "acme-accept-terms", - // TODO: Usage + Name: "acme-accept-terms", + Usage: "To accept the ACME ToS", EnvVars: []string{"ACME_ACCEPT_TERMS"}, }, &cli.StringFlag{ - Name: "acme-eab-kid", - // TODO: Usage + Name: "acme-eab-kid", + Usage: "Register the current account to the ACME server with external binding.", EnvVars: []string{"ACME_EAB_KID"}, }, &cli.StringFlag{ - Name: "acme-eab-hmac", - // TODO: Usage + Name: "acme-eab-hmac", + Usage: "Register the current account to the ACME server with external binding.", EnvVars: []string{"ACME_EAB_HMAC"}, }, &cli.StringFlag{ Name: "dns-provider", - Usage: "Use DNS-Challenge for main domain\n\nRead more at: https://go-acme.github.io/lego/dns/", + Usage: "Use DNS-Challenge for main domain. Read more at: https://go-acme.github.io/lego/dns/", EnvVars: []string{"DNS_PROVIDER"}, }, &cli.StringFlag{ diff --git a/cmd/main.go b/cmd/main.go index a1c3b97..8a65d43 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -14,7 +14,6 @@ import ( "github.com/rs/zerolog/log" "github.com/urfave/cli/v2" - "codeberg.org/codeberg/pages/server" "codeberg.org/codeberg/pages/server/cache" "codeberg.org/codeberg/pages/server/certificates" "codeberg.org/codeberg/pages/server/gitea" @@ -48,7 +47,9 @@ func Serve(ctx *cli.Context) error { rawDomain := ctx.String("raw-domain") mainDomainSuffix := ctx.String("pages-domain") rawInfoPage := ctx.String("raw-info-page") - listeningAddress := fmt.Sprintf("%s:%s", ctx.String("host"), ctx.String("port")) + listeningHost := ctx.String("host") + listeningSSLAddress := fmt.Sprintf("%s:%d", listeningHost, ctx.Uint("port")) + listeningHTTPAddress := fmt.Sprintf("%s:%d", listeningHost, ctx.Uint("http-port")) enableHTTPServer := ctx.Bool("enable-http-server") allowedCorsDomains := AllowedCorsDomains @@ -91,22 +92,14 @@ func Serve(ctx *cli.Context) error { return err } - // Create handler based on settings - httpsHandler := handler.Handler(mainDomainSuffix, rawDomain, - giteaClient, - rawInfoPage, - BlacklistedPaths, allowedCorsDomains, - dnsLookupCache, canonicalDomainCache) - - httpHandler := server.SetupHTTPACMEChallengeServer(challengeCache) - - // Setup listener and TLS - log.Info().Msgf("Listening on https://%s", listeningAddress) - listener, err := net.Listen("tcp", listeningAddress) + // Create listener for SSL connections + log.Info().Msgf("Listening on https://%s", listeningSSLAddress) + listener, err := net.Listen("tcp", listeningSSLAddress) if err != nil { return fmt.Errorf("couldn't create listener: %v", err) } + // Setup listener for SSL connections listener = tls.NewListener(listener, certificates.TLSConfig(mainDomainSuffix, giteaClient, acmeClient, @@ -119,18 +112,29 @@ func Serve(ctx *cli.Context) error { go certificates.MaintainCertDB(certMaintainCtx, interval, acmeClient, mainDomainSuffix, certDB) if enableHTTPServer { + // Create handler for http->https redirect and http acme challenges + httpHandler := certificates.SetupHTTPACMEChallengeServer(challengeCache) + + // Create listener for http and start listening go func() { - log.Info().Msg("Start HTTP server listening on :80") - err := http.ListenAndServe("[::]:80", httpHandler) + log.Info().Msgf("Start HTTP server listening on %s", listeningHTTPAddress) + err := http.ListenAndServe(listeningHTTPAddress, httpHandler) if err != nil { log.Panic().Err(err).Msg("Couldn't start HTTP fastServer") } }() } - // Start the web fastServer + // Create ssl handler based on settings + sslHandler := handler.Handler(mainDomainSuffix, rawDomain, + giteaClient, + rawInfoPage, + BlacklistedPaths, allowedCorsDomains, + dnsLookupCache, canonicalDomainCache) + + // Start the ssl listener log.Info().Msgf("Start listening on %s", listener.Addr()) - if err := http.Serve(listener, httpsHandler); err != nil { + if err := http.Serve(listener, sslHandler); err != nil { log.Panic().Err(err).Msg("Couldn't start fastServer") } diff --git a/cmd/setup.go b/cmd/setup.go index bb9f8cb..cde4bc9 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -43,6 +43,11 @@ func createAcmeClient(ctx *cli.Context, enableHTTPServer bool, challengeCache ca 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) } + if acmeEabHmac != "" && acmeEabKID == "" { + return nil, fmt.Errorf("%w: ACME_EAB_HMAC also needs ACME_EAB_KID to be set", ErrAcmeMissConfig) + } else if acmeEabHmac == "" && acmeEabKID != "" { + return nil, fmt.Errorf("%w: ACME_EAB_KID also needs ACME_EAB_HMAC to be set", ErrAcmeMissConfig) + } return certificates.NewAcmeClient( acmeAccountConf, diff --git a/server/certificates/acme_config.go b/server/certificates/acme_config.go index 69568e6..12ad7c6 100644 --- a/server/certificates/acme_config.go +++ b/server/certificates/acme_config.go @@ -14,6 +14,8 @@ import ( "github.com/rs/zerolog/log" ) +const challengePath = "/.well-known/acme-challenge/" + func setupAcmeConfig(configFile, acmeAPI, acmeMail, acmeEabHmac, acmeEabKID string, acmeAcceptTerms bool) (*lego.Config, error) { var myAcmeAccount AcmeAccount var myAcmeConfig *lego.Config diff --git a/server/certificates/cached_challengers.go b/server/certificates/cached_challengers.go index 6ce6e67..02474b3 100644 --- a/server/certificates/cached_challengers.go +++ b/server/certificates/cached_challengers.go @@ -1,11 +1,15 @@ package certificates import ( + "net/http" + "strings" "time" "github.com/go-acme/lego/v4/challenge" "codeberg.org/codeberg/pages/server/cache" + "codeberg.org/codeberg/pages/server/context" + "codeberg.org/codeberg/pages/server/utils" ) type AcmeTLSChallengeProvider struct { @@ -39,3 +43,18 @@ func (a AcmeHTTPChallengeProvider) CleanUp(domain, token, _ string) error { a.challengeCache.Remove(domain + "/" + token) return nil } + +func SetupHTTPACMEChallengeServer(challengeCache cache.SetGetKey) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + ctx := context.New(w, req) + if strings.HasPrefix(ctx.Path(), challengePath) { + challenge, ok := challengeCache.Get(utils.TrimHostPort(ctx.Host()) + "/" + strings.TrimPrefix(ctx.Path(), challengePath)) + if !ok || challenge == nil { + ctx.String("no challenge for this token", http.StatusNotFound) + } + ctx.String(challenge.(string)) + } else { + ctx.Redirect("https://"+ctx.Host()+ctx.Path(), http.StatusMovedPermanently) + } + } +} diff --git a/server/certificates/certificates.go b/server/certificates/certificates.go index 3ea440f..707672c 100644 --- a/server/certificates/certificates.go +++ b/server/certificates/certificates.go @@ -36,22 +36,23 @@ func TLSConfig(mainDomainSuffix string, return &tls.Config{ // check DNS name & get certificate from Let's Encrypt GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) { - sni := strings.ToLower(strings.TrimSpace(info.ServerName)) - if len(sni) < 1 { - return nil, errors.New("missing sni") + domain := strings.ToLower(strings.TrimSpace(info.ServerName)) + if len(domain) < 1 { + return nil, errors.New("missing domain info via SNI (RFC 4366, Section 3.1)") } + // https request init is actually a acme challenge if info.SupportedProtos != nil { for _, proto := range info.SupportedProtos { if proto != tlsalpn01.ACMETLS1Protocol { continue } - challenge, ok := challengeCache.Get(sni) + challenge, ok := challengeCache.Get(domain) if !ok { return nil, errors.New("no challenge for this domain") } - cert, err := tlsalpn01.ChallengeCert(sni, challenge.(string)) + cert, err := tlsalpn01.ChallengeCert(domain, challenge.(string)) if err != nil { return nil, err } @@ -61,22 +62,22 @@ func TLSConfig(mainDomainSuffix string, targetOwner := "" mayObtainCert := true - if strings.HasSuffix(sni, mainDomainSuffix) || strings.EqualFold(sni, mainDomainSuffix[1:]) { + if strings.HasSuffix(domain, mainDomainSuffix) || strings.EqualFold(domain, mainDomainSuffix[1:]) { // deliver default certificate for the main domain (*.codeberg.page) - sni = mainDomainSuffix + domain = mainDomainSuffix } else { var targetRepo, targetBranch string - targetOwner, targetRepo, targetBranch = dnsutils.GetTargetFromDNS(sni, mainDomainSuffix, dnsLookupCache) + targetOwner, targetRepo, targetBranch = dnsutils.GetTargetFromDNS(domain, mainDomainSuffix, dnsLookupCache) if targetOwner == "" { // DNS not set up, return main certificate to redirect to the docs - sni = mainDomainSuffix + domain = mainDomainSuffix } else { targetOpt := &upstream.Options{ TargetOwner: targetOwner, TargetRepo: targetRepo, TargetBranch: targetBranch, } - _, valid := targetOpt.CheckCanonicalDomain(giteaClient, sni, mainDomainSuffix, canonicalDomainCache) + _, valid := targetOpt.CheckCanonicalDomain(giteaClient, domain, mainDomainSuffix, canonicalDomainCache) if !valid { // We shouldn't obtain a certificate when we cannot check if the // repository has specified this domain in the `.domains` file. @@ -85,30 +86,34 @@ func TLSConfig(mainDomainSuffix string, } } - if tlsCertificate, ok := keyCache.Get(sni); ok { + if tlsCertificate, ok := keyCache.Get(domain); ok { // we can use an existing certificate object return tlsCertificate.(*tls.Certificate), nil } var tlsCertificate *tls.Certificate var err error - if tlsCertificate, err = acmeClient.retrieveCertFromDB(sni, mainDomainSuffix, false, certDB); err != nil { - // request a new certificate - if strings.EqualFold(sni, mainDomainSuffix) { + if tlsCertificate, err = acmeClient.retrieveCertFromDB(domain, mainDomainSuffix, false, certDB); err != nil { + if !errors.Is(err, database.ErrNotFound) { + return nil, err + } + // we could not find a cert in db, request a new certificate + + // first check if we are allowed to obtain a cert for this domain + if strings.EqualFold(domain, mainDomainSuffix) { return nil, errors.New("won't request certificate for main domain, something really bad has happened") } - if !mayObtainCert { - return nil, fmt.Errorf("won't request certificate for %q", sni) + return nil, fmt.Errorf("won't request certificate for %q", domain) } - tlsCertificate, err = acmeClient.obtainCert(acmeClient.legoClient, []string{sni}, nil, targetOwner, false, mainDomainSuffix, certDB) + tlsCertificate, err = acmeClient.obtainCert(acmeClient.legoClient, []string{domain}, nil, targetOwner, false, mainDomainSuffix, certDB) if err != nil { return nil, err } } - if err := keyCache.Set(sni, tlsCertificate, 15*time.Minute); err != nil { + if err := keyCache.Set(domain, tlsCertificate, 15*time.Minute); err != nil { return nil, err } return tlsCertificate, nil @@ -164,7 +169,7 @@ func (c *AcmeClient) retrieveCertFromDB(sni, mainDomainSuffix string, useDnsProv if !strings.EqualFold(sni, mainDomainSuffix) { tlsCertificate.Leaf, err = x509.ParseCertificate(tlsCertificate.Certificate[0]) if err != nil { - return nil, fmt.Errorf("error parsin leaf tlsCert: %w", err) + return nil, fmt.Errorf("error parsing leaf tlsCert: %w", err) } // renew certificates 7 days before they expire diff --git a/server/setup.go b/server/setup.go deleted file mode 100644 index 282e692..0000000 --- a/server/setup.go +++ /dev/null @@ -1,27 +0,0 @@ -package server - -import ( - "net/http" - "strings" - - "codeberg.org/codeberg/pages/server/cache" - "codeberg.org/codeberg/pages/server/context" - "codeberg.org/codeberg/pages/server/utils" -) - -func SetupHTTPACMEChallengeServer(challengeCache cache.SetGetKey) http.HandlerFunc { - challengePath := "/.well-known/acme-challenge/" - - return func(w http.ResponseWriter, req *http.Request) { - ctx := context.New(w, req) - if strings.HasPrefix(ctx.Path(), challengePath) { - challenge, ok := challengeCache.Get(utils.TrimHostPort(ctx.Host()) + "/" + strings.TrimPrefix(ctx.Path(), challengePath)) - if !ok || challenge == nil { - ctx.String("no challenge for this token", http.StatusNotFound) - } - ctx.String(challenge.(string)) - } else { - ctx.Redirect("https://"+ctx.Host()+ctx.Path(), http.StatusMovedPermanently) - } - } -}