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.
- egress_proxy/__init__.py +10 -0
- egress_proxy/client/__init__.py +0 -0
- egress_proxy/client/aiohttp/__init__.py +8 -0
- egress_proxy/client/aiohttp/factory.py +17 -0
- egress_proxy/client/aiohttp/middleware.py +47 -0
- egress_proxy/client/httpx/__init__.py +9 -0
- egress_proxy/client/httpx/client.py +31 -0
- egress_proxy/client/httpx/route.py +46 -0
- egress_proxy/client/httpx/transport.py +47 -0
- egress_proxy/extras.py +13 -0
- egress_proxy/protocol.py +14 -0
- egress_proxy/py.typed +0 -0
- egress_proxy/server/__init__.py +3 -0
- egress_proxy/server/addresses.py +55 -0
- egress_proxy/server/application.py +53 -0
- egress_proxy/server/asgi.py +7 -0
- egress_proxy/server/forward.py +159 -0
- egress_proxy/server/headers.py +133 -0
- egress_proxy/server/main.py +86 -0
- egress_proxy/server/rejection.py +28 -0
- egress_proxy/server/routes.py +114 -0
- egress_proxy/server/settings.py +33 -0
- egress_proxy/server/upstreams.py +71 -0
- egress_proxy-0.1.0.dist-info/METADATA +312 -0
- egress_proxy-0.1.0.dist-info/RECORD +29 -0
- egress_proxy-0.1.0.dist-info/WHEEL +4 -0
- egress_proxy-0.1.0.dist-info/entry_points.txt +3 -0
- egress_proxy-0.1.0.dist-info/licenses/LICENSE +202 -0
- egress_proxy-0.1.0.dist-info/licenses/NOTICE +5 -0
egress_proxy/__init__.py
ADDED
|
@@ -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
|
egress_proxy/protocol.py
ADDED
|
@@ -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,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)]
|