simonmittag/jabba

View on GitHub
route.go

Summary

Maintainability
A
3 hrs
Test Coverage
A
93%
package j8a

import (
    "errors"
    "fmt"
    "github.com/asaskevich/govalidator"
    urlverifier "github.com/davidmytton/url-verifier"
    "golang.org/x/net/idna"
    "net/http"
    "net/url"
    "regexp"
    "strings"
    "time"
)

// Aboutj8a special Resource alias for internal endpoint
const about string = "about"

type WeightedSlugs []string
type RoutePath WeightedSlugs
type DNSNameComponents WeightedSlugs

func NewRoutePath(r Route) RoutePath {
    rps := strings.Split(r.Path, slashS)
    return RoutePath(WeightedSlugs(rps).trimEmptySlug())
}

func NewDNSNameComponents(r Route) DNSNameComponents {
    rhs := strings.Split(r.PunyHost, dot)
    for i, j := 0, len(rhs)-1; i < j; i, j = i+1, j-1 {
        rhs[i], rhs[j] = rhs[j], rhs[i]
    }
    return DNSNameComponents(WeightedSlugs(rhs).trimEmptySlug())
}

func (w WeightedSlugs) trimEmptySlug() WeightedSlugs {
    if len(w[0]) == 0 && len(w) > 1 {
        w = w[1:]
    }
    return w
}

func (w WeightedSlugs) trimNextSlug() (WeightedSlugs, error) {
    if len(w) > 1 {
        w = w[1:]
        return w, nil
    } else {
        return nil, errors.New("no more slugs")
    }
}

func (w WeightedSlugs) Less(w2 WeightedSlugs) bool {
    less := false
    if len(w[0]) > len(w2[0]) {
        less = true
    } else if len(w[0]) == len(w2[0]) {
        wn, e := w.trimNextSlug()
        w2n, e2 := w2.trimNextSlug()
        if e == nil && e2 == nil {
            less = wn.Less(w2n)
        } else if e2 != nil {
            /////aaarrggghhh this fixes an issue because we share sorting slug paths and DNS name segments with the same alg
            if w[0] == STAR {
                less = false
            } else {
                less = true
            }
        }
    }
    return less
}

type Routes []Route

func (s Routes) Len() int {
    return len(s)
}
func (s Routes) Swap(i, j int) {
    s[i], s[j] = s[j], s[i]
}
func (s Routes) Less(i, j int) bool {
    if s[i].PunyHost == s[j].PunyHost {
        return s.PathIsLess(i, j)
    } else {
        return s.HostIsLess(i, j)
    }
}

func (s Routes) HostIsLess(i int, j int) bool {
    return WeightedSlugs(NewDNSNameComponents(s[i])).Less(WeightedSlugs(NewDNSNameComponents(s[j])))
}

func (s Routes) PathIsLess(i int, j int) bool {
    pis := NewRoutePath(s[i])
    pjs := NewRoutePath(s[j])

    less := false
    if (s[i].PathType == exact && s[j].PathType == exact) || (s[i].PathType == prefixS && s[j].PathType == prefixS) {
        less = WeightedSlugs(pis).Less(WeightedSlugs(pjs))
    } else {
        less = s[i].PathType == exact
    }
    return less
}

// Route maps a Path to an upstream resource
type Route struct {
    Host              string         //idna host pattern
    PunyHost          string         //punycode host pattern
    CompiledPunyHost  *regexp.Regexp // as regex
    Path              string
    PathType          string // exact | prefix
    CompiledPathRegex *regexp.Regexp
    Transform         string
    Resource          string
    Policy            string
    Jwt               string
}

const wildcard = "*"

func (route *Route) validHostPattern() (bool, error) {
    //first check the name is a valid idna name.
    p := idna.New(
        idna.ValidateLabels(true),
        //this has to be off it disallows * for registration
        //idna.ValidateForRegistration(),
        idna.StrictDomainName(true))
    _, err := p.ToUnicode(route.Host)
    if err != nil {
        return false, err
    } else {
        a, err := p.ToASCII(route.Host)
        if err != nil {
            return false, err
        }

        for i, j := range strings.Split(a, ".") {
            if len(j) == 0 {
                return false, errors.New("dns name segments cannot be empty string")
            }
            if j == wildcard && i != 0 {
                return false, errors.New("wildcard can only be at far left of domain name")
            }
            if strings.Contains(j, wildcard) && len(j) > 1 {
                return false, errors.New("wildcard can only be used for entire subdomains, not regex style")
            }
        }

        //now perform DNS name validation on ascii format.
        v2 := govalidator.IsDNSName(a)
        if v2 {
            return true, nil
        } else {
            //this validator does not pass wildcard names so we have to
            if strings.HasPrefix(a, wildcard) {
                return true, nil
            } else {
                return false, errors.New("not a valid DNS name after idna normalisation " + a)
            }
        }
    }
}

func (route *Route) compileHostPattern() error {
    if b, e := route.validHostPattern(); b {
        al, e1 := idna.ToASCII(route.Host)
        if e1 == nil {
            route.PunyHost = al
            re, _ := regexp.Compile(startS + al)
            route.CompiledPunyHost = re
        }
        return e1
    } else {
        return e
    }
}

func (route *Route) validPath() (bool, error) {
    const fakeHost = "http://127.0.0.1"
    defaultError := errors.New(fmt.Sprintf("route %v not a valid URL path", route.Path))

    _, err := url.ParseRequestURI(fakeHost + route.Path)
    if err != nil {
        return false, defaultError
    }
    _, err = url.Parse(fakeHost + route.Path)
    if err != nil {
        return false, defaultError
    }
    _, err = urlverifier.NewVerifier().Verify(fakeHost + route.Path)
    if err != nil {
        return false, defaultError
    }
    if len(route.Path) == 0 {
        return false, defaultError
    }
    if strings.Contains(route.Path, " ") {
        return false, errors.New(fmt.Sprintf("route %v not a valid URL path, may not contain space character", route.Path))
    }
    if strings.Index(route.Path, "/") != 0 {
        return false, errors.New(fmt.Sprintf("route %v not a valid URL path, does not start with '/'", route.Path))
    }
    if e := route.compilePath(); e != nil {
        return false, e
    }
    return true, nil
}

const startS = "^"
const dollarS = "$"
const exact = "exact"

func (route *Route) compilePath() error {
    compileMe := route.Path
    if string(compileMe[0]) != startS {
        compileMe = startS + compileMe
    }
    if strings.EqualFold(exact, route.PathType) {
        compileMe = compileMe + dollarS
    }
    var err error
    route.CompiledPathRegex, err = regexp.Compile(compileMe)
    return err
}

const slashS = "/"

func (route Route) match(request *http.Request) bool {
    if len(route.PunyHost) > 0 {
        return route.matchHostHeader(request) &&
            route.matchURIPath(request)
    } else {
        return route.matchURIPath(request)
    }
}

func (route Route) matchHostHeader(request *http.Request) bool {
    //safety measure in case we got a unicode host header
    al, _ := idna.ToASCII(request.Host)
    return route.CompiledPunyHost.MatchString(al) &&
        len(strings.Split(route.PunyHost, ".")) == len(strings.Split(request.Host, "."))
}

func (route Route) matchURIPath(request *http.Request) bool {
    match := route.CompiledPathRegex.MatchString(request.URL.Path)
    if !match &&
        route.PathType == prefixS &&
        len(request.URL.Path) > 0 &&
        string(request.URL.Path[len(request.URL.Path)-1]) != slashS {
        match = route.CompiledPathRegex.MatchString(request.URL.Path + slashS)
    }
    return match
}

// Deprecated
func (route Route) matchURI_Naive(request *http.Request) bool {
    return route.CompiledPathRegex.MatchString(request.URL.Path)
}

const upstreamResourceMapped = "upstream resource mapped"
const policyMsg = "policy"
const upResource = "upResource"
const routeMsg = "route"
const labelMsg = "label"
const defaultMsg = "default"
const routeMapped = "route mapped"
const routeNotMapped = "route not mapped"
const emptyString = ""

// maps a route to a URL. Returns the URL, the name of the mapped policy and whether mapping was successful
func (route Route) mapURL(proxy *Proxy) (*URL, string, bool) {
    var policy Policy
    var policyLabel string
    if len(route.Policy) > 0 {
        policy = Runner.Policies[route.Policy]
        policyLabel = policy.resolveLabel()
    }

    resource := Runner.Resources[route.Resource]
    if resource == nil {
        return nil, emptyString, false
    }
    //if a policy exists, we match resources with a label. TODO: this should be an interface

    if len(route.Policy) > 0 {
        for _, resourceMapping := range resource {
            for _, resourceLabel := range resourceMapping.Labels {
                if policyLabel == resourceLabel {
                    infoOrTraceEv(proxy).Str(routeMsg, route.Path).
                        Str(upResource, resourceMapping.URL.String()).
                        Str(labelMsg, resourceLabel).
                        Str(policyMsg, route.Policy).
                        Str(XRequestID, proxy.XRequestID).
                        Int64(dwnElpsdMicros, time.Since(proxy.Dwn.startDate).Microseconds()).
                        Msg(upstreamResourceMapped)
                    return &resourceMapping.URL, policyLabel, true
                }
            }
        }
    } else {
        infoOrTraceEv(proxy).
            Str(routeMsg, route.Path).
            Str(policyMsg, defaultMsg).
            Str(XRequestID, proxy.XRequestID).
            Str(upResource, resource[0].URL.String()).
            Msg(routeMapped)
        return &resource[0].URL, defaultMsg, true
    }

    infoOrTraceEv(proxy).
        Str(routeMsg, route.Path).
        Str(XRequestID, proxy.XRequestID).
        Msg(routeNotMapped)

    return nil, emptyString, false
}

func (route Route) hasJwt() bool {
    return len(route.Jwt) > 0
}

type RoutePathTypes []string

func NewRoutePathTypes() RoutePathTypes {
    return RoutePathTypes([]string{"exact", "prefix"})
}

func (r RoutePathTypes) isValid(t string) bool {
    m := false
    for _, rp := range r {
        if strings.EqualFold(t, rp) {
            m = true
        }
    }
    return m
}