moonchen commented on code in PR #13737:
URL: https://github.com/apache/trafficserver/pull/13737#discussion_r4340555199
##########
src/iocore/net/ConnectionTracker.cc:
##########
@@ -410,21 +410,21 @@ ConnectionTracker::clear_client_exempt_list()
_global_config->client_exempt_list.clear();
}
+bool
+ConnectionTracker::is_client_exempt(IpEndpoint const &addr)
+{
+ if (_global_config == nullptr) {
+ return false;
+ }
+
+ ts::bravo::shared_lock<ts::bravo::shared_mutex>
lock(_global_config->client_exempt_list_mutex);
+ return _global_config->client_exempt_list.contains(swoc::IPAddr{addr});
Review Comment:
`clear_client_exempt_list()` takes the exclusive lock,
`std::lock_guard<ts::bravo::shared_mutex>` at line 409, as do the other writers
and the records callback. This PR only moves the existing shared-lock lookup
into `is_client_exempt()`.
##########
tests/uranium_tests/client_connection/test_connections_throttle_exempt.py:
##########
@@ -0,0 +1,316 @@
+"""Verify that proxy.config.http.per_client.connection.exempt_list exempts
client connections from
+proxy.config.net.connections_throttle."""
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+
+from collections.abc import Iterable, Mapping
+import re
+import time
+from typing import Any
+
+import pytest
+
+from tools.uranium.services import ATS, ATSFactory, ServiceFactory,
wait_for_file_lines
+
+from .throttle_client import attempt, hold, request
+
+EXEMPT_LIST_VAR = "proxy.config.http.per_client.connection.exempt_list"
+OPEN = "proxy.process.net.connections_currently_open"
+EXEMPT_OPEN = "proxy.process.net.per_client.connections_exempt_currently_open"
+THROTTLED = "proxy.process.net.connections_throttled_in"
+EXEMPT = "proxy.process.net.per_client.connections_exempt_in"
+
+# An IPv4 and an IPv6 prefix longer than the address. If either were read as
/0, it would exempt the first address, the
+# loopback address of its family. The second address is the loopback address
of the other family.
+INVALID_PREFIXES = [
+ pytest.param("192.0.2.1/256", "127.0.0.1", "::1", id="ipv4"),
+ pytest.param("2001:db8::1/256", "::1", "127.0.0.1", id="ipv6"),
+]
+
+
+def invalid_entry_warning(entry: str) -> str:
+ """Return a regular expression for the warning that rejects the exempt
list entry @a entry."""
+
+ return re.escape(f"WARNING: {EXEMPT_LIST_VAR}: '{entry}' is not a valid IP
range")
+
+
+def create_ats(ats_factory: ATSFactory, name: str, **options: Any) -> ATS:
+ """Create a Traffic Server that serves the health check with the statichit
plugin, so that it needs no origin.
+
+ :param name: The process name.
+ :param options: The options of ATSFactory.create().
+ """
+
+ ats = ats_factory.create(name, **options)
+ if not ats.plugin_exists("statichit.so"):
+ pytest.skip("statichit.so is required")
+ ats.write_config_file("healthcheck.txt", "OK\n")
+ healthcheck = ats.config_directory / "healthcheck.txt"
+ ats.remap_config.add_line(f"map / http://127.0.0.1/ @plugin=statichit.so
@pparam=--file-path={healthcheck}")
+ return ats
+
+
+def metrics(ats: ATS, names: Iterable[str]) -> dict[str, int]:
+ """Return the values of the metrics @a names, read in one traffic_ctl
call."""
+
+ result = ats.traffic_ctl("metric", "get", *names)
+ assert result.returncode == 0, result.output
+ return {name: int(value) for name, value in (line.split() for line in
result.stdout.splitlines())}
+
+
+def wait_for_metrics(ats: ATS, expected: Mapping[str, int], timeout: float =
10) -> None:
+ """Wait until one read of the metrics shows each metric in @a expected at
its value.
+
+ :param timeout: How many seconds to wait.
+ """
+
+ deadline = time.monotonic() + timeout
+ values = metrics(ats, expected)
+ while values != expected and time.monotonic() < deadline:
+ time.sleep(0.1)
+ values = metrics(ats, expected)
+ assert values == expected, f"Expected the metrics {expected}, found
{values}"
+
+
+def stop_and_check_diags(ats: ATS) -> None:
+ """Stop @a ats and require that diags.log has no errors. Teardown checks
only for FATAL."""
+
+ ats.stop()
+ diags = ats.diags_log.read_text(errors="replace")
+ assert "ERROR:" not in diags, f"diags.log should contain no
errors:\n{diags}"
+
+
+class ThrottledTrafficServer:
+ """A Traffic Server whose connections_throttle is filled by connections
from one address."""
+
+ # An accept is refused once int(connections_currently_open * 1.1) >=
connections_throttle,
+ # so for a throttle below 11 the held connections fill it exactly.
+ THROTTLE = 5
+
+ def __init__(
+ self,
+ ats_factory: ATSFactory,
+ services: ServiceFactory,
+ exempt_list: str,
+ held: str,
+ other: str,
+ accept_threads: int = 1) -> None:
+ """Start Traffic Server and fill connections_throttle.
+
+ :param exempt_list: The value of
proxy.config.http.per_client.connection.exempt_list.
+ :param held: The address, 127.0.0.1 or ::1, of the connections that
fill the throttle.
+ :param other: The other address.
+ :param accept_threads: The value of proxy.config.accept_threads, which
selects the accept path.
+ """
+
+ self.held = held
+ self.other = other
+ # The expected counter values. None when a retry makes the count
unknown.
+ self._throttled = 0
+ self._exempt_count: int | None = 0
+ self.ats = self._configure(ats_factory, services, exempt_list,
accept_threads)
+ self.ats.start()
+ self._held = hold(held, self._port(held, "plain"), self.THROTTLE)
+ self._wait_for_counters()
+
+ def _configure(self, ats_factory: ATSFactory, services: ServiceFactory,
exempt_list: str, accept_threads: int) -> ATS:
+ """Configure Traffic Server with plain, TLS, allow-plain, and PROXY
protocol ports in both families."""
+
+ ats = create_ats(ats_factory, "ts", enable_tls=True)
+ self._ports = {
+ "plain": (ats.http_port, ats.ipv6_port),
+ "tls": (ats.https_port, ats.ipv6_https_port),
+ "allow-plain": (services.allocate_port(),
services.allocate_port()),
+ "proxy": (ats.proxy_protocol_port, services.allocate_port()),
+ }
+ flags = {"plain": "", "tls": ":ssl", "allow-plain":
":ssl:allow-plain", "proxy": ":pp"}
+ server_ports = " ".join(f"{ipv4}{flags[kind]}
{ipv6}{flags[kind]}:ipv6" for kind, (ipv4, ipv6) in self._ports.items())
+ ats.records.update(
+ {
+ "proxy.config.http.server_ports": server_ports,
+ "proxy.config.http.proxy_protocol_allowlist": "127.0.0.1,::1",
+ "proxy.config.diags.debug.enabled": 1,
+ "proxy.config.diags.debug.tags": "iocore_net_accept|statichit",
+ "proxy.config.accept_threads": accept_threads,
+ "proxy.config.net.connections_throttle": self.THROTTLE,
+ EXEMPT_LIST_VAR: exempt_list,
+ # Hold the incomplete requests for the whole test.
+ "proxy.config.http.accept_no_activity_timeout": 300,
+ "proxy.config.http.transaction_no_activity_timeout_in": 300,
+ })
+ return ats
+
+ def _port(self, host: str, kind: str) -> int:
+ """Return the port of @a kind ('plain', 'tls', 'allow-plain' or
'proxy') in the family of @a host."""
+
+ return self._ports[kind][1 if ":" in host else 0]
+
+ def _wait_for_counters(self) -> None:
+ """Wait for the counters to reach their expected values, with no
exempt connection open."""
+
+ expected = {OPEN: self.THROTTLE, EXEMPT_OPEN: 0, THROTTLED:
self._throttled}
+ if self._exempt_count is not None:
+ expected[EXEMPT] = self._exempt_count
+ wait_for_metrics(self.ats, expected)
+
+ def refused(self, host: str, kind: str = "plain", proxy_source: str = "",
retry: float = 0) -> None:
+ """Check that a connection from @a host is refused and counted in
connections_throttled_in.
+
+ :param proxy_source: Claim this source address in a PROXY protocol
header.
+ :param retry: Retry for this many seconds, for a reload to take effect.
+ """
+
+ self._throttled += 1
+ if retry:
+ # The attempts that are served before the reload takes effect are
counted as exempt.
+ self._exempt_count = None
+ request(host, self._port(host, kind), "refused", retry=retry, tls=kind
== "tls", proxy_source=proxy_source)
+ self._wait_for_counters()
+
+ def served(self, kind: str = "plain", proxy_source: str = "") -> None:
+ """Check that a connection from the other address is served, and
counted as an exempt open connection.
+
+ :param proxy_source: Claim this source address in a PROXY protocol
header.
+ """
+
+ assert self._exempt_count is not None, "A served check needs a known
exempt count."
+ self._exempt_count += 1
+ open_counts: dict[str, int] = {}
+
+ def read_open_counts() -> None:
+ open_counts.update(metrics(self.ats, (OPEN, EXEMPT_OPEN)))
+
+ outcome = attempt(
+ self.other, self._port(self.other, kind), tls=kind == "tls",
proxy_source=proxy_source, while_open=read_open_counts)
+ assert outcome == "served", f"A {kind} connection from {self.other}
was refused"
+ assert open_counts == {OPEN: self.THROTTLE + 1, EXEMPT_OPEN: 1}, "The
open exempt connection is counted as exempt."
Review Comment:
The accept path increments both gauges in `count_client_connection()` before
ATS reads the request, so they are already up when the response arrives, and
`traffic_ctl metric get` reads the live atomic value. Only the decrements on
close happen later, which is why `_wait_for_counters()` polls after the
connection closes.
##########
tests/uranium_tests/client_connection/test_connections_throttle_exempt.py:
##########
@@ -0,0 +1,316 @@
+"""Verify that proxy.config.http.per_client.connection.exempt_list exempts
client connections from
+proxy.config.net.connections_throttle."""
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+
+from collections.abc import Iterable, Mapping
+import re
+import time
+from typing import Any
+
+import pytest
+
+from tools.uranium.services import ATS, ATSFactory, ServiceFactory,
wait_for_file_lines
+
+from .throttle_client import attempt, hold, request
+
+EXEMPT_LIST_VAR = "proxy.config.http.per_client.connection.exempt_list"
+OPEN = "proxy.process.net.connections_currently_open"
+EXEMPT_OPEN = "proxy.process.net.per_client.connections_exempt_currently_open"
+THROTTLED = "proxy.process.net.connections_throttled_in"
+EXEMPT = "proxy.process.net.per_client.connections_exempt_in"
+
+# An IPv4 and an IPv6 prefix longer than the address. If either were read as
/0, it would exempt the first address, the
+# loopback address of its family. The second address is the loopback address
of the other family.
+INVALID_PREFIXES = [
+ pytest.param("192.0.2.1/256", "127.0.0.1", "::1", id="ipv4"),
+ pytest.param("2001:db8::1/256", "::1", "127.0.0.1", id="ipv6"),
+]
+
+
+def invalid_entry_warning(entry: str) -> str:
+ """Return a regular expression for the warning that rejects the exempt
list entry @a entry."""
+
+ return re.escape(f"WARNING: {EXEMPT_LIST_VAR}: '{entry}' is not a valid IP
range")
+
+
+def create_ats(ats_factory: ATSFactory, name: str, **options: Any) -> ATS:
+ """Create a Traffic Server that serves the health check with the statichit
plugin, so that it needs no origin.
+
+ :param name: The process name.
+ :param options: The options of ATSFactory.create().
+ """
+
+ ats = ats_factory.create(name, **options)
+ if not ats.plugin_exists("statichit.so"):
+ pytest.skip("statichit.so is required")
+ ats.write_config_file("healthcheck.txt", "OK\n")
+ healthcheck = ats.config_directory / "healthcheck.txt"
+ ats.remap_config.add_line(f"map / http://127.0.0.1/ @plugin=statichit.so
@pparam=--file-path={healthcheck}")
+ return ats
+
+
+def metrics(ats: ATS, names: Iterable[str]) -> dict[str, int]:
+ """Return the values of the metrics @a names, read in one traffic_ctl
call."""
+
+ result = ats.traffic_ctl("metric", "get", *names)
+ assert result.returncode == 0, result.output
+ return {name: int(value) for name, value in (line.split() for line in
result.stdout.splitlines())}
+
+
+def wait_for_metrics(ats: ATS, expected: Mapping[str, int], timeout: float =
10) -> None:
+ """Wait until one read of the metrics shows each metric in @a expected at
its value.
+
+ :param timeout: How many seconds to wait.
+ """
+
+ deadline = time.monotonic() + timeout
+ values = metrics(ats, expected)
+ while values != expected and time.monotonic() < deadline:
+ time.sleep(0.1)
+ values = metrics(ats, expected)
+ assert values == expected, f"Expected the metrics {expected}, found
{values}"
+
+
+def stop_and_check_diags(ats: ATS) -> None:
+ """Stop @a ats and require that diags.log has no errors. Teardown checks
only for FATAL."""
+
+ ats.stop()
+ diags = ats.diags_log.read_text(errors="replace")
+ assert "ERROR:" not in diags, f"diags.log should contain no
errors:\n{diags}"
+
+
+class ThrottledTrafficServer:
+ """A Traffic Server whose connections_throttle is filled by connections
from one address."""
+
+ # An accept is refused once int(connections_currently_open * 1.1) >=
connections_throttle,
+ # so for a throttle below 11 the held connections fill it exactly.
+ THROTTLE = 5
+
+ def __init__(
+ self,
+ ats_factory: ATSFactory,
+ services: ServiceFactory,
+ exempt_list: str,
+ held: str,
+ other: str,
+ accept_threads: int = 1) -> None:
+ """Start Traffic Server and fill connections_throttle.
+
+ :param exempt_list: The value of
proxy.config.http.per_client.connection.exempt_list.
+ :param held: The address, 127.0.0.1 or ::1, of the connections that
fill the throttle.
+ :param other: The other address.
+ :param accept_threads: The value of proxy.config.accept_threads, which
selects the accept path.
+ """
+
+ self.held = held
+ self.other = other
+ # The expected counter values. None when a retry makes the count
unknown.
+ self._throttled = 0
+ self._exempt_count: int | None = 0
+ self.ats = self._configure(ats_factory, services, exempt_list,
accept_threads)
+ self.ats.start()
+ self._held = hold(held, self._port(held, "plain"), self.THROTTLE)
+ self._wait_for_counters()
+
+ def _configure(self, ats_factory: ATSFactory, services: ServiceFactory,
exempt_list: str, accept_threads: int) -> ATS:
+ """Configure Traffic Server with plain, TLS, allow-plain, and PROXY
protocol ports in both families."""
+
+ ats = create_ats(ats_factory, "ts", enable_tls=True)
+ self._ports = {
+ "plain": (ats.http_port, ats.ipv6_port),
+ "tls": (ats.https_port, ats.ipv6_https_port),
+ "allow-plain": (services.allocate_port(),
services.allocate_port()),
+ "proxy": (ats.proxy_protocol_port, services.allocate_port()),
+ }
+ flags = {"plain": "", "tls": ":ssl", "allow-plain":
":ssl:allow-plain", "proxy": ":pp"}
+ server_ports = " ".join(f"{ipv4}{flags[kind]}
{ipv6}{flags[kind]}:ipv6" for kind, (ipv4, ipv6) in self._ports.items())
+ ats.records.update(
+ {
+ "proxy.config.http.server_ports": server_ports,
+ "proxy.config.http.proxy_protocol_allowlist": "127.0.0.1,::1",
+ "proxy.config.diags.debug.enabled": 1,
+ "proxy.config.diags.debug.tags": "iocore_net_accept|statichit",
+ "proxy.config.accept_threads": accept_threads,
+ "proxy.config.net.connections_throttle": self.THROTTLE,
+ EXEMPT_LIST_VAR: exempt_list,
+ # Hold the incomplete requests for the whole test.
+ "proxy.config.http.accept_no_activity_timeout": 300,
+ "proxy.config.http.transaction_no_activity_timeout_in": 300,
+ })
+ return ats
+
+ def _port(self, host: str, kind: str) -> int:
+ """Return the port of @a kind ('plain', 'tls', 'allow-plain' or
'proxy') in the family of @a host."""
+
+ return self._ports[kind][1 if ":" in host else 0]
+
+ def _wait_for_counters(self) -> None:
+ """Wait for the counters to reach their expected values, with no
exempt connection open."""
+
+ expected = {OPEN: self.THROTTLE, EXEMPT_OPEN: 0, THROTTLED:
self._throttled}
+ if self._exempt_count is not None:
+ expected[EXEMPT] = self._exempt_count
+ wait_for_metrics(self.ats, expected)
+
+ def refused(self, host: str, kind: str = "plain", proxy_source: str = "",
retry: float = 0) -> None:
+ """Check that a connection from @a host is refused and counted in
connections_throttled_in.
+
+ :param proxy_source: Claim this source address in a PROXY protocol
header.
+ :param retry: Retry for this many seconds, for a reload to take effect.
+ """
+
+ self._throttled += 1
+ if retry:
+ # The attempts that are served before the reload takes effect are
counted as exempt.
+ self._exempt_count = None
+ request(host, self._port(host, kind), "refused", retry=retry, tls=kind
== "tls", proxy_source=proxy_source)
+ self._wait_for_counters()
+
+ def served(self, kind: str = "plain", proxy_source: str = "") -> None:
+ """Check that a connection from the other address is served, and
counted as an exempt open connection.
+
+ :param proxy_source: Claim this source address in a PROXY protocol
header.
+ """
+
+ assert self._exempt_count is not None, "A served check needs a known
exempt count."
+ self._exempt_count += 1
+ open_counts: dict[str, int] = {}
+
+ def read_open_counts() -> None:
+ open_counts.update(metrics(self.ats, (OPEN, EXEMPT_OPEN)))
+
+ outcome = attempt(
+ self.other, self._port(self.other, kind), tls=kind == "tls",
proxy_source=proxy_source, while_open=read_open_counts)
+ assert outcome == "served", f"A {kind} connection from {self.other}
was refused"
+ assert open_counts == {OPEN: self.THROTTLE + 1, EXEMPT_OPEN: 1}, "The
open exempt connection is counted as exempt."
+ self._wait_for_counters()
+
+ def reload(self, exempt_list: str, warning: str = "") -> None:
+ """Set the exempt list with traffic_ctl and reload.
+
+ :param warning: Wait for this regular expression in diags.log after
the reload.
+ """
+
+ for command in (("config", "set", EXEMPT_LIST_VAR, exempt_list),
("config", "reload")):
+ result = self.ats.traffic_ctl(*command)
+ assert result.returncode == 0, result.output
+ if warning:
+ wait_for_file_lines(self.ats.diags_log, warning, 1)
+
+ def finish(self) -> None:
+ """Close the held connections, stop Traffic Server, and check
diags.log."""
+
+ for sock in self._held:
+ sock.close()
+ stop_and_check_diags(self.ats)
+
+
+def test_memory_limit_applies(ats_factory: ATSFactory) -> None:
+ """Check that the memory limit still refuses a client in the list."""
+
+ ats = create_ats(ats_factory, "ts")
+ ats.records.update(
+ {
+ "proxy.config.http.server_ports": f"{ats.http_port}
{ats.ipv6_port}:ipv6",
+ EXEMPT_LIST_VAR: "::1",
+ # Less than any resident set size, so every new client connection
is refused once memory use is checked.
+ "proxy.config.memory.max_usage": 1,
+ })
+ ats.start()
+ # Traffic Server checks memory use every 10 seconds, and serves the health
check until then.
+ request("::1", ats.ipv6_port, "refused", retry=30)
+ wait_for_metrics(ats, {THROTTLED: 1})
+ stop_and_check_diags(ats)
+
+
+# accept_threads 1 accepts in a dedicated thread; 0 accepts on each ET_NET
thread.
[email protected]("accept_threads", [1, 0], ids=["accept_threads_1",
"accept_threads_0"])
+def test_exempt_ipv6(ats_factory: ATSFactory, services: ServiceFactory,
accept_threads: int) -> None:
+ """Fill the throttle from 127.0.0.1, and check ::1 over each kind of port,
and a reload of the list."""
+
+ # A network, a range, and an empty entry that match no test client, and
::1.
+ t = ThrottledTrafficServer(
+ ats_factory,
+ services,
+ "192.0.2.0/24, 198.51.100.1-198.51.100.9, ::1,",
+ held="127.0.0.1",
+ other="::1",
+ accept_threads=accept_threads)
+ # IPv4 is refused at the limit.
+ t.refused(t.held)
+ # ::1 is served over TCP, TLS, plain HTTP on an allow-plain TLS port, and
a PROXY protocol port that claims an
+ # address not in the list.
+ t.served()
+ t.served(kind="tls")
+ t.served(kind="allow-plain")
+ t.served(kind="proxy", proxy_source="2001:db8::1")
+ # IPv4 is refused on a PROXY protocol port that claims ::1.
+ t.refused(t.held, kind="proxy", proxy_source="::1")
Review Comment:
The throttle refuses this connection at accept, before ATS reads the PROXY
header, so the family of the header does not affect the outcome. The counter
check after it requires `connections_throttled_in` to rise by exactly one, so a
connection closed for a bad header would fail the test. Claiming `::1`, the
address in the list, is the most direct case.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]