From a53b27c62e792e09380876db916289c77f2ae2e0 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 9 Oct 2019 19:22:46 -0600 Subject: http: Add work-in-progress cache handler module This migrates a feature that was previously reserved for enterprise users, according to https://github.com/caddyserver/caddy/issues/2786. The cache HTTP handler will be a high-performing, distributed cache layer for HTTP requests. Right now, the implementation is a very basic proof-of-concept, and further development is required. --- modules/caddyhttp/httpcache/httpcache.go | 205 +++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 modules/caddyhttp/httpcache/httpcache.go (limited to 'modules') diff --git a/modules/caddyhttp/httpcache/httpcache.go b/modules/caddyhttp/httpcache/httpcache.go new file mode 100644 index 0000000..1b2cfd2 --- /dev/null +++ b/modules/caddyhttp/httpcache/httpcache.go @@ -0,0 +1,205 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpcache + +import ( + "bytes" + "encoding/gob" + "fmt" + "io" + "log" + "net/http" + "sync" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" + "github.com/golang/groupcache" +) + +func init() { + caddy.RegisterModule(Cache{}) +} + +// Cache implements a simple distributed cache. +type Cache struct { + group *groupcache.Group +} + +// CaddyModule returns the Caddy module information. +func (Cache) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + Name: "http.handlers.cache", + New: func() caddy.Module { return new(Cache) }, + } +} + +// Provision provisions c. +func (c *Cache) Provision(ctx caddy.Context) error { + // TODO: proper pool configuration + me := "http://localhost:5555" + // TODO: Make max size configurable + maxSize := int64(512 << 20) + poolMu.Lock() + if pool == nil { + pool = groupcache.NewHTTPPool(me) + c.group = groupcache.NewGroup(groupName, maxSize, groupcache.GetterFunc(c.getter)) + } else { + c.group = groupcache.GetGroup(groupName) + } + pool.Set(me) + poolMu.Unlock() + + return nil +} + +// Validate validates c. +func (c *Cache) Validate() error { + // TODO: implement + return nil +} + +func (c *Cache) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { + // TODO: proper RFC implementation of cache control headers... + if r.Header.Get("Cache-Control") == "no-cache" || r.Method != "GET" { + return next.ServeHTTP(w, r) + } + + ctx := getterContext{w, r, next} + + // TODO: rigorous performance testing + + // TODO: pretty much everything else to handle the nuances of HTTP caching... + + // TODO: groupcache has no explicit cache eviction, so we need to embed + // all information related to expiring cache entries into the key; right + // now we just use the request URI as a proof-of-concept + key := r.RequestURI + + var cachedBytes []byte + err := c.group.Get(ctx, key, groupcache.AllocatingByteSliceSink(&cachedBytes)) + if err == errUncacheable { + return nil + } + if err != nil { + return err + } + + // the cached bytes consists of two parts: first a + // gob encoding of the status and header, immediately + // followed by the raw bytes of the response body + rdr := bytes.NewReader(cachedBytes) + + // read the header and status first + var hs headerAndStatus + err = gob.NewDecoder(rdr).Decode(&hs) + if err != nil { + return err + } + + // set and write the cached headers + for k, v := range hs.Header { + w.Header()[k] = v + } + w.WriteHeader(hs.Status) + + // write the cached response body + io.Copy(w, rdr) + + return nil +} + +func (c *Cache) getter(ctx groupcache.Context, key string, dest groupcache.Sink) error { + combo := ctx.(getterContext) + + // the buffer will store the gob-encoded header, then the body + buf := bufPool.Get().(*bytes.Buffer) + buf.Reset() + defer bufPool.Put(buf) + + // we need to record the response if we are to cache it; only cache if + // request is successful (TODO: there's probably much more nuance needed here) + var rr caddyhttp.ResponseRecorder + rr = caddyhttp.NewResponseRecorder(combo.rw, buf, func(status int) bool { + shouldBuf := status < 300 + + if shouldBuf { + // store the header before the body, so we can efficiently + // and conveniently use a single buffer for both; gob + // decoder will only read up to end of gob message, and + // the rest will be the body, which will be written + // implicitly for us by the recorder + err := gob.NewEncoder(buf).Encode(headerAndStatus{ + Header: rr.Header(), + Status: status, + }) + if err != nil { + log.Printf("[ERROR] Encoding headers for cache entry: %v; not caching this request", err) + return false + } + } + + return shouldBuf + }) + + // execute next handlers in chain + err := combo.next.ServeHTTP(rr, combo.req) + if err != nil { + return err + } + + // if response body was not buffered, response was + // already written and we are unable to cache + if !rr.Buffered() { + return errUncacheable + } + + // add to cache + dest.SetBytes(buf.Bytes()) + + return nil +} + +type headerAndStatus struct { + Header http.Header + Status int +} + +type getterContext struct { + rw http.ResponseWriter + req *http.Request + next caddyhttp.Handler +} + +var bufPool = sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, +} + +var ( + pool *groupcache.HTTPPool + poolMu sync.Mutex +) + +var errUncacheable = fmt.Errorf("uncacheable") + +const groupName = "http_requests" + +// Interface guards +var ( + _ caddy.Provisioner = (*Cache)(nil) + _ caddy.Validator = (*Cache)(nil) + _ caddyhttp.MiddlewareHandler = (*Cache)(nil) +) -- cgit v1.2.3 From bcbe1c220de99146a13bcc786ec7f5017681de73 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 9 Oct 2019 19:28:07 -0600 Subject: reverse_proxy: Add local circuit breaker This migrates a feature that was previously reserved for enterprise users, according to https://github.com/caddyserver/caddy/issues/2786. The local circuit breaker is a simple metrics counter that can cause the reverse proxy to consider a backend unhealthy before it actually goes offline, by measuring recent latencies over a sliding window. Credit to Danny Navarro --- modules/caddyhttp/reverseproxy/circuitbreaker.go | 152 +++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 modules/caddyhttp/reverseproxy/circuitbreaker.go (limited to 'modules') diff --git a/modules/caddyhttp/reverseproxy/circuitbreaker.go b/modules/caddyhttp/reverseproxy/circuitbreaker.go new file mode 100644 index 0000000..de2a6f9 --- /dev/null +++ b/modules/caddyhttp/reverseproxy/circuitbreaker.go @@ -0,0 +1,152 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package reverseproxy + +import ( + "fmt" + "sync/atomic" + "time" + + "github.com/caddyserver/caddy/v2" + "github.com/vulcand/oxy/memmetrics" +) + +func init() { + caddy.RegisterModule(localCircuitBreaker{}) +} + +// localCircuitBreaker implements circuit breaking functionality +// for requests within this process over a sliding time window. +type localCircuitBreaker struct { + tripped int32 + cbType int32 + threshold float64 + metrics *memmetrics.RTMetrics + tripTime time.Duration + Config +} + +// CaddyModule returns the Caddy module information. +func (localCircuitBreaker) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + Name: "http.handlers.reverse_proxy.circuit_breakers.local", + New: func() caddy.Module { return new(localCircuitBreaker) }, + } +} + +// Provision sets up a configured circuit breaker. +func (c *localCircuitBreaker) Provision(ctx caddy.Context) error { + t, ok := typeCB[c.Type] + if !ok { + return fmt.Errorf("type is not defined") + } + + if c.TripTime == "" { + c.TripTime = defaultTripTime + } + + tw, err := time.ParseDuration(c.TripTime) + if err != nil { + return fmt.Errorf("cannot parse trip_time duration, %v", err.Error()) + } + + mt, err := memmetrics.NewRTMetrics() + if err != nil { + return fmt.Errorf("cannot create new metrics: %v", err.Error()) + } + + c.cbType = t + c.tripTime = tw + c.threshold = c.Threshold + c.metrics = mt + c.tripped = 0 + + return nil +} + +// Ok returns whether the circuit breaker is tripped or not. +func (c *localCircuitBreaker) Ok() bool { + tripped := atomic.LoadInt32(&c.tripped) + return tripped == 0 +} + +// RecordMetric records a response status code and execution time of a request. This function should be run in a separate goroutine. +func (c *localCircuitBreaker) RecordMetric(statusCode int, latency time.Duration) { + c.metrics.Record(statusCode, latency) + c.checkAndSet() +} + +// Ok checks our metrics to see if we should trip our circuit breaker, or if the fallback duration has completed. +func (c *localCircuitBreaker) checkAndSet() { + var isTripped bool + + switch c.cbType { + case typeErrorRatio: + // check if amount of network errors exceed threshold over sliding window, threshold for comparison should be < 1.0 i.e. .5 = 50th percentile + if c.metrics.NetworkErrorRatio() > c.threshold { + isTripped = true + } + case typeLatency: + // check if threshold in milliseconds is reached and trip + hist, err := c.metrics.LatencyHistogram() + if err != nil { + return + } + + l := hist.LatencyAtQuantile(c.threshold) + if l.Nanoseconds()/int64(time.Millisecond) > int64(c.threshold) { + isTripped = true + } + case typeStatusCodeRatio: + // check ratio of error status codes of sliding window, threshold for comparison should be < 1.0 i.e. .5 = 50th percentile + if c.metrics.ResponseCodeRatio(500, 600, 0, 600) > c.threshold { + isTripped = true + } + } + + if isTripped { + c.metrics.Reset() + atomic.AddInt32(&c.tripped, 1) + + // wait tripTime amount before allowing operations to resume. + t := time.NewTimer(c.tripTime) + <-t.C + + atomic.AddInt32(&c.tripped, -1) + } +} + +// Config represents the configuration of a circuit breaker. +type Config struct { + Threshold float64 `json:"threshold"` + Type string `json:"type"` + TripTime string `json:"trip_time"` +} + +const ( + typeLatency = iota + 1 + typeErrorRatio + typeStatusCodeRatio + defaultTripTime = "5s" +) + +var ( + // typeCB handles converting a Config Type value to the internal circuit breaker types. + typeCB = map[string]int32{ + "latency": typeLatency, + "error_ratio": typeErrorRatio, + "status_ratio": typeStatusCodeRatio, + } +) -- cgit v1.2.3 From 20fe9cf024898c73725eab0d4306dfd3b6ccd6d8 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 9 Oct 2019 19:34:14 -0600 Subject: tls: Add pem_loader module This migrates a feature that was previously reserved for enterprise users, according to https://github.com/caddyserver/caddy/issues/2786. The PEM loader allows you to embed PEM files (certificates and keys) directly into your config, rather than requiring them to be stored on potentially insecure storage, which adds attack vectors. This is useful in automated settings where sensitive key material is stored only in memory. Note that if the config is persisted to disk, that added benefit may go away, but there will still be the benefit of having lesser dependence on external files. --- modules/caddytls/pemloader.go | 65 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 modules/caddytls/pemloader.go (limited to 'modules') diff --git a/modules/caddytls/pemloader.go b/modules/caddytls/pemloader.go new file mode 100644 index 0000000..30a491c --- /dev/null +++ b/modules/caddytls/pemloader.go @@ -0,0 +1,65 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package caddytls + +import ( + "crypto/tls" + "fmt" + + "github.com/caddyserver/caddy/v2" +) + +func init() { + caddy.RegisterModule(PEMLoader{}) +} + +// PEMLoader loads certificates and their associated keys by +// decoding their PEM blocks directly. This has the advantage +// of not needing to store them on disk at all. +type PEMLoader []CertKeyPEMPair + +// CaddyModule returns the Caddy module information. +func (PEMLoader) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + Name: "tls.certificates.load_pem", + New: func() caddy.Module { return PEMLoader{} }, + } +} + +// CertKeyPEMPair pairs certificate and key PEM blocks. +type CertKeyPEMPair struct { + CertificatePEM string `json:"certificate"` + KeyPEM string `json:"key"` + Tags []string `json:"tags,omitempty"` +} + +// LoadCertificates returns the certificates contained in pl. +func (pl PEMLoader) LoadCertificates() ([]Certificate, error) { + var certs []Certificate + for i, pair := range pl { + cert, err := tls.X509KeyPair([]byte(pair.CertificatePEM), []byte(pair.KeyPEM)) + if err != nil { + return nil, fmt.Errorf("PEM pair %d: %v", i, err) + } + certs = append(certs, Certificate{ + Certificate: cert, + Tags: pair.Tags, + }) + } + return certs, nil +} + +// Interface guard +var _ CertificateLoader = (PEMLoader)(nil) -- cgit v1.2.3 From dedcfd4e3da1297cda2d09776e7c8135e0d960ce Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 9 Oct 2019 19:38:26 -0600 Subject: tls: Add distributed_stek module This migrates a feature that was previously reserved for enterprise users, according to https://github.com/caddyserver/caddy/issues/2786. TLS session ticket keys are sensitive, so they should be rotated on a regular basis. Only Caddy does this by default. However, a cluster of servers that rotate keys without synchronization will lose the benefits of having sessions in the first place if the client is routed to a different backend. This module coordinates STEK rotation in a fleet so the same keys are used, and rotated, across the whole cluster. No other server does this, but Twitter wrote about how they hacked together a solution a few years ago: https://blog.twitter.com/engineering/en_us/a/2013/forward-secrecy-at-twitter.html --- .../caddytls/distributedstek/distributedstek.go | 228 +++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 modules/caddytls/distributedstek/distributedstek.go (limited to 'modules') diff --git a/modules/caddytls/distributedstek/distributedstek.go b/modules/caddytls/distributedstek/distributedstek.go new file mode 100644 index 0000000..a0c4cd2 --- /dev/null +++ b/modules/caddytls/distributedstek/distributedstek.go @@ -0,0 +1,228 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package distributedstek provides TLS session ticket ephemeral +// keys (STEKs) in a distributed fashion by utilizing configured +// storage for locking and key sharing. This allows a cluster of +// machines to optimally resume TLS sessions in a load-balanced +// environment without any hassle. This is similar to what +// Twitter does, but without needing to rely on SSH, as it is +// built into the web server this way: +// https://blog.twitter.com/engineering/en_us/a/2013/forward-secrecy-at-twitter.html +package distributedstek + +import ( + "bytes" + "encoding/gob" + "encoding/json" + "fmt" + "log" + "time" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddytls" + "github.com/mholt/certmagic" +) + +func init() { + caddy.RegisterModule(Provider{}) +} + +// Provider implements a distributed STEK provider. +type Provider struct { + Storage json.RawMessage `json:"storage,omitempty"` + + storage certmagic.Storage + stekConfig *caddytls.SessionTicketService + timer *time.Timer +} + +// CaddyModule returns the Caddy module information. +func (Provider) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + Name: "tls.stek.distributed", + New: func() caddy.Module { return new(Provider) }, + } +} + +// Provision provisions s. +func (s *Provider) Provision(ctx caddy.Context) error { + // unpack the storage module to use, if different from the default + if s.Storage != nil { + val, err := ctx.LoadModuleInline("module", "caddy.storage", s.Storage) + if err != nil { + return fmt.Errorf("loading TLS storage module: %s", err) + } + cmStorage, err := val.(caddy.StorageConverter).CertMagicStorage() + if err != nil { + return fmt.Errorf("creating TLS storage configuration: %v", err) + } + s.storage = cmStorage + s.Storage = nil // allow GC to deallocate + } + + // otherwise, use default storage + if s.storage == nil { + s.storage = ctx.Storage() + } + + return nil +} + +// Initialize sets the configuration for s and returns the starting keys. +func (s *Provider) Initialize(config *caddytls.SessionTicketService) ([][32]byte, error) { + // keep a reference to the config; we'll need it when rotating keys + s.stekConfig = config + + dstek, err := s.getSTEK() + if err != nil { + return nil, err + } + + // create timer for the remaining time on the interval; + // this timer is cleaned up only when rotate() returns + s.timer = time.NewTimer(time.Until(dstek.NextRotation)) + + return dstek.Keys, nil +} + +// Next returns a channel which transmits the latest session ticket keys. +func (s *Provider) Next(doneChan <-chan struct{}) <-chan [][32]byte { + keysChan := make(chan [][32]byte) + go s.rotate(doneChan, keysChan) + return keysChan +} + +func (s *Provider) loadSTEK() (distributedSTEK, error) { + var sg distributedSTEK + gobBytes, err := s.storage.Load(stekFileName) + if err != nil { + return sg, err // don't wrap, in case error is certmagic.ErrNotExist + } + dec := gob.NewDecoder(bytes.NewReader(gobBytes)) + err = dec.Decode(&sg) + if err != nil { + return sg, fmt.Errorf("STEK gob corrupted: %v", err) + } + return sg, nil +} + +func (s *Provider) storeSTEK(dstek distributedSTEK) error { + var buf bytes.Buffer + err := gob.NewEncoder(&buf).Encode(dstek) + if err != nil { + return fmt.Errorf("encoding STEK gob: %v", err) + } + err = s.storage.Store(stekFileName, buf.Bytes()) + if err != nil { + return fmt.Errorf("storing STEK gob: %v", err) + } + return nil +} + +// getSTEK locks and loads the current STEK from storage. If none +// currently exists, a new STEK is created and persisted. If the +// current STEK is outdated (NextRotation time is in the past), +// then it is rotated and persisted. The resulting STEK is returned. +func (s *Provider) getSTEK() (distributedSTEK, error) { + s.storage.Lock(stekLockName) + defer s.storage.Unlock(stekLockName) + + // load the current STEKs from storage + dstek, err := s.loadSTEK() + if _, isNotExist := err.(certmagic.ErrNotExist); isNotExist { + // if there is none, then make some right away + dstek, err = s.rotateKeys(dstek) + if err != nil { + return dstek, fmt.Errorf("creating new STEK: %v", err) + } + } else if err != nil { + // some other error, that's a problem + return dstek, fmt.Errorf("loading STEK: %v", err) + } else if time.Now().After(dstek.NextRotation) { + // if current STEKs are outdated, rotate them + dstek, err = s.rotateKeys(dstek) + if err != nil { + return dstek, fmt.Errorf("rotating keys: %v", err) + } + } + + return dstek, nil +} + +// rotateKeys rotates the keys of oldSTEK and returns the new distributedSTEK +// with updated keys and timestamps. It stores the returned STEK in storage, +// so this function must only be called in a storage-provided lock. +func (s *Provider) rotateKeys(oldSTEK distributedSTEK) (distributedSTEK, error) { + var newSTEK distributedSTEK + var err error + + newSTEK.Keys, err = s.stekConfig.RotateSTEKs(oldSTEK.Keys) + if err != nil { + return newSTEK, err + } + + now := time.Now() + newSTEK.LastRotation = now + newSTEK.NextRotation = now.Add(time.Duration(s.stekConfig.RotationInterval)) + + err = s.storeSTEK(newSTEK) + if err != nil { + return newSTEK, err + } + + return newSTEK, nil +} + +// rotate rotates keys on a regular basis, sending each updated set of +// keys down keysChan, until doneChan is closed. +func (s *Provider) rotate(doneChan <-chan struct{}, keysChan chan<- [][32]byte) { + for { + select { + case <-s.timer.C: + dstek, err := s.getSTEK() + if err != nil { + // TODO: improve this handling + log.Printf("[ERROR] Loading STEK: %v", err) + continue + } + + // send the updated keys to the service + keysChan <- dstek.Keys + + // timer channel is already drained, so reset directly (see godoc) + s.timer.Reset(time.Until(dstek.NextRotation)) + + case <-doneChan: + // again, see godocs for why timer is stopped this way + if !s.timer.Stop() { + <-s.timer.C + } + return + } + } +} + +type distributedSTEK struct { + Keys [][32]byte + LastRotation, NextRotation time.Time +} + +const ( + stekLockName = "stek_check" + stekFileName = "stek/stek.bin" +) + +// Interface guard +var _ caddytls.STEKProvider = (*Provider)(nil) -- cgit v1.2.3 From 85ce15a5ad0c0a0e24ed026b1f06a5a573c74283 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 9 Oct 2019 19:41:45 -0600 Subject: tls: Add custom certificate selection policy This migrates a feature that was previously reserved for enterprise users, according to https://github.com/caddyserver/caddy/issues/2786. Custom certificate selection policies allow advanced control over which cert is selected when multiple qualify to satisfy a TLS handshake. --- modules/caddytls/certselection.go | 71 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 modules/caddytls/certselection.go (limited to 'modules') diff --git a/modules/caddytls/certselection.go b/modules/caddytls/certselection.go new file mode 100644 index 0000000..b56185a --- /dev/null +++ b/modules/caddytls/certselection.go @@ -0,0 +1,71 @@ +package caddytls + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "math/big" + + "github.com/caddyserver/caddy/v2" + "github.com/mholt/certmagic" +) + +func init() { + caddy.RegisterModule(Policy{}) +} + +// Policy represents a policy for selecting the certificate used to +// complete a handshake when there may be multiple options. All fields +// specified must match the candidate certificate for it to be chosen. +// This was needed to solve https://github.com/caddyserver/caddy/issues/2588. +type Policy struct { + SerialNumber *big.Int `json:"serial_number,omitempty"` + SubjectOrganization string `json:"subject_organization,omitempty"` + PublicKeyAlgorithm PublicKeyAlgorithm `json:"public_key_algorithm,omitempty"` + Tag string `json:"tag,omitempty"` +} + +// CaddyModule returns the Caddy module information. +func (Policy) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + Name: "tls.certificate_selection.custom", + New: func() caddy.Module { return new(Policy) }, + } +} + +// SelectCertificate implements certmagic.CertificateSelector. +func (p Policy) SelectCertificate(_ *tls.ClientHelloInfo, choices []certmagic.Certificate) (certmagic.Certificate, error) { + for _, cert := range choices { + if p.SerialNumber != nil && cert.SerialNumber.Cmp(p.SerialNumber) != 0 { + continue + } + + if p.PublicKeyAlgorithm != PublicKeyAlgorithm(x509.UnknownPublicKeyAlgorithm) && + PublicKeyAlgorithm(cert.PublicKeyAlgorithm) != p.PublicKeyAlgorithm { + continue + } + + if p.SubjectOrganization != "" { + var matchOrg bool + for _, org := range cert.Subject.Organization { + if p.SubjectOrganization == org { + matchOrg = true + break + } + } + if !matchOrg { + continue + } + } + + if p.Tag != "" && !cert.HasTag(p.Tag) { + continue + } + + return cert, nil + } + return certmagic.Certificate{}, fmt.Errorf("no certificates matched custom selection policy") +} + +// Interface guard +var _ certmagic.CertificateSelector = (*Policy)(nil) -- cgit v1.2.3 From 26cc8837084f9cea6057e9908f0b5bde0eb15d3e Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Thu, 10 Oct 2019 11:02:16 -0600 Subject: http: Add Starlark handler This migrates a feature that was previously reserved for enterprise users, according to #2786. The Starlark integration needs to be updated since this was made before some significant changes in the v2 code base. When functional, it makes it possible to have very dynamic HTTP handlers. This will be a long-term ongoing project. Credit to Danny Navarro --- modules/caddyhttp/starlarkmw/example/caddy.json | 19 +++ .../caddyhttp/starlarkmw/internal/lib/module.go | 165 ++++++++++++++++++++ modules/caddyhttp/starlarkmw/starlarkmw.go | 172 +++++++++++++++++++++ .../caddyhttp/starlarkmw/tools/gen/example.star | 40 +++++ 4 files changed, 396 insertions(+) create mode 100644 modules/caddyhttp/starlarkmw/example/caddy.json create mode 100644 modules/caddyhttp/starlarkmw/internal/lib/module.go create mode 100644 modules/caddyhttp/starlarkmw/starlarkmw.go create mode 100644 modules/caddyhttp/starlarkmw/tools/gen/example.star (limited to 'modules') diff --git a/modules/caddyhttp/starlarkmw/example/caddy.json b/modules/caddyhttp/starlarkmw/example/caddy.json new file mode 100644 index 0000000..66f9f2c --- /dev/null +++ b/modules/caddyhttp/starlarkmw/example/caddy.json @@ -0,0 +1,19 @@ +{ + "apps": { + "http": { + "servers": { + "MY_SERVER": { + "listen": [":3001"], + "routes": [ + { + "handle": { + "handler": "starlark", + "script": "def setup(r):\n\t# create some middlewares specific to this request\n\ttemplates = loadModule('http.handlers.templates', {'include_root': './includes'})\n\tmidChain = execute([templates])\n\ndef serveHTTP (rw, r):\n\trw.Write('Hello world, from Starlark!')\n" + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/modules/caddyhttp/starlarkmw/internal/lib/module.go b/modules/caddyhttp/starlarkmw/internal/lib/module.go new file mode 100644 index 0000000..a7164cd --- /dev/null +++ b/modules/caddyhttp/starlarkmw/internal/lib/module.go @@ -0,0 +1,165 @@ +package lib + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/caddyserver/caddy/v2/modules/caddyhttp" + + "github.com/caddyserver/caddy/v2" + "go.starlark.net/starlark" +) + +// ResponderModule represents a module that satisfies the caddyhttp handler. +type ResponderModule struct { + Name string + Cfg json.RawMessage + Instance caddyhttp.Handler +} + +func (r ResponderModule) Freeze() {} +func (r ResponderModule) Hash() (uint32, error) { return 0, fmt.Errorf("unhashable: responder module") } +func (r ResponderModule) String() string { return "responder module" } +func (r ResponderModule) Type() string { return "responder module" } +func (r ResponderModule) Truth() starlark.Bool { return true } + +// Middleware represents a module that satisfies the starlark Value interface. +type Middleware struct { + Name string + Cfg json.RawMessage + Instance caddyhttp.MiddlewareHandler +} + +func (r Middleware) Freeze() {} +func (r Middleware) Hash() (uint32, error) { return 0, fmt.Errorf("unhashable: middleware") } +func (r Middleware) String() string { return "middleware" } +func (r Middleware) Type() string { return "middleware" } +func (r Middleware) Truth() starlark.Bool { return true } + +// LoadMiddleware represents the method exposed to starlark to load a Caddy module. +type LoadMiddleware struct { + Middleware Middleware + Ctx caddy.Context +} + +func (r LoadMiddleware) Freeze() {} +func (r LoadMiddleware) Hash() (uint32, error) { return 0, fmt.Errorf("unhashable: loadMiddleware") } +func (r LoadMiddleware) String() string { return "loadMiddleware" } +func (r LoadMiddleware) Type() string { return "function: loadMiddleware" } +func (r LoadMiddleware) Truth() starlark.Bool { return true } + +// Run is the method bound to the starlark loadMiddleware function. +func (r *LoadMiddleware) Run(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var name string + var cfg *starlark.Dict + err := starlark.UnpackPositionalArgs(fn.Name(), args, kwargs, 2, &name, &cfg) + if err != nil { + return starlark.None, fmt.Errorf("unpacking arguments: %v", err.Error()) + } + + js := json.RawMessage(cfg.String()) + + if strings.Index(name, "http.handlers.") == -1 { + name = fmt.Sprintf("http.handlers.%s", name) + } + + inst, err := r.Ctx.LoadModule(name, js) + if err != nil { + return starlark.None, err + } + + mid, ok := inst.(caddyhttp.MiddlewareHandler) + if !ok { + return starlark.None, fmt.Errorf("could not assert as middleware handler") + } + + m := Middleware{ + Name: name, + Cfg: js, + Instance: mid, + } + + r.Middleware = m + + return m, nil +} + +// LoadResponder represents the method exposed to starlark to load a Caddy middleware responder. +type LoadResponder struct { + Module ResponderModule + Ctx caddy.Context +} + +func (r LoadResponder) Freeze() {} +func (r LoadResponder) Hash() (uint32, error) { return 0, fmt.Errorf("unhashable: loadModule") } +func (r LoadResponder) String() string { return "loadModule" } +func (r LoadResponder) Type() string { return "function: loadModule" } +func (r LoadResponder) Truth() starlark.Bool { return true } + +// Run is the method bound to the starlark loadResponder function. +func (r *LoadResponder) Run(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var name string + var cfg *starlark.Dict + err := starlark.UnpackPositionalArgs(fn.Name(), args, kwargs, 2, &name, &cfg) + if err != nil { + return starlark.None, fmt.Errorf("unpacking arguments: %v", err.Error()) + } + + js := json.RawMessage(cfg.String()) + + if strings.Index(name, "http.handlers.") == -1 { + name = fmt.Sprintf("http.handlers.%s", name) + } + + inst, err := r.Ctx.LoadModule(name, js) + if err != nil { + return starlark.None, err + } + + res, ok := inst.(caddyhttp.Handler) + if !ok { + return starlark.None, fmt.Errorf("could not assert as responder") + } + + m := ResponderModule{ + Name: name, + Cfg: js, + Instance: res, + } + + r.Module = m + + return m, nil +} + +// Execute represents the method exposed to starlark to build a middleware chain. +type Execute struct { + Modules []Middleware +} + +func (r Execute) Freeze() {} +func (r Execute) Hash() (uint32, error) { return 0, fmt.Errorf("unhashable: execute") } +func (r Execute) String() string { return "execute" } +func (r Execute) Type() string { return "function: execute" } +func (r Execute) Truth() starlark.Bool { return true } + +// Run is the method bound to the starlark execute function. +func (r *Execute) Run(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var mids *starlark.List + err := starlark.UnpackPositionalArgs(fn.Name(), args, kwargs, 1, &mids) + if err != nil { + return starlark.None, fmt.Errorf("unpacking arguments: %v", err.Error()) + } + + for i := 0; i < mids.Len(); i++ { + val, ok := mids.Index(i).(Middleware) + if !ok { + return starlark.None, fmt.Errorf("cannot get module from execute") + } + + r.Modules = append(r.Modules, val) + } + + return starlark.None, nil +} diff --git a/modules/caddyhttp/starlarkmw/starlarkmw.go b/modules/caddyhttp/starlarkmw/starlarkmw.go new file mode 100644 index 0000000..007ddb4 --- /dev/null +++ b/modules/caddyhttp/starlarkmw/starlarkmw.go @@ -0,0 +1,172 @@ +package starlarkmw + +import ( + "context" + "fmt" + "net/http" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" + caddyscript "github.com/caddyserver/caddy/v2/pkg/caddyscript/lib" + "github.com/caddyserver/caddy/v2/modules/caddyhttp/starlarkmw/internal/lib" + "github.com/starlight-go/starlight/convert" + "go.starlark.net/starlark" +) + +func init() { + caddy.RegisterModule(StarlarkMW{}) +} + +// StarlarkMW represents a middleware responder written in starlark +type StarlarkMW struct { + Script string `json:"script"` + serveHTTP *starlark.Function + setup *starlark.Function + thread *starlark.Thread + loadMiddleware *lib.LoadMiddleware + execute *lib.Execute + globals *starlark.StringDict + gctx caddy.Context + rctx caddy.Context + rcancel context.CancelFunc +} + +// CaddyModule returns the Caddy module information. +func (StarlarkMW) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + Name: "http.handlers.starlark", + New: func() caddy.Module { return new(StarlarkMW) }, + } +} + +// ServeHTTP responds to an http request with starlark. +func (s *StarlarkMW) ServeHTTP(w http.ResponseWriter, r *http.Request) error { + var mwcancel context.CancelFunc + var mwctx caddy.Context + + // call setup() to prepare the middleware chain if it is defined + if s.setup != nil { + mwctx, mwcancel = caddy.NewContext(s.gctx) + defer mwcancel() + + s.loadMiddleware.Ctx = mwctx + args := starlark.Tuple{caddyscript.HTTPRequest{Req: r}} + + _, err := starlark.Call(new(starlark.Thread), s.setup, args, nil) + if err != nil { + return fmt.Errorf("starlark setup(), %v", err) + } + } + + // dynamically build middleware chain for each request + stack := caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + wr, err := convert.ToValue(w) + if err != nil { + return fmt.Errorf("cannot convert response writer to starlark value") + } + + args := starlark.Tuple{wr, caddyscript.HTTPRequest{Req: r}} + v, err := starlark.Call(new(starlark.Thread), s.serveHTTP, args, nil) + if err != nil { + return fmt.Errorf("starlark serveHTTP(), %v", err) + } + + // if a responder type was returned from starlark we should run it otherwise it + // is expected to handle the request + if resp, ok := v.(lib.ResponderModule); ok { + return resp.Instance.ServeHTTP(w, r) + } + + return nil + }) + + // TODO :- make middlewareResponseWriter exported and wrap w with that + var mid []caddyhttp.Middleware + for _, m := range s.execute.Modules { + mid = append(mid, func(next caddyhttp.HandlerFunc) caddyhttp.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + return m.Instance.ServeHTTP(w, r, next) + } + }) + } + + for i := len(mid) - 1; i >= 0; i-- { + stack = mid[i](stack) + } + + s.execute.Modules = nil + + return stack(w, r) +} + +// Cleanup cleans up any modules loaded during the creation of a starlark route. +func (s *StarlarkMW) Cleanup() error { + s.rcancel() + return nil +} + +// Provision sets up the starlark env. +func (s *StarlarkMW) Provision(ctx caddy.Context) error { + // store global context + s.gctx = ctx + + // setup context for cleaning up any modules loaded during starlark script parsing phase + rctx, cancel := caddy.NewContext(ctx) + s.rcancel = cancel + + // setup starlark global env + env := make(starlark.StringDict) + loadMiddleware := lib.LoadMiddleware{} + loadResponder := lib.LoadResponder{ + Ctx: rctx, + } + execute := lib.Execute{} + + lr := starlark.NewBuiltin("loadResponder", loadResponder.Run) + lr = lr.BindReceiver(&loadResponder) + env["loadResponder"] = lr + + lm := starlark.NewBuiltin("loadMiddleware", loadMiddleware.Run) + lm = lm.BindReceiver(&loadMiddleware) + env["loadMiddleware"] = lm + + ex := starlark.NewBuiltin("execute", execute.Run) + ex = ex.BindReceiver(&execute) + env["execute"] = ex + + // import caddyscript lib + env["time"] = caddyscript.Time{} + env["regexp"] = caddyscript.Regexp{} + + // configure starlark + thread := new(starlark.Thread) + s.thread = thread + + // run starlark script + globals, err := starlark.ExecFile(thread, "", s.Script, env) + if err != nil { + return fmt.Errorf("starlark exec file: %v", err.Error()) + } + + // extract defined methods to setup middleware chain and responder, setup is not required + var setup *starlark.Function + if _, ok := globals["setup"]; ok { + setup, ok = globals["setup"].(*starlark.Function) + if !ok { + return fmt.Errorf("setup function not defined in starlark script") + } + } + + serveHTTP, ok := globals["serveHTTP"].(*starlark.Function) + if !ok { + return fmt.Errorf("serveHTTP function not defined in starlark script") + } + + s.setup = setup + s.serveHTTP = serveHTTP + s.loadMiddleware = &loadMiddleware + s.execute = &execute + s.globals = &globals + + return nil +} diff --git a/modules/caddyhttp/starlarkmw/tools/gen/example.star b/modules/caddyhttp/starlarkmw/tools/gen/example.star new file mode 100644 index 0000000..6ccab32 --- /dev/null +++ b/modules/caddyhttp/starlarkmw/tools/gen/example.star @@ -0,0 +1,40 @@ +# any module that provisions resources +proxyConfig = { + 'load_balance_type': 'round_robin', + 'upstreams': [ + { + 'host': 'http://localhost:8080', + 'circuit_breaker': { + 'type': 'status_ratio', + 'threshold': 0.5 + } + }, + { + 'host': 'http://localhost:8081' + } + ] +} + +sfConfig = { + 'root': '/Users/dev/Desktop', + 'browse': {}, +} + +proxy = loadResponder('reverse_proxy', proxyConfig) +static_files = loadResponder('file_server', sfConfig) + +def setup(r): + # create some middlewares specific to this request + mid = [] + + if r.query.get('log') == 'true': + logMid = loadMiddleware('log', {'file': 'access.log'}) + mid.append(logMid) + + execute(mid) + +def serveHTTP(w, r): + if r.url.find('static') > 0: + return static_files + + return proxy -- cgit v1.2.3