From 9a3d1c36dce7c7d9c460dfdb684f6e4bae5848f9 Mon Sep 17 00:00:00 2001
From: 6543 <6543@obermui.de>
Date: Mon, 13 Feb 2023 20:14:45 +0000
Subject: [PATCH] Document more flags & make http port customizable (#183)

Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/183
---
 .gitignore                                |  1 +
 Justfile                                  |  3 ++
 cmd/flags.go                              | 34 +++++++++++-------
 cmd/main.go                               | 40 +++++++++++----------
 cmd/setup.go                              |  5 +++
 server/certificates/acme_config.go        |  2 ++
 server/certificates/cached_challengers.go | 19 ++++++++++
 server/certificates/certificates.go       | 43 +++++++++++++----------
 server/setup.go                           | 27 --------------
 9 files changed, 97 insertions(+), 77 deletions(-)
 delete mode 100644 server/setup.go

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)
-		}
-	}
-}