diff --git a/contrib/completions/bash/openshift b/contrib/completions/bash/openshift index 838b0be22ecd..edd63e727498 100644 --- a/contrib/completions/bash/openshift +++ b/contrib/completions/bash/openshift @@ -19225,6 +19225,10 @@ _openshift_infra_f5-router() flags_with_completion=() flags_completion=() + flags+=("--allow-wildcard-routes") + local_nonpersistent_flags+=("--allow-wildcard-routes") + flags+=("--allowed-domains=") + local_nonpersistent_flags+=("--allowed-domains=") flags+=("--as=") local_nonpersistent_flags+=("--as=") flags+=("--certificate-authority=") @@ -19247,6 +19251,8 @@ _openshift_infra_f5-router() local_nonpersistent_flags+=("--config=") flags+=("--context=") local_nonpersistent_flags+=("--context=") + flags+=("--denied-domains=") + local_nonpersistent_flags+=("--denied-domains=") flags+=("--f5-host=") local_nonpersistent_flags+=("--f5-host=") flags+=("--f5-http-vserver=") @@ -19360,6 +19366,10 @@ _openshift_infra_router() flags_with_completion=() flags_completion=() + flags+=("--allow-wildcard-routes") + local_nonpersistent_flags+=("--allow-wildcard-routes") + flags+=("--allowed-domains=") + local_nonpersistent_flags+=("--allowed-domains=") flags+=("--as=") local_nonpersistent_flags+=("--as=") flags+=("--certificate-authority=") @@ -19388,6 +19398,8 @@ _openshift_infra_router() local_nonpersistent_flags+=("--default-certificate-dir=") flags+=("--default-certificate-path=") local_nonpersistent_flags+=("--default-certificate-path=") + flags+=("--denied-domains=") + local_nonpersistent_flags+=("--denied-domains=") flags+=("--extended-validation") local_nonpersistent_flags+=("--extended-validation") flags+=("--fields=") diff --git a/contrib/completions/zsh/openshift b/contrib/completions/zsh/openshift index 72c0f87a2978..ae03aadd0715 100644 --- a/contrib/completions/zsh/openshift +++ b/contrib/completions/zsh/openshift @@ -19386,6 +19386,10 @@ _openshift_infra_f5-router() flags_with_completion=() flags_completion=() + flags+=("--allow-wildcard-routes") + local_nonpersistent_flags+=("--allow-wildcard-routes") + flags+=("--allowed-domains=") + local_nonpersistent_flags+=("--allowed-domains=") flags+=("--as=") local_nonpersistent_flags+=("--as=") flags+=("--certificate-authority=") @@ -19408,6 +19412,8 @@ _openshift_infra_f5-router() local_nonpersistent_flags+=("--config=") flags+=("--context=") local_nonpersistent_flags+=("--context=") + flags+=("--denied-domains=") + local_nonpersistent_flags+=("--denied-domains=") flags+=("--f5-host=") local_nonpersistent_flags+=("--f5-host=") flags+=("--f5-http-vserver=") @@ -19521,6 +19527,10 @@ _openshift_infra_router() flags_with_completion=() flags_completion=() + flags+=("--allow-wildcard-routes") + local_nonpersistent_flags+=("--allow-wildcard-routes") + flags+=("--allowed-domains=") + local_nonpersistent_flags+=("--allowed-domains=") flags+=("--as=") local_nonpersistent_flags+=("--as=") flags+=("--certificate-authority=") @@ -19549,6 +19559,8 @@ _openshift_infra_router() local_nonpersistent_flags+=("--default-certificate-dir=") flags+=("--default-certificate-path=") local_nonpersistent_flags+=("--default-certificate-path=") + flags+=("--denied-domains=") + local_nonpersistent_flags+=("--denied-domains=") flags+=("--extended-validation") local_nonpersistent_flags+=("--extended-validation") flags+=("--fields=") diff --git a/docs/man/man1/openshift-infra-f5-router.1 b/docs/man/man1/openshift-infra-f5-router.1 index d7151dc734ca..51d20a496f97 100644 --- a/docs/man/man1/openshift-infra-f5-router.1 +++ b/docs/man/man1/openshift-infra-f5-router.1 @@ -26,6 +26,14 @@ that you must have a cluster\-wide administrative role to view all namespaces. .SH OPTIONS +.PP +\fB\-\-allow\-wildcard\-routes\fP=false + Allow wildcard host names for routes + +.PP +\fB\-\-allowed\-domains\fP=[] + List of comma separated domains to allow in routes. If specified, only the domains in this list will be allowed routes. Note that domains in the denied list take precedence over the ones in the allowed list + .PP \fB\-\-api\-version\fP="" DEPRECATED: The API version to use when talking to the server @@ -58,6 +66,10 @@ that you must have a cluster\-wide administrative role to view all namespaces. \fB\-\-context\fP="" The name of the kubeconfig context to use +.PP +\fB\-\-denied\-domains\fP=[] + List of comma separated domains to deny in routes + .PP \fB\-\-f5\-host\fP="" The host of F5 BIG\-IP's management interface diff --git a/docs/man/man1/openshift-infra-router.1 b/docs/man/man1/openshift-infra-router.1 index 9754e8e4e9d0..cb10cfba3673 100644 --- a/docs/man/man1/openshift-infra-router.1 +++ b/docs/man/man1/openshift-infra-router.1 @@ -34,6 +34,14 @@ that you must have a cluster\-wide administrative role to view all namespaces. .SH OPTIONS +.PP +\fB\-\-allow\-wildcard\-routes\fP=false + Allow wildcard host names for routes + +.PP +\fB\-\-allowed\-domains\fP=[] + List of comma separated domains to allow in routes. If specified, only the domains in this list will be allowed routes. Note that domains in the denied list take precedence over the ones in the allowed list + .PP \fB\-\-api\-version\fP="" DEPRECATED: The API version to use when talking to the server @@ -78,6 +86,10 @@ that you must have a cluster\-wide administrative role to view all namespaces. \fB\-\-default\-certificate\-path\fP="" A path to default certificate to use for routes that don't expose a TLS server cert; in PEM format +.PP +\fB\-\-denied\-domains\fP=[] + List of comma separated domains to deny in routes + .PP \fB\-\-extended\-validation\fP=true If set, then an additional extended validation step is performed on all routes admitted in by this router. Defaults to true and enables the extended validation checks. diff --git a/images/router/haproxy/Dockerfile b/images/router/haproxy/Dockerfile index 0654fced4523..ae8f0c93c3a8 100644 --- a/images/router/haproxy/Dockerfile +++ b/images/router/haproxy/Dockerfile @@ -16,7 +16,7 @@ RUN INSTALL_PKGS="haproxy" && \ yum clean all && \ mkdir -p /var/lib/haproxy/router/{certs,cacerts} && \ mkdir -p /var/lib/haproxy/{conf,run,bin,log} && \ - touch /var/lib/haproxy/conf/{{os_http_be,os_edge_http_be,os_tcp_be,os_sni_passthrough,os_reencrypt,os_edge_http_expose,os_edge_http_redirect,cert_config}.map,haproxy.config} && \ + touch /var/lib/haproxy/conf/{{os_http_be,os_edge_http_be,os_tcp_be,os_sni_passthrough,os_reencrypt,os_edge_http_expose,os_edge_http_redirect,cert_config,os_wildcard_domain}.map,haproxy.config} && \ chmod -R 777 /var && \ setcap 'cap_net_bind_service=ep' /usr/sbin/haproxy diff --git a/images/router/haproxy/conf/haproxy-config.template b/images/router/haproxy/conf/haproxy-config.template index 95a4090abf62..be2d9229bf01 100644 --- a/images/router/haproxy/conf/haproxy-config.template +++ b/images/router/haproxy/conf/haproxy-config.template @@ -100,15 +100,36 @@ frontend public acl secure_redirect base,map_beg(/var/lib/haproxy/conf/os_edge_http_redirect.map) -m found redirect scheme https if secure_redirect +{{ if matchPattern "true|TRUE" (env "ROUTER_ALLOW_WILDCARD_ROUTES" "")}} + # Check for wildcard domains with redirected http routes. + acl wildcard_domain hdr(host),map_reg(/var/lib/haproxy/conf/os_wildcard_domain.map) -m found + + acl wildcard_secure_redirect base,map_reg(/var/lib/haproxy/conf/os_edge_http_redirect.map) -m found + redirect scheme https if wildcard_domain wildcard_secure_redirect + +{{ end }} + # Check if it is an edge route exposed insecurely. acl edge_http_expose base,map_beg(/var/lib/haproxy/conf/os_edge_http_expose.map) -m found use_backend be_edge_http_%[base,map_beg(/var/lib/haproxy/conf/os_edge_http_expose.map)] if edge_http_expose + # map to http backend + # Search from most specific to general path (host case). + acl http_backend base,map_beg(/var/lib/haproxy/conf/os_http_be.map) -m found + use_backend be_http_%[base,map_beg(/var/lib/haproxy/conf/os_http_be.map)] if http_backend + +{{ if matchPattern "true|TRUE" (env "ROUTER_ALLOW_WILDCARD_ROUTES" "")}} + # Check for wildcard domains with exposed http routes. + acl wildcard_edge_http_expose base,map_reg(/var/lib/haproxy/conf/os_edge_http_expose.map) -m found + use_backend be_edge_http_%[base,map_beg(/var/lib/haproxy/conf/os_edge_http_expose.map)] if wildcard_domain wildcard_edge_http_expose + # map to http backend # Search from most specific to general path (host case). # Note: If no match, haproxy uses the default_backend, no other # use_backend directives below this will be processed. - use_backend be_http_%[base,map_beg(/var/lib/haproxy/conf/os_http_be.map)] + use_backend be_http_%[base,map_reg(/var/lib/haproxy/conf/os_http_be.map)] if wildcard_domain + +{{ end }} default_backend openshift_default @@ -125,6 +146,15 @@ frontend public_ssl acl sni_passthrough req.ssl_sni,map(/var/lib/haproxy/conf/os_sni_passthrough.map) -m found use_backend be_tcp_%[req.ssl_sni,map(/var/lib/haproxy/conf/os_tcp_be.map)] if sni sni_passthrough +{{ if matchPattern "true|TRUE" (env "ROUTER_ALLOW_WILDCARD_ROUTES" "")}} + # Check for wildcard domains with passthrough. + acl sni_wildcard_domain req.ssl_sni,map_reg(/var/lib/haproxy/conf/os_wildcard_domain.map) -m found + + acl sni_wildcard_passthrough req.ssl_sni,map_reg(/var/lib/haproxy/conf/os_sni_passthrough.map) -m found + use_backend be_tcp_%[req.ssl_sni,map_reg(/var/lib/haproxy/conf/os_tcp_be.map)] if sni sni_wildcard_domain sni_wildcard_passthrough + +{{ end }} + # if the route is SNI and NOT passthrough enter the termination flow use_backend be_sni if sni @@ -160,11 +190,25 @@ frontend fe_sni # Search from most specific to general path (host case). use_backend be_secure_%[base,map_beg(/var/lib/haproxy/conf/os_reencrypt.map)] if reencrypt + # map to http backend + # Search from most specific to general path (host case). + acl http_backend base,map_beg(/var/lib/haproxy/conf/os_edge_http_be.map) -m found + use_backend be_edge_http_%[base,map_beg(/var/lib/haproxy/conf/os_edge_http_be.map)] if http_backend + +{{ if matchPattern "true|TRUE" (env "ROUTER_ALLOW_WILDCARD_ROUTES" "")}} + # Check for wildcard domains with redirected or exposed http routes. + acl sni_wildcard_domain hdr(host),map_reg(/var/lib/haproxy/conf/os_wildcard_domain.map) -m found + + acl wildcard_reencrypt base,map_reg(/var/lib/haproxy/conf/os_reencrypt.map) -m found + use_backend be_secure_%[base,map_reg(/var/lib/haproxy/conf/os_reencrypt.map)] if sni_wildcard_domain wildcard_reencrypt + # map to http backend # Search from most specific to general path (host case). # Note: If no match, haproxy uses the default_backend, no other # use_backend directives below this will be processed. - use_backend be_edge_http_%[base,map_beg(/var/lib/haproxy/conf/os_edge_http_be.map)] + use_backend be_edge_http_%[base,map_reg(/var/lib/haproxy/conf/os_edge_http_be.map)] if sni_wildcard_domain + +{{ end }} default_backend openshift_default @@ -197,11 +241,24 @@ frontend fe_no_sni # Search from most specific to general path (host case). use_backend be_secure_%[base,map_beg(/var/lib/haproxy/conf/os_reencrypt.map)] if reencrypt + # map to http backend + # Search from most specific to general path (host case). + acl edge_http_backend base,map_beg(/var/lib/haproxy/conf/os_edge_http_be.map) -m found + use_backend be_edge_http_%[base,map_beg(/var/lib/haproxy/conf/os_edge_http_be.map)] if edge_http_backend + +{{ if matchPattern "true|TRUE" (env "ROUTER_ALLOW_WILDCARD_ROUTES" "")}} + acl host_wildcard_domain req.ssl_sni,map_reg(/var/lib/haproxy/conf/os_wildcard_domain.map) -m found + + acl host_reencrypt base,map_reg(/var/lib/haproxy/conf/os_reencrypt.map) -m found + use_backend be_secure_%[base,map_reg(/var/lib/haproxy/conf/os_reencrypt.map)] if host_wildcard_domain host_reencrypt + # map to http backend # Search from most specific to general path (host case). # Note: If no match, haproxy uses the default_backend, no other # use_backend directives below this will be processed. - use_backend be_edge_http_%[base,map_beg(/var/lib/haproxy/conf/os_edge_http_be.map)] + use_backend be_edge_http_%[base,map_reg(/var/lib/haproxy/conf/os_edge_http_be.map)] if host_wildcard_domain + +{{ end }} default_backend openshift_default @@ -321,8 +378,8 @@ backend be_edge_http_{{$cfgIdx}} {{ end }}{{/* end iterate over services */}} {{ end }}{{/* end if tls==edge/none */}} -# Secure backend, pass through {{ if eq $cfg.TLSTermination "passthrough" }} +# Secure backend, pass through backend be_tcp_{{$cfgIdx}} {{ if ne (env "ROUTER_SYSLOG_ADDRESS" "") ""}} option tcplog @@ -385,8 +442,8 @@ backend be_tcp_{{$cfgIdx}} {{ end }}{{/* end iterate over services*/}} {{ end }}{{/*end tls==passthrough*/}} -# Secure backend which requires re-encryption {{ if eq $cfg.TLSTermination "reencrypt" }} +# Secure backend which requires re-encryption backend be_secure_{{$cfgIdx}} mode http option redispatch @@ -462,6 +519,23 @@ backend be_secure_{{$cfgIdx}} {{ end }}{{/* end haproxy config template */}} {{/*--------------------------------- END OF HAPROXY CONFIG, BELOW ARE MAPPING FILES ------------------------*/}} +{{/* + os_wildcard_domain.map: contains a mapping of wildcard hosts for a + [sub]domain regexps. This map is used to check if + a host matches a [sub]domain with has wildcard support. +*/}} +{{ define "/var/lib/haproxy/conf/os_wildcard_domain.map" }} +{{ if matchPattern "true|TRUE" (env "ROUTER_ALLOW_WILDCARD_ROUTES" "")}} +{{ range $idx, $cfg := .State }} +{{ if ne $cfg.Host ""}} +{{ if $cfg.IsWildcard }} +{{genDomainWildcardRegexp $cfg.Host "" true}} 1 +{{ end }} +{{ end }} +{{ end }} +{{ end }}{{/* end if router allows wildcard routes */}} +{{ end }}{{/* end wildcard domain map template */}} + {{/* os_http_be.map: contains a mapping of www.example.com -> . This map is used to discover the correct backend by attaching a prefix (be_http_) by use_backend statements if acls are matched. @@ -469,7 +543,11 @@ backend be_secure_{{$cfgIdx}} {{ define "/var/lib/haproxy/conf/os_http_be.map" }} {{ range $idx, $cfg := .State }} {{ if and (ne $cfg.Host "") (eq $cfg.TLSTermination "")}} +{{ if $cfg.IsWildcard }} +{{genDomainWildcardRegexp $cfg.Host $cfg.Path false}} {{$idx}} +{{ else }} {{$cfg.Host}}{{$cfg.Path}} {{$idx}} +{{ end }} {{ end }} {{ end }} {{ end }}{{/* end http host map template */}} @@ -481,7 +559,11 @@ backend be_secure_{{$cfgIdx}} {{ define "/var/lib/haproxy/conf/os_edge_http_be.map" }} {{ range $idx, $cfg := .State }} {{ if and (ne $cfg.Host "") (eq $cfg.TLSTermination "edge")}} +{{ if $cfg.IsWildcard }} +{{genDomainWildcardRegexp $cfg.Host $cfg.Path false}} {{$idx}} +{{ else }} {{$cfg.Host}}{{$cfg.Path}} {{$idx}} +{{ end }} {{ end }} {{ end }} {{ end }}{{/* end edge http host map template */}} @@ -494,7 +576,11 @@ backend be_secure_{{$cfgIdx}} {{ define "/var/lib/haproxy/conf/os_edge_http_expose.map" }} {{ range $idx, $cfg := .State }} {{ if and (ne $cfg.Host "") (and (eq $cfg.TLSTermination "edge") (eq $cfg.InsecureEdgeTerminationPolicy "Allow"))}} +{{ if $cfg.IsWildcard }} +{{genDomainWildcardRegexp $cfg.Host $cfg.Path false}} {{$idx}} +{{ else }} {{$cfg.Host}}{{$cfg.Path}} {{$idx}} +{{ end }} {{ end }} {{ end }} {{ end }}{{/* end edge insecure expose http host map template */}} @@ -507,7 +593,11 @@ backend be_secure_{{$cfgIdx}} {{ define "/var/lib/haproxy/conf/os_edge_http_redirect.map" }} {{ range $idx, $cfg := .State }} {{ if and (ne $cfg.Host "") (and (eq $cfg.TLSTermination "edge") (eq $cfg.InsecureEdgeTerminationPolicy "Redirect"))}} +{{ if $cfg.IsWildcard }} +{{genDomainWildcardRegexp $cfg.Host $cfg.Path false}} {{$idx}} +{{ else }} {{$cfg.Host}}{{$cfg.Path}} {{$idx}} +{{ end }} {{ end }} {{ end }} {{ end }}{{/* end edge insecure redirect http host map template */}} @@ -520,7 +610,11 @@ backend be_secure_{{$cfgIdx}} {{ define "/var/lib/haproxy/conf/os_tcp_be.map" }} {{ range $idx, $cfg := .State }} {{ if and (eq $cfg.Path "") (and (ne $cfg.Host "") (or (eq $cfg.TLSTermination "passthrough") (eq $cfg.TLSTermination "reencrypt"))) }} +{{ if $cfg.IsWildcard }} +{{genDomainWildcardRegexp $cfg.Host "" true}} {{$idx}} +{{ else }} {{$cfg.Host}} {{$idx}} +{{ end }} {{ end }} {{ end }} {{ end }}{{/* end tcp host map template */}} @@ -532,7 +626,11 @@ backend be_secure_{{$cfgIdx}} {{ define "/var/lib/haproxy/conf/os_sni_passthrough.map" }} {{ range $idx, $cfg := .State }} {{ if and (eq $cfg.Path "") (eq $cfg.TLSTermination "passthrough") }} +{{ if $cfg.IsWildcard }} +{{genDomainWildcardRegexp $cfg.Host "" true}} {{$idx}} +{{ else }} {{$cfg.Host}} 1 +{{ end }} {{ end }} {{ end }} {{ end }}{{/* end sni passthrough map template */}} @@ -545,7 +643,11 @@ backend be_secure_{{$cfgIdx}} {{ define "/var/lib/haproxy/conf/os_reencrypt.map" }} {{ range $idx, $cfg := .State }} {{ if and (ne $cfg.Host "") (eq $cfg.TLSTermination "reencrypt") }} +{{ if $cfg.IsWildcard }} +{{genDomainWildcardRegexp $cfg.Host $cfg.Path false}} {{$idx}} +{{ else }} {{$cfg.Host}}{{$cfg.Path}} {{$idx}} +{{ end }} {{ end }} {{ end }} {{ end }}{{/* end reencrypt map template */}} diff --git a/pkg/cmd/infra/router/f5.go b/pkg/cmd/infra/router/f5.go index 3c0b1606a590..f9991fe3df18 100644 --- a/pkg/cmd/infra/router/f5.go +++ b/pkg/cmd/infra/router/f5.go @@ -14,6 +14,7 @@ import ( ocmd "github.com/openshift/origin/pkg/cmd/cli/cmd" "github.com/openshift/origin/pkg/cmd/util" "github.com/openshift/origin/pkg/cmd/util/clientcmd" + routeapi "github.com/openshift/origin/pkg/route/api" "github.com/openshift/origin/pkg/router/controller" f5plugin "github.com/openshift/origin/pkg/router/f5" ) @@ -154,6 +155,23 @@ func (o *F5RouterOptions) Validate() error { return o.F5Router.Validate() } +// F5RouteAdmitterFunc returns a func that checks if a route is a +// wildcard route and currently denies it. +func (o *F5RouterOptions) F5RouteAdmitterFunc() controller.RouteAdmissionFunc { + return func(route *routeapi.Route) error { + if err := o.AdmissionCheck(route); err != nil { + return err + } + + if _, wildcard := routeapi.NormalizeWildcardHost(route.Spec.Host); wildcard { + // TODO: F5 wildcard route support. + return fmt.Errorf("Wildcard routes are currently not supported by the F5 router") + } + + return nil + } +} + // Run launches an F5 route sync process using the provided options. It never exits. func (o *F5RouterOptions) Run() error { cfg := f5plugin.F5PluginConfig{ @@ -177,7 +195,8 @@ func (o *F5RouterOptions) Run() error { } statusPlugin := controller.NewStatusAdmitter(f5Plugin, oc, o.RouterName) - plugin := controller.NewUniqueHost(statusPlugin, o.RouteSelectionFunc(), statusPlugin) + uniqueHostPlugin := controller.NewUniqueHost(statusPlugin, o.RouteSelectionFunc(), statusPlugin) + plugin := controller.NewHostAdmitter(uniqueHostPlugin, o.F5RouteAdmitterFunc(), false, statusPlugin) factory := o.RouterSelection.NewFactory(oc, kc) controller := factory.Create(plugin) diff --git a/pkg/cmd/infra/router/router.go b/pkg/cmd/infra/router/router.go index 61575c684f6f..6805cbe9cb1a 100644 --- a/pkg/cmd/infra/router/router.go +++ b/pkg/cmd/infra/router/router.go @@ -43,6 +43,15 @@ type RouterSelection struct { ProjectLabels labels.Selector IncludeUDP bool + + DeniedDomains []string + BlacklistedDomains sets.String + + AllowedDomains []string + WhitelistedDomains sets.String + + AllowWildcardRoutes bool + RestrictSubdomainOwnership bool } // Bind sets the appropriate labels @@ -55,6 +64,9 @@ func (o *RouterSelection) Bind(flag *pflag.FlagSet) { flag.StringVar(&o.ProjectLabelSelector, "project-labels", cmdutil.Env("PROJECT_LABELS", ""), "A label selector to apply to projects to watch; if '*' watches all projects the client can access") flag.StringVar(&o.NamespaceLabelSelector, "namespace-labels", cmdutil.Env("NAMESPACE_LABELS", ""), "A label selector to apply to namespaces to watch") flag.BoolVar(&o.IncludeUDP, "include-udp-endpoints", false, "If true, UDP endpoints will be considered as candidates for routing") + flag.StringSliceVar(&o.DeniedDomains, "denied-domains", envVarAsStrings("ROUTER_DENIED_DOMAINS", "", ","), "List of comma separated domains to deny in routes") + flag.StringSliceVar(&o.AllowedDomains, "allowed-domains", envVarAsStrings("ROUTER_ALLOWED_DOMAINS", "", ","), "List of comma separated domains to allow in routes. If specified, only the domains in this list will be allowed routes. Note that domains in the denied list take precedence over the ones in the allowed list") + flag.BoolVar(&o.AllowWildcardRoutes, "allow-wildcard-routes", cmdutil.Env("ROUTER_ALLOW_WILDCARD_ROUTES", "") == "true", "Allow wildcard host names for routes") } // RouteSelectionFunc returns a func that identifies the host for a route. @@ -83,6 +95,50 @@ func (o *RouterSelection) RouteSelectionFunc() controller.RouteHostFunc { } } +func (o *RouterSelection) AdmissionCheck(route *routeapi.Route) error { + if len(route.Spec.Host) < 1 { + return nil + } + + if hostInDomainList(route.Spec.Host, o.BlacklistedDomains) { + glog.V(4).Infof("host %s in list of denied domains", route.Spec.Host) + return fmt.Errorf("host in list of denied domains") + } + + if o.WhitelistedDomains.Len() > 0 { + glog.V(4).Infof("Checking if host %s is in the list of allowed domains", route.Spec.Host) + if hostInDomainList(route.Spec.Host, o.WhitelistedDomains) { + glog.V(4).Infof("host %s admitted - in the list of allowed domains", route.Spec.Host) + return nil + } + + glog.V(4).Infof("host %s rejected - not in the list of allowed domains", route.Spec.Host) + return fmt.Errorf("host not in the allowed list of domains") + } + + glog.V(4).Infof("host %s admitted", route.Spec.Host) + return nil +} + +// RouteAdmissionFunc returns a func that checks if a route can be admitted +// based on blacklist & whitelist checks and wildcard routes policy setting. +// Note: The blacklist settings trumps the whitelist ones. +func (o *RouterSelection) RouteAdmissionFunc() controller.RouteAdmissionFunc { + return func(route *routeapi.Route) error { + if err := o.AdmissionCheck(route); err != nil { + return err + } + + if _, wildcard := routeapi.NormalizeWildcardHost(route.Spec.Host); wildcard { + if !o.AllowWildcardRoutes { + return fmt.Errorf("wildcard routes are not allowed") + } + } + + return nil + } +} + // Complete converts string representations of field and label selectors to their parsed equivalent, or // returns an error. func (o *RouterSelection) Complete() error { @@ -138,6 +194,13 @@ func (o *RouterSelection) Complete() error { } o.NamespaceLabels = s } + + o.BlacklistedDomains = sets.NewString(o.DeniedDomains...) + o.WhitelistedDomains = sets.NewString(o.AllowedDomains...) + + // Restrict subdomains is currently enforced for wildcard routes. + o.RestrictSubdomainOwnership = o.AllowWildcardRoutes + return nil } @@ -198,3 +261,28 @@ func (n namespaceNames) NamespaceNames() (sets.String, error) { } return names, nil } + +func envVarAsStrings(name, defaultValue, seperator string) []string { + strlist := []string{} + if env := cmdutil.Env(name, defaultValue); env != "" { + values := strings.Split(env, seperator) + for i := range values { + if val := strings.TrimSpace(values[i]); val != "" { + strlist = append(strlist, val) + } + } + } + return strlist +} + +func hostInDomainList(host string, domains sets.String) bool { + if domains.Has(host) { + return true + } + + if idx := strings.IndexRune(host, '.'); idx > 0 { + return hostInDomainList(host[idx+1:], domains) + } + + return false +} diff --git a/pkg/cmd/infra/router/template.go b/pkg/cmd/infra/router/template.go index c6be93213327..482b9c833351 100644 --- a/pkg/cmd/infra/router/template.go +++ b/pkg/cmd/infra/router/template.go @@ -207,7 +207,8 @@ func (o *TemplateRouterOptions) Run() error { if o.ExtendedValidation { nextPlugin = controller.NewExtendedValidator(nextPlugin, controller.RejectionRecorder(statusPlugin)) } - plugin := controller.NewUniqueHost(nextPlugin, o.RouteSelectionFunc(), controller.RejectionRecorder(statusPlugin)) + uniqueHostPlugin := controller.NewUniqueHost(nextPlugin, o.RouteSelectionFunc(), controller.RejectionRecorder(statusPlugin)) + plugin := controller.NewHostAdmitter(uniqueHostPlugin, o.RouteAdmissionFunc(), o.RestrictSubdomainOwnership, controller.RejectionRecorder(statusPlugin)) factory := o.RouterSelection.NewFactory(oc, kc) controller := factory.Create(plugin) diff --git a/pkg/route/api/helper.go b/pkg/route/api/helper.go index 1d6e92588e68..8b1e66a8ea1e 100644 --- a/pkg/route/api/helper.go +++ b/pkg/route/api/helper.go @@ -1,9 +1,15 @@ package api import ( + "strings" + kapi "k8s.io/kubernetes/pkg/api" ) +const ( + RouteWildcardPrefix = "*." +) + // IngressConditionStatus returns the first status and condition matching the provided ingress condition type. Conditions // prefer the first matching entry and clients are allowed to ignore later conditions of the same type. func IngressConditionStatus(ingress *RouteIngress, t RouteIngressConditionType) (kapi.ConditionStatus, RouteIngressCondition) { @@ -20,11 +26,29 @@ func RouteLessThan(route1, route2 *Route) bool { if route1.CreationTimestamp.Before(route2.CreationTimestamp) { return true } - if route1.CreationTimestamp == route2.CreationTimestamp && route1.UID < route2.UID { - return true + + if route1.CreationTimestamp == route2.CreationTimestamp { + if route1.UID < route2.UID { + return true + } + if route1.Namespace < route2.Namespace { + return true + } + return route1.Name < route2.Name } - if route1.Namespace < route2.Namespace { - return true + + return false +} + +// NormalizeWildcardHost tests if a host is wildcarded and returns +// the "normalized" (domain name currently) form of the host. +func NormalizeWildcardHost(host string) (string, bool) { + if len(host) > 0 { + if strings.HasPrefix(host, RouteWildcardPrefix) { + // For wildcard hosts, strip the prefix. + return host[len(RouteWildcardPrefix):], true + } } - return route1.Name < route2.Name + + return host, false } diff --git a/pkg/route/api/helper_test.go b/pkg/route/api/helper_test.go index be670df3c77c..9be2f54be3c0 100644 --- a/pkg/route/api/helper_test.go +++ b/pkg/route/api/helper_test.go @@ -9,9 +9,12 @@ import ( ) func TestRouteLessThan(t *testing.T) { + current := unversioned.Now() + older := unversioned.Time{Time: current.Add(-1 * time.Minute)} + r := Route{ ObjectMeta: kapi.ObjectMeta{ - CreationTimestamp: unversioned.Now().Rfc3339Copy(), + CreationTimestamp: current.Rfc3339Copy(), UID: "alpha", Namespace: "alpha", Name: "alpha", @@ -49,6 +52,27 @@ func TestRouteLessThan(t *testing.T) { Name: "beta", }, }, true}, + {Route{ + ObjectMeta: kapi.ObjectMeta{ + CreationTimestamp: older, + UID: r.UID, + Namespace: r.Namespace, + Name: "beta", + }, + }, false}, + {Route{ + ObjectMeta: kapi.ObjectMeta{ + CreationTimestamp: older, + UID: r.UID, + Name: "gamma", + }, + }, false}, + {Route{ + ObjectMeta: kapi.ObjectMeta{ + CreationTimestamp: older, + Name: "delta", + }, + }, false}, {r, false}, } @@ -64,3 +88,53 @@ func TestRouteLessThan(t *testing.T) { } } } + +func TestNormalizeWildcardHost(t *testing.T) { + tests := []struct { + name string + host string + expectation string + wildcard bool + }{ + { + name: "plain", + host: "www.host.test", + expectation: "www.host.test", + wildcard: false, + }, + { + name: "aceswild", + host: "*.aceswild.test", + expectation: "aceswild.test", + wildcard: true, + }, + { + name: "otherwild", + host: "aces.*.test", + expectation: "aces.*.test", + wildcard: false, + }, + { + name: "Invalid host", + host: "*.aces.*.test", + expectation: "aces.*.test", + wildcard: true, + }, + { + name: "No host", + host: "", + expectation: "", + wildcard: false, + }, + } + + for _, tc := range tests { + host, flag := NormalizeWildcardHost(tc.host) + + if flag != tc.wildcard { + t.Errorf("Test case %s expected %t got %t", tc.name, tc.wildcard, flag) + } else if host != tc.expectation { + t.Errorf("Test case %s expected %v got %v", tc.name, tc.expectation, host) + } + } +} diff --git a/pkg/route/api/validation/validation.go b/pkg/route/api/validation/validation.go index 1db3c4794790..18e44890e4fb 100644 --- a/pkg/route/api/validation/validation.go +++ b/pkg/route/api/validation/validation.go @@ -26,7 +26,8 @@ func ValidateRoute(route *routeapi.Route) field.ErrorList { //host is not required but if it is set ensure it meets DNS requirements if len(route.Spec.Host) > 0 { - if len(kvalidation.IsDNS1123Subdomain(route.Spec.Host)) != 0 { + hostname, _ := routeapi.NormalizeWildcardHost(route.Spec.Host) + if len(kvalidation.IsDNS1123Subdomain(hostname)) != 0 { result = append(result, field.Invalid(specPath.Child("host"), route.Spec.Host, "host must conform to DNS 952 subdomain conventions")) } } diff --git a/pkg/route/api/validation/validation_test.go b/pkg/route/api/validation/validation_test.go index 0983218cb83f..299c0335a5d7 100644 --- a/pkg/route/api/validation/validation_test.go +++ b/pkg/route/api/validation/validation_test.go @@ -181,6 +181,34 @@ func TestValidateRoute(t *testing.T) { }, expectedErrors: 1, }, + { + name: "Wildcard host", + route: &api.Route{ + ObjectMeta: kapi.ObjectMeta{ + Name: "name", + Namespace: "aceswild", + }, + Spec: api.RouteSpec{ + Host: "*.aceswild.com", + To: createRouteSpecTo("serviceName", "Service"), + }, + }, + expectedErrors: 0, + }, + { + name: "Invalid Wildcard host", + route: &api.Route{ + ObjectMeta: kapi.ObjectMeta{ + Name: "name", + Namespace: "wildly", + }, + Spec: api.RouteSpec{ + Host: "*.not.*.wild.ly", + To: createRouteSpecTo("serviceName", "Service"), + }, + }, + expectedErrors: 1, + }, { name: "No service name", route: &api.Route{ @@ -997,3 +1025,146 @@ func TestExtendedValidateRoute(t *testing.T) { } } } + +func TestValidateRouteWildcard(t *testing.T) { + tests := []struct { + name string + route *api.Route + errorExpectation bool + }{ + { + name: "No Name", + route: &api.Route{ + ObjectMeta: kapi.ObjectMeta{ + Namespace: "foo", + }, + Spec: api.RouteSpec{ + Host: "host", + To: createRouteSpecTo("serviceName", "Service"), + }, + }, + errorExpectation: false, + }, + { + name: "Named host", + route: &api.Route{ + ObjectMeta: kapi.ObjectMeta{ + Name: "named", + }, + Spec: api.RouteSpec{ + Host: "www.name.test", + To: createRouteSpecTo("serviceName", "Service"), + }, + }, + errorExpectation: false, + }, + { + name: "aceswild", + route: &api.Route{ + ObjectMeta: kapi.ObjectMeta{ + Name: "aceswild", + }, + Spec: api.RouteSpec{ + Host: "*.aceswild.test", + To: createRouteSpecTo("serviceName", "Service"), + }, + }, + errorExpectation: false, + }, + { + name: "another wild", + route: &api.Route{ + ObjectMeta: kapi.ObjectMeta{ + Name: "anotherwild", + }, + Spec: api.RouteSpec{ + Host: "*.where.the.wild.things.ar", + To: createRouteSpecTo("serviceName", "Service"), + }, + }, + errorExpectation: false, + }, + { + name: "Invalid host", + route: &api.Route{ + ObjectMeta: kapi.ObjectMeta{ + Name: "invalid", + Namespace: "foo", + }, + Spec: api.RouteSpec{ + Host: "aces.*.test", + To: createRouteSpecTo("serviceName", "Service"), + }, + }, + errorExpectation: true, + }, + { + name: "Bad wildcard host", + route: &api.Route{ + ObjectMeta: kapi.ObjectMeta{ + Name: "badwildcard", + Namespace: "foo", + }, + Spec: api.RouteSpec{ + Host: "*.aces.*.test", + To: createRouteSpecTo("serviceName", "Service"), + }, + }, + errorExpectation: true, + }, + { + name: "Another bad wildcard host", + route: &api.Route{ + ObjectMeta: kapi.ObjectMeta{ + Name: "badwildcard2", + Namespace: "foo", + }, + Spec: api.RouteSpec{ + Host: "*aces.wild.test", + To: createRouteSpecTo("serviceName", "Service"), + }, + }, + errorExpectation: true, + }, + { + name: "Yet another bad wildcard host", + route: &api.Route{ + ObjectMeta: kapi.ObjectMeta{ + Name: "badwildcard3", + Namespace: "foo", + }, + Spec: api.RouteSpec{ + Host: "aces*.wild.test", + To: createRouteSpecTo("serviceName", "Service"), + }, + }, + errorExpectation: true, + }, + { + name: "And one more bad wildcard host", + route: &api.Route{ + ObjectMeta: kapi.ObjectMeta{ + Name: "badwildcard4", + Namespace: "foo", + }, + Spec: api.RouteSpec{ + Host: "a*es.wild.test", + To: createRouteSpecTo("serviceName", "Service"), + }, + }, + errorExpectation: true, + }, + } + + for _, tc := range tests { + errs := ValidateRoute(tc.route) + + if tc.errorExpectation { + if len(errs) == 0 { + t.Errorf("Test case %s expected error(s), got none.", tc.name) + } + } else if len(errs) > 1 { + t.Errorf("Test case %s expected no error(s), got %d: %v", tc.name, len(errs), errs) + } + } +} diff --git a/pkg/router/controller/host_admitter.go b/pkg/router/controller/host_admitter.go new file mode 100644 index 000000000000..ba3a063edbee --- /dev/null +++ b/pkg/router/controller/host_admitter.go @@ -0,0 +1,204 @@ +package controller + +import ( + "fmt" + "strings" + + "github.com/golang/glog" + kapi "k8s.io/kubernetes/pkg/api" + "k8s.io/kubernetes/pkg/util/sets" + "k8s.io/kubernetes/pkg/watch" + + routeapi "github.com/openshift/origin/pkg/route/api" + "github.com/openshift/origin/pkg/router" +) + +// RouteAdmissionFunc determines whether or not to admit a route. +type RouteAdmissionFunc func(*routeapi.Route) error + +// SubdomainToRouteMap contains all routes associated with a subdomain - +// fully qualified and wildcard routes. +type SubdomainToRouteMap map[string][]*routeapi.Route + +// RemoveRoute removes any existing route(s) for a subdomain. +func (srm SubdomainToRouteMap) RemoveRoute(key string, route *routeapi.Route) bool { + k := 0 + removed := false + + m := srm[key] + for i, v := range m { + if m[i].Namespace == route.Namespace && m[i].Name == route.Name { + removed = true + } else { + m[k] = v + k++ + } + } + + // set the slice length to the final size. + m = m[:k] + + if len(m) > 0 { + srm[key] = m + } else { + delete(srm, key) + } + + return removed +} + +func (srm SubdomainToRouteMap) InsertRoute(key string, route *routeapi.Route) { + // To replace any existing route[s], first we remove all old entries. + srm.RemoveRoute(key, route) + + m := srm[key] + for idx := range m { + if routeapi.RouteLessThan(route, m[idx]) { + m = append(m, &routeapi.Route{}) + // From: https://github.com/golang/go/wiki/SliceTricks + copy(m[idx+1:], m[idx:]) + m[idx] = route + srm[key] = m + + // Ensure we return from here as we change the iterator. + return + } + } + + // Newest route or empty slice, add to the end. + srm[key] = append(m, route) +} + +// HostAdmitter implements the router.Plugin interface to add admission +// control checks for routes in template based, backend-agnostic routers. +type HostAdmitter struct { + // plugin is the next plugin in the chain. + plugin router.Plugin + + // admitter is a route admission function used to determine whether + // or not to admit routes. + admitter RouteAdmissionFunc + + // recorder is an interface for indicating route rejections. + recorder RejectionRecorder + + // restrictOwnership adds admission checks to restrict ownership + // (of subdomains) to a single owner/namespace. + restrictOwnership bool + + // subdomainToRoute contains all routes associated with a subdomain + // (includes fully qualified and wildcard routes). + subdomainToRoute SubdomainToRouteMap +} + +// NewHostAdmitter creates a plugin wrapper that checks whether or not to +// admit routes and relay them to the next plugin in the chain. +// Recorder is an interface for indicating why a route was rejected. +func NewHostAdmitter(plugin router.Plugin, fn RouteAdmissionFunc, restrict bool, recorder RejectionRecorder) *HostAdmitter { + return &HostAdmitter{ + plugin: plugin, + admitter: fn, + recorder: recorder, + + restrictOwnership: restrict, + subdomainToRoute: make(SubdomainToRouteMap), + } +} + +// HandleEndpoints processes watch events on the Endpoints resource. +func (p *HostAdmitter) HandleEndpoints(eventType watch.EventType, endpoints *kapi.Endpoints) error { + return p.plugin.HandleEndpoints(eventType, endpoints) +} + +// HandleRoute processes watch events on the Route resource. +func (p *HostAdmitter) HandleRoute(eventType watch.EventType, route *routeapi.Route) error { + if err := p.admitter(route); err != nil { + glog.Errorf("Route %s not admitted: %s", routeNameKey(route), err.Error()) + p.recorder.RecordRouteRejection(route, "RouteNotAdmitted", err.Error()) + return err + } + + if p.restrictOwnership && len(route.Spec.Host) > 0 { + switch eventType { + case watch.Added, watch.Modified: + if err := p.addRoute(route); err != nil { + glog.Errorf("Route %s not admitted: %s", routeNameKey(route), err.Error()) + p.recorder.RecordRouteRejection(route, "SubdomainAlreadyClaimed", err.Error()) + return err + } + + case watch.Deleted: + if subdomain := getSubdomain(route.Spec.Host); len(subdomain) > 0 { + p.subdomainToRoute.RemoveRoute(subdomain, route) + } + } + } + + return p.plugin.HandleRoute(eventType, route) +} + +// HandleAllowedNamespaces limits the scope of valid routes to only those that match +// the provided namespace list. +func (p *HostAdmitter) HandleNamespaces(namespaces sets.String) error { + return p.plugin.HandleNamespaces(namespaces) +} + +func (p *HostAdmitter) SetLastSyncProcessed(processed bool) error { + return p.plugin.SetLastSyncProcessed(processed) +} + +// addRoute admits routes based on subdomain ownership - returns errors if the route is not admitted. +func (p *HostAdmitter) addRoute(route *routeapi.Route) error { + subdomain := getSubdomain(route.Spec.Host) + if len(subdomain) == 0 { + return nil + } + + routeList, ok := p.subdomainToRoute[subdomain] + if !ok { + p.subdomainToRoute.InsertRoute(subdomain, route) + return nil + } + + oldest := routeList[0] + if oldest.Namespace == route.Namespace { + p.subdomainToRoute.InsertRoute(subdomain, route) + return nil + } + + // Route is in another namespace, land grab check here. + if routeapi.RouteLessThan(oldest, route) { + glog.V(4).Infof("Route %s cannot take subdomain %s from %s", routeNameKey(route), subdomain, routeNameKey(oldest)) + err := fmt.Errorf("a route in another namespace holds subdomain %s and is older than %s", subdomain, route.Name) + p.recorder.RecordRouteRejection(route, "SubdomainAlreadyClaimed", err.Error()) + return err + } + + // Namespace of this route is now the proud owner of the subdomain. + glog.V(4).Infof("Route %s is reclaiming subdomain %s from namespace %s", routeNameKey(route), subdomain, oldest.Namespace) + + // Delete all the routes belonging to the previous "owner" (namespace). + for idx := range routeList { + msg := fmt.Sprintf("a route in another namespace %s owns subdomain %s", route.Namespace, subdomain) + glog.V(4).Infof("Route %s not admitted: %s", routeNameKey(routeList[idx]), msg) + p.recorder.RecordRouteRejection(routeList[idx], "SubdomainAlreadyClaimed", msg) + p.plugin.HandleRoute(watch.Deleted, routeList[idx]) + } + + // And claim the subdomain. + p.subdomainToRoute[subdomain] = []*routeapi.Route{route} + return nil +} + +func getSubdomain(host string) string { + if len(host) < 1 { + return host + } + + parts := strings.SplitAfterN(host, ".", 2) + if len(parts) < 2 { + return "" + } + + return parts[1] +} diff --git a/pkg/router/controller/host_admitter_test.go b/pkg/router/controller/host_admitter_test.go new file mode 100644 index 000000000000..a11bef1bfbb2 --- /dev/null +++ b/pkg/router/controller/host_admitter_test.go @@ -0,0 +1,317 @@ +package controller + +import ( + "fmt" + "strings" + "testing" + "time" + + kapi "k8s.io/kubernetes/pkg/api" + "k8s.io/kubernetes/pkg/api/unversioned" + "k8s.io/kubernetes/pkg/watch" + + routeapi "github.com/openshift/origin/pkg/route/api" +) + +const ( + BlockedTestDomain = "domain.blocked.test" +) + +type rejectionRecorder struct { + rejections map[string]string +} + +func (_ rejectionRecorder) rejectionKey(route *routeapi.Route) string { + return route.Namespace + "-" + route.Name +} + +func (r rejectionRecorder) RecordRouteRejection(route *routeapi.Route, reason, message string) { + r.rejections[r.rejectionKey(route)] = reason +} + +func wildcardAdmitter(route *routeapi.Route) error { + if len(route.Spec.Host) < 1 { + return nil + } + + if strings.HasSuffix(route.Spec.Host, "."+BlockedTestDomain) { + return fmt.Errorf("host is not allowed") + } + + return nil +} + +func wildcardRejecter(route *routeapi.Route) error { + if len(route.Spec.Host) < 1 { + return nil + } + + if strings.HasSuffix(route.Spec.Host, "."+BlockedTestDomain) { + return fmt.Errorf("host is not allowed") + } + + _, wildcard := routeapi.NormalizeWildcardHost(route.Spec.Host) + if wildcard { + return fmt.Errorf("wildcards not admitted test") + } + + return nil +} + +func TestHostAdmit(t *testing.T) { + p := &fakePlugin{} + admitter := NewHostAdmitter(p, wildcardAdmitter, true, LogRejections) + tests := []struct { + name string + host string + errors bool + }{ + { + name: "nohost", + errors: false, + }, + { + name: "allowed", + host: "www.host.admission.test", + errors: false, + }, + { + name: "blocked", + host: "www." + BlockedTestDomain, + errors: true, + }, + { + name: "wildcard", + host: "*.aces.wild.test", + errors: false, + }, + { + name: "blockedwildcard", + host: "*." + BlockedTestDomain, + errors: true, + }, + } + + for _, tc := range tests { + route := &routeapi.Route{ + ObjectMeta: kapi.ObjectMeta{ + Name: tc.name, + Namespace: "allow", + }, + Spec: routeapi.RouteSpec{Host: tc.host}, + } + + err := admitter.HandleRoute(watch.Added, route) + if tc.errors { + if err == nil { + t.Fatalf("Test case %s expected errors, got none", tc.name) + } + } else { + if err != nil { + t.Fatalf("Test case %s expected no errors, got %v", tc.name, err) + } + } + } +} + +func TestWildcardHostDeny(t *testing.T) { + p := &fakePlugin{} + admitter := NewHostAdmitter(p, wildcardRejecter, false, LogRejections) + tests := []struct { + name string + host string + errors bool + }{ + { + name: "nohost", + errors: false, + }, + { + name: "allowed", + host: "www.host.admission.test", + errors: false, + }, + { + name: "blocked", + host: "www.wildcard." + BlockedTestDomain, + errors: true, + }, + { + name: "wildcard", + host: "*.aces.wild.test", + errors: true, + }, + { + name: "blockedwildcard", + host: "*.wildcard." + BlockedTestDomain, + errors: true, + }, + { + name: "anotherblockedwildcard", + host: "api.wildcard." + BlockedTestDomain, + errors: true, + }, + } + + for _, tc := range tests { + route := &routeapi.Route{ + ObjectMeta: kapi.ObjectMeta{ + Name: tc.name, + Namespace: "deny", + }, + Spec: routeapi.RouteSpec{Host: tc.host}, + } + + err := admitter.HandleRoute(watch.Added, route) + if tc.errors { + if err == nil { + t.Fatalf("Test case %s expected errors, got none", tc.name) + } + } else { + if err != nil { + t.Fatalf("Test case %s expected no errors, got %v", tc.name, err) + } + } + } +} + +func TestWildcardSubDomainOwnership(t *testing.T) { + p := &fakePlugin{} + + recorder := rejectionRecorder{rejections: make(map[string]string)} + admitter := NewHostAdmitter(p, wildcardAdmitter, true, recorder) + + oldest := unversioned.Time{Time: time.Now()} + + ownerRoute := &routeapi.Route{ + ObjectMeta: kapi.ObjectMeta{ + CreationTimestamp: oldest, + Name: "first", + Namespace: "owner", + }, + Spec: routeapi.RouteSpec{ + Host: "owner.namespace.test", + }, + } + + err := admitter.HandleRoute(watch.Added, ownerRoute) + if err != nil { + t.Fatalf("Owner route not admitted: %v", err) + } + + tests := []struct { + createdAt unversioned.Time + name string + namespace string + host string + reason string + }{ + { + name: "nohost", + namespace: "something", + }, + { + name: "blockedhost", + namespace: "blocked", + host: "www.wildcard." + BlockedTestDomain, + reason: "RouteNotAdmitted", + }, + { + createdAt: unversioned.Time{Time: oldest.Add(2 * time.Hour)}, + name: "diffnamespace", + namespace: "notowner", + host: "www.namespace.test", + reason: "SubdomainAlreadyClaimed", + }, + { + createdAt: unversioned.Time{Time: oldest.Add(2 * time.Hour)}, + name: "diffns2", + namespace: "fortytwo", + host: "www.namespace.test", + reason: "SubdomainAlreadyClaimed", + }, + { + createdAt: unversioned.Time{Time: oldest.Add(3 * time.Hour)}, + name: "host2diffns2", + namespace: "fortytwo", + host: "api.namespace.test", + reason: "SubdomainAlreadyClaimed", + }, + { + createdAt: unversioned.Time{Time: oldest.Add(4 * time.Hour)}, + name: "ownernshost", + namespace: "owner", + host: "api.namespace.test", + }, + } + + for _, tc := range tests { + route := &routeapi.Route{ + ObjectMeta: kapi.ObjectMeta{ + CreationTimestamp: tc.createdAt, + Name: tc.name, + Namespace: tc.namespace, + }, + Spec: routeapi.RouteSpec{Host: tc.host}, + } + + err := admitter.HandleRoute(watch.Added, route) + if tc.reason != "" { + if err == nil { + t.Fatalf("Test case %s expected errors, got none", tc.name) + } + + k := recorder.rejectionKey(route) + if recorder.rejections[k] != tc.reason { + t.Fatalf("Test case %s expected error %s, got %s", tc.name, tc.reason, recorder.rejections[k]) + } + } else { + if err != nil { + t.Fatalf("Test case %s expected no errors, got %v", tc.name, err) + } + } + } + + wildcardRoute := &routeapi.Route{ + ObjectMeta: kapi.ObjectMeta{ + CreationTimestamp: unversioned.Time{Time: oldest.Add(time.Hour)}, + Name: "wildcard-owner", + Namespace: "owner", + }, + Spec: routeapi.RouteSpec{ + Host: "*.namespace.test", + }, + } + + err = admitter.HandleRoute(watch.Added, wildcardRoute) + if err != nil { + t.Fatalf("Wildcard route not admitted: %v", err) + } + + // bounce all the routes from the namespace "owner" and claim + // ownership of the subdomain for the namespace "bouncer". + bouncer := &routeapi.Route{ + ObjectMeta: kapi.ObjectMeta{ + CreationTimestamp: unversioned.Time{Time: oldest.Add(-1 * time.Hour)}, + Name: "hosted", + Namespace: "bouncer", + }, + Spec: routeapi.RouteSpec{ + Host: "api.namespace.test", + }, + } + + err = admitter.HandleRoute(watch.Added, bouncer) + if err != nil { + t.Fatalf("bouncer route expected no errors, got %v", err) + } + + // The bouncer route should kick out the owner and wildcard routes. + bouncedRoutes := []*routeapi.Route{ownerRoute, wildcardRoute} + for _, route := range bouncedRoutes { + k := recorder.rejectionKey(route) + if recorder.rejections[k] != "SubdomainAlreadyClaimed" { + t.Fatalf("bounced route %s expected a subdomain already claimed error, got %s", k, recorder.rejections[k]) + } + } +} diff --git a/pkg/router/template/plugin.go b/pkg/router/template/plugin.go index 802587e988aa..b7497efff146 100644 --- a/pkg/router/template/plugin.go +++ b/pkg/router/template/plugin.go @@ -107,6 +107,8 @@ func NewTemplatePlugin(cfg TemplatePluginConfig, lookupSvc ServiceLookup) (*Temp "matchPattern": matchPattern, //anchors provided regular expression and evaluates against given string "isInteger": isInteger, //determines if a given variable is an integer "matchValues": matchValues, //compares a given string to a list of allowed strings + + "genDomainWildcardRegexp": genDomainWildcardRegexp, //generates a regular expression matching wildcard hosts (and paths) for a [sub]domain } masterTemplate, err := template.New("config").Funcs(globalFuncs).ParseFiles(cfg.TemplatePath) if err != nil { diff --git a/pkg/router/template/router.go b/pkg/router/template/router.go index b0d33d3150c8..c8dd288c4dd9 100644 --- a/pkg/router/template/router.go +++ b/pkg/router/template/router.go @@ -203,6 +203,23 @@ func matchPattern(pattern, s string) bool { return false } +// Generate a regular expression to match wildcard hosts (and paths if any) +// for a [sub]domain. +func genDomainWildcardRegexp(hostname, path string, exactPath bool) string { + route := &routeapi.Route{Spec: routeapi.RouteSpec{Host: hostname}} + host, wildcard := routeapi.NormalizeWildcardHost(route.Spec.Host) + if !wildcard { + return fmt.Sprintf("%s%s", host, path) + } + + expr := regexp.QuoteMeta(fmt.Sprintf(".%s%s", host, path)) + if exactPath { + return fmt.Sprintf("[^\\.]*%s", expr) + } + + return fmt.Sprintf("[^\\.]*%s(|/.*)", expr) +} + func endpointsForAlias(alias ServiceAliasConfig, svc ServiceUnit) []Endpoint { if len(alias.PreferPort) == 0 { return svc.EndpointTable @@ -514,6 +531,7 @@ func (r *templateRouter) routeKey(route *routeapi.Route) string { func (r *templateRouter) AddRoute(serviceID string, weight int32, route *routeapi.Route, host string) bool { backendKey := r.routeKey(route) + _, wildcard := routeapi.NormalizeWildcardHost(route.Spec.Host) config, ok := r.state[backendKey] if !ok { @@ -522,6 +540,7 @@ func (r *templateRouter) AddRoute(serviceID string, weight int32, route *routeap Namespace: route.Namespace, Host: host, Path: route.Spec.Path, + IsWildcard: wildcard, Annotations: route.Annotations, ServiceUnitNames: make(map[string]int32), } diff --git a/pkg/router/template/types.go b/pkg/router/template/types.go index cc348e24ef90..b7ac70c46910 100644 --- a/pkg/router/template/types.go +++ b/pkg/router/template/types.go @@ -43,6 +43,9 @@ type ServiceAliasConfig struct { // Hash of the route name - used to obscure cookieId RoutingKeyName string + // IsWildcard indicates this service unit needs wildcarding support. + IsWildcard bool + // Annotations attached to this route Annotations map[string]string