Skip to content

Commit e6b4ba5

Browse files
committed
feature: letsencrypt http.Handler integration via autocert with different storage/cache systems to be used by different deployments
Signed-off-by: Sandor Szuecs <sandor.szuecs@zalando.de>
1 parent b0a2c7d commit e6b4ba5

File tree

5 files changed

+303
-0
lines changed

5 files changed

+303
-0
lines changed

config/config.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strings"
1212
"time"
1313

14+
"golang.org/x/crypto/acme/autocert"
1415
"gopkg.in/yaml.v2"
1516

1617
log "github.com/sirupsen/logrus"
@@ -238,6 +239,14 @@ type Config struct {
238239
// TLS Config
239240
KubernetesEnableTLS bool `yaml:"kubernetes-enable-tls"`
240241

242+
// Letsencrypt
243+
EnableLetsencrypt bool `yaml:"enable-letsencrypt"`
244+
LetsencryptCache string `yaml:"letsencrypt-cache"`
245+
LetsencryptEmail string `yaml:"letsencrypt-email"`
246+
LetsencryptDomains *listFlag `yaml:"letsencrypt-domains"`
247+
LetsencryptDirectoryURL string `yaml:"letsencrypt-directory-url"`
248+
LetsencryptUserAgent string `yaml:"letsencrypt-user-agent"`
249+
241250
// API Monitoring
242251
ApiUsageMonitoringEnable bool `yaml:"enable-api-usage-monitoring"`
243252
ApiUsageMonitoringRealmKeys string `yaml:"api-usage-monitoring-realm-keys"`
@@ -344,6 +353,7 @@ func NewConfig() *Config {
344353
cfg.LuaModules = commaListFlag()
345354
cfg.LuaSources = commaListFlag()
346355
cfg.Oauth2GrantTokeninfoKeys = commaListFlag()
356+
cfg.LetsencryptDomains = commaListFlag()
347357

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

572+
// Letsencrypt
573+
flag.BoolVar(&cfg.EnableLetsencrypt, "enable-letsencrypt", false, "enables letsencrypt autocert handling on the proxy")
574+
flag.StringVar(&cfg.LetsencryptCache, "letsencrypt-cache", "", "Configure the autocert cert cache <inmemory|remote|directory>")
575+
flag.StringVar(&cfg.LetsencryptEmail, "letsencrypt-email", "", "Sets letsencrypt email address such that you can be reached by letsencrypt if something goes wrong")
576+
flag.Var(cfg.LetsencryptDomains, "letsencrypt-domains", "An allow list of domains for autocert handling")
577+
flag.StringVar(&cfg.LetsencryptDirectoryURL, "letsencrypt-directory-url", "", "Sets directory URL for testing")
578+
flag.StringVar(&cfg.LetsencryptUserAgent, "letsencrypt-user-agent", "", "Sets httpclient useragent that calls letsencrypt that enables letsencrypt to limit you if something goes wrong")
579+
562580
// API Monitoring:
563581
flag.BoolVar(&cfg.ApiUsageMonitoringEnable, "enable-api-usage-monitoring", false, "enables the apiUsageMonitoring filter")
564582
flag.StringVar(&cfg.ApiUsageMonitoringRealmKeys, "api-usage-monitoring-realm-keys", "", "name of the property in the JWT payload that contains the authority realm")
@@ -1097,9 +1115,35 @@ func (c *Config) ToOptions() skipper.Options {
10971115
})
10981116
}
10991117

1118+
if c.EnableLetsencrypt {
1119+
wrappers = append(wrappers, func(handler http.Handler) http.Handler {
1120+
return net.NewLetsencrypt(
1121+
c.getLetsencryptCache(),
1122+
c.LetsencryptEmail,
1123+
c.LetsencryptDirectoryURL,
1124+
c.LetsencryptUserAgent,
1125+
c.LetsencryptDomains.values,
1126+
).Handler(handler)
1127+
})
1128+
1129+
}
1130+
11001131
return options
11011132
}
11021133

1134+
func (c *Config) getLetsencryptCache() autocert.Cache {
1135+
switch c.LetsencryptCache {
1136+
case "directory":
1137+
return autocert.DirCache(os.TempDir())
1138+
case "remote":
1139+
return &net.RemoteCache{
1140+
Client: &net.RedisRingClient{},
1141+
}
1142+
default:
1143+
return &net.InmemoryCache{}
1144+
}
1145+
}
1146+
11031147
func (c *Config) getMinTLSVersion() uint16 {
11041148
tlsVersionTable := map[string]uint16{
11051149
"1.3": tls.VersionTLS13,

config/config_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ func defaultConfig(with func(*Config)) *Config {
162162
ClusterRatelimitMaxGroupShards: 1,
163163
ValidateQuery: true,
164164
ValidateQueryLog: true,
165+
LetsencryptDomains: commaListFlag(),
165166
LuaModules: commaListFlag(),
166167
LuaSources: commaListFlag(),
167168
OpenPolicyAgentCleanerInterval: openpolicyagent.DefaultCleanIdlePeriod,

net/letsencrypt.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package net
2+
3+
import (
4+
"context"
5+
"crypto/tls"
6+
"fmt"
7+
"net"
8+
"net/http"
9+
"regexp"
10+
"strings"
11+
"sync"
12+
13+
"golang.org/x/crypto/acme"
14+
"golang.org/x/crypto/acme/autocert"
15+
)
16+
17+
type InmemoryCache struct {
18+
m sync.Map
19+
}
20+
21+
func (ic *InmemoryCache) Get(ctx context.Context, key string) ([]byte, error) {
22+
if dat, ok := ic.m.Load(key); !ok {
23+
return nil, fmt.Errorf("missing key %q", key)
24+
} else {
25+
if data, ok := dat.([]byte); !ok {
26+
return nil, fmt.Errorf("failed to convert %q to []byte", dat)
27+
} else {
28+
return data, nil
29+
}
30+
}
31+
}
32+
33+
func (ic *InmemoryCache) Put(ctx context.Context, key string, data []byte) error {
34+
ic.m.Store(key, data)
35+
return nil
36+
}
37+
38+
func (ic *InmemoryCache) Delete(ctx context.Context, key string) error {
39+
ic.m.Delete(key)
40+
return nil
41+
}
42+
43+
type RemoteCache struct {
44+
Client *RedisRingClient
45+
}
46+
47+
func (rc *RemoteCache) Get(ctx context.Context, key string) ([]byte, error) {
48+
res, err := rc.Client.Get(ctx, key)
49+
if err != nil {
50+
return nil, err
51+
}
52+
return []byte(res), nil
53+
}
54+
55+
func (rc *RemoteCache) Delete(ctx context.Context, key string) error {
56+
return rc.Client.Del(ctx, key)
57+
}
58+
59+
func (rc *RemoteCache) Put(ctx context.Context, key string, val []byte) error {
60+
_, err := rc.Client.Set(ctx, key, val, 0)
61+
return err
62+
}
63+
64+
func (rc *RemoteCache) Close() {
65+
rc.Client.Close()
66+
}
67+
68+
type Letsencrypt struct {
69+
manager *autocert.Manager
70+
}
71+
72+
// NewLetsencrypt creates a letsencrypt handler to automatically handle CSR challenges.
73+
//
74+
// The cache argument can be either
75+
//
76+
// - autocert.DirCache for a filesystem cache
77+
// - inmemoryCache for in memory cache
78+
// - remoteCache for redis based production cache to be shared between multiple skipper processes
79+
func NewLetsencrypt(cache autocert.Cache, email, directoryURL, userAgent string, proposedDomains []string) *Letsencrypt {
80+
domains := make([]string, 0, len(proposedDomains))
81+
for _, s := range proposedDomains {
82+
if validateDomain(s) {
83+
domains = append(domains, s)
84+
}
85+
}
86+
87+
manager := &autocert.Manager{
88+
Cache: cache,
89+
Email: email,
90+
HostPolicy: autocert.HostWhitelist(domains...),
91+
Prompt: autocert.AcceptTOS,
92+
Client: &acme.Client{
93+
DirectoryURL: directoryURL,
94+
UserAgent: userAgent,
95+
HTTPClient: http.DefaultClient,
96+
},
97+
}
98+
99+
return &Letsencrypt{
100+
manager: manager,
101+
}
102+
}
103+
104+
func (le *Letsencrypt) Handler(fallback http.Handler) http.Handler {
105+
return le.manager.HTTPHandler(fallback)
106+
}
107+
108+
func (le *Letsencrypt) TLSConfig() *tls.Config {
109+
return le.manager.TLSConfig()
110+
}
111+
112+
// Listener returns a net.Listener that need to be closed on exit or
113+
// you leak a goroutine
114+
func (le *Letsencrypt) Listener() net.Listener {
115+
return le.manager.Listener()
116+
}
117+
118+
func (le *Letsencrypt) Client() *acme.Client {
119+
return le.manager.Client
120+
}
121+
122+
func (le *Letsencrypt) Close() {
123+
le.Listener().Close()
124+
}
125+
126+
var domainRegex = regexp.MustCompile("^[a-z0-9]+$")
127+
128+
func validateDomain(s string) bool {
129+
i := 0
130+
for w := range strings.SplitSeq(s, ".") {
131+
if !domainRegex.MatchString(w) {
132+
return false
133+
}
134+
i++
135+
}
136+
return i > 1
137+
}

net/letsencrypt_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package net
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
"github.com/zalando/skipper/net/redistest"
9+
)
10+
11+
func TestRemoteCache(t *testing.T) {
12+
t.Logf("create redis..")
13+
redisAddr, done := redistest.NewTestRedis(t)
14+
defer done()
15+
if redisAddr == "" {
16+
t.Fatal("Failed to create redis 1")
17+
}
18+
19+
redisAddr2, done2 := redistest.NewTestRedis(t)
20+
defer done2()
21+
if redisAddr2 == "" {
22+
t.Fatal("Failed to create redis 2")
23+
}
24+
25+
rc := RemoteCache{
26+
Client: NewRedisRingClient(&RedisOptions{
27+
Addrs: []string{redisAddr, redisAddr2},
28+
}),
29+
}
30+
defer rc.Close()
31+
32+
if err := rc.Put(context.Background(), "foo", []byte("bar")); err != nil {
33+
t.Fatalf("Failed to put: %v", err)
34+
}
35+
36+
if v, err := rc.Get(context.Background(), "foo"); err != nil {
37+
t.Fatalf("Failed to get: %v", err)
38+
} else {
39+
t.Logf("%T %v %s", v, v, v)
40+
if string(v) != "bar" {
41+
t.Fatalf("Failed to get result, got: %q", string(v))
42+
}
43+
}
44+
45+
if err := rc.Delete(context.Background(), "foo"); err != nil {
46+
t.Fatalf("Failed to delete: %v", err)
47+
}
48+
}
49+
50+
func TestInmemoryCache(t *testing.T) {
51+
rc := &InmemoryCache{}
52+
53+
if _, err := rc.Get(context.Background(), "foo"); err == nil {
54+
t.Fatal(`Failed can not get "foo" on empty cache`)
55+
}
56+
57+
if err := rc.Put(context.Background(), "foo", []byte("bar")); err != nil {
58+
t.Fatalf("Failed to put: %v", err)
59+
}
60+
61+
if v, err := rc.Get(context.Background(), "foo"); err != nil {
62+
t.Fatalf("Failed to get: %v", err)
63+
} else {
64+
t.Logf("%T %v %s", v, v, v)
65+
}
66+
67+
if err := rc.Delete(context.Background(), "foo"); err != nil {
68+
t.Fatalf("Failed to delete: %v", err)
69+
}
70+
71+
if err := rc.Put(context.Background(), "foo2", []byte("ü")); err != nil {
72+
t.Fatalf("Failed to put: %v", err)
73+
}
74+
75+
if v, err := rc.Get(context.Background(), "foo2"); err != nil {
76+
t.Fatalf("Failed to get: %v", err)
77+
} else {
78+
t.Logf("%T %v %s", v, v, v)
79+
}
80+
81+
}
82+
83+
func TestLetsencrypt(t *testing.T) {
84+
invalidDomain := "s_.example.org"
85+
if validateDomain(invalidDomain) {
86+
t.Fatalf("Failed to validate invalid domain %q", invalidDomain)
87+
}
88+
validDomain := "example.org"
89+
if !validateDomain(validDomain) {
90+
t.Fatalf("Failed to validate valid domain %q", validDomain)
91+
}
92+
93+
le := NewLetsencrypt(&InmemoryCache{}, "skipper@example.org", "https://acme-staging-v02.api.letsencrypt.org/directory", "skipper-test TestLetsencrypt", []string{validDomain})
94+
defer le.Close()
95+
if le.manager.Client != nil {
96+
dir, err := le.manager.Client.Discover(context.TODO())
97+
if err != nil {
98+
t.Fatalf("Failed to discover: %v", err)
99+
}
100+
t.Logf("order: %s", dir.OrderURL)
101+
102+
defer func() {
103+
if le.manager.Client.HTTPClient != nil {
104+
le.manager.Client.HTTPClient.CloseIdleConnections()
105+
}
106+
}()
107+
}
108+
109+
require.NotNil(t, le.Client(), "client should not be nil")
110+
require.NotNil(t, le.TLSConfig(), "TLSConfig should not be nil")
111+
require.NotNil(t, le.Handler(nil), "http.Handler should not be nil")
112+
113+
li := le.Listener()
114+
defer li.Close()
115+
t.Logf("listener %v", li.Addr())
116+
}

net/redisclient.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,11 @@ func (r *RedisRingClient) SetAddrs(ctx context.Context, addrs []string) {
391391
r.ring.SetAddrs(createAddressMap(addrs))
392392
}
393393

394+
func (r *RedisRingClient) Del(ctx context.Context, key string) error {
395+
res := r.ring.Del(ctx, key)
396+
return res.Err()
397+
}
398+
394399
func (r *RedisRingClient) Get(ctx context.Context, key string) (string, error) {
395400
res := r.ring.Get(ctx, key)
396401
return res.Val(), res.Err()

0 commit comments

Comments
 (0)