Merge remote-tracking branch 'main'

This commit is contained in:
video-prize-ranch 2023-02-15 20:22:44 -05:00
commit 42ee97e0cf
No known key found for this signature in database
23 changed files with 354 additions and 131 deletions

View file

@ -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

View file

@ -1,11 +1,17 @@
package certificates
import (
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/rs/zerolog/log"
"codeberg.org/codeberg/pages/server/cache"
"codeberg.org/codeberg/pages/server/context"
)
type AcmeTLSChallengeProvider struct {
@ -39,3 +45,39 @@ func (a AcmeHTTPChallengeProvider) CleanUp(domain, token, _ string) error {
a.challengeCache.Remove(domain + "/" + token)
return nil
}
func SetupHTTPACMEChallengeServer(challengeCache cache.SetGetKey, sslPort uint) http.HandlerFunc {
// handle custom-ssl-ports to be added on https redirects
portPart := ""
if sslPort != 443 {
portPart = fmt.Sprintf(":%d", sslPort)
}
return func(w http.ResponseWriter, req *http.Request) {
ctx := context.New(w, req)
domain := ctx.TrimHostPort()
// it's an acme request
if strings.HasPrefix(ctx.Path(), challengePath) {
challenge, ok := challengeCache.Get(domain + "/" + strings.TrimPrefix(ctx.Path(), challengePath))
if !ok || challenge == nil {
log.Info().Msgf("HTTP-ACME challenge for '%s' failed: token not found", domain)
ctx.String("no challenge for this token", http.StatusNotFound)
}
log.Info().Msgf("HTTP-ACME challenge for '%s' succeeded", domain)
ctx.String(challenge.(string))
return
}
// it's a normal http request that needs to be redirected
u, err := url.Parse(fmt.Sprintf("https://%s%s%s", domain, portPart, ctx.Path()))
if err != nil {
log.Error().Err(err).Msg("could not craft http to https redirect")
ctx.String("", http.StatusInternalServerError)
}
newURL := u.String()
log.Debug().Msgf("redirect http to https: %s", newURL)
ctx.Redirect(newURL, http.StatusMovedPermanently)
}
}

View file

@ -30,28 +30,31 @@ var ErrUserRateLimitExceeded = errors.New("rate limit exceeded: 10 certificates
func TLSConfig(mainDomainSuffix string,
giteaClient *gitea.Client,
acmeClient *AcmeClient,
firstDefaultBranch string,
keyCache, challengeCache, dnsLookupCache, canonicalDomainCache cache.SetGetKey,
certDB database.CertDB,
) *tls.Config {
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
}
log.Info().Msgf("Detect ACME-TLS1 challenge for '%s'", domain)
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 +64,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, firstDefaultBranch, 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 +88,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 +171,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

View file

@ -4,13 +4,15 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"codeberg.org/codeberg/pages/server/database"
)
func TestMockCert(t *testing.T) {
db, err := database.NewTmpDB()
assert.NoError(t, err)
db := database.NewMockCertDB(t)
db.Mock.On("Put", mock.Anything, mock.Anything).Return(nil)
cert, err := mockCert("example.com", "some error msg", "codeberg.page", db)
assert.NoError(t, err)
if assert.NotEmpty(t, cert) {

View file

@ -8,6 +8,9 @@ import (
"github.com/rs/zerolog/log"
)
//go:generate go install github.com/vektra/mockery/v2@latest
//go:generate mockery --name CertDB --output . --filename mock.go --inpackage --case underscore
type CertDB interface {
Close() error
Put(name string, cert *certificate.Resource) error

View file

@ -1,49 +1,122 @@
// Code generated by mockery v2.20.0. DO NOT EDIT.
package database
import (
"fmt"
"time"
"github.com/OrlovEvgeny/go-mcache"
"github.com/go-acme/lego/v4/certificate"
certificate "github.com/go-acme/lego/v4/certificate"
mock "github.com/stretchr/testify/mock"
)
var _ CertDB = tmpDB{}
type tmpDB struct {
intern *mcache.CacheDriver
ttl time.Duration
// MockCertDB is an autogenerated mock type for the CertDB type
type MockCertDB struct {
mock.Mock
}
func (p tmpDB) Close() error {
_ = p.intern.Close()
return nil
}
// Close provides a mock function with given fields:
func (_m *MockCertDB) Close() error {
ret := _m.Called()
func (p tmpDB) Put(name string, cert *certificate.Resource) error {
return p.intern.Set(name, cert, p.ttl)
}
func (p tmpDB) Get(name string) (*certificate.Resource, error) {
cert, has := p.intern.Get(name)
if !has {
return nil, fmt.Errorf("cert for %q not found", name)
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return cert.(*certificate.Resource), nil
return r0
}
func (p tmpDB) Delete(key string) error {
p.intern.Remove(key)
return nil
// Delete provides a mock function with given fields: key
func (_m *MockCertDB) Delete(key string) error {
ret := _m.Called(key)
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(key)
} else {
r0 = ret.Error(0)
}
return r0
}
func (p tmpDB) Items(page, pageSize int) ([]*Cert, error) {
return nil, fmt.Errorf("items not implemented for tmpDB")
// Get provides a mock function with given fields: name
func (_m *MockCertDB) Get(name string) (*certificate.Resource, error) {
ret := _m.Called(name)
var r0 *certificate.Resource
var r1 error
if rf, ok := ret.Get(0).(func(string) (*certificate.Resource, error)); ok {
return rf(name)
}
if rf, ok := ret.Get(0).(func(string) *certificate.Resource); ok {
r0 = rf(name)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*certificate.Resource)
}
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(name)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
func NewTmpDB() (CertDB, error) {
return &tmpDB{
intern: mcache.New(),
ttl: time.Minute,
}, nil
// Items provides a mock function with given fields: page, pageSize
func (_m *MockCertDB) Items(page int, pageSize int) ([]*Cert, error) {
ret := _m.Called(page, pageSize)
var r0 []*Cert
var r1 error
if rf, ok := ret.Get(0).(func(int, int) ([]*Cert, error)); ok {
return rf(page, pageSize)
}
if rf, ok := ret.Get(0).(func(int, int) []*Cert); ok {
r0 = rf(page, pageSize)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*Cert)
}
}
if rf, ok := ret.Get(1).(func(int, int) error); ok {
r1 = rf(page, pageSize)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Put provides a mock function with given fields: name, cert
func (_m *MockCertDB) Put(name string, cert *certificate.Resource) error {
ret := _m.Called(name, cert)
var r0 error
if rf, ok := ret.Get(0).(func(string, *certificate.Resource) error); ok {
r0 = rf(name, cert)
} else {
r0 = ret.Error(0)
}
return r0
}
type mockConstructorTestingTNewMockCertDB interface {
mock.TestingT
Cleanup(func())
}
// NewMockCertDB creates a new instance of MockCertDB. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewMockCertDB(t mockConstructorTestingTNewMockCertDB) *MockCertDB {
mock := &MockCertDB{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View file

@ -11,9 +11,11 @@ import (
// lookupCacheTimeout specifies the timeout for the DNS lookup cache.
var lookupCacheTimeout = 15 * time.Minute
var defaultPagesRepo = "pages"
// 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 string, dnsLookupCache cache.SetGetKey) (targetOwner, targetRepo, targetBranch string) {
func GetTargetFromDNS(domain, mainDomainSuffix, firstDefaultBranch string, dnsLookupCache cache.SetGetKey) (targetOwner, targetRepo, targetBranch string) {
// Get CNAME or TXT
var cname string
var err error
@ -50,10 +52,10 @@ func GetTargetFromDNS(domain, mainDomainSuffix string, dnsLookupCache cache.SetG
targetBranch = cnameParts[len(cnameParts)-3]
}
if targetRepo == "" {
targetRepo = "pages"
targetRepo = defaultPagesRepo
}
if targetBranch == "" && targetRepo != "pages" {
targetBranch = "pages"
if targetBranch == "" && targetRepo != defaultPagesRepo {
targetBranch = firstDefaultBranch
}
// if targetBranch is still empty, the caller must find the default branch
return

View file

@ -17,7 +17,6 @@ const (
headerAccessControlAllowOrigin = "Access-Control-Allow-Origin"
headerAccessControlAllowMethods = "Access-Control-Allow-Methods"
defaultPagesRepo = "pages"
defaultPagesBranch = "pages"
)
// Handler handles a single HTTP request to the web server.
@ -25,6 +24,7 @@ func Handler(mainDomainSuffix, rawDomain string,
giteaClient *gitea.Client,
rawInfoPage string,
blacklistedPaths, allowedCorsDomains []string,
defaultPagesBranches []string,
dnsLookupCache, canonicalDomainCache, redirectsCache cache.SetGetKey,
) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
@ -98,6 +98,7 @@ func Handler(mainDomainSuffix, rawDomain string,
log.Debug().Msg("subdomain request detecded")
handleSubDomain(log, ctx, giteaClient,
mainDomainSuffix,
defaultPagesBranches,
trimmedHost,
pathElements,
canonicalDomainCache, redirectsCache)
@ -107,6 +108,7 @@ func Handler(mainDomainSuffix, rawDomain string,
mainDomainSuffix,
trimmedHost,
pathElements,
defaultPagesBranches[0],
dnsLookupCache, canonicalDomainCache, redirectsCache)
}
}

View file

@ -18,10 +18,11 @@ func handleCustomDomain(log zerolog.Logger, ctx *context.Context, giteaClient *g
mainDomainSuffix string,
trimmedHost string,
pathElements []string,
firstDefaultBranch string,
dnsLookupCache, canonicalDomainCache, redirectsCache cache.SetGetKey,
) {
// Serve pages from custom domains
targetOwner, targetRepo, targetBranch := dns.GetTargetFromDNS(trimmedHost, mainDomainSuffix, dnsLookupCache)
targetOwner, targetRepo, targetBranch := dns.GetTargetFromDNS(trimmedHost, mainDomainSuffix, firstDefaultBranch, dnsLookupCache)
if targetOwner == "" {
html.ReturnErrorPage(ctx,
"could not obtain repo owner from custom domain",
@ -52,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, dnsLookupCache)
targetOwner, _, _ = dns.GetTargetFromDNS(strings.SplitN(canonicalDomain, "/", 2)[0], mainDomainSuffix, firstDefaultBranch, dnsLookupCache)
if targetOwner != "" {
ctx.Redirect("https://"+canonicalDomain+"/"+targetOpt.TargetPath, http.StatusTemporaryRedirect)
return

View file

@ -7,6 +7,7 @@ import (
"strings"
"github.com/rs/zerolog"
"golang.org/x/exp/slices"
"codeberg.org/codeberg/pages/html"
"codeberg.org/codeberg/pages/server/cache"
@ -17,6 +18,7 @@ import (
func handleSubDomain(log zerolog.Logger, ctx *context.Context, giteaClient *gitea.Client,
mainDomainSuffix string,
defaultPagesBranches []string,
trimmedHost string,
pathElements []string,
canonicalDomainCache, redirectsCache cache.SetGetKey,
@ -63,12 +65,21 @@ func handleSubDomain(log zerolog.Logger, ctx *context.Context, giteaClient *gite
// Check if the first directory is a branch for the defaultPagesRepo
// example.codeberg.page/@main/index.html
if strings.HasPrefix(pathElements[0], "@") {
targetBranch := pathElements[0][1:]
// if the default pages branch can be determined exactly, it does not need to be set
if len(defaultPagesBranches) == 1 && slices.Contains(defaultPagesBranches, targetBranch) {
// example.codeberg.org/@pages/... redirects to example.codeberg.org/...
ctx.Redirect("/"+strings.Join(pathElements[1:], "/"), http.StatusTemporaryRedirect)
return
}
log.Debug().Msg("main domain preparations, now trying with specified branch")
if targetOpt, works := tryBranch(log, ctx, giteaClient, &upstream.Options{
TryIndexPages: true,
TargetOwner: targetOwner,
TargetRepo: defaultPagesRepo,
TargetBranch: pathElements[0][1:],
TargetBranch: targetBranch,
TargetPath: path.Join(pathElements[1:]...),
}, true); works {
log.Trace().Msg("tryUpstream: serve default pages repo with specified branch")
@ -81,19 +92,36 @@ func handleSubDomain(log zerolog.Logger, ctx *context.Context, giteaClient *gite
return
}
// Check if the first directory is a repo with a defaultPagesRepo branch
// example.codeberg.page/myrepo/index.html
// example.codeberg.page/pages/... is not allowed here.
log.Debug().Msg("main domain preparations, now trying with specified repo")
if pathElements[0] != defaultPagesRepo {
for _, defaultPagesBranch := range defaultPagesBranches {
// Check if the first directory is a repo with a default pages branch
// example.codeberg.page/myrepo/index.html
// example.codeberg.page/{PAGES_BRANCHE}/... is not allowed here.
log.Debug().Msg("main domain preparations, now trying with specified repo")
if pathElements[0] != defaultPagesBranch {
if targetOpt, works := tryBranch(log, ctx, giteaClient, &upstream.Options{
TryIndexPages: true,
TargetOwner: targetOwner,
TargetRepo: pathElements[0],
TargetBranch: defaultPagesBranch,
TargetPath: path.Join(pathElements[1:]...),
}, false); works {
log.Debug().Msg("tryBranch, now trying upstream 5")
tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache)
return
}
}
// Try to use the defaultPagesRepo on an default pages branch
// example.codeberg.page/index.html
log.Debug().Msg("main domain preparations, now trying with default repo")
if targetOpt, works := tryBranch(log, ctx, giteaClient, &upstream.Options{
TryIndexPages: true,
TargetOwner: targetOwner,
TargetRepo: pathElements[0],
TargetRepo: defaultPagesRepo,
TargetBranch: defaultPagesBranch,
TargetPath: path.Join(pathElements[1:]...),
TargetPath: path.Join(pathElements...),
}, false); works {
log.Debug().Msg("tryBranch, now trying upstream 5")
log.Debug().Msg("tryBranch, now trying upstream 6")
tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache)
return
}

View file

@ -18,6 +18,7 @@ func TestHandlerPerformance(t *testing.T) {
"https://docs.codeberg.org/pages/raw-content/",
[]string{"/.well-known/acme-challenge/"},
[]string{"raw.codeberg.org", "fonts.codeberg.org", "design.codeberg.org"},
[]string{"pages"},
cache.NewKeyValueCache(),
cache.NewKeyValueCache(),
cache.NewKeyValueCache(),

View file

@ -21,7 +21,7 @@ func tryUpstream(ctx *context.Context, giteaClient *gitea.Client,
redirectsCache cache.SetGetKey,
) {
// check if a canonical domain exists on a request on MainDomain
if strings.HasSuffix(trimmedHost, mainDomainSuffix) {
if strings.HasSuffix(trimmedHost, mainDomainSuffix) && !options.ServeRaw {
canonicalDomain, _ := options.CheckCanonicalDomain(giteaClient, "", mainDomainSuffix, canonicalDomainCache)
if !strings.HasSuffix(strings.SplitN(canonicalDomain, "/", 2)[0], mainDomainSuffix) {
canonicalPath := ctx.Req.RequestURI

View file

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

View file

@ -168,7 +168,7 @@ func (o *Options) Upstream(ctx *context.Context, giteaClient *gitea.Client) (fin
ctx.Redirect(ctx.Path()+"/", http.StatusTemporaryRedirect)
return true
}
if strings.HasSuffix(ctx.Path(), "/index.html") {
if strings.HasSuffix(ctx.Path(), "/index.html") && !o.ServeRaw {
ctx.Redirect(strings.TrimSuffix(ctx.Path(), "index.html"), http.StatusTemporaryRedirect)
return true
}