map: Reimplement; multiple outputs; optimize

This commit is contained in:
Matthew Holt 2020-10-02 14:23:40 -06:00
parent 023d702f30
commit 25d2b4bf29
6 changed files with 246 additions and 147 deletions

View file

@ -75,10 +75,10 @@ func parseBind(h Helper) ([]ConfigValue, error) {
// load <paths...> // load <paths...>
// ca <acme_ca_endpoint> // ca <acme_ca_endpoint>
// ca_root <pem_file> // ca_root <pem_file>
// dns <provider_name> // dns <provider_name> [...]
// on_demand // on_demand
// eab <key_id> <mac_key> // eab <key_id> <mac_key>
// issuer <module_name> ... // issuer <module_name> [...]
// } // }
// //
func parseTLS(h Helper) ([]ConfigValue, error) { func parseTLS(h Helper) ([]ConfigValue, error) {

View file

@ -430,7 +430,7 @@ func (tc *Tester) AssertResponse(req *http.Request, expectedStatusCode int, expe
body := string(bytes) body := string(bytes)
if !strings.Contains(body, expectedBody) { if body != expectedBody {
tc.t.Errorf("requesting \"%s\" expected response body \"%s\" but got \"%s\"", req.RequestURI, expectedBody, body) tc.t.Errorf("requesting \"%s\" expected response body \"%s\" but got \"%s\"", req.RequestURI, expectedBody, body)
} }

View file

@ -8,7 +8,6 @@ import (
) )
func TestMap(t *testing.T) { func TestMap(t *testing.T) {
// arrange // arrange
tester := caddytest.NewTester(t) tester := caddytest.NewTester(t)
tester.InitServer(`{ tester.InitServer(`{
@ -18,25 +17,24 @@ func TestMap(t *testing.T) {
localhost:9080 { localhost:9080 {
map http.request.method dest-name { map {http.request.method} {dest-1} {dest-2} {
default unknown default unknown
G.T get-called ~G.T get-called
POST post-called POST post-called foobar
} }
respond /version 200 { respond /version 200 {
body "hello from localhost {dest-name}" body "hello from localhost {dest-1} {dest-2}"
} }
} }
`, "caddyfile") `, "caddyfile")
// act and assert // act and assert
tester.AssertGetResponse("http://localhost:9080/version", 200, "hello from localhost get-called") tester.AssertGetResponse("http://localhost:9080/version", 200, "hello from localhost get-called ")
tester.AssertPostResponseBody("http://localhost:9080/version", []string{}, bytes.NewBuffer([]byte{}), 200, "hello from localhost post-called") tester.AssertPostResponseBody("http://localhost:9080/version", []string{}, bytes.NewBuffer([]byte{}), 200, "hello from localhost post-called foobar")
} }
func TestMapRespondWithDefault(t *testing.T) { func TestMapRespondWithDefault(t *testing.T) {
// arrange // arrange
tester := caddytest.NewTester(t) tester := caddytest.NewTester(t)
tester.InitServer(`{ tester.InitServer(`{
@ -46,9 +44,9 @@ func TestMapRespondWithDefault(t *testing.T) {
localhost:9080 { localhost:9080 {
map http.request.method dest-name { map {http.request.method} {dest-name} {
default unknown default unknown
GET get-called GET get-called
} }
respond /version 200 { respond /version 200 {
@ -63,80 +61,75 @@ func TestMapRespondWithDefault(t *testing.T) {
} }
func TestMapAsJson(t *testing.T) { func TestMapAsJson(t *testing.T) {
// arrange // arrange
tester := caddytest.NewTester(t) tester := caddytest.NewTester(t)
tester.InitServer(`{ tester.InitServer(`
{
"apps": { "apps": {
"http": { "http": {
"http_port": 9080, "http_port": 9080,
"https_port": 9443, "https_port": 9443,
"servers": { "servers": {
"srv0": { "srv0": {
"listen": [ "listen": [
":9080" ":9080"
], ],
"routes": [
{
"handle": [
{
"handler": "subroute",
"routes": [ "routes": [
{ {
"handle": [ "handle": [
{
"handler": "map",
"source": "http.request.method",
"destination": "dest-name",
"default": "unknown",
"items": [
{ {
"expression": "GET", "handler": "subroute",
"value": "get-called" "routes": [
}, {
{ "handle": [
"expression": "POST", {
"value": "post-called" "handler": "map",
"source": "{http.request.method}",
"destinations": ["dest-name"],
"defaults": ["unknown"],
"mappings": [
{
"input": "GET",
"outputs": ["get-called"]
},
{
"input": "POST",
"outputs": ["post-called"]
}
]
}
]
},
{
"handle": [
{
"body": "hello from localhost {dest-name}",
"handler": "static_response",
"status_code": 200
}
],
"match": [
{
"path": ["/version"]
}
]
}
]
} }
] ],
} "match": [
] {
}, "host": ["localhost"]
{ }
"handle": [ ],
{ "terminal": true
"body": "hello from localhost {dest-name}",
"handler": "static_response",
"status_code": 200
}
],
"match": [
{
"path": [
"/version"
]
}
]
} }
] ]
}
],
"match": [
{
"host": [
"localhost"
]
}
],
"terminal": true
} }
]
} }
} }
}
} }
} }`, "json")
`, "json")
tester.AssertGetResponse("http://localhost:9080/version", 200, "hello from localhost get-called") tester.AssertGetResponse("http://localhost:9080/version", 200, "hello from localhost get-called")
tester.AssertPostResponseBody("http://localhost:9080/version", []string{}, bytes.NewBuffer([]byte{}), 200, "hello from localhost post-called") tester.AssertPostResponseBody("http://localhost:9080/version", []string{}, bytes.NewBuffer([]byte{}), 200, "hello from localhost post-called")

View file

@ -99,7 +99,7 @@ func TestDefaultSNI(t *testing.T) {
// act and assert // act and assert
// makes a request with no sni // makes a request with no sni
tester.AssertGetResponse("https://127.0.0.1:9443/version", 200, "hello from a") tester.AssertGetResponse("https://127.0.0.1:9443/version", 200, "hello from a.caddy.localhost")
} }
func TestDefaultSNIWithNamedHostAndExplicitIP(t *testing.T) { func TestDefaultSNIWithNamedHostAndExplicitIP(t *testing.T) {
@ -204,7 +204,6 @@ func TestDefaultSNIWithNamedHostAndExplicitIP(t *testing.T) {
} }
func TestDefaultSNIWithPortMappingOnly(t *testing.T) { func TestDefaultSNIWithPortMappingOnly(t *testing.T) {
// arrange // arrange
tester := caddytest.NewTester(t) tester := caddytest.NewTester(t)
tester.InitServer(` tester.InitServer(`
@ -273,7 +272,7 @@ func TestDefaultSNIWithPortMappingOnly(t *testing.T) {
// act and assert // act and assert
// makes a request with no sni // makes a request with no sni
tester.AssertGetResponse("https://127.0.0.1:9443/version", 200, "hello from a") tester.AssertGetResponse("https://127.0.0.1:9443/version", 200, "hello from a.caddy.localhost")
} }
func TestHttpOnlyOnDomainWithSNI(t *testing.T) { func TestHttpOnlyOnDomainWithSNI(t *testing.T) {

View file

@ -15,6 +15,8 @@
package maphandler package maphandler
import ( import (
"strings"
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp" "github.com/caddyserver/caddy/v2/modules/caddyhttp"
) )
@ -23,49 +25,75 @@ func init() {
httpcaddyfile.RegisterHandlerDirective("map", parseCaddyfile) httpcaddyfile.RegisterHandlerDirective("map", parseCaddyfile)
} }
// parseCaddyfile sets up the handler for a map from Caddyfile tokens. Syntax: // parseCaddyfile sets up the map handler from Caddyfile tokens. Syntax:
// //
// map <source> <dest> { // map [<matcher>] <source> <destinations...> {
// [default <default>] - used if not match is found // [~]<input> <outputs...>
// [<regexp> <replacement>] - regular expression to match against the source find and the matching replacement value // default <defaults...>
// ...
// } // }
// //
// The map takes a source variable and maps it into the dest variable. The mapping process // If the input value is prefixed with a tilde (~), then the input will be parsed as a
// will check the source variable for the first successful match against a list of regular expressions. // regular expression.
// If a successful match is found the dest variable will contain the replacement value.
// If no successful match is found and the default is specified then the dest will contain the default value.
// //
// The number of outputs for each mapping must not be more than the number of destinations.
// However, for convenience, there may be fewer outputs than destinations and any missing
// outputs will be filled in implicitly.
func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
m := new(Handler) var handler Handler
for h.Next() { for h.Next() {
// first see if source and dest are configured // source
if h.NextArg() { if !h.NextArg() {
m.Source = h.Val() return nil, h.ArgErr()
if h.NextArg() { }
m.Destination = h.Val() handler.Source = h.Val()
}
// destinations
handler.Destinations = h.RemainingArgs()
if len(handler.Destinations) == 0 {
return nil, h.Err("missing destination argument(s)")
} }
// load the rules // mappings
for h.NextBlock(0) { for h.NextBlock(0) {
expression := h.Val() // defaults are a special case
if expression == "default" { if h.Val() == "default" {
args := h.RemainingArgs() if len(handler.Defaults) > 0 {
if len(args) != 1 { return nil, h.Err("defaults already defined")
return m, h.ArgErr()
} }
m.Default = args[0] handler.Defaults = h.RemainingArgs()
} else { for len(handler.Defaults) < len(handler.Destinations) {
args := h.RemainingArgs() handler.Defaults = append(handler.Defaults, "")
if len(args) != 1 {
return m, h.ArgErr()
} }
m.Items = append(m.Items, Item{Expression: expression, Value: args[0]}) continue
} }
// every other line maps one input to one or more outputs
in := h.Val()
outs := h.RemainingArgs()
// cannot have more outputs than destinations
if len(outs) > len(handler.Destinations) {
return nil, h.Err("too many outputs")
}
// for convenience, can have fewer outputs than destinations, but the
// underlying handler won't accept that, so we fill in empty values
for len(outs) < len(handler.Destinations) {
outs = append(outs, "")
}
// create the mapping
mapping := Mapping{Outputs: outs}
if strings.HasPrefix(in, "~") {
mapping.InputRegexp = in[1:]
} else {
mapping.Input = in
}
handler.Mappings = append(handler.Mappings, mapping)
} }
} }
return m, nil return handler, nil
} }

View file

@ -15,8 +15,11 @@
package maphandler package maphandler
import ( import (
"fmt"
"log"
"net/http" "net/http"
"regexp" "regexp"
"strings"
"github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/modules/caddyhttp" "github.com/caddyserver/caddy/v2/modules/caddyhttp"
@ -26,27 +29,26 @@ func init() {
caddy.RegisterModule(Handler{}) caddy.RegisterModule(Handler{})
} }
// Handler is a middleware that maps a source placeholder to a destination // Handler implements a middleware that maps inputs to outputs. Specifically, it
// placeholder. // compares a source value against the map inputs, and for one that matches, it
// // applies the output values to each destination. Destinations become placeholder
// The mapping process happens early in the request handling lifecycle so that // names.
// the Destination placeholder is calculated and available for substitution.
// The Items array contains pairs of regex expressions and values, the
// Source is matched against the expression, if they match then the destination
// placeholder is set to the value.
//
// The Default is optional, if no Item expression is matched then the value of
// the Default will be used.
// //
// Mapped placeholders are not evaluated until they are used, so even for very
// large mappings, this handler is quite efficient.
type Handler struct { type Handler struct {
// Source is a placeholder // Source is the placeholder from which to get the input value.
Source string `json:"source,omitempty"` Source string `json:"source,omitempty"`
// Destination is a new placeholder
Destination string `json:"destination,omitempty"` // Destinations are the placeholders in which to store the outputs.
// Default is an optional value to use if no other was found Destinations []string `json:"destinations,omitempty"`
Default string `json:"default,omitempty"`
// Items is an array of regex expressions and values // Mappings from source values (inputs) to destination values (outputs).
Items []Item `json:"items,omitempty"` // The first matching mapping will be applied.
Mappings []Mapping `json:"mappings,omitempty"`
// If no mappings match, the default value will be applied (optional).
Defaults []string
} }
// CaddyModule returns the Caddy module information. // CaddyModule returns the Caddy module information.
@ -57,10 +59,52 @@ func (Handler) CaddyModule() caddy.ModuleInfo {
} }
} }
// Provision will compile all regular expressions // Provision sets up h.
func (h *Handler) Provision(_ caddy.Context) error { func (h *Handler) Provision(_ caddy.Context) error {
for i := 0; i < len(h.Items); i++ { for j, dest := range h.Destinations {
h.Items[i].compiled = regexp.MustCompile(h.Items[i].Expression) h.Destinations[j] = strings.Trim(dest, "{}")
}
for i, m := range h.Mappings {
if m.InputRegexp == "" {
continue
}
if m.Input != "" {
return fmt.Errorf("mapping %d has both input and input_regexp fields specified, which is confusing", i)
}
var err error
h.Mappings[i].re, err = regexp.Compile(m.InputRegexp)
if err != nil {
return fmt.Errorf("compiling regexp for mapping %d: %v", i, err)
}
}
// TODO: improve efficiency even further by using an actual map type
// for the non-regexp mappings, OR sort them and do a binary search
return nil
}
// Validate ensures that h is configured properly.
func (h *Handler) Validate() error {
nDest, nDef := len(h.Destinations), len(h.Defaults)
if nDef > 0 && nDef != nDest {
return fmt.Errorf("%d destinations != %d defaults", nDest, nDef)
}
seen := make(map[string]int)
for i, m := range h.Mappings {
// prevent duplicate mappings
if prev, ok := seen[m.Input]; ok {
return fmt.Errorf("mapping %d has a duplicate input '%s' previously used with mapping %d", i, m.Input, prev)
}
seen[m.Input] = i
// ensure mappings have 1:1 output-to-destination correspondence
nOut := len(m.Outputs)
if nOut != nDest {
return fmt.Errorf("mapping %d has %d outputs but there are %d destinations defined", i, nOut, nDest)
}
} }
return nil return nil
} }
@ -68,38 +112,73 @@ func (h *Handler) Provision(_ caddy.Context) error {
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
// get the source value, if the source value was not found do no // defer work until a variable is actually evaluated by using replacer's Map callback
// replacement. repl.Map(func(key string) (interface{}, bool) {
val, ok := repl.GetString(h.Source) // return early if the variable is not even a configured destination
if ok { destIdx := h.destinationIndex(key)
found := false if destIdx < 0 {
for i := 0; i < len(h.Items); i++ { return nil, false
if h.Items[i].compiled.MatchString(val) { }
found = true
repl.Set(h.Destination, h.Items[i].Value) input := repl.ReplaceAll(h.Source, "")
break
// find the first mapping matching the input and return
// the requested destination/output value
for _, m := range h.Mappings {
log.Printf("MAPPING: %+v", m)
if m.re != nil {
if m.re.MatchString(input) {
return m.Outputs[destIdx], true
}
continue
}
if input == m.Input {
log.Printf("RETURNING: %s", m.Outputs[destIdx])
return m.Outputs[destIdx], true
} }
} }
if !found && h.Default != "" { // fall back to default if no match
repl.Set(h.Destination, h.Default) if len(h.Defaults) > destIdx {
return h.Defaults[destIdx], true
} }
}
return nil, true
})
return next.ServeHTTP(w, r) return next.ServeHTTP(w, r)
} }
// Item defines each entry in the map // destinationIndex returns the positional index of the destination
type Item struct { // is name is a known destination; otherwise it returns -1.
// Expression is the regular expression searched for func (h Handler) destinationIndex(name string) int {
Expression string `json:"expression,omitempty"` for i, dest := range h.Destinations {
// Value to use once the expression has been found if dest == name {
Value string `json:"value,omitempty"` return i
// compiled expression, internal use }
compiled *regexp.Regexp }
return -1
}
// Mapping describes a mapping from input to outputs.
type Mapping struct {
// The input value to match. Must be distinct from other mappings.
// Mutually exclusive to input_regexp.
Input string `json:"input,omitempty"`
// The input regular expression to match. Mutually exclusive to input.
InputRegexp string `json:"input_regexp,omitempty"`
// Upon a match with the input, each output is positionally correlated
// with each destination of the parent handler.
Outputs []string `json:"outputs,omitempty"`
re *regexp.Regexp
} }
// Interface guards // Interface guards
var ( var (
_ caddy.Provisioner = (*Handler)(nil) _ caddy.Provisioner = (*Handler)(nil)
_ caddy.Validator = (*Handler)(nil)
_ caddyhttp.MiddlewareHandler = (*Handler)(nil) _ caddyhttp.MiddlewareHandler = (*Handler)(nil)
) )