Skip to content

Commit

Permalink
Add support for wildcard hosts on Ingresses (#3381)
Browse files Browse the repository at this point in the history
- Wildcard virtualhosts now allowed
- Envoy's virtualhost matching does not match on a single DNS label like
we want to implement
- For wildcard host routes, we add a header match rule
to match on the ":authority" header that is more strict and will only
match one DNS label wildcard
- Adds an integration test to validate behavior with a wildcard
certificate
- Passes ingress conformance tests for wildcards
- Adds the "regex" header match strategy (only internal for now)
- Ensure regex header matches are sorted correctly (after exact, before
contains matches)

Fixes #2138
Updates #2139

Signed-off-by: Sunjay Bhatia <[email protected]>
  • Loading branch information
sunjayBhatia authored Apr 26, 2021
1 parent 2982d58 commit c7e1f44
Show file tree
Hide file tree
Showing 17 changed files with 599 additions and 62 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ check-integration:
integration: check-integration
./_integration/testsuite/make-kind-cluster.sh
./_integration/testsuite/install-contour-working.sh
./_integration/testsuite/run-test-case.sh ./_integration/testsuite/httpproxy/*.yaml ./_integration/testsuite/gatewayapi/*.yaml
./_integration/testsuite/run-test-case.sh ./_integration/testsuite/ingress/*.yaml ./_integration/testsuite/httpproxy/*.yaml ./_integration/testsuite/gatewayapi/*.yaml
./_integration/testsuite/cleanup.sh

check-ingress-conformance: ## Run Ingress controller conformance
Expand Down
165 changes: 165 additions & 0 deletions _integration/testsuite/ingress/001-ingress-tls-wildcard-host.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# Copyright Project Contour 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.

import data.contour.resources

# Ensure that cert-manager is installed.
# Version check the certificates resource.

Group := "cert-manager.io"
Version := "v1"

have_certmanager_version {
v := resources.versions["certificates"]
v[_].Group == Group
v[_].Version == Version
}

skip[msg] {
not resources.is_supported("certificates")
msg := "cert-manager is not installed"
}

skip[msg] {
not have_certmanager_version

avail := resources.versions["certificates"]

msg := concat("\n", [
sprintf("cert-manager version %s/%s is not installed", [Group, Version]),
"available versions:",
yaml.marshal(avail)
])
}

---

# Create a self-signed issuer to give us secrets.

apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: selfsigned
spec:
selfSigned: {}

---

apiVersion: apps/v1
kind: Deployment
metadata:
name: ingress-conformance-echo
$apply:
fixture:
as: echo

---

apiVersion: v1
kind: Service
metadata:
name: ingress-conformance-echo
$apply:
fixture:
as: echo

---

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wildcard-cert
spec:
dnsNames:
- "*.projectcontour.io"
secretName: wildcard
issuerRef:
name: selfsigned

---

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wildcard-ingress
spec:
tls:
- hosts:
- "*.projectcontour.io"
secretName: wildcard
rules:
- host: "*.projectcontour.io"
http:
paths:
- pathType: Prefix
path: "/"
backend:
service:
name: echo
port:
number: 80

---

import data.contour.http.client
import data.contour.http.client.url
import data.contour.http.expect

import data.builtin.result

# Hostname, SNI, expected status code.
cases := [
[ "random1.projectcontour.io", "", 200 ],
[ "random2.projectcontour.io", "random2.projectcontour.io", 200 ],
[ "a.random3.projectcontour.io", "a.random3.projectcontour.io", 404 ],
[ "random4.projectcontour.io", "other-random4.projectcontour.io", 421 ],
[ "random5.projectcontour.io", "a.random5.projectcontour.io", 421 ],
[ "random6.projectcontour.io:9999", "random6.projectcontour.io", 200 ],
]

request_for_host[host] = request {
c := cases[_]
host := c[0]
sni := c[1]
request := {
"method": "GET",
"url": url.https("/echo"),
"headers": {
"Host": host,
"User-Agent": client.ua("ingress-tls-wildcard"),
},
"tls_server_name": sni,
"tls_insecure_skip_verify": true,
}
}

response_for_host[host] = resp {
c := cases[_]
host := c[0]
request := request_for_host[host]
resp := http.send(request)
}

# Ensure that we get a response for each test case.
error_missing_responses {
count(cases) != count(response_for_host)
}

check_for_status_code [msg] {
c := cases[_]
host := c[0]
status := c[2]
resp := response_for_host[host]
msg := expect.response_status_is(resp, status)
}
57 changes: 30 additions & 27 deletions internal/dag/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2620,16 +2620,7 @@ func TestDAGInsert(t *testing.T) {
},
Spec: networking_v1.IngressSpec{
Rules: []networking_v1.IngressRule{{
// no hostname
IngressRuleValue: networking_v1.IngressRuleValue{
HTTP: &networking_v1.HTTPIngressRuleValue{
Paths: []networking_v1.HTTPIngressPath{{
Backend: *backendv1("kuard", intstr.FromString("http")),
}},
},
},
}, {
Host: "*",
// No hostname.
IngressRuleValue: networking_v1.IngressRuleValue{
HTTP: &networking_v1.HTTPIngressRuleValue{
Paths: []networking_v1.HTTPIngressPath{{
Expand All @@ -2638,11 +2629,13 @@ func TestDAGInsert(t *testing.T) {
},
},
}, {
// Allow wildcard as first label.
// K8s will only allow hostnames with wildcards of this form.
Host: "*.example.com",
IngressRuleValue: networking_v1.IngressRuleValue{
HTTP: &networking_v1.HTTPIngressRuleValue{
Paths: []networking_v1.HTTPIngressPath{{
Backend: *backendv1("kuarder", intstr.FromInt(8080)),
Backend: *backendv1("kuard", intstr.FromString("http")),
}},
},
},
Expand Down Expand Up @@ -3339,7 +3332,7 @@ func TestDAGInsert(t *testing.T) {
},
Spec: v1beta1.IngressSpec{
Rules: []v1beta1.IngressRule{{
// no hostname
// No hostname.
IngressRuleValue: v1beta1.IngressRuleValue{
HTTP: &v1beta1.HTTPIngressRuleValue{
Paths: []v1beta1.HTTPIngressPath{{
Expand All @@ -3350,7 +3343,9 @@ func TestDAGInsert(t *testing.T) {
},
},
}, {
Host: "*",
// Allow wildcard as first label.
// K8s will only allow hostnames with wildcards of this form.
Host: "*.example.com",
IngressRuleValue: v1beta1.IngressRuleValue{
HTTP: &v1beta1.HTTPIngressRuleValue{
Paths: []v1beta1.HTTPIngressPath{{
Expand All @@ -3361,18 +3356,6 @@ func TestDAGInsert(t *testing.T) {
}},
},
},
}, {
Host: "*.example.com",
IngressRuleValue: v1beta1.IngressRuleValue{
HTTP: &v1beta1.HTTPIngressRuleValue{
Paths: []v1beta1.HTTPIngressPath{{
Backend: v1beta1.IngressBackend{
ServiceName: "kuarder",
ServicePort: intstr.FromInt(8080),
},
}},
},
},
}},
},
}
Expand Down Expand Up @@ -7343,7 +7326,6 @@ func TestDAGInsert(t *testing.T) {
},
),
},
// issue 1234
"insert ingress with wildcard hostnames": {
objs: []interface{}{
s1,
Expand All @@ -7354,6 +7336,17 @@ func TestDAGInsert(t *testing.T) {
Port: 80,
VirtualHosts: virtualhosts(
virtualhost("*", prefixroute("/", service(s1))),
virtualhost("*.example.com", &Route{
PathMatchCondition: &PrefixMatchCondition{Prefix: "/"},
HeaderMatchConditions: []HeaderMatchCondition{
{
Name: ":authority",
MatchType: HeaderMatchTypeRegex,
Value: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?\\.example\\.com",
},
},
Clusters: clusters(service(s1)),
}),
),
},
),
Expand Down Expand Up @@ -7592,7 +7585,6 @@ func TestDAGInsert(t *testing.T) {
},
),
},
// issue 1234
"ingressv1: insert ingress with wildcard hostnames": {
objs: []interface{}{
s1,
Expand All @@ -7603,6 +7595,17 @@ func TestDAGInsert(t *testing.T) {
Port: 80,
VirtualHosts: virtualhosts(
virtualhost("*", prefixroute("/", service(s1))),
virtualhost("*.example.com", &Route{
PathMatchCondition: &PrefixMatchCondition{Prefix: "/"},
HeaderMatchConditions: []HeaderMatchCondition{
{
Name: ":authority",
MatchType: HeaderMatchTypeRegex,
Value: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?\\.example\\.com",
},
},
Clusters: clusters(service(s1)),
}),
),
},
),
Expand Down
4 changes: 4 additions & 0 deletions internal/dag/dag.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ const (

// HeaderMatchTypePresent matches a header if it is present in a request.
HeaderMatchTypePresent = "present"

// HeaderMatchTypeRegex matches a header if it matches the provided regular
// expression.
HeaderMatchTypeRegex = "regex"
)

// HeaderMatchCondition matches request headers by MatchType
Expand Down
33 changes: 26 additions & 7 deletions internal/dag/ingress_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
package dag

import (
"regexp"
"strings"

"k8s.io/apimachinery/pkg/util/intstr"
Expand Down Expand Up @@ -109,12 +110,9 @@ func (p *IngressProcessor) computeIngresses() {

func (p *IngressProcessor) computeIngressRule(ing *networking_v1.Ingress, rule networking_v1.IngressRule) {
host := rule.Host
if strings.Contains(host, "*") {
// reject hosts with wildcard characters.
return
}

// If host name is blank, rewrite to Envoy's * default host.
if host == "" {
// if host name is blank, rewrite to Envoy's * default host.
host = "*"
}

Expand Down Expand Up @@ -156,7 +154,7 @@ func (p *IngressProcessor) computeIngressRule(ing *networking_v1.Ingress, rule n
continue
}

r, err := route(ing, path, pathType, s, clientCertSecret, p.FieldLogger)
r, err := route(ing, rule.Host, path, pathType, s, clientCertSecret, p.FieldLogger)
if err != nil {
p.WithError(err).
WithField("name", ing.GetName()).
Expand All @@ -181,8 +179,12 @@ func (p *IngressProcessor) computeIngressRule(ing *networking_v1.Ingress, rule n
}
}

const singleDNSLabelWildcardRegex = "^[a-z0-9]([-a-z0-9]*[a-z0-9])?"

var _ = regexp.MustCompile(singleDNSLabelWildcardRegex)

// route builds a dag.Route for the supplied Ingress.
func route(ingress *networking_v1.Ingress, path string, pathType networking_v1.PathType, service *Service, clientCertSecret *Secret, log logrus.FieldLogger) (*Route, error) {
func route(ingress *networking_v1.Ingress, host string, path string, pathType networking_v1.PathType, service *Service, clientCertSecret *Secret, log logrus.FieldLogger) (*Route, error) {
log = log.WithFields(logrus.Fields{
"name": ingress.Name,
"namespace": ingress.Namespace,
Expand Down Expand Up @@ -228,6 +230,23 @@ func route(ingress *networking_v1.Ingress, path string, pathType networking_v1.P
}
}

// If we have a wildcard match, add a header match regex rule to match the
// hostname so we can be sure to only match one DNS label. This is required
// as Envoy's virtualhost hostname wildcard matching can match multiple
// labels. This match ignores a port in the hostname in case it is present.
if strings.HasPrefix(host, "*.") {
r.HeaderMatchConditions = []HeaderMatchCondition{
{
// Internally Envoy uses the HTTP/2 ":authority" header in
// place of the HTTP/1 "host" header.
// See: https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#config-route-v3-headermatcher
Name: ":authority",
MatchType: HeaderMatchTypeRegex,
Value: singleDNSLabelWildcardRegex + regexp.QuoteMeta(host[1:]),
},
}
}

return r, nil
}

Expand Down
Loading

0 comments on commit c7e1f44

Please sign in to comment.