egress-proxy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ """egress-proxy: self-hosted egress proxy with drop-in integrations for Python HTTP clients.
2
+
3
+ Source: https://github.com/botifyex/egress-proxy
4
+ Author: botifyex
5
+ License: Apache-2.0
6
+ """
7
+
8
+ from importlib.metadata import version
9
+
10
+ __version__ = version("egress-proxy")
File without changes
@@ -0,0 +1,8 @@
1
+ from egress_proxy.extras import require
2
+
3
+ require("client-aiohttp", "aiohttp", "yarl")
4
+
5
+ from egress_proxy.client.aiohttp.factory import session
6
+ from egress_proxy.client.aiohttp.middleware import Middleware, Refused
7
+
8
+ __all__ = ["Middleware", "Refused", "session"]
@@ -0,0 +1,17 @@
1
+ from collections.abc import Sequence
2
+ from typing import Any
3
+
4
+ import aiohttp
5
+
6
+ from egress_proxy.client.aiohttp.middleware import Middleware
7
+
8
+
9
+ def session(
10
+ *,
11
+ egress_url: str,
12
+ egress_key: str,
13
+ middlewares: Sequence[aiohttp.ClientMiddlewareType] = (),
14
+ **options: Any,
15
+ ) -> aiohttp.ClientSession:
16
+ egress = Middleware(egress_url=egress_url, egress_key=egress_key)
17
+ return aiohttp.ClientSession(middlewares=(*middlewares, egress), **options)
@@ -0,0 +1,47 @@
1
+ import aiohttp
2
+ from yarl import URL
3
+
4
+ from egress_proxy.protocol import Failure, Header
5
+
6
+
7
+ class Refused(aiohttp.ClientError):
8
+ pass
9
+
10
+
11
+ ERRORS: dict[Failure, type[aiohttp.ClientError]] = {
12
+ Failure.UNAUTHORIZED: Refused,
13
+ Failure.INVALID: Refused,
14
+ Failure.TIMEOUT: aiohttp.ServerTimeoutError,
15
+ Failure.UNREACHABLE: aiohttp.ClientConnectionError,
16
+ }
17
+
18
+
19
+ class Middleware:
20
+ def __init__(self, *, egress_url: str, egress_key: str) -> None:
21
+ self.url = URL(egress_url)
22
+ self.key = egress_key
23
+
24
+ def rewrite(self, request: aiohttp.ClientRequest) -> None:
25
+ if Header.TARGET in request.headers:
26
+ return
27
+ request.headers[Header.TARGET] = str(request.url)
28
+ request.headers[Header.KEY] = self.key
29
+ request.headers[aiohttp.hdrs.HOST] = self.url.host_port_subcomponent or ""
30
+ request.url = self.url
31
+
32
+ async def reject(self, response: aiohttp.ClientResponse) -> aiohttp.ClientError:
33
+ failure = Failure(response.headers[Header.FAILURE])
34
+ message = await response.text()
35
+ response.release()
36
+ return ERRORS[failure](message)
37
+
38
+ async def __call__(
39
+ self,
40
+ request: aiohttp.ClientRequest,
41
+ handler: aiohttp.ClientHandlerType,
42
+ ) -> aiohttp.ClientResponse:
43
+ self.rewrite(request)
44
+ response = await handler(request)
45
+ if Header.FAILURE in response.headers:
46
+ raise await self.reject(response)
47
+ return response
@@ -0,0 +1,9 @@
1
+ from egress_proxy.extras import require
2
+
3
+ require("client-httpx", "httpx")
4
+
5
+ from egress_proxy.client.httpx.client import AsyncClient, Client
6
+ from egress_proxy.client.httpx.route import Refused
7
+ from egress_proxy.client.httpx.transport import AsyncTransport, Transport
8
+
9
+ __all__ = ["AsyncClient", "AsyncTransport", "Client", "Refused", "Transport"]
@@ -0,0 +1,31 @@
1
+ from typing import Any
2
+
3
+ import httpx
4
+
5
+ from egress_proxy.client.httpx.transport import AsyncTransport, Transport
6
+
7
+
8
+ class Client(httpx.Client):
9
+ def __init__(
10
+ self,
11
+ *,
12
+ egress_url: str,
13
+ egress_key: str,
14
+ egress_transport: httpx.BaseTransport | None = None,
15
+ **options: Any,
16
+ ) -> None:
17
+ transport = Transport(egress_url=egress_url, egress_key=egress_key, egress_transport=egress_transport)
18
+ super().__init__(transport=transport, **options)
19
+
20
+
21
+ class AsyncClient(httpx.AsyncClient):
22
+ def __init__(
23
+ self,
24
+ *,
25
+ egress_url: str,
26
+ egress_key: str,
27
+ egress_transport: httpx.AsyncBaseTransport | None = None,
28
+ **options: Any,
29
+ ) -> None:
30
+ transport = AsyncTransport(egress_url=egress_url, egress_key=egress_key, egress_transport=egress_transport)
31
+ super().__init__(transport=transport, **options)
@@ -0,0 +1,46 @@
1
+ from dataclasses import dataclass
2
+
3
+ import httpx
4
+
5
+ from egress_proxy.protocol import Failure, Header
6
+
7
+
8
+ class Refused(httpx.TransportError):
9
+ pass
10
+
11
+
12
+ ERRORS: dict[Failure, type[httpx.TransportError]] = {
13
+ Failure.UNAUTHORIZED: Refused,
14
+ Failure.INVALID: Refused,
15
+ Failure.TIMEOUT: httpx.TimeoutException,
16
+ Failure.UNREACHABLE: httpx.ConnectError,
17
+ }
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Route:
22
+ url: httpx.URL
23
+ key: str
24
+
25
+ def headers(self, request: httpx.Request) -> httpx.Headers:
26
+ headers = request.headers.copy()
27
+ headers["host"] = self.url.netloc.decode("ascii")
28
+ headers[Header.KEY] = self.key
29
+ headers[Header.TARGET] = str(request.url)
30
+ return headers
31
+
32
+ def rewrite(self, request: httpx.Request) -> httpx.Request:
33
+ return httpx.Request(
34
+ request.method,
35
+ self.url,
36
+ headers=self.headers(request),
37
+ stream=request.stream,
38
+ extensions=request.extensions,
39
+ )
40
+
41
+ def failed(self, response: httpx.Response) -> bool:
42
+ return Header.FAILURE in response.headers
43
+
44
+ def error(self, request: httpx.Request, response: httpx.Response) -> httpx.TransportError:
45
+ failure = Failure(response.headers[Header.FAILURE])
46
+ return ERRORS[failure](response.text, request=request)
@@ -0,0 +1,47 @@
1
+ import httpx
2
+
3
+ from egress_proxy.client.httpx.route import Route
4
+
5
+
6
+ class Transport(httpx.BaseTransport):
7
+ def __init__(
8
+ self,
9
+ *,
10
+ egress_url: str,
11
+ egress_key: str,
12
+ egress_transport: httpx.BaseTransport | None = None,
13
+ ) -> None:
14
+ self.route = Route(httpx.URL(egress_url), egress_key)
15
+ self.inner = egress_transport or httpx.HTTPTransport()
16
+
17
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
18
+ response = self.inner.handle_request(self.route.rewrite(request))
19
+ if self.route.failed(response):
20
+ response.read()
21
+ raise self.route.error(request, response)
22
+ return response
23
+
24
+ def close(self) -> None:
25
+ self.inner.close()
26
+
27
+
28
+ class AsyncTransport(httpx.AsyncBaseTransport):
29
+ def __init__(
30
+ self,
31
+ *,
32
+ egress_url: str,
33
+ egress_key: str,
34
+ egress_transport: httpx.AsyncBaseTransport | None = None,
35
+ ) -> None:
36
+ self.route = Route(httpx.URL(egress_url), egress_key)
37
+ self.inner = egress_transport or httpx.AsyncHTTPTransport()
38
+
39
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
40
+ response = await self.inner.handle_async_request(self.route.rewrite(request))
41
+ if self.route.failed(response):
42
+ await response.aread()
43
+ raise self.route.error(request, response)
44
+ return response
45
+
46
+ async def aclose(self) -> None:
47
+ await self.inner.aclose()
egress_proxy/extras.py ADDED
@@ -0,0 +1,13 @@
1
+ from importlib import import_module
2
+
3
+
4
+ class Missing(ModuleNotFoundError):
5
+ pass
6
+
7
+
8
+ def require(extra: str, *libraries: str) -> None:
9
+ for library in libraries:
10
+ try:
11
+ import_module(library)
12
+ except ModuleNotFoundError as error:
13
+ raise Missing(f"{library} is not installed: pip install 'egress-proxy[{extra}]'") from error
@@ -0,0 +1,14 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class Header(StrEnum):
5
+ KEY = "x-egress-key"
6
+ TARGET = "x-egress-target"
7
+ FAILURE = "x-egress-failure"
8
+
9
+
10
+ class Failure(StrEnum):
11
+ UNAUTHORIZED = "unauthorized"
12
+ INVALID = "invalid"
13
+ TIMEOUT = "timeout"
14
+ UNREACHABLE = "unreachable"
egress_proxy/py.typed ADDED
File without changes
@@ -0,0 +1,3 @@
1
+ from egress_proxy.extras import require
2
+
3
+ require("server", "aiohttp", "certifi", "httptools", "pydantic_settings", "uvicorn", "yarl")
@@ -0,0 +1,55 @@
1
+ import ipaddress
2
+ import socket
3
+ from typing import TypeAlias
4
+
5
+ from aiohttp.abc import AbstractResolver, ResolveResult
6
+ from aiohttp.resolver import DefaultResolver
7
+
8
+ Address: TypeAlias = ipaddress.IPv4Address | ipaddress.IPv6Address
9
+
10
+ NAT64 = ipaddress.ip_network("64:ff9b::/96")
11
+
12
+
13
+ class Local(OSError):
14
+ pass
15
+
16
+
17
+ def unwrapped(address: ipaddress.IPv6Address) -> list[Address]:
18
+ inner: list[Address | None] = [address.ipv4_mapped, address.sixtofour, *(address.teredo or ())]
19
+ if address in NAT64:
20
+ inner.append(ipaddress.IPv4Address(int(address) & 0xFFFFFFFF))
21
+ return [item for item in inner if item is not None]
22
+
23
+
24
+ def embedded(address: Address) -> list[Address]:
25
+ if isinstance(address, ipaddress.IPv4Address):
26
+ return [address]
27
+ return [address, *unwrapped(address)]
28
+
29
+
30
+ def public(address: Address) -> bool:
31
+ return all(item.is_global and not item.is_multicast for item in embedded(address))
32
+
33
+
34
+ def literal(host: str) -> Address | None:
35
+ try:
36
+ return ipaddress.ip_address(host.strip("[]"))
37
+ except ValueError:
38
+ return None
39
+
40
+
41
+ class Guard(AbstractResolver):
42
+ def __init__(self, inner: AbstractResolver | None = None) -> None:
43
+ self.inner = inner or DefaultResolver()
44
+
45
+ async def resolve(
46
+ self, host: str, port: int = 0, family: socket.AddressFamily = socket.AF_INET
47
+ ) -> list[ResolveResult]:
48
+ results = await self.inner.resolve(host, port, family)
49
+ allowed = [result for result in results if public(ipaddress.ip_address(result["host"]))]
50
+ if not allowed:
51
+ raise Local(f"{host} resolves only to local addresses")
52
+ return allowed
53
+
54
+ async def close(self) -> None:
55
+ await self.inner.close()
@@ -0,0 +1,53 @@
1
+ from egress_proxy.server.asgi import Receive, Scope, Send
2
+ from egress_proxy.server.forward import Forward
3
+ from egress_proxy.server.routes import balancer
4
+ from egress_proxy.server.settings import Proxy
5
+ from egress_proxy.server.upstreams import configured
6
+
7
+
8
+ class Unstarted(RuntimeError):
9
+ pass
10
+
11
+
12
+ class Application:
13
+ def __init__(self, proxy: Proxy) -> None:
14
+ self.proxy = proxy
15
+ self.upstreams = configured(proxy)
16
+ self.forward: Forward | None = None
17
+
18
+ async def startup(self) -> None:
19
+ self.forward = Forward(balancer(self.proxy, self.upstreams), self.proxy)
20
+
21
+ async def shutdown(self) -> None:
22
+ if self.forward is not None:
23
+ await self.forward.balancer.close()
24
+ self.forward = None
25
+
26
+ async def lifespan(self, receive: Receive, send: Send) -> None:
27
+ while True:
28
+ message = await receive()
29
+ if message["type"] == "lifespan.startup":
30
+ await self.startup()
31
+ await send({"type": "lifespan.startup.complete"})
32
+ continue
33
+ await self.shutdown()
34
+ await send({"type": "lifespan.shutdown.complete"})
35
+ return
36
+
37
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
38
+ if scope["type"] == "lifespan":
39
+ await self.lifespan(receive, send)
40
+ return
41
+ if scope["type"] != "http":
42
+ return
43
+ if self.forward is None:
44
+ raise Unstarted("application must complete lifespan startup before serving requests")
45
+ await self.forward.handle(scope, receive, send)
46
+
47
+
48
+ def application(proxy: Proxy) -> Application:
49
+ return Application(proxy)
50
+
51
+
52
+ def create() -> Application:
53
+ return application(Proxy())
@@ -0,0 +1,7 @@
1
+ from collections.abc import Awaitable, Callable, MutableMapping
2
+ from typing import Any, TypeAlias
3
+
4
+ Scope: TypeAlias = MutableMapping[str, Any]
5
+ Message: TypeAlias = MutableMapping[str, Any]
6
+ Receive: TypeAlias = Callable[[], Awaitable[Message]]
7
+ Send: TypeAlias = Callable[[Message], Awaitable[None]]
@@ -0,0 +1,159 @@
1
+ import hmac
2
+ from collections.abc import AsyncIterator
3
+ from typing import TypeAlias
4
+
5
+ import aiohttp
6
+ from aiohttp_socks import ProxyConnectionError, ProxyError, ProxyTimeoutError
7
+ from yarl import URL
8
+
9
+ from egress_proxy.protocol import Failure, Header
10
+ from egress_proxy.server.addresses import Local, literal, public
11
+ from egress_proxy.server.asgi import Receive, Scope, Send
12
+ from egress_proxy.server.headers import REQUEST, RESPONSE, Pairs, filtered
13
+ from egress_proxy.server.rejection import Rejection
14
+ from egress_proxy.server.routes import Balancer, Route
15
+ from egress_proxy.server.settings import Proxy
16
+
17
+ Headers: TypeAlias = dict[bytes, bytes]
18
+
19
+ SCHEMES = frozenset({"http", "https"})
20
+
21
+ METHODS = frozenset({"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"})
22
+
23
+ KEY = Header.KEY.encode()
24
+
25
+ TARGET = Header.TARGET.encode()
26
+
27
+ UNREACHABLE = (ProxyConnectionError, ProxyTimeoutError)
28
+
29
+ REFUSED = (*UNREACHABLE, ProxyError)
30
+
31
+
32
+ def decoded(pairs: Pairs) -> list[tuple[str, str]]:
33
+ return [(name.decode("latin-1"), value.decode("latin-1")) for name, value in pairs]
34
+
35
+
36
+ async def chunks(receive: Receive) -> AsyncIterator[bytes]:
37
+ while True:
38
+ message = await receive()
39
+ if message["type"] == "http.disconnect":
40
+ return
41
+ if message.get("body"):
42
+ yield message["body"]
43
+ if not message.get("more_body", False):
44
+ return
45
+
46
+
47
+ class Stream:
48
+ def __init__(self, receive: Receive, present: bool) -> None:
49
+ self.receive = receive
50
+ self.present = present
51
+ self.started = False
52
+
53
+ async def chunks(self) -> AsyncIterator[bytes]:
54
+ self.started = True
55
+ async for chunk in chunks(self.receive):
56
+ yield chunk
57
+
58
+ def body(self) -> AsyncIterator[bytes] | None:
59
+ return self.chunks() if self.present else None
60
+
61
+
62
+ class Forward:
63
+ def __init__(self, balancer: Balancer, proxy: Proxy) -> None:
64
+ self.balancer = balancer
65
+ self.key = proxy.key.get_secret_value().encode()
66
+ self.local = proxy.allow_local
67
+
68
+ def authorize(self, headers: Headers) -> None:
69
+ if not hmac.compare_digest(headers.get(KEY, b""), self.key):
70
+ raise Rejection(Failure.UNAUTHORIZED, "egress key is missing or invalid")
71
+
72
+ def method(self, scope: Scope) -> str:
73
+ method: str = scope["method"]
74
+ if method not in METHODS:
75
+ raise Rejection(Failure.INVALID, f"method {method} is not supported")
76
+ return method
77
+
78
+ def target(self, headers: Headers) -> URL:
79
+ value = headers.get(TARGET)
80
+ if value is None:
81
+ raise Rejection(Failure.INVALID, f"'{Header.TARGET}' header is required")
82
+ text = value.decode("latin-1")
83
+ try:
84
+ url = URL(text, encoded=True)
85
+ except ValueError as error:
86
+ raise Rejection(Failure.INVALID, f"'{text}' is not a valid url") from error
87
+ if url.scheme not in SCHEMES or not url.host:
88
+ raise Rejection(Failure.INVALID, f"'{text}' must be an absolute http or https url")
89
+ self.reachable(url)
90
+ return url
91
+
92
+ def reachable(self, url: URL) -> None:
93
+ address = literal(url.host or "")
94
+ if not self.local and address is not None and not public(address):
95
+ raise Rejection(Failure.INVALID, f"{url} points to a local address")
96
+
97
+ def unreachable(self, url: URL, error: aiohttp.ClientError) -> Rejection:
98
+ if isinstance(getattr(error, "os_error", None), Local):
99
+ return Rejection(Failure.INVALID, f"{url} resolves only to local addresses")
100
+ return Rejection(Failure.UNREACHABLE, f"{url} is unreachable")
101
+
102
+ def stream(self, headers: Headers, receive: Receive) -> Stream:
103
+ return Stream(receive, b"content-length" in headers or b"transfer-encoding" in headers)
104
+
105
+ async def send(
106
+ self,
107
+ route: Route,
108
+ method: str,
109
+ url: URL,
110
+ headers: list[tuple[str, str]],
111
+ stream: Stream,
112
+ ) -> aiohttp.ClientResponse:
113
+ try:
114
+ return await route.client.request(method, url, headers=headers, data=stream.body(), allow_redirects=False)
115
+ except TimeoutError as error:
116
+ raise Rejection(Failure.TIMEOUT, f"{url} timed out") from error
117
+ except aiohttp.ClientError as error:
118
+ raise self.unreachable(url, error) from error
119
+
120
+ def failed(self, route: Route, url: URL, error: Exception) -> Rejection:
121
+ if isinstance(error, UNREACHABLE):
122
+ self.balancer.rest(route)
123
+ return Rejection(Failure.UNREACHABLE, f"upstream proxy {route.name} is unreachable")
124
+ return Rejection(Failure.UNREACHABLE, f"{url} is unreachable through upstream proxy {route.name}")
125
+
126
+ async def request(self, scope: Scope, receive: Receive) -> aiohttp.ClientResponse:
127
+ headers: Headers = dict(scope["headers"])
128
+ self.authorize(headers)
129
+ method = self.method(scope)
130
+ url = self.target(headers)
131
+ forwarded = decoded(filtered(scope["headers"], REQUEST))
132
+ stream = self.stream(headers, receive)
133
+ failure = Rejection(Failure.UNREACHABLE, f"{url} is unreachable")
134
+ for route in self.balancer.candidates():
135
+ try:
136
+ return await self.send(route, method, url, forwarded, stream)
137
+ except REFUSED as error:
138
+ failure = self.failed(route, url, error)
139
+ if stream.started:
140
+ break
141
+ raise failure
142
+
143
+ async def relay(self, upstream: aiohttp.ClientResponse, send: Send) -> None:
144
+ headers = filtered(list(upstream.raw_headers), RESPONSE)
145
+ await send({"type": "http.response.start", "status": upstream.status, "headers": headers})
146
+ async for chunk in upstream.content.iter_any():
147
+ await send({"type": "http.response.body", "body": chunk, "more_body": True})
148
+ await send({"type": "http.response.body", "body": b""})
149
+
150
+ async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
151
+ try:
152
+ upstream = await self.request(scope, receive)
153
+ except Rejection as rejection:
154
+ await rejection.send(send)
155
+ return
156
+ try:
157
+ await self.relay(upstream, send)
158
+ finally:
159
+ upstream.release()
@@ -0,0 +1,133 @@
1
+ from dataclasses import dataclass
2
+ from functools import lru_cache
3
+ from typing import TypeAlias
4
+
5
+ from egress_proxy.protocol import Header
6
+
7
+ Pairs: TypeAlias = list[tuple[bytes, bytes]]
8
+
9
+ HOP = frozenset(
10
+ {
11
+ "connection",
12
+ "keep-alive",
13
+ "proxy-authenticate",
14
+ "proxy-authorization",
15
+ "proxy-connection",
16
+ "te",
17
+ "trailer",
18
+ "transfer-encoding",
19
+ "upgrade",
20
+ "xonnection",
21
+ "xroxy-connection",
22
+ }
23
+ )
24
+
25
+ UNSAFE = frozenset({"proxy"})
26
+
27
+ ADDRESSES = (
28
+ "forwarded",
29
+ "forward-for",
30
+ "client-ip",
31
+ "clientip",
32
+ "real-ip",
33
+ "remote-addr",
34
+ "remote-ip",
35
+ "connecting-ip",
36
+ "user-ip",
37
+ "originating-ip",
38
+ "original-ip",
39
+ "socketip",
40
+ )
41
+
42
+ ADDRESSED = frozenset(
43
+ {
44
+ "x-ip",
45
+ "x-from-ip",
46
+ "source-ip",
47
+ "coming-from",
48
+ "x-coming-from",
49
+ "connect-via-ip",
50
+ "x-delegate-remote-host",
51
+ "x-imforwards",
52
+ "x-proxymesh-ip",
53
+ "cf-pseudo-ipv4",
54
+ }
55
+ )
56
+
57
+ MARKERS = frozenset(
58
+ {
59
+ "via",
60
+ "x-bluecoat-via",
61
+ "x-proxy-id",
62
+ "cdn-loop",
63
+ "cf-ew-via",
64
+ "cf-ipcountry",
65
+ "cf-ray",
66
+ "cf-visitor",
67
+ "fastly-ff",
68
+ "akamai-origin-hop",
69
+ "x-akamai-edgescape",
70
+ "x-azure-fdid",
71
+ "x-azure-ref",
72
+ "x-amzn-trace-id",
73
+ "x-host",
74
+ "x-original-host",
75
+ "x-backend-host",
76
+ "x-gateway-host",
77
+ "x-url-scheme",
78
+ "front-end-https",
79
+ }
80
+ )
81
+
82
+ EDGES = ("x-envoy-", "cloudfront-viewer-")
83
+
84
+ PRIVATE = frozenset(header.value for header in Header)
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Policy:
89
+ exact: frozenset[str]
90
+ fragments: tuple[str, ...] = ()
91
+ prefixes: tuple[str, ...] = ()
92
+
93
+ def excluded(self, name: str) -> bool:
94
+ if name in self.exact or name.startswith(self.prefixes):
95
+ return True
96
+ return any(fragment in name for fragment in self.fragments)
97
+
98
+
99
+ REQUEST = Policy(
100
+ exact=HOP | UNSAFE | ADDRESSED | MARKERS | PRIVATE | {"host"},
101
+ fragments=ADDRESSES,
102
+ prefixes=EDGES,
103
+ )
104
+
105
+ RESPONSE = Policy(exact=HOP | PRIVATE)
106
+
107
+
108
+ def canonical(text: str) -> str:
109
+ return text.strip().lower().replace("_", "-")
110
+
111
+
112
+ @lru_cache(maxsize=4096)
113
+ def normalized(name: bytes) -> str:
114
+ return canonical(name.decode("latin-1"))
115
+
116
+
117
+ @lru_cache(maxsize=4096)
118
+ def blocked(policy: Policy, name: bytes) -> bool:
119
+ return policy.excluded(normalized(name))
120
+
121
+
122
+ def declared(pairs: Pairs) -> frozenset[str]:
123
+ values = (value.decode("latin-1") for name, value in pairs if normalized(name) == "connection")
124
+ return frozenset(canonical(token) for value in values for token in value.split(","))
125
+
126
+
127
+ def kept(name: bytes, policy: Policy, hop: frozenset[str]) -> bool:
128
+ return not blocked(policy, name) and normalized(name) not in hop
129
+
130
+
131
+ def filtered(pairs: Pairs, policy: Policy) -> Pairs:
132
+ hop = declared(pairs)
133
+ return [(name, value) for name, value in pairs if kept(name, policy, hop)]