diff --git a/.github/actions/collect-logs/action.yml b/.github/actions/collect-logs/action.yml index cc69445152..bf74a79d78 100644 --- a/.github/actions/collect-logs/action.yml +++ b/.github/actions/collect-logs/action.yml @@ -34,13 +34,17 @@ runs: if test -f ~/.kube/config; then make tools/bin/kubectl mkdir /tmp/test-logs/cluster + tools/bin/kubectl get hosts --all-namespaces -o yaml >/tmp/test-logs/cluster/all-hosts.yaml || true tools/bin/kubectl get pods --all-namespaces >/tmp/test-logs/cluster/all-pods.txt || true tools/bin/kubectl describe pods --all-namespaces >/tmp/test-logs/cluster/all-pods-described.txt || true tools/bin/kubectl get pods --all-namespaces -ocustom-columns="name:.metadata.name,namespace:.metadata.namespace" --no-headers | while read -r name namespace; do tools/bin/kubectl --namespace="$namespace" logs "$name" >"/tmp/test-logs/cluster/pod.${namespace}.${name}.log" || true done + + tools/bin/kubectl cp xfpredirect:/tmp/ambassador/snapshots /tmp/test-logs/cluster/xfpredirect.snapshots || true fi + cp /tmp/*.yaml /tmp/test-logs || true - name: "Upload Logs" uses: actions/upload-artifact@v2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1163ff7ae9..8e39cbfa77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,15 +132,29 @@ it will be removed; but as it won't be user-visible this isn't considered a brea the specified non-negative window period in seconds before doing an Envoy reconfiguration. Default is "1" if not set. -- Bugfix: If a `Host` or `TLSContext` contained a hostname with a `:` then when using the +- Bugfix: If a `Host` or `TLSContext` contained a hostname with a `:` then when using the diagnostics endpoints `ambassador/v0/diagd` then an error would be thrown due to the parsing logic - not being able to handle the extra colon. This has been fixed and Emissary-ingress will not throw + not being able to handle the extra colon. This has been fixed and Emissary-ingress will not throw an error when parsing envoy metrics for the diagnostics user interface. - Feature: It is now possible to set `custom_tags` in the `TracingService`. Trace tags can be set based on literal values, environment variables, or request headers. (Thanks to Paul!) ([#4181]) +- Bugfix: Emissary-ingress 2.0.0 introduced a bug where a `TCPMapping` that uses SNI, instead of + using the hostname glob in the `TCPMapping`, uses the hostname glob in the `Host` that the TLS + termination configuration comes from. + +- Bugfix: Emissary-ingress 2.0.0 introduced a bug where a `TCPMapping` that terminates TLS must have + a corresponding `Host` that it can take the TLS configuration from. This was semi-intentional, but + didn't make much sense. You can now use a `TLSContext` without a `Host`as in Emissary-ingress 1.y + releases, or a `Host` with or without a `TLSContext` as in prior 2.y releases. + +- Bugfix: Prior releases of Emissary-ingress had the arbitrary limitation that a `TCPMapping` cannot + be used on the same port that HTTP is served on, even if TLS+SNI would make this possible. + Emissary-ingress now allows `TCPMappings` to be used on the same `Listener` port as HTTP `Hosts`, + as long as that `Listener` terminates TLS. + [#4354]: https://github.com/emissary-ingress/emissary/issues/4354 [#4181]: https://github.com/emissary-ingress/emissary/pull/4181 @@ -315,6 +329,20 @@ it will be removed; but as it won't be user-visible this isn't considered a brea the specified non-negative window period in seconds before doing an Envoy reconfiguration. Default is "1" if not set. +- Bugfix: Emissary-ingress 2.0.0 introduced a bug where a `TCPMapping` that uses SNI, instead of + using the hostname glob in the `TCPMapping`, uses the hostname glob in the `Host` that the TLS + termination configuration comes from. + +- Bugfix: Emissary-ingress 2.0.0 introduced a bug where a `TCPMapping` that terminates TLS must have + a corresponding `Host` that it can take the TLS configuration from. This was semi-intentional, but + didn't make much sense. You can now use a `TLSContext` without a `Host`as in Emissary-ingress 1.y + releases, or a `Host` with or without a `TLSContext` as in prior 2.y releases. + +- Bugfix: Prior releases of Emissary-ingress had the arbitrary limitation that a `TCPMapping` cannot + be used on the same port that HTTP is served on, even if TLS+SNI would make this possible. + Emissary-ingress now allows `TCPMappings` to be used on the same `Listener` port as HTTP `Hosts`, + as long as that `Listener` terminates TLS. + ## [1.14.5] TBD [1.14.5]: https://github.com/emissary-ingress/emissary/compare/v2.3.2...v1.14.5 diff --git a/build-aux/.gitignore b/build-aux/.gitignore index ee1bac1479..945919888f 100644 --- a/build-aux/.gitignore +++ b/build-aux/.gitignore @@ -33,6 +33,10 @@ # Remove the tail of this list when the commit making the change gets # far enough in to the past. +# whenever-we-stop-working-on-2.x +/pytest-kat-envoy2.txt +/pytest-kat-envoy3.txt + # 2019-07-01 /teleproxy /kubeapply diff --git a/builder/builder.mk b/builder/builder.mk index 5179bd7b35..48a94f47b6 100644 --- a/builder/builder.mk +++ b/builder/builder.mk @@ -321,7 +321,7 @@ pytest-kat-envoy3: push-pytest-images # doing this all at once is too much for C $(MAKE) pytest KAT_RUN_MODE=envoy PYTEST_ARGS="$$PYTEST_ARGS python/tests/kat" # ... so we have a separate rule to run things split up build-aux/.pytest-kat.txt.stamp: $(OSS_HOME)/venv push-pytest-images FORCE - . venv/bin/activate && set -o pipefail && pytest --collect-only python/tests/kat 2>&1 | sed -En 's/.*/\1/p' | sed 's/[].].*//' | sort -u > $@ + . venv/bin/activate && set -o pipefail && pytest --collect-only python/tests/kat 2>&1 | sed -En 's/.*/\1/p' | cut -d. -f1 | sort -u > $@ build-aux/pytest-kat.txt: build-aux/%: build-aux/.%.stamp $(tools/copy-ifchanged) $(tools/copy-ifchanged) $< $@ clean: build-aux/.pytest-kat.txt.stamp.rm build-aux/pytest-kat.txt.rm diff --git a/builder/copy-gold.sh b/builder/copy-gold.sh index 4f3ef85963..30d4736d98 100644 --- a/builder/copy-gold.sh +++ b/builder/copy-gold.sh @@ -116,5 +116,19 @@ copy_gold xfpredirect copy_gold empty empty-namespace copy_gold plain plain-namespace copy_gold tcpmappingtest tcp-namespace +copy_gold tcpmappingbasictest +copy_gold tcpmappingcrossnamespacetest +copy_gold tcpmappingtlsoriginationbooltest +copy_gold tcpmappingtlsoriginationv2schemetest +copy_gold tcpmappingtlsoriginationcontexttest +copy_gold tcpmappingtlsoriginationcontextwithdottest +copy_gold tcpmappingtlsoriginationcontextcrossnamespacetest +copy_gold tcpmappingtlsterminationbasictest +copy_gold tcpmappingtlsterminationcrossnamespacetest +copy_gold tcpmappingsnisharedcontexttest +copy_gold tcpmappingsniseparatecontextstest +copy_gold tcpmappingsniwithhttptest +copy_gold tcpmappingaddresstest +copy_gold tcpmappingweighttest printf "\n" diff --git a/docs/releaseNotes.yml b/docs/releaseNotes.yml index 1a871861d3..fe71118dc0 100644 --- a/docs/releaseNotes.yml +++ b/docs/releaseNotes.yml @@ -102,12 +102,12 @@ items: body: >- The AMBASSADOR_RECONFIG_MAX_DELAY env var can be optionally set to batch changes for the specified non-negative window period in seconds before doing an Envoy reconfiguration. Default is "1" if not set. - + - title: Diagnostics stats properly handles parsing envoy metrics with colons type: bugfix body: >- - If a Host or TLSContext contained a hostname with a : then when using the - diagnostics endpoints ambassador/v0/diagd then an error would be thrown due to the parsing logic not + If a Host or TLSContext contained a hostname with a : then when using the + diagnostics endpoints ambassador/v0/diagd then an error would be thrown due to the parsing logic not being able to handle the extra colon. This has been fixed and $productName$ will not throw an error when parsing envoy metrics for the diagnostics user interface. @@ -122,6 +122,31 @@ items: - title: "#4181" link: https://github.com/emissary-ingress/emissary/pull/4181 + - title: TCPMappings use correct SNI configuration + type: bugfix + body: >- + $productName$ 2.0.0 introduced a bug where a TCPMapping that uses SNI, + instead of using the hostname glob in the TCPMapping, uses the hostname glob + in the Host that the TLS termination configuration comes from. + + - title: TCPMappings configure TLS termination without a Host resource + type: bugfix + body: >- + $productName$ 2.0.0 introduced a bug where a TCPMapping that terminates TLS + must have a corresponding Host that it can take the TLS configuration from. + This was semi-intentional, but didn't make much sense. You can now use a + TLSContext without a Hostas in $productName$ 1.y releases, or a + Host with or without a TLSContext as in prior 2.y releases. + + - title: TCPMappings and HTTP Hosts can coexist on Listeners that terminate TLS + type: bugfix + body: >- + Prior releases of $productName$ had the arbitrary limitation that a + TCPMapping cannot be used on the same port that HTTP is served on, even if + TLS+SNI would make this possible. $productName$ now allows TCPMappings to be + used on the same Listener port as HTTP Hosts, as long as that + Listener terminates TLS. + - version: 3.1.1 prevVersion: 3.1.0 date: 'TBD' @@ -352,6 +377,31 @@ items: The AMBASSADOR_RECONFIG_MAX_DELAY env var can be optionally set to batch changes for the specified non-negative window period in seconds before doing an Envoy reconfiguration. Default is "1" if not set. + - title: TCPMappings use correct SNI configuration + type: bugfix + body: >- + $productName$ 2.0.0 introduced a bug where a TCPMapping that uses SNI, + instead of using the hostname glob in the TCPMapping, uses the hostname glob + in the Host that the TLS termination configuration comes from. + + - title: TCPMappings configure TLS termination without a Host resource + type: bugfix + body: >- + $productName$ 2.0.0 introduced a bug where a TCPMapping that terminates TLS + must have a corresponding Host that it can take the TLS configuration from. + This was semi-intentional, but didn't make much sense. You can now use a + TLSContext without a Hostas in $productName$ 1.y releases, or a + Host with or without a TLSContext as in prior 2.y releases. + + - title: TCPMappings and HTTP Hosts can coexist on Listeners that terminate TLS + type: bugfix + body: >- + Prior releases of $productName$ had the arbitrary limitation that a + TCPMapping cannot be used on the same port that HTTP is served on, even if + TLS+SNI would make this possible. $productName$ now allows TCPMappings to be + used on the same Listener port as HTTP Hosts, as long as that + Listener terminates TLS. + - version: 1.14.5 date: 'TBD' notes: diff --git a/python/ambassador/envoy/v3/v3listener.py b/python/ambassador/envoy/v3/v3listener.py index 05a1f6a3e0..c76481cd37 100644 --- a/python/ambassador/envoy/v3/v3listener.py +++ b/python/ambassador/envoy/v3/v3listener.py @@ -14,7 +14,7 @@ import logging import sys from os import environ -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union from typing import cast as typecast from ...ir.irhost import IRHost @@ -41,51 +41,74 @@ # also can have a TLS context to say which certificate to serve if a connection is to be # processed by the chain. # -# A basic asymmetry of the chain is that the filter_chain_match can only do hostname matching -# if TLS (and thus SNI) is in play, which means for our purposes that a chain _with_ TLS enabled -# is fundamentally different from a chain _without_ TLS enabled. We encapsulate that idea in -# the "type" parameter, which can be "http", "https", or "tcp" depending on how the chain will -# be used. (And yes, that implies that at the moment, you can't mix HTTP Mappings and TCP Mappings -# on the same port. Possible near-future feature.) +# A basic asymmetry of the chain is that the filter_chain_match can only do hostname matching if SNI +# is available (i.e. we're terminating TLS), which means for our purposes that a chain _with_ TLS +# enabled is fundamentally different from a chain _without_ TLS enabled. Whether a chain has TLS +# enabled can be checked with the truthiness of `chain.context`. -class V3Chain(dict): - def __init__(self, config: "V3Config", type: str, host: Optional[IRHost]) -> None: +class V3Chain: + _config: "V3Config" + _logger: logging.Logger + _log_debug: bool + + # We can have multiple hosts here, primarily so that cleartext HTTP chains can DTRT. + context: Optional["IRTLSContext"] + hosts: Dict[str, Union[IRHost, IRTCPMappingGroup]] + routes: List[DictifiedV3Route] + + def __init__(self, config: "V3Config", context: Optional["IRTLSContext"]) -> None: self._config = config self._logger = self._config.ir.logger self._log_debug = self._logger.isEnabledFor(logging.DEBUG) - self.type = type - - # We can have multiple hosts here, primarily so that HTTP chains can DTRT -- - # but it would be fine to have multiple HTTPS hosts too, as long as they all - # share a TLSContext. - self.context: Optional[IRTLSContext] = None - self.hosts: Dict[str, IRHost] = {} - - # It's OK if an HTTP chain has no Host. - if host: - self.add_host(host) - - self.routes: List[DictifiedV3Route] = [] - self.tcpmappings: List[IRTCPMappingGroup] = [] + self.context = context + self.hosts = {} + self.routes = [] - def add_host(self, host: IRHost) -> None: - self.hosts[host.hostname] = host + def add_tcphost(self, tcpmapping: IRTCPMappingGroup) -> None: + if self._log_debug: + self._logger.debug( + f" CHAIN UPDATE: add TCP host: hostname={repr(tcpmapping.get('host'))}" + ) - # Don't mess with the context if we're an HTTP chain... - if self.type.lower() == "http": + if len(self.hosts) > 0: + # If we have SNI, then each host gets its own chain, so we should never have more than 1 + # self.hosts; if we don't have SNI then a single TLSContext takes over the entire chain + # and so we shouldn't have more than 1 self.hosts then either. + other = next(iter(self.hosts.values())) + other_type = "TCPMapping" if isinstance(other, IRTCPMappingGroup) else "Host" + tcpmapping.post_error( + "TCPMapping {tcpmapping.name}: discarding because it conflicts with {other_type} {other.name}" + ) return - # OK, we're some type where TLS makes sense. Do the thing. - if host.context: - if not self.context: - self.context = host.context - elif self.context != host.context: - self._config.ir.post_error( - "Chain context mismatch: Host %s cannot combine with %s" - % (host.name, ", ".join(sorted(self.hosts.keys()))) + self.hosts[tcpmapping.get("host") or "*"] = tcpmapping + + def add_httphost(self, host: IRHost) -> None: + if self._log_debug: + self._logger.debug(f" CHAIN UPDATE: add HTTP host: hostname={repr(host.hostname)}") + + if self.context: + # If we have SNI, then each host gets its own chain, so we should never have more than 1 + # self.hosts + if len(self.hosts) > 0: + other = next(iter(self.hosts.values())) + other_type = "TCPMapping" if isinstance(other, IRTCPMappingGroup) else "Host" + host.post_error( + "TLS Host {host.name}: discarding because it conflicts with {other_type} {other.name}" ) + return + else: + # If we don't have SNI then a single TLSContext takes over the entire chain. + for other in self.hosts.values(): + if isinstance(other, IRTCPMappingGroup): + host.post_error( + "Cleartext Host {host.name}: discarding because it conflicts with TCPMapping {other.name}" + ) + return + + self.hosts[host.hostname] = host def hostglobs(self) -> List[str]: # Get a list of host globs currently set up for this chain. @@ -93,26 +116,47 @@ def hostglobs(self) -> List[str]: def matching_hosts(self, route: V3Route) -> List[IRHost]: # Get a list of _IRHosts_ that the given route should be matched with. - rv: List[IRHost] = [ - host for host in self.hosts.values() if host.matches_httpgroup(route._group) - ] - + rv: List[IRHost] = [] + for host in self.hosts.values(): + if isinstance(host, IRHost) and host.matches_httpgroup(route._group): + rv.append(host) return rv def add_route(self, route: DictifiedV3Route) -> None: self.routes.append(route) - def add_tcpmapping(self, tcpmapping: IRTCPMappingGroup) -> None: - self.tcpmappings.append(tcpmapping) - def __str__(self) -> str: ctxstr = f" ctx {self.context.name}" if self.context else "" - return "CHAIN: %s%s [ %s ]" % ( - self.type.upper(), - ctxstr, - ", ".join(sorted(self.hostglobs())), - ) + return f"CHAIN: tls={bool(self.context)} hostglobs={repr(sorted(self.hostglobs()))}" + + +def tlscontext_for_tcpmapping( + irgroup: IRTCPMappingGroup, config: "V3Config" +) -> Optional["IRTLSContext"]: + group_host = irgroup.get("host") + if not group_host: + return None + + # We can pair directly with a 'TLSContext', or get a TLS config through a 'Host'. + # + # Give 'Hosts' precedence. Why? IDK, it felt right. + + # First, Hosts: + + for irhost in sorted(config.ir.get_hosts(), key=lambda h: h.hostname): + if irhost.context and hostglob_matches(irhost.hostname, group_host): + return irhost.context + + # Second, TLSContexts: + + for context in config.ir.get_tls_contexts(): + for context_host in context.get("hosts") or []: + # Note: this is *not* glob matching. + if context_host == group_host: + return context + + return None # Model an Envoy listener. @@ -124,16 +168,55 @@ def __str__(self) -> str: # here is all about constructing the Envoy configuration implied by the IRListener. -class V3Listener(dict): +class V3Listener: + config: "V3Config" + _irlistener: IRListener + + @property + def http3_enabled(self) -> bool: + return self._irlistener.http3_enabled + + @property + def socket_protocol(self) -> Literal["TCP", "UDP"]: + return self._irlistener.socket_protocol + + @property + def bind_address(self) -> str: + return self._irlistener.bind_address + + @property + def port(self) -> int: + return self._irlistener.port + + @property + def bind_to(self) -> str: + return self._irlistener.bind_to() + + @property + def _stats_prefix(self) -> str: + return self._irlistener.statsPrefix + + @property + def _security_model(self) -> Literal["XFP", "SECURE", "INSECURE"]: + return self._irlistener.securityModel + + @property + def _l7_depth(self) -> int: + return self._irlistener.get("l7Depth", 0) + + @property + def _insecure_only(self) -> bool: + return self._irlistener.insecure_only + + @property + def per_connection_buffer_limit_bytes(self) -> Optional[int]: + return self.config.ir.ambassador_module.get("buffer_limit_bytes", None) + def __init__(self, config: "V3Config", irlistener: IRListener) -> None: super().__init__() self.config = config - self.http3_enabled = irlistener.http3_enabled - self.socket_protocol = irlistener.socket_protocol - self.bind_address = irlistener.bind_address - self.port = irlistener.port - self.bind_to = irlistener.bind_to() + self._irlistener = irlistener # We cache the IRListener to use its match method later bindstr = ( f"-{irlistener.socket_protocol.lower()}-{self.bind_address}" @@ -142,15 +225,8 @@ def __init__(self, config: "V3Config", irlistener: IRListener) -> None: ) self.name = irlistener.name or f"ambassador-listener{bindstr}-{self.port}" - self.use_proxy_proto = False self.listener_filters: List[dict] = [] self.traffic_direction: str = "UNSPECIFIED" - self.per_connection_buffer_limit_bytes: Optional[int] = None - self._irlistener = irlistener # We cache the IRListener to use its match method later - self._stats_prefix = irlistener.statsPrefix - self._security_model: str = irlistener.securityModel - self._l7_depth: int = irlistener.get("l7Depth", 0) - self._insecure_only: bool = False self._filter_chains: List[dict] = [] self._base_http_config: Optional[Dict[str, Any]] = None self._chains: Dict[str, V3Chain] = {} @@ -162,16 +238,9 @@ def __init__(self, config: "V3Config", irlistener: IRListener) -> None: self._log_debug = self.config.ir.logger.isEnabledFor(logging.DEBUG) if self._log_debug: self.config.ir.logger.debug( - f"V3Listener {self.name} created -- {self._security_model}, l7Depth {self._l7_depth}" + f"V3Listener {self.name}: created: port={self.port} security_model={self._security_model} l7depth={self._l7_depth}" ) - # If the IRListener is marked insecure-only, so are we. - self._insecure_only = irlistener.insecure_only - - buffer_limit_bytes = self.config.ir.ambassador_module.get("buffer_limit_bytes", None) - if buffer_limit_bytes: - self.per_connection_buffer_limit_bytes = buffer_limit_bytes - # Build out our listener filters, and figure out if we're an HTTP listener # in the process. for proto in irlistener.protocolStack: @@ -195,22 +264,15 @@ def __init__(self, config: "V3Config", irlistener: IRListener) -> None: self.listener_filters.append({"name": "envoy.filters.listener.tls_inspector"}) if proto == "TCP": - # TCP doesn't require any specific listener filters, but it - # does require stuff in the filter chains. We can go ahead and - # tackle that here. - for irgroup in self.config.ir.ordered_groups(): - # Only look at TCPMappingGroups here... - if not isinstance(irgroup, IRTCPMappingGroup): - continue - - # ...and make sure the group in question wants the same bind - # address that we do. - if irgroup.bind_to() != self.bind_to: - continue - - self.add_tcp_group(irgroup) - - def add_chain(self, chain_type: str, host: Optional[IRHost]) -> V3Chain: + # Nothing to do. + pass + + def add_chain( + self, + chain_type: Literal["tcp", "http", "https"], + context: Optional["IRTLSContext"], + hostname: str, + ) -> V3Chain: # Add a chain for a specific Host to this listener, while dealing with the fundamental # asymmetry that filter_chain_match can - and should - use SNI whenever the chain has # TLS available, but that's simply not available for chains without TLS. @@ -223,87 +285,35 @@ def add_chain(self, chain_type: str, host: Optional[IRHost]) -> V3Chain: # answer is just that it would needlessly add nesting to all our loops and such (this # is also why there's no vhost data structure). - chain_key = chain_type - hoststr = host.hostname if host else "(no host)" - hostname = (host.hostname if host else None) or "*" + if chain_type == "http": + assert not context + if chain_type == "https": + assert context - if host: - chain_key = "%s-%s" % (chain_type, hostname) + hostname = hostname or "*" - chain = self._chains.get(chain_key) + chain_key = "tls" if context else "cleartext" + # I (LukeShu) can't really give an explanation of why `or chain_type == 'http'` belongs in + # this expression (it's what the above comment "we can - and do - separate HTTP chains into + # specific domains" is referring to), other than that it needs to be here in order for + # compute_routes() to work correctly. Maybe that's bad and we should go fix + # compute_routes() and remove `or chain_type = 'http'`... I'd have to study compute_routes() + # a lot more in order to be able to answer that; but in the mean time, including it in the + # expression keeps things working. + if context or chain_type == "http": + chain_key += f"-{hostname}" - if chain is not None: - if host: - chain.add_host(host) - if self._log_debug: - self.config.ir.logger.debug( - " CHAIN ADD: host %s chain_key %s -- %s", hoststr, chain_key, chain - ) - else: - if self._log_debug: - self.config.ir.logger.debug( - " CHAIN NOOP: host %s chain_key %s -- %s", hoststr, chain_key, chain - ) - else: - chain = V3Chain(self.config, chain_type, host) + chain = self._chains.get(chain_key) + verb = "REUSED" if chain else "CREATE" + if chain is None: + chain = V3Chain(self.config, context) self._chains[chain_key] = chain - if self._log_debug: - self.config.ir.logger.debug( - " CHAIN CREATE: host %s chain_key %s -- %s", hoststr, chain_key, chain - ) - - return chain - - def add_tcp_group(self, irgroup: IRTCPMappingGroup) -> None: - # The TCP analog of add_chain -- it adds a chain, too, but works with a TCP - # mapping group rather than a Host. Same deal applies with TLS: you can't do - # host-based matching without it. - - group_host = irgroup.get("host", None) - if self._log_debug: self.config.ir.logger.debug( - "V3Listener %s on %s: take TCPMappingGroup on %s (%s)", - self.name, - self.bind_to, - irgroup.bind_to(), - group_host or "i'*'", + f" CHAIN {verb}: tls={bool(context)} host={repr(hostname)} => chains[{repr(chain_key)}]={chain}" ) - if not group_host: - # Special case. No Host in a TCPMapping means an unconditional forward, - # so just add this immediately as a "*" chain. - chain = self.add_chain("tcp", None) - chain.add_tcpmapping(irgroup) - else: - # What matching Hosts do we have? - for host in sorted(self.config.ir.get_hosts(), key=lambda h: h.hostname): - # They're asking for a hostname match here, which _cannot happen_ without - # SNI -- so don't take any hosts that don't have a TLSContext. - - if not host.context: - if self._log_debug: - self.config.ir.logger.debug( - "V3Listener %s @ %s TCP %s: skip %s", - self.name, - self.bind_to, - group_host, - host, - ) - continue - - if self._log_debug: - self.config.ir.logger.debug( - "V3Listener %s @ %s TCP %s: consider %s", - self.name, - self.bind_to, - group_host, - host, - ) - - if hostglob_matches(host.hostname, group_host): - chain = self.add_chain("tcp", host) - chain.add_tcpmapping(irgroup) + return chain # access_log constructs the access_log configuration for this V3Listener def access_log(self) -> List[dict]: @@ -597,39 +607,38 @@ def base_http_config(self) -> Dict[str, Any]: def finalize(self) -> None: if self._log_debug: - self.config.ir.logger.debug(f"V3Listener: ==== finalize {self}") - - # OK. Assemble the high-level stuff for Envoy. - self.address = { - "socket_address": { - "address": self.bind_address, - "port_value": self.port, - "protocol": self.socket_protocol, ## "TCP" or "UDP" - } - } + self.config.ir.logger.debug(f"V3Listener {self}: finalize ============================") + + # We do TCP chains before HTTP chains so that TCPMappings have precedence over Hosts. This + # is important because 2.x releases prior to 2.4 required you to create a Host for the + # TCPMapping to steal the TLS termination config from (so TCPMapping users coming from 2.3 + # will _very likely_ have "conflicting" Hosts and TCPMappings), and also didn't support + # TCPMappings and Hosts on the same Listener (so 2.3 didn't see these as "conflicts"). But + # now that we do support them together on the same Listener, we do see them as conflicts, + # and so we keep compatibility with 2.3 by saying "in the event of a conflict, TCPMappings + # have precedence over Hosts." + self.compute_tcpchains() + self.finalize_tcp() - # Next, deal with HTTP stuff if this is an HTTP Listener. if self._base_http_config: - self.compute_chains() + self.compute_httpchains() self.compute_routes() self.finalize_http() - else: - # TCP is a lot simpler. - self.finalize_tcp() def finalize_tcp(self) -> None: # Finalize a TCP listener, which amounts to walking all our TCP chains and # setting up Envoy configuration structures for them. - logger = self.config.ir.logger - for chain_key, chain in self._chains.items(): - if chain.type != "tcp": - continue + self.config.ir.logger.debug(" finalize_tcp") + for chain_key, chain in self._chains.items(): if self._log_debug: - logger.debug("BUILD CHAIN %s - %s", chain_key, chain) + self.config.ir.logger.debug(f" build chain[{repr(chain_key)}]={chain}") + + for irgroup in chain.hosts.values(): + if not isinstance(irgroup, IRTCPMappingGroup): + continue - for irgroup in chain.tcpmappings: # First up, which clusters do we need to talk to? clusters = [ {"name": mapping.cluster.envoy_name, "weight": mapping._weight} @@ -647,7 +656,10 @@ def finalize_tcp(self) -> None: } # OK. Basic filter chain entry next. - filter_chain: Dict[str, Any] = {"filters": [tcp_filter]} + filter_chain: Dict[str, Any] = { + "name": f"tcphost-{irgroup.name}", + "filters": [tcp_filter], + } # The chain as a whole has a single matcher. filter_chain_match: Dict[str, Any] = {} @@ -675,7 +687,7 @@ def finalize_tcp(self) -> None: # make sure that we don't have two chains with an empty filter_match # criterion (since Envoy will reject such a configuration). - if len(chain_hosts) > 0: + if len(chain_hosts) > 0 and ("*" not in chain_hosts): filter_chain_match["server_names"] = chain_hosts # Once all of that is done, hook in the match... @@ -684,19 +696,54 @@ def finalize_tcp(self) -> None: # ...and stick this chain into our filter. self._filter_chains.append(filter_chain) - def compute_chains(self) -> None: + def compute_tcpchains(self) -> None: + self.config.ir.logger.debug(" compute_tcpchains") + + for irgroup in self.config.ir.ordered_groups(): + # Only look at TCPMappingGroups here... + if not isinstance(irgroup, IRTCPMappingGroup): + continue + + if self._log_debug: + self.config.ir.logger.debug(f" consider {irgroup}") + + # ...and make sure the group in question wants the same bind + # address that we do. + if irgroup.bind_to() != self.bind_to: + self.config.ir.logger.debug(" reject") + continue + + self.config.ir.logger.debug(" accept") + + # Add a chain, same as we do in compute_httpchains, just for a 'TCPMappingGroup' rather + # than for a 'Host'. Same deal applies with TLS: you can't do host-based matching + # without it. + + group_host = irgroup.get("host", None) + if not group_host: # cleartext + # Special case. No Host in a TCPMapping means an unconditional forward, + # so just add this immediately as a "*" chain. + self.add_chain("tcp", None, "*").add_tcphost(irgroup) + else: # TLS/SNI + context = tlscontext_for_tcpmapping(irgroup, self.config) + if not context: + irgroup.post_error("No matching TLSContext found, disabling!") + continue + self.add_chain("tcp", context, group_host).add_tcphost(irgroup) + + def compute_httpchains(self) -> None: # Compute the set of chains we need, HTTP version. The core here is matching # up Hosts with this Listener, and creating a chain for each Host. - self.config.ir.logger.debug("V3Listener %s: checking hosts for %s", self.name, self) + self.config.ir.logger.debug(" compute_httpchains") for host in sorted(self.config.ir.get_hosts(), key=lambda h: h.hostname): if self._log_debug: - self.config.ir.logger.debug(" consider %s", host) + self.config.ir.logger.debug(f" consider {host}") # First up: drop this host if nothing matches at all. if not self._irlistener.matches_host(host): - # Bzzzt. + self.config.ir.logger.debug(" reject: hostglobs don't match") continue # OK, if we're still here, then it's a question of matching the Listener's @@ -711,55 +758,46 @@ def compute_chains(self) -> None: # here.) if self._insecure_only and (self.port != host.insecure_addl_port): - if self._log_debug: - self.config.ir.logger.debug( - " drop %s, insecure-only port mismatch", host.name - ) - + self.config.ir.logger.debug(" reject: insecure-only port mismatch") continue # OK, we can't drop it for that, so we need to check the actions. - security_model = self._security_model - secure_action = host.secure_action - insecure_action = host.insecure_action - # If the Listener's securityModel is SECURE, but this host has a secure_action # of Reject (or empty), we'll skip this host, because the only requests this # Listener can ever produce will be rejected. In any other case, we'll set up an # HTTPS chain for this Host, as long as we think TLS is OK. - - will_reject_secure = (not secure_action) or (secure_action == "Reject") - if self._tls_ok and (not ((security_model == "SECURE") and will_reject_secure)): - if self._log_debug: - self.config.ir.logger.debug(" take SECURE %s", host) - - self.add_chain("https", host) + host_will_reject_secure = (not host.secure_action) or (host.secure_action == "Reject") + if ( + self._tls_ok + and host.context + and (not ((self._security_model == "SECURE") and host_will_reject_secure)) + ): + self.config.ir.logger.debug(" accept SECURE") + self.add_chain("https", host.context, host.hostname).add_httphost(host) # Same idea on the insecure side: only skip the Host if the Listener's securityModel # is INSECURE but the Host's insecure_action is Reject. - - if not ((security_model == "INSECURE") and (insecure_action == "Reject")): - if self._log_debug: - self.config.ir.logger.debug(" take INSECURE %s", host) - - self.add_chain("http", host) + if not ((self._security_model == "INSECURE") and (host.insecure_action == "Reject")): + self.config.ir.logger.debug(" accept INSECURE") + self.add_chain("http", None, host.hostname).add_httphost(host) def compute_routes(self) -> None: # Compute the set of valid HTTP routes for _each chain_ in this Listener. # # Note that a route using XFP can match _any_ chain, whether HTTP or HTTPS. - logger = self.config.ir.logger + self.config.ir.logger.debug(" compute_routes") for chain_key, chain in self._chains.items(): + if self._log_debug: + self.config.ir.logger.debug(f" consider chain[{repr(chain_key)}]={chain}") + # Only look at HTTP(S) chains. - if (chain.type != "http") and (chain.type != "https"): + if not any(isinstance(h, IRHost) for h in chain.hosts.values()): + self.config.ir.logger.debug(" reject: is non-HTTP") continue - if self._log_debug: - logger.debug("MATCH CHAIN %s - %s", chain_key, chain) - # Remember whether we found an ACME route. found_acme = False @@ -768,19 +806,21 @@ def compute_routes(self) -> None: # V3RouteVariants to lazily cache some of the work that we're doing across chains. for rv in self.config.route_variants: if self._log_debug: - logger.debug(" CHECK ROUTE: %s", v3prettyroute(dict(rv.route))) + self.config.ir.logger.debug( + f" consider route {v3prettyroute(dict(rv.route))}" + ) matching_hosts = chain.matching_hosts(rv.route) if self._log_debug: - logger.debug( - " = matching_hosts %s", ", ".join([h.hostname for h in matching_hosts]) + self.config.ir.logger.debug( + f" matching_hosts={[h.hostname for h in matching_hosts]}" ) if not matching_hosts: if self._log_debug: - logger.debug( - f" drop outright: no hosts match {sorted(rv.route['_host_constraints'])}" + self.config.ir.logger.debug( + f" reject: no hosts match {sorted(rv.route['_host_constraints'])}" ) continue @@ -793,6 +833,9 @@ def compute_routes(self) -> None: candidates: List[Tuple[IRHost, str, str, V3RouteVariants]] = [] hostname = host.hostname + if self._log_debug: + self.config.ir.logger.debug(f" host={hostname}") + if (host.secure_action is not None) and (self._security_model != "INSECURE"): # We have a secure action, and we're willing to believe that at least some of # our requests will be secure. @@ -834,13 +877,8 @@ def compute_routes(self) -> None: # really mean "redirect to HTTPS" specifically. if self._log_debug: - logger.debug( - " %s - %s: accept on %s %s%s", - matcher, - action, - self.name, - hostname, - extra_info, + self.config.ir.logger.debug( + f" route: accept matcher={matcher} action={action} {extra_info}" ) variant = dict(rv.get_variant(matcher, action.lower())) @@ -848,40 +886,44 @@ def compute_routes(self) -> None: chain.add_route(variant) else: if self._log_debug: - logger.debug( - " %s - %s: drop from %s %s%s", - matcher, - action, - self.name, - hostname, - extra_info, + self.config.ir.logger.debug( + f" route: reject matcher={matcher} action={action} {extra_info}" ) # If we're on Edge Stack and we don't already have an ACME route, add one. if self.config.ir.edge_stack_allowed and not found_acme: - # This route is needed to trigger an ExtAuthz request for the AuthService. - # The auth service grabs the challenge and does the right thing. - # Rather than try to route to some existing cluster we can just return a - # direct response. What we return doesn't really matter but - # to match existing Edge Stack behavior we return a 404 response. + # The target cluster doesn't actually matter -- the auth service grabs the + # challenge and does the right thing. But we do need a cluster that actually + # exists, so use the sidecar cluster. + + if not self.config.ir.sidecar_cluster_name: + # Uh whut? how is Edge Stack running exactly? + raise Exception( + "Edge Stack claims to be running, but we have no sidecar cluster??" + ) - if self._log_debug: - logger.debug(" punching a hole for ACME") + self.config.ir.logger.debug(" punching a hole for ACME") + + # Make sure to include _host_constraints in here for now. + # + # XXX This is needed only because we're dictifying the V3Route too early. - # Make sure to include _host_constraints in here for now so it can be - # applied to the correct vhost during future proccessing chain.routes.insert( 0, { "_host_constraints": set(), "match": {"case_sensitive": True, "prefix": "/.well-known/acme-challenge/"}, - "direct_response": {"status": 404}, + "route": { + "cluster": self.config.ir.sidecar_cluster_name, + "prefix_rewrite": "/.well-known/acme-challenge/", + "timeout": "3.000s", + }, }, ) if self._log_debug: for route in chain.routes: - logger.debug(" CHAIN ROUTE: %s" % v3prettyroute(route)) + self.config.ir.logger.debug(" CHAIN ROUTE: %s" % v3prettyroute(route)) def finalize_http(self) -> None: # Finalize everything HTTP. Like the TCP side of the world, this is about walking @@ -890,21 +932,30 @@ def finalize_http(self) -> None: # All of our HTTP chains get collapsed into a single chain with (likely) multiple # domains here. + self.config.ir.logger.debug(" finalize_http") + filter_chains: Dict[str, Dict[str, Any]] = {} for chain_key, chain in self._chains.items(): + if not any(isinstance(h, IRHost) for h in chain.hosts.values()): + continue + if self._log_debug: - self._irlistener.logger.debug("FHTTP %s / %s / %s", self, chain_key, chain) + self._irlistener.logger.debug(f" build chain[{repr(chain_key)}]={chain}") filter_chain: Optional[Dict[str, Any]] = None - # http/3 is built on quic which has TLS built-in. This means that our UDP Listener will only ever need routes - # that match the TLS Filter chain and will diverge from the TCP listener in that it will not support redirect - # therefore, we can exclude duplicating the filterchain and routes so hitting this endpoint using non-tls http will fail - if (chain.type == "http") and self.isProtocolUDP() and self.http3_enabled: - continue + if not chain.context: # cleartext + # http/3 is built on quic which has TLS built-in. This means that our UDP Listener will only ever need routes + # that match the TLS Filter chain and will diverge from the TCP listener in that it will not support redirect + # therefore, we can exclude duplicating the filterchain and routes so hitting this endpoint using non-tls http will fail + if self.isProtocolUDP() and self.http3_enabled: + continue - if chain.type == "http": + if self._log_debug: + self._irlistener.logger.debug( + f" cleartext for hostglobs={chain.hostglobs()}" + ) # All HTTP chains get collapsed into one here, using domains to separate them. # This works because we don't need to offer TLS certs (we can't anyway), and # because of that, SNI (and thus filter server_names matches) aren't things. @@ -917,7 +968,11 @@ def finalize_http(self) -> None: self._irlistener.logger.debug( "FHTTP create filter_chain %s / empty match", chain_key ) - filter_chain = {"filter_chain_match": {}, "_vhosts": {}} + filter_chain = { + "name": "httphost-shared", + "filter_chain_match": {}, + "_vhosts": {}, + } filter_chains[chain_key] = filter_chain else: @@ -927,15 +982,21 @@ def finalize_http(self) -> None: chain_key, len(filter_chain["_vhosts"]), ) - elif chain.type == "https": + else: # TLS/SNI # Since chain_key is a dictionary key in its own right, we can't already # have a matching chain for this. - filter_chain = {"_vhosts": {}} + filter_chain = { + "name": f"httpshost-{next(iter(chain.hosts.values())).name}", + "_vhosts": {}, + } filter_chain_match: Dict[str, Any] = {} chain_hosts = chain.hostglobs() + if self._log_debug: + self._irlistener.logger.debug(f" tls for hostglobs={chain_hosts}") + # Set up the server_names part of the match, if we have any names. # # Note that "*" is _not allowed_ in server_names, though e.g. "*.example.com" @@ -958,52 +1019,42 @@ def finalize_http(self) -> None: "quic" if self.isProtocolUDP() and self.http3_enabled else "tls" ) - if chain.context: - # ...uh. How could we not have a context if we're doing TLS? - # Note that we're modifying the filter_chain itself here, not - # filter_chain_match. - envoy_ctx = V3TLSContext(chain.context) + envoy_ctx = V3TLSContext(chain.context) + envoy_tls_config = { + "name": "envoy.transport_sockets.tls", + "typed_config": { + "@type": "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext", + **envoy_ctx, + }, + } + + if self.isProtocolUDP(): envoy_tls_config = { - "name": "envoy.transport_sockets.tls", + "name": "envoy.transport_sockets.quic", "typed_config": { - "@type": "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext", - **envoy_ctx, + "@type": "type.googleapis.com/envoy.extensions.transport_sockets.quic.v3.QuicDownstreamTransport", + "downstream_tls_context": {**envoy_ctx}, }, } - if self.isProtocolUDP(): - envoy_tls_config = { - "name": "envoy.transport_sockets.quic", - "typed_config": { - "@type": "type.googleapis.com/envoy.extensions.transport_sockets.quic.v3.QuicDownstreamTransport", - "downstream_tls_context": {**envoy_ctx}, - }, - } - - filter_chain["transport_socket"] = envoy_tls_config - - else: - # Envoy doesn't like having a UDP Listener with QUIC filter chain that doesn't have a "transport_socket" set with a TLS - # certificate. If the fall-back cert is removed or no certificate is provided then we should not add the quic filter chain - if self.isProtocolUDP() and self.http3_enabled: - self._irlistener.logger.warn( - f"Listener: quic network protocol requires a TLSContext to be provided, no tls context found for Listener: {self._irlistener.bind_to()}" - ) - continue + filter_chain["transport_socket"] = envoy_tls_config # Finally, stash the match in the chain... filter_chain["filter_chain_match"] = filter_chain_match # ...and save it. filter_chains[chain_key] = filter_chain - else: - # The chain type is neither HTTP nor HTTPS -- must be a TCP chain. Skip it. - continue # OK, we have the filter_chain variable set -- build the Envoy virtual_hosts for it. for host in chain.hosts.values(): + if not isinstance(host, IRHost): + continue + + if self._log_debug: + self._irlistener.logger.debug(f" adding vhost {repr(host.hostname)}") + # Make certain that no internal keys from the route make it into the Envoy # configuration. routes = [] @@ -1087,7 +1138,13 @@ def finalize_http(self) -> None: def as_dict(self) -> dict: listener: dict = { "name": self.name, - "address": self.address, + "address": { + "socket_address": { + "address": self.bind_address, + "port_value": self.port, + "protocol": self.socket_protocol, ## "TCP" or "UDP" + } + }, "filter_chains": self._filter_chains, "traffic_direction": self.traffic_direction, } @@ -1108,14 +1165,6 @@ def as_dict(self) -> dict: return listener - def pretty(self) -> dict: - return { - "name": self.name, - "bind_address": self.bind_address, - "port": self.port, - "chains": self._chains, - } - def __str__(self) -> str: return "" % ( "HTTP" if self._base_http_config else "TCP", @@ -1136,25 +1185,23 @@ def isProtocolUDP(self) -> bool: @classmethod def generate(cls, config: "V3Config") -> None: config.listeners = [] - logger = config.ir.logger for key in config.ir.listeners.keys(): irlistener = config.ir.listeners[key] v3listener = V3Listener(config, irlistener) v3listener.finalize() - config.ir.logger.info(f"V3Listener: ==== GENERATED {v3listener}") - - if v3listener._log_debug: - for k in sorted(v3listener._chains.keys()): - chain = v3listener._chains[k] - config.ir.logger.debug(" %s", chain) - - for r in chain.routes: - config.ir.logger.debug(" %s", v3prettyroute(r)) + config.ir.logger.info(f"V3Listener {v3listener}: generated ===========================") + if config.ir.logger.isEnabledFor(logging.DEBUG): + if v3listener._log_debug: + for k in sorted(v3listener._chains.keys()): + chain = v3listener._chains[k] + config.ir.logger.debug(f" chain {chain}") + for r in chain.routes: + config.ir.logger.debug(f" route {v3prettyroute(r)}") # Does this listener have any filter chains? if v3listener._filter_chains: config.listeners.append(v3listener) else: - irlistener.post_error("No matching Hosts found, disabling!") + irlistener.post_error("No matching Hosts/TCPMappings found, disabling!") diff --git a/python/ambassador/ir/irutils.py b/python/ambassador/ir/irutils.py index 886394b775..de6fb36cba 100644 --- a/python/ambassador/ir/irutils.py +++ b/python/ambassador/ir/irutils.py @@ -189,13 +189,13 @@ def selector_matches( if not match: # If there's no matchLabels to match, return True. - logger.debug(" no matchLabels in selector => True") + logger.debug(" no matchLabels in selector => True") return True # If we have stuff to match on, but no labels to actually match them, we # can short-circuit (and skip a weirder conditional down in the loop). if not labels: - logger.debug(" no incoming labels => False") + logger.debug(" no incoming labels => False") return False if disable_strict_selectors(): @@ -204,18 +204,18 @@ def selector_matches( logger.debug(" selector match for %s=%s => True", k, v) return True - logger.debug(" selector miss on %s=%s", k, v) + logger.debug(" selector miss on %s=%s", k, v) - logger.debug(" all selectors miss => False") + logger.debug(" all selectors miss => False") return False else: # For every label in mappingSelector, there must be a label with same value in the Mapping itself. for k, v in match.items(): if labels.get(k) == v: - logger.debug(" selector match for %s=%s => True", k, v) + logger.debug(" selector match for %s=%s => True", k, v) else: - logger.debug(" selector miss for %s=%s => False", k, v) + logger.debug(" selector miss for %s=%s => False", k, v) return False - logger.debug(" all selectors match => True") + logger.debug(" all selectors match => True") return True diff --git a/python/kat/harness.py b/python/kat/harness.py index b0d8c6f917..5d4c3cf1b1 100755 --- a/python/kat/harness.py +++ b/python/kat/harness.py @@ -344,6 +344,7 @@ class Node(ABC): namespace: str = None # type: ignore is_ambassador = False local_result: Optional[Dict[str, str]] = None + xfail: Optional[str] def __init__(self, *args, **kwargs) -> None: # If self.skip is set to true, this node is skipped diff --git a/python/tests/kat/t_redirect.py b/python/tests/kat/t_redirect.py index c8b8f056a0..0be52e6054 100644 --- a/python/tests/kat/t_redirect.py +++ b/python/tests/kat/t_redirect.py @@ -261,7 +261,7 @@ def manifests(self): apiVersion: getambassador.io/v3alpha1 kind: Listener metadata: - name: ambassador-listener-8080 + name: {self.path.k8s} spec: ambassador_id: [{self.ambassador_id}] port: 8080 @@ -275,7 +275,7 @@ def manifests(self): apiVersion: getambassador.io/v3alpha1 kind: Host metadata: - name: weird-xfp-test-host + name: {self.path.k8s} spec: ambassador_id: [{self.ambassador_id}] requestPolicy: diff --git a/python/tests/kat/t_tcpmapping.py b/python/tests/kat/t_tcpmapping.py index fd4c4bf736..fd5e721624 100644 --- a/python/tests/kat/t_tcpmapping.py +++ b/python/tests/kat/t_tcpmapping.py @@ -1,7 +1,7 @@ -from typing import Generator, Tuple, Union +from typing import Dict, Generator, Literal, Tuple, Union from abstract_tests import HTTP, AmbassadorTest, Node, ServiceType -from kat.harness import Query +from kat.harness import Query, abstract_test from tests.integration.manifests import namespace_manifest from tests.selfsigned import TLSCerts @@ -321,3 +321,991 @@ def check(self): assert ( tls_enabled == tls_wanted ), f"{idx}: TLS status {tls_enabled} != wanted {tls_wanted}" + + +class TCPMappingBasicTest(AmbassadorTest): + extra_ports = [6789] + target: ServiceType + + def init(self) -> None: + self.target = HTTP() + + def manifests(self) -> str: + return ( + format( + """ +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s} +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6789 + service: {self.target.path.fqdn}:80 +""" + ) + + super().manifests() + ) + + def queries(self): + yield Query(self.url("", port=6789)) + + def check(self): + assert self.results[0].json["backend"] == self.target.path.k8s + assert self.results[0].json["request"]["tls"]["enabled"] == False + + +class TCPMappingCrossNamespaceTest(AmbassadorTest): + extra_ports = [6789] + target: ServiceType + + def init(self) -> None: + self.target = HTTP(namespace="other-namespace") + + def manifests(self) -> str: + return ( + namespace_manifest("other-namespace") + + format( + """ +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s} +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6789 + service: {self.target.path.fqdn}:80 +""" + ) + + super().manifests() + ) + + def queries(self): + yield Query(self.url("", port=6789)) + + def check(self): + assert self.results[0].json["backend"] == self.target.path.k8s + assert self.results[0].json["request"]["tls"]["enabled"] == False + + +class TCPMappingTLSOriginationBoolTest(AmbassadorTest): + extra_ports = [6789] + target: ServiceType + + def init(self) -> None: + self.target = HTTP() + + def manifests(self) -> str: + return ( + format( + """ +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s} +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6789 + service: {self.target.path.fqdn}:443 + tls: true +""" + ) + + super().manifests() + ) + + def queries(self): + yield Query(self.url("", port=6789)) + + def check(self): + assert self.results[0].json["backend"] == self.target.path.k8s + assert self.results[0].json["request"]["tls"]["enabled"] == True + + +class TCPMappingTLSOriginationV2SchemeTest(AmbassadorTest): + """apiVersion v2 TCPMappings don't support a scheme:// on the 'service' field; if you provide + one, then it is ignored. Since apiVersion v3alpha1 adds support for scheme://, add a test to + make sure we don't break anyone who is inadvertently depending on it being ignored in v2.""" + + extra_ports = [6789, 6790] + target: ServiceType + + def init(self) -> None: + self.xfail = "bug (2.3): v2 TCPMappings don't ignore the scheme" + self.target = HTTP() + + def manifests(self) -> str: + return ( + format( + """ +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s}-1 +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6789 + service: https://{self.target.path.fqdn}:443 +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s}-2 +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6790 + service: https://{self.target.path.fqdn}:80 +""" + ) + + super().manifests() + ) + + def queries(self): + yield Query( + self.url("", port=6789), expected=400 + ) # kat-server returns HTTP 400 "Client sent an HTTP request to an HTTPS server." + yield Query(self.url("", port=6789, scheme="https"), insecure=True) + yield Query(self.url("", port=6790)) + + def check(self): + assert self.results[1].json["backend"] == self.target.path.k8s + assert self.results[1].json["request"]["tls"]["enabled"] == True + assert self.results[2].json["backend"] == self.target.path.k8s + assert self.results[2].json["request"]["tls"]["enabled"] == False + + +class TCPMappingTLSOriginationV3SchemeTest(AmbassadorTest): + extra_ports = [6789] + target: ServiceType + + def init(self) -> None: + self.target = HTTP() + + def manifests(self) -> str: + return ( + format( + """ +--- +apiVersion: getambassador.io/v3alpha1 +kind: TCPMapping +metadata: + name: {self.name.k8s}-1 +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6789 + service: https://{self.target.path.fqdn}:443 +""" + ) + + super().manifests() + ) + + def queries(self): + yield Query(self.url("", port=6789)) + + def check(self): + assert self.results[0].json["backend"] == self.target.path.k8s + assert self.results[0].json["request"]["tls"]["enabled"] == True + + +class TCPMappingTLSOriginationContextTest(AmbassadorTest): + extra_ports = [6789] + target: ServiceType + + def init(self) -> None: + self.target = HTTP() + + def manifests(self) -> str: + # Hafta provide a client cert, see https://github.com/emissary-ingress/emissary/issues/4476 + return ( + f""" +--- +apiVersion: v1 +kind: Secret +metadata: + name: {self.name.k8s}-clientcert +type: kubernetes.io/tls +data: + tls.crt: {TLSCerts["presto.example.com"].k8s_crt} + tls.key: {TLSCerts["presto.example.com"].k8s_key} +--- +apiVersion: getambassador.io/v2 +kind: TLSContext +metadata: + name: {self.name.k8s}-tlsclient +spec: + ambassador_id: [ {self.ambassador_id} ] + secret: {self.name.k8s}-clientcert + sni: my-funny-name +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s} +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6789 + service: {self.target.path.fqdn}:443 + tls: {self.name.k8s}-tlsclient +""" + + super().manifests() + ) + + def queries(self): + yield Query(self.url("", port=6789)) + + def check(self): + assert self.results[0].json["backend"] == self.target.path.k8s + assert self.results[0].json["request"]["tls"]["enabled"] == True + assert self.results[0].json["request"]["tls"]["server-name"] == "my-funny-name" + + +class TCPMappingTLSOriginationContextWithDotTest(AmbassadorTest): + extra_ports = [6789] + target: ServiceType + + def init(self) -> None: + self.target = HTTP() + + def manifests(self) -> str: + # Hafta provide a client cert, see https://github.com/emissary-ingress/emissary/issues/4476 + return ( + f""" +--- +apiVersion: v1 +kind: Secret +metadata: + name: {self.name.k8s}-clientcert +type: kubernetes.io/tls +data: + tls.crt: {TLSCerts["presto.example.com"].k8s_crt} + tls.key: {TLSCerts["presto.example.com"].k8s_key} +--- +apiVersion: getambassador.io/v2 +kind: TLSContext +metadata: + name: {self.name.k8s}.tlsclient +spec: + ambassador_id: [ {self.ambassador_id} ] + secret: {self.name.k8s}-clientcert + sni: my-hilarious-name +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s} +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6789 + service: {self.target.path.fqdn}:443 + tls: {self.name.k8s}.tlsclient +""" + + super().manifests() + ) + + def queries(self): + yield Query(self.url("", port=6789)) + + def check(self): + assert self.results[0].json["backend"] == self.target.path.k8s + assert self.results[0].json["request"]["tls"]["enabled"] == True + assert self.results[0].json["request"]["tls"]["server-name"] == "my-hilarious-name" + + +class TCPMappingTLSOriginationContextCrossNamespaceTest(AmbassadorTest): + """This test is a little funny. You can actually select a TLSContext from any namespace without + specifying the namespace. That's bad design, but at the same time we don't want to break anyone + by changing it.""" + + extra_ports = [6789] + target: ServiceType + + def init(self) -> None: + self.target = HTTP() + + def manifests(self) -> str: + # Hafta provide a client cert, see https://github.com/emissary-ingress/emissary/issues/4476 + return ( + namespace_manifest("other-namespace") + + f""" +--- +apiVersion: v1 +kind: Secret +metadata: + name: {self.name.k8s}-clientcert + namespace: other-namespace +type: kubernetes.io/tls +data: + tls.crt: {TLSCerts["presto.example.com"].k8s_crt} + tls.key: {TLSCerts["presto.example.com"].k8s_key} +--- +apiVersion: getambassador.io/v2 +kind: TLSContext +metadata: + name: {self.name.k8s}-tlsclient + namespace: other-namespace +spec: + ambassador_id: [ {self.ambassador_id} ] + secret: {self.name.k8s}-clientcert + sni: my-hysterical-name +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s} +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6789 + service: {self.target.path.fqdn}:443 + tls: {self.name.k8s}-tlsclient +""" + + super().manifests() + ) + + def queries(self): + yield Query(self.url("", port=6789)) + + def check(self): + assert self.results[0].json["backend"] == self.target.path.k8s + assert self.results[0].json["request"]["tls"]["enabled"] == True + assert self.results[0].json["request"]["tls"]["server-name"] == "my-hysterical-name" + + +@abstract_test +class TCPMappingTLSTerminationTest(AmbassadorTest): + tls_src: Literal["tlscontext", "host"] + + @classmethod + def variants(cls) -> Generator[Node, None, None]: + for tls_src in ["tlscontext", "host"]: + yield cls(tls_src, name="{self.tls_src}") + + def init(self, tls_src: Literal["tlscontext", "host"]) -> None: + self.tls_src = tls_src + + def manifests(self) -> str: + return ( + f""" +--- +apiVersion: getambassador.io/v2 +kind: Host +metadata: + name: {self.path.k8s} +spec: + ambassador_id: [ {self.ambassador_id} ] + hostname: {self.path.fqdn} + acmeProvider: + authority: none + requestPolicy: + insecure: + action: Route + additionalPort: 8080 +""" + + super().manifests() + ) + + +class TCPMappingTLSTerminationBasicTest(TCPMappingTLSTerminationTest): + extra_ports = [6789] + target: ServiceType + + def init(self, tls_src: Literal["tlscontext", "host"]) -> None: + super().init(tls_src) + self.target = HTTP() + + def manifests(self) -> str: + return ( + f""" +--- +apiVersion: v1 +kind: Secret +metadata: + name: {self.name.k8s}-servercert +type: kubernetes.io/tls +data: + tls.crt: {TLSCerts["tls-context-host-2"].k8s_crt} + tls.key: {TLSCerts["tls-context-host-2"].k8s_key} +""" + + ( + f""" +--- +apiVersion: getambassador.io/v2 +kind: TLSContext +metadata: + name: {self.name.k8s}-tlsserver +spec: + ambassador_id: [ {self.ambassador_id} ] + secret: {self.name.k8s}-servercert + hosts: [ "tls-context-host-2" ] +""" + if self.tls_src == "tlscontext" + else f""" +--- +apiVersion: getambassador.io/v2 +kind: Host +metadata: + name: {self.name.k8s}-tlsserver +spec: + ambassador_id: [ {self.ambassador_id} ] + hostname: "tls-context-host-2" + tlsSecret: + name: {self.name.k8s}-servercert +""" + ) + + f""" +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s} +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6789 + host: tls-context-host-2 + service: {self.target.path.fqdn}:80 +""" + + super().manifests() + ) + + def queries(self): + yield Query( + self.url("", scheme="https", port=6789), + sni=True, + headers={"Host": "tls-context-host-2"}, + ca_cert=TLSCerts["tls-context-host-2"].pubcert, + ) + + def check(self): + assert self.results[0].json["backend"] == self.target.path.k8s + assert self.results[0].json["request"]["tls"]["enabled"] == False + + +class TCPMappingTLSTerminationCrossNamespaceTest(TCPMappingTLSTerminationTest): + extra_ports = [6789] + target: ServiceType + + def init(self, tls_src: Literal["tlscontext", "host"]) -> None: + super().init(tls_src) + self.target = HTTP() + + def manifests(self) -> str: + return ( + namespace_manifest("other-namespace") + + f""" +--- +apiVersion: v1 +kind: Secret +metadata: + name: {self.name.k8s}-servercert + namespace: other-namespace +type: kubernetes.io/tls +data: + tls.crt: {TLSCerts["tls-context-host-2"].k8s_crt} + tls.key: {TLSCerts["tls-context-host-2"].k8s_key} +""" + + ( + f""" +--- +apiVersion: getambassador.io/v2 +kind: TLSContext +metadata: + name: {self.name.k8s}-tlsserver + namespace: other-namespace +spec: + ambassador_id: [ {self.ambassador_id} ] + secret: {self.name.k8s}-servercert + hosts: [ "tls-context-host-2" ] +""" + if self.tls_src == "tlscontext" + else f""" +--- +apiVersion: getambassador.io/v2 +kind: Host +metadata: + name: {self.name.k8s}-tlsserver + namespace: other-namespace +spec: + ambassador_id: [ {self.ambassador_id} ] + hostname: "tls-context-host-2" + tlsSecret: + name: {self.name.k8s}-servercert +""" + ) + + f""" +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s} +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6789 + host: tls-context-host-2 + service: {self.target.path.fqdn}:80 +""" + + super().manifests() + ) + + def queries(self): + yield Query( + self.url("", scheme="https", port=6789), + sni=True, + headers={"Host": "tls-context-host-2"}, + ca_cert=TLSCerts["tls-context-host-2"].pubcert, + ) + + def check(self): + assert self.results[0].json["backend"] == self.target.path.k8s + assert self.results[0].json["request"]["tls"]["enabled"] == False + + +class TCPMappingSNISharedContextTest(TCPMappingTLSTerminationTest): + extra_ports = [6789] + target_a: ServiceType + target_b: ServiceType + + def init(self, tls_src: Literal["tlscontext", "host"]) -> None: + super().init(tls_src) + self.target_a = HTTP(name="target-a") + self.target_b = HTTP(name="target-b") + + def manifests(self) -> str: + # Note that TCPMapping.spec.host matches with TLSContext.spec.hosts based on simple string + # matching, not globbing. See irbasemapping.py:match_tls_context() + return ( + f""" +--- +apiVersion: v1 +kind: Secret +metadata: + name: {self.name.k8s}-servercert +type: kubernetes.io/tls +data: + tls.crt: {TLSCerts["*.domain.com"].k8s_crt} + tls.key: {TLSCerts["*.domain.com"].k8s_key} +""" + + ( + f""" +--- +apiVersion: getambassador.io/v2 +kind: TLSContext +metadata: + name: {self.name.k8s}-tlsserver +spec: + ambassador_id: [ {self.ambassador_id} ] + secret: {self.name.k8s}-servercert + hosts: + - "a.domain.com" + - "b.domain.com" +""" + if self.tls_src == "tlscontext" + else f""" +--- +apiVersion: getambassador.io/v2 +kind: Host +metadata: + name: {self.name.k8s}-tlsserver +spec: + ambassador_id: [ {self.ambassador_id} ] + hostname: "*.domain.com" + tlsSecret: + name: {self.name.k8s}-servercert + requestPolicy: + insecure: + action: Route + additionalPort: 8080 +""" + ) + + f""" +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s}-a +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6789 + host: a.domain.com + service: {self.target_a.path.fqdn}:80 +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s}-b +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6789 + host: b.domain.com + service: {self.target_b.path.fqdn}:80 +""" + + super().manifests() + ) + + def queries(self): + yield Query( + self.url("", scheme="https", port=6789), + sni=True, + headers={"Host": "a.domain.com"}, + ca_cert=TLSCerts["*.domain.com"].pubcert, + ) + yield Query( + self.url("", scheme="https", port=6789), + sni=True, + headers={"Host": "b.domain.com"}, + ca_cert=TLSCerts["*.domain.com"].pubcert, + ) + + def check(self): + assert self.results[0].json["backend"] == self.target_a.path.k8s + assert self.results[0].json["request"]["tls"]["enabled"] == False + assert self.results[1].json["backend"] == self.target_b.path.k8s + assert self.results[1].json["request"]["tls"]["enabled"] == False + + +class TCPMappingSNISeparateContextsTest(TCPMappingTLSTerminationTest): + extra_ports = [6789] + target_a: ServiceType + target_b: ServiceType + + def init(self, tls_src: Literal["tlscontext", "host"]) -> None: + super().init(tls_src) + self.target_a = HTTP(name="target-a") + self.target_b = HTTP(name="target-b") + + def manifests(self) -> str: + return ( + f""" +--- +apiVersion: v1 +kind: Secret +metadata: + name: {self.name.k8s}-servercert-a +type: kubernetes.io/tls +data: + tls.crt: {TLSCerts["tls-context-host-1"].k8s_crt} + tls.key: {TLSCerts["tls-context-host-1"].k8s_key} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {self.name.k8s}-servercert-b +type: kubernetes.io/tls +data: + tls.crt: {TLSCerts["tls-context-host-2"].k8s_crt} + tls.key: {TLSCerts["tls-context-host-2"].k8s_key} +""" + + ( + f""" +--- +apiVersion: getambassador.io/v2 +kind: TLSContext +metadata: + name: {self.name.k8s}-tlsserver-a +spec: + ambassador_id: [ {self.ambassador_id} ] + secret: {self.name.k8s}-servercert-a + hosts: [tls-context-host-1] +--- +apiVersion: getambassador.io/v2 +kind: TLSContext +metadata: + name: {self.name.k8s}-tlsserver-b +spec: + ambassador_id: [ {self.ambassador_id} ] + secret: {self.name.k8s}-servercert-b + hosts: [tls-context-host-2] +""" + if self.tls_src == "tlscontext" + else f""" +--- +apiVersion: getambassador.io/v2 +kind: Host +metadata: + name: {self.name.k8s}-tlsserver-a +spec: + ambassador_id: [ {self.ambassador_id} ] + hostname: "tls-context-host-1" + tlsSecret: + name: {self.name.k8s}-servercert-a +--- +apiVersion: getambassador.io/v2 +kind: Host +metadata: + name: {self.name.k8s}-tlsserver-b +spec: + ambassador_id: [ {self.ambassador_id} ] + hostname: "tls-context-host-2" + tlsSecret: + name: {self.name.k8s}-servercert-b +""" + ) + + f""" +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s}-a +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6789 + host: tls-context-host-1 + service: {self.target_a.path.fqdn}:80 +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s}-b +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6789 + host: tls-context-host-2 + service: {self.target_b.path.fqdn}:80 +""" + + super().manifests() + ) + + def queries(self): + yield Query( + self.url("", scheme="https", port=6789), + sni=True, + headers={"Host": "tls-context-host-1"}, + ca_cert=TLSCerts["tls-context-host-1"].pubcert, + ) + yield Query( + self.url("", scheme="https", port=6789), + sni=True, + headers={"Host": "tls-context-host-2"}, + ca_cert=TLSCerts["tls-context-host-2"].pubcert, + ) + + def check(self): + assert self.results[0].json["backend"] == self.target_a.path.k8s + assert self.results[0].json["request"]["tls"]["enabled"] == False + assert self.results[1].json["backend"] == self.target_b.path.k8s + assert self.results[1].json["request"]["tls"]["enabled"] == False + + +class TCPMappingSNIWithHTTPTest(AmbassadorTest): + # Note: TCPMappingSNIWithHTTPTest does *not* inherit from TCPMappingTLSTerminationTest because + # TCPMappingSNIWithHTTPTest wants to take more ownership of the HTTP Host. + + target: ServiceType + + tls_src: Literal["tlscontext", "host"] + + @classmethod + def variants(cls) -> Generator[Node, None, None]: + for tls_src in ["tlscontext", "host"]: + yield cls(tls_src, name="{self.tls_src}") + + def init(self, tls_src: Literal["tlscontext", "host"]) -> None: + self.tls_src = tls_src + self.target = HTTP() + + def manifests(self) -> str: + return ( + f""" +# HTTP Host ########################################################## +--- +apiVersion: v1 +kind: Secret +metadata: + name: {self.name.k8s} +type: kubernetes.io/tls +data: + tls.crt: {TLSCerts["tls-context-host-1"].k8s_crt} + tls.key: {TLSCerts["tls-context-host-1"].k8s_key} +--- +apiVersion: getambassador.io/v2 +kind: Host +metadata: + name: {self.path.k8s} +spec: + ambassador_id: [ {self.ambassador_id} ] + hostname: {self.path.fqdn} + acmeProvider: + authority: none + tlsSecret: + name: {self.name.k8s} +# TCPMapping ######################################################### +--- +apiVersion: v1 +kind: Secret +metadata: + name: {self.name.k8s}-servercert +type: kubernetes.io/tls +data: + tls.crt: {TLSCerts["tls-context-host-2"].k8s_crt} + tls.key: {TLSCerts["tls-context-host-2"].k8s_key} +""" + + ( + f""" +--- +apiVersion: getambassador.io/v2 +kind: TLSContext +metadata: + name: {self.name.k8s}-tlsserver +spec: + ambassador_id: [ {self.ambassador_id} ] + secret: {self.name.k8s}-servercert + hosts: [ "tls-context-host-2" ] +""" + if self.tls_src == "tlscontext" + else f""" +--- +apiVersion: getambassador.io/v2 +kind: Host +metadata: + name: {self.name.k8s}-tlsserver +spec: + ambassador_id: [ {self.ambassador_id} ] + hostname: "tls-context-host-2" + tlsSecret: + name: {self.name.k8s}-servercert +""" + ) + + f""" +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s} +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 8443 + host: tls-context-host-2 + service: {self.target.path.fqdn}:80 +""" + + super().manifests() + ) + + def scheme(self): + return "https" + + def queries(self): + yield Query( + self.url(""), + sni=True, + headers={"Host": "tls-context-host-2"}, + ca_cert=TLSCerts["tls-context-host-2"].pubcert, + ) + + def check(self): + assert self.results[0].json["backend"] == self.target.path.k8s + assert self.results[0].json["request"]["tls"]["enabled"] == False + + +class TCPMappingAddressTest(AmbassadorTest): + extra_ports = [6789, 6790] + target: ServiceType + + def init(self) -> None: + self.target = HTTP() + + def manifests(self) -> str: + return ( + format( + """ +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s}-local-only +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6789 + address: 127.0.0.1 + service: {self.target.path.fqdn}:80 +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s}-proxy +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6790 + service: localhost:6789 +""" + ) + + super().manifests() + ) + + def queries(self): + # Check that it only bound to localhost and doesn't allow external connections. + yield Query( + self.url("", port=6789), error=["connection reset by peer", "EOF", "connection refused"] + ) + # Use a second mapping that proxies to the first to check that it was even created. + yield Query(self.url("", port=6790)) + + def check(self): + assert self.results[1].json["backend"] == self.target.path.k8s + assert self.results[1].json["request"]["tls"]["enabled"] == False + + +class TCPMappingWeightTest(AmbassadorTest): + extra_ports = [6789] + target70: ServiceType + target30: ServiceType + + def init(self) -> None: + self.target70 = HTTP(name="tgt70") + self.target30 = HTTP(name="tgt30") + + def manifests(self) -> str: + return ( + format( + """ +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s}-70 +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6789 + service: {self.target70.path.fqdn}:80 + weight: 70 +--- +apiVersion: getambassador.io/v2 +kind: TCPMapping +metadata: + name: {self.name.k8s}-30 +spec: + ambassador_id: [ {self.ambassador_id} ] + port: 6789 + service: {self.target30.path.fqdn}:80 + weight: 30 +""" + ) + + super().manifests() + ) + + def queries(self): + for i in range(1000): + yield Query(self.url("", port=6789)) + + def check(self): + counts: Dict[str, int] = {} + for result in self.results: + backend = result.json["backend"] + counts[backend] = counts.get(backend, 0) + 1 + assert counts[self.target70.path.k8s] + counts[self.target30.path.k8s] == 1000 + # Probabalistic, margin might need tuned + margin = 150 + assert abs(counts[self.target70.path.k8s] - 700) < margin + assert abs(counts[self.target30.path.k8s] - 300) < margin + + +# TODO: Add tests for all of the config knobs for the upstream connection: +# - enable_ipv4: false +# - enable_ipv6: false +# - circuit_breakers +# - idle_timeout_ms +# - resolver +# +# TODO: Add tests for the config knobs for stats: +# - cluster_tag +# - stats_name (v3alpha1 only) diff --git a/python/tests/kat/t_tls.py b/python/tests/kat/t_tls.py index b9547cc99a..15aa76fa62 100644 --- a/python/tests/kat/t_tls.py +++ b/python/tests/kat/t_tls.py @@ -1,3 +1,5 @@ +import hashlib +from base64 import b64decode from typing import Generator, List, Tuple, Union from abstract_tests import HTTP, AmbassadorTest, Node, ServiceType @@ -193,19 +195,28 @@ def queries(self): ) def check(self): + cert = TLSCerts["presto.example.com"].pubcert + # base64-decode the cert data after removing the "---BEGIN CERTIFICATE---" / "---END CERTIFICATE---" lines. + certraw = b64decode("\n".join(l for l in cert.split("\n") if not l.startswith("-"))) + # take the sha256 sum aof that. + certhash = hashlib.sha256(certraw).hexdigest() + assert self.results[0].backend assert self.results[0].backend.request assert self.results[0].backend.request.headers["x-forwarded-client-cert"] == [ - 'Hash=c2d41a5977dcd28a3ba21f59ed5508cc6538defa810843d8a593e668306c8c4f;Subject="CN=presto.example.com,OU=Engineering,O=Presto,L=Bangalore,ST=KA,C=IN"' - ] + f'Hash={certhash};Subject="CN=presto.example.com,OU=Engineering,O=Ambassador Labs,L=Boston,ST=MA,C=US"' + ], ( + "unexpected x-forwarded-client-cert value: %s" + % self.results[0].backend.request.headers["x-forwarded-client-cert"] + ) assert self.results[0].backend.request.headers["x-cert-start"] == [ - "2019-01-10T19:19:52.000Z" + "2021-11-10T13:12:00.000Z" ], ( "unexpected x-cert-start value: %s" % self.results[0].backend.request.headers["x-cert-start"] ) assert self.results[0].backend.request.headers["x-cert-end"] == [ - "2118-12-17T19:19:52.000Z" + "2099-11-10T13:12:00.000Z" ], ( "unexpected x-cert-end value: %s" % self.results[0].backend.request.headers["x-cert-end"] @@ -213,13 +224,13 @@ def check(self): assert self.results[1].backend assert self.results[1].backend.request assert self.results[0].backend.request.headers["x-cert-start-custom"] == [ - "Jan 10 19:19:52 2019 UTC" + "Nov 10 13:12:00 2021 UTC" ], ( "unexpected x-cert-start-custom value: %s" % self.results[1].backend.request.headers["x-cert-start-custom"] ) assert self.results[0].backend.request.headers["x-cert-end-custom"] == [ - "Dec 17 19:19:52 2118 UTC" + "Nov 10 13:12:00 2099 UTC" ], ( "unexpected x-cert-end-custom value: %s" % self.results[0].backend.request.headers["x-cert-end-custom"] @@ -454,8 +465,17 @@ def manifests(self) -> str: ) def config(self) -> Generator[Union[str, Tuple[Node, str]], None, None]: - yield self, self.format( - """ + fingerprint = ( + hashlib.sha1( + ( + TLSCerts["localhost"].pubcert + "\n" + TLSCerts["localhost"].privkey + "\n" + ).encode("utf-8") + ) + .hexdigest() + .upper() + ) + + yield self, f""" --- apiVersion: getambassador.io/v3alpha1 kind: Module @@ -465,10 +485,9 @@ def config(self) -> Generator[Union[str, Tuple[Node, str]], None, None]: upstream: secret: test-origination-secret upstream-files: - cert_chain_file: /tmp/ambassador/snapshots/default/secrets-decoded/test-origination-secret/F94E4DCF30ABC50DEF240AA8024599B67CC03991.crt - private_key_file: /tmp/ambassador/snapshots/default/secrets-decoded/test-origination-secret/F94E4DCF30ABC50DEF240AA8024599B67CC03991.key + cert_chain_file: /tmp/ambassador/snapshots/default/secrets-decoded/test-origination-secret/{fingerprint}.crt + private_key_file: /tmp/ambassador/snapshots/default/secrets-decoded/test-origination-secret/{fingerprint}.key """ - ) yield self, self.format( """