Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"
"time"

"golang.org/x/crypto/acme/autocert"
"gopkg.in/yaml.v2"

"github.com/prometheus/client_golang/prometheus"
Expand Down Expand Up @@ -265,6 +266,14 @@ type Config struct {
// TLS Config
KubernetesEnableTLS bool `yaml:"kubernetes-enable-tls"`

// Letsencrypt
EnableLetsencrypt bool `yaml:"enable-letsencrypt"`
LetsencryptCache string `yaml:"letsencrypt-cache"`
LetsencryptEmail string `yaml:"letsencrypt-email"`
LetsencryptDomains *listFlag `yaml:"letsencrypt-domains"`
LetsencryptDirectoryURL string `yaml:"letsencrypt-directory-url"`
LetsencryptUserAgent string `yaml:"letsencrypt-user-agent"`

// API Monitoring
ApiUsageMonitoringEnable bool `yaml:"enable-api-usage-monitoring"`
ApiUsageMonitoringRealmKeys string `yaml:"api-usage-monitoring-realm-keys"`
Expand Down Expand Up @@ -399,6 +408,7 @@ func NewConfig() *Config {
cfg.ProxyDenyListCIDRs = commaListFlag()
cfg.ProxySkipListCIDRs = commaListFlag()
cfg.EnsureDataClients = commaListFlag()
cfg.LetsencryptDomains = commaListFlag()

flag := flag.NewFlagSet("", flag.ExitOnError)
flag.StringVar(&cfg.ConfigFile, "config-file", "", "if provided the flags will be loaded/overwritten by the values on the file (yaml)")
Expand Down Expand Up @@ -639,6 +649,14 @@ func NewConfig() *Config {
// Exclude insecure cipher suites
flag.BoolVar(&cfg.ExcludeInsecureCipherSuites, "exclude-insecure-cipher-suites", false, "excludes insecure cipher suites")

// Letsencrypt
flag.BoolVar(&cfg.EnableLetsencrypt, "enable-letsencrypt", false, "enables letsencrypt autocert handling on the proxy")
flag.StringVar(&cfg.LetsencryptCache, "letsencrypt-cache", "directory", "Configure the autocert cert cache <inmemory|remote|directory>. If you use certbot, you need to use directory.")
flag.StringVar(&cfg.LetsencryptEmail, "letsencrypt-email", "", "Sets letsencrypt email address such that you can be reached by letsencrypt if something goes wrong")
flag.Var(cfg.LetsencryptDomains, "letsencrypt-domains", "An allow list of domains for autocert handling")
flag.StringVar(&cfg.LetsencryptDirectoryURL, "letsencrypt-directory-url", "", "Sets directory URL for testing, defaults to autocert.DefaultACMEDirectory")
flag.StringVar(&cfg.LetsencryptUserAgent, "letsencrypt-user-agent", "", "Sets httpclient useragent that calls letsencrypt that enables letsencrypt to limit you if something goes wrong")

// API Monitoring:
flag.BoolVar(&cfg.ApiUsageMonitoringEnable, "enable-api-usage-monitoring", false, "enables the apiUsageMonitoring filter")
flag.StringVar(&cfg.ApiUsageMonitoringRealmKeys, "api-usage-monitoring-realm-keys", "", "name of the property in the JWT payload that contains the authority realm")
Expand Down Expand Up @@ -904,6 +922,7 @@ func (c *Config) ToOptions() skipper.Options {
MaxMatcherBufferSize: c.MaxMatcherBufferSize,
EnableBreakers: c.EnableBreakers,
BreakerSettings: c.Breakers,
LetsencryptCache: c.LetsencryptCache,
EnableRatelimiters: c.EnableRatelimiters,
RatelimitSettings: c.Ratelimits,
EnableRouteFIFOMetrics: c.EnableRouteFIFOMetrics,
Expand Down Expand Up @@ -1240,6 +1259,7 @@ func (c *Config) ToOptions() skipper.Options {
}
})
}

if c.ValidateQueryLog {
wrappers = append(wrappers, func(handler http.Handler) http.Handler {
return &net.ValidateQueryLogHandler{
Expand All @@ -1257,9 +1277,35 @@ func (c *Config) ToOptions() skipper.Options {
})
}

if c.EnableLetsencrypt {
options.Letsencrypt = net.NewLetsencrypt(
c.getLetsencryptCache(),
c.LetsencryptEmail,
c.LetsencryptDirectoryURL,
c.LetsencryptUserAgent,
c.LetsencryptDomains.values,
)

wrappers = append(wrappers, func(handler http.Handler) http.Handler {
return options.Letsencrypt.Handler(handler)
})
}

return options
}

func (c *Config) getLetsencryptCache() autocert.Cache {
switch c.LetsencryptCache {
case "directory":
return autocert.DirCache(os.TempDir())
case "remote":
// postpone to skipper.go and use net.(*Letsencrypt).SetCache()
return nil
default:
return &net.InmemoryCache{}
}
}

func (c *Config) getMinTLSVersion() uint16 {
tlsVersionTable := map[string]uint16{
"1.3": tls.VersionTLS13,
Expand Down
1 change: 1 addition & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ func defaultConfig(with func(*Config)) *Config {
ClusterRatelimitMaxGroupShards: 1,
ValidateQuery: true,
ValidateQueryLog: true,
LetsencryptDomains: commaListFlag(),
EnableLua: false,
LuaModules: commaListFlag(),
LuaSources: commaListFlag(),
Expand Down
152 changes: 152 additions & 0 deletions net/letsencrypt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package net

import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"regexp"
"strings"
"sync"

"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
)

type InmemoryCache struct {
m sync.Map
}

func (ic *InmemoryCache) Get(ctx context.Context, key string) ([]byte, error) {
if dat, ok := ic.m.Load(key); !ok {
return nil, fmt.Errorf("missing key %q", key)
} else {
if data, ok := dat.([]byte); !ok {
return nil, fmt.Errorf("failed to convert %q to []byte", dat)
} else {
return data, nil
}
}
}

func (ic *InmemoryCache) Put(ctx context.Context, key string, data []byte) error {
ic.m.Store(key, data)
return nil
}

func (ic *InmemoryCache) Delete(ctx context.Context, key string) error {
ic.m.Delete(key)
return nil
}

type RemoteCacheClient interface {
Get(ctx context.Context, key string) (string, error)
Del(ctx context.Context, key string) error
Set(ctx context.Context, key string, val string) (string, error)
Close() error
}

type RemoteCache struct {
Client RemoteCacheClient
}

func (rc *RemoteCache) Get(ctx context.Context, key string) ([]byte, error) {
res, err := rc.Client.Get(ctx, key)
if err != nil {
return nil, err
}
return []byte(res), nil
}

func (rc *RemoteCache) Delete(ctx context.Context, key string) error {
return rc.Client.Del(ctx, key)
}

func (rc *RemoteCache) Put(ctx context.Context, key string, val []byte) error {
_, err := rc.Client.Set(ctx, key, string(val))
return err
}

func (rc *RemoteCache) Close() error {
return rc.Client.Close()
}

type Letsencrypt struct {
manager *autocert.Manager
}

// NewLetsencrypt creates a letsencrypt handler to automatically handle CSR challenges.
//
// The cache argument can be either
//
// - autocert.DirCache for a filesystem cache
// - inmemoryCache for in memory cache
// - remoteCache for redis based production cache to be shared between multiple skipper processes
func NewLetsencrypt(cache autocert.Cache, email, directoryURL, userAgent string, proposedDomains []string) *Letsencrypt {
domains := make([]string, 0, len(proposedDomains))
for _, s := range proposedDomains {
if validateDomain(s) {
domains = append(domains, s)
}
}

if directoryURL == "" {
directoryURL = autocert.DefaultACMEDirectory
}

manager := &autocert.Manager{
Cache: cache,
Email: email,
HostPolicy: autocert.HostWhitelist(domains...),
Prompt: autocert.AcceptTOS,
Client: &acme.Client{
DirectoryURL: directoryURL,
UserAgent: userAgent,
HTTPClient: http.DefaultClient,
},
}

return &Letsencrypt{
manager: manager,
}
}

func (le *Letsencrypt) SetCache(cache autocert.Cache) {
le.manager.Cache = cache
}

func (le *Letsencrypt) Handler(fallback http.Handler) http.Handler {
return le.manager.HTTPHandler(fallback)
}

func (le *Letsencrypt) TLSConfig() *tls.Config {
return le.manager.TLSConfig()
}

// Listener returns a net.Listener that need to be closed on exit or
// you leak a goroutine
func (le *Letsencrypt) Listener() net.Listener {
return le.manager.Listener()
}

func (le *Letsencrypt) Client() *acme.Client {
return le.manager.Client
}

func (le *Letsencrypt) Close() {
le.Listener().Close()
}

var domainRegex = regexp.MustCompile("^[a-z0-9-]+$")

func validateDomain(s string) bool {
i := 0
for w := range strings.SplitSeq(s, ".") {
if !domainRegex.MatchString(w) {
return false
}
i++
}
return i > 1
}
121 changes: 121 additions & 0 deletions net/letsencrypt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package net

import (
"context"
"testing"

"github.com/stretchr/testify/require"
"github.com/zalando/skipper/net/valkeytest"
)

func TestRemoteCache(t *testing.T) {
t.Logf("create valkey..")
valkeyAddr, done := valkeytest.NewTestValkey(t)
defer done()
if valkeyAddr == "" {
t.Fatal("Failed to create valkey 1")
}

valkeyAddr2, done2 := valkeytest.NewTestValkey(t)
defer done2()
if valkeyAddr2 == "" {
t.Fatal("Failed to create valkey 2")
}

client, err := NewValkeyRingClient(&ValkeyOptions{
Addrs: []string{valkeyAddr, valkeyAddr2},
})
if err != nil {
t.Fatalf("Failed to create remote cahce client: %v", err)
}

rc := RemoteCache{
Client: client,
}
defer rc.Close()

if err := rc.Put(context.Background(), "foo", []byte("bar")); err != nil {
t.Fatalf("Failed to put: %v", err)
}

if v, err := rc.Get(context.Background(), "foo"); err != nil {
t.Fatalf("Failed to get: %v", err)
} else {
t.Logf("%T %v %s", v, v, v)
if string(v) != "bar" {
t.Fatalf("Failed to get result, got: %q", string(v))
}
}

if err := rc.Delete(context.Background(), "foo"); err != nil {
t.Fatalf("Failed to delete: %v", err)
}
}

func TestInmemoryCache(t *testing.T) {
rc := &InmemoryCache{}

if _, err := rc.Get(context.Background(), "foo"); err == nil {
t.Fatal(`Failed can not get "foo" on empty cache`)
}

if err := rc.Put(context.Background(), "foo", []byte("bar")); err != nil {
t.Fatalf("Failed to put: %v", err)
}

if v, err := rc.Get(context.Background(), "foo"); err != nil {
t.Fatalf("Failed to get: %v", err)
} else {
t.Logf("%T %v %s", v, v, v)
}

if err := rc.Delete(context.Background(), "foo"); err != nil {
t.Fatalf("Failed to delete: %v", err)
}

if err := rc.Put(context.Background(), "foo2", []byte("ü")); err != nil {
t.Fatalf("Failed to put: %v", err)
}

if v, err := rc.Get(context.Background(), "foo2"); err != nil {
t.Fatalf("Failed to get: %v", err)
} else {
t.Logf("%T %v %s", v, v, v)
}

}

func TestLetsencrypt(t *testing.T) {
invalidDomain := "s_.example.org"
if validateDomain(invalidDomain) {
t.Fatalf("Failed to validate invalid domain %q", invalidDomain)
}
validDomain := "example.org"
if !validateDomain(validDomain) {
t.Fatalf("Failed to validate valid domain %q", validDomain)
}

le := NewLetsencrypt(&InmemoryCache{}, "skipper@example.org", "https://acme-staging-v02.api.letsencrypt.org/directory", "skipper-test TestLetsencrypt", []string{validDomain})
defer le.Close()
if le.manager.Client != nil {
dir, err := le.manager.Client.Discover(context.TODO())
if err != nil {
t.Fatalf("Failed to discover: %v", err)
}
t.Logf("order: %s", dir.OrderURL)

defer func() {
if le.manager.Client.HTTPClient != nil {
le.manager.Client.HTTPClient.CloseIdleConnections()
}
}()
}

require.NotNil(t, le.Client(), "client should not be nil")
require.NotNil(t, le.TLSConfig(), "TLSConfig should not be nil")
require.NotNil(t, le.Handler(nil), "http.Handler should not be nil")

li := le.Listener()
defer li.Close()
t.Logf("listener %v", li.Addr())
}
5 changes: 5 additions & 0 deletions net/redisclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,11 @@ func (r *RedisRingClient) SetAddrs(ctx context.Context, addrs []string) {
r.ring.SetAddrs(createAddressMap(addrs))
}

func (r *RedisRingClient) Del(ctx context.Context, key string) error {
res := r.ring.Del(ctx, key)
return res.Err()
}

func (r *RedisRingClient) Get(ctx context.Context, key string) (string, error) {
res := r.ring.Get(ctx, key)
return res.Val(), res.Err()
Expand Down
Loading
Loading