steerable-egress-proxy 0.3.0__tar.gz
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.
- steerable_egress_proxy-0.3.0/PKG-INFO +40 -0
- steerable_egress_proxy-0.3.0/README.md +33 -0
- steerable_egress_proxy-0.3.0/pyproject.toml +27 -0
- steerable_egress_proxy-0.3.0/setup.cfg +4 -0
- steerable_egress_proxy-0.3.0/src/steerable_egress_proxy/__init__.py +20 -0
- steerable_egress_proxy-0.3.0/src/steerable_egress_proxy/__main__.py +75 -0
- steerable_egress_proxy-0.3.0/src/steerable_egress_proxy/proxy.py +256 -0
- steerable_egress_proxy-0.3.0/src/steerable_egress_proxy.egg-info/PKG-INFO +40 -0
- steerable_egress_proxy-0.3.0/src/steerable_egress_proxy.egg-info/SOURCES.txt +11 -0
- steerable_egress_proxy-0.3.0/src/steerable_egress_proxy.egg-info/dependency_links.txt +1 -0
- steerable_egress_proxy-0.3.0/src/steerable_egress_proxy.egg-info/entry_points.txt +2 -0
- steerable_egress_proxy-0.3.0/src/steerable_egress_proxy.egg-info/top_level.txt +1 -0
- steerable_egress_proxy-0.3.0/tests/test_proxy.py +218 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: steerable-egress-proxy
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Steerable optional component — local allow-listing CONNECT egress proxy. Gives per-host egress control on platforms whose sandbox can only pin ports (Seatbelt) or only drop the network namespace (bwrap): confine the sidecar to localhost:<proxy> and let the proxy own the host list.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# steerable-egress-proxy
|
|
9
|
+
|
|
10
|
+
Optional Steerable component: a local, allow-listing `CONNECT` egress proxy.
|
|
11
|
+
|
|
12
|
+
## Why it exists
|
|
13
|
+
|
|
14
|
+
The sidecar's OS sandboxes cannot do per-host egress on their own:
|
|
15
|
+
macOS Seatbelt degrades hostnames to ports (`*:443`), and Linux bwrap can
|
|
16
|
+
only drop the whole network namespace. The remedy on both is the same —
|
|
17
|
+
confine the sidecar to `localhost:<proxy port>` and let this proxy own the
|
|
18
|
+
host list. See `docs/spec/safety.md` ("Egress allow-list") for the full
|
|
19
|
+
threat model.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
steerable-egress-proxy --bind 127.0.0.1:8899 \
|
|
25
|
+
--allow api.deepseek.com \
|
|
26
|
+
--allow localhost:11434
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- Only `CONNECT host:port` is served (HTTPS tunneling). Plain-HTTP
|
|
30
|
+
forwarding and TLS interception are deliberately out of v1 scope.
|
|
31
|
+
- Bare `host` entries allow ports 443 and 80, mirroring the Seatbelt
|
|
32
|
+
profile semantics so the two layers agree.
|
|
33
|
+
- Fail-closed by construction: an empty allow-list is a startup error,
|
|
34
|
+
not "open"; targets off the list get `403`; non-CONNECT gets `405`.
|
|
35
|
+
- Request heads are capped at 16 KiB; upstream dials time out after 10s.
|
|
36
|
+
|
|
37
|
+
Wire-up with the sandbox: set the sidecar's egress allow-list to
|
|
38
|
+
`localhost:8899` only, and point the sidecar's HTTP stack at the proxy
|
|
39
|
+
(`HTTPS_PROXY=http://127.0.0.1:8899` — httpx honors it). The sandbox then
|
|
40
|
+
pins the process to the proxy and the proxy enforces the host list.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# steerable-egress-proxy
|
|
2
|
+
|
|
3
|
+
Optional Steerable component: a local, allow-listing `CONNECT` egress proxy.
|
|
4
|
+
|
|
5
|
+
## Why it exists
|
|
6
|
+
|
|
7
|
+
The sidecar's OS sandboxes cannot do per-host egress on their own:
|
|
8
|
+
macOS Seatbelt degrades hostnames to ports (`*:443`), and Linux bwrap can
|
|
9
|
+
only drop the whole network namespace. The remedy on both is the same —
|
|
10
|
+
confine the sidecar to `localhost:<proxy port>` and let this proxy own the
|
|
11
|
+
host list. See `docs/spec/safety.md` ("Egress allow-list") for the full
|
|
12
|
+
threat model.
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
steerable-egress-proxy --bind 127.0.0.1:8899 \
|
|
18
|
+
--allow api.deepseek.com \
|
|
19
|
+
--allow localhost:11434
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- Only `CONNECT host:port` is served (HTTPS tunneling). Plain-HTTP
|
|
23
|
+
forwarding and TLS interception are deliberately out of v1 scope.
|
|
24
|
+
- Bare `host` entries allow ports 443 and 80, mirroring the Seatbelt
|
|
25
|
+
profile semantics so the two layers agree.
|
|
26
|
+
- Fail-closed by construction: an empty allow-list is a startup error,
|
|
27
|
+
not "open"; targets off the list get `403`; non-CONNECT gets `405`.
|
|
28
|
+
- Request heads are capped at 16 KiB; upstream dials time out after 10s.
|
|
29
|
+
|
|
30
|
+
Wire-up with the sandbox: set the sidecar's egress allow-list to
|
|
31
|
+
`localhost:8899` only, and point the sidecar's HTTP stack at the proxy
|
|
32
|
+
(`HTTPS_PROXY=http://127.0.0.1:8899` — httpx honors it). The sandbox then
|
|
33
|
+
pins the process to the proxy and the proxy enforces the host list.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "steerable-egress-proxy"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "Steerable optional component — local allow-listing CONNECT egress proxy. Gives per-host egress control on platforms whose sandbox can only pin ports (Seatbelt) or only drop the network namespace (bwrap): confine the sidecar to localhost:<proxy> and let the proxy own the host list."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
# Zero runtime dependencies by design: the proxy sits on the security
|
|
8
|
+
# boundary, so its supply chain must stay auditable. asyncio streams only.
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
steerable-egress-proxy = "steerable_egress_proxy.__main__:main"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["setuptools>=68", "wheel"]
|
|
16
|
+
build-backend = "setuptools.build_meta"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools]
|
|
19
|
+
package-dir = {"" = "src"}
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
23
|
+
|
|
24
|
+
[tool.uv.sources]
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""steerable-egress-proxy — local allow-listing CONNECT egress proxy.
|
|
2
|
+
|
|
3
|
+
Optional component. See docs/spec/safety.md "Egress allow-list": OS
|
|
4
|
+
sandboxes degrade to port-level (Seatbelt) or namespace-level (bwrap)
|
|
5
|
+
egress; per-host enforcement needs a local proxy that owns the host list.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .proxy import (
|
|
9
|
+
AllowList,
|
|
10
|
+
EgressProxyServer,
|
|
11
|
+
ProxyConfig,
|
|
12
|
+
parse_allow_entry,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AllowList",
|
|
17
|
+
"EgressProxyServer",
|
|
18
|
+
"ProxyConfig",
|
|
19
|
+
"parse_allow_entry",
|
|
20
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""CLI: `steerable-egress-proxy --bind 127.0.0.1:8899 --allow api.deepseek.com ...`
|
|
2
|
+
|
|
3
|
+
Misconfiguration fails loud at startup: no `--allow` entries (or a
|
|
4
|
+
malformed one) exits non-zero before the socket opens — a proxy with an
|
|
5
|
+
unintended list is worse than no proxy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import asyncio
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from .proxy import AllowList, EgressProxyServer, ProxyConfig
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv: list[str] | None = None) -> int:
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="steerable-egress-proxy",
|
|
20
|
+
description="Local allow-listing CONNECT egress proxy (v1: no TLS interception).",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--bind",
|
|
24
|
+
default="127.0.0.1:8899",
|
|
25
|
+
help="listen address (default 127.0.0.1:8899)",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--allow",
|
|
29
|
+
action="append",
|
|
30
|
+
default=[],
|
|
31
|
+
metavar="HOST[:PORT]",
|
|
32
|
+
help="allowed CONNECT target; repeatable. Bare host allows 443 and 80.",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--connect-timeout",
|
|
36
|
+
type=float,
|
|
37
|
+
default=10.0,
|
|
38
|
+
help="upstream dial timeout in seconds (default 10)",
|
|
39
|
+
)
|
|
40
|
+
args = parser.parse_args(argv)
|
|
41
|
+
|
|
42
|
+
bind_host, sep, bind_port_s = args.bind.rpartition(":")
|
|
43
|
+
if not sep or not bind_host:
|
|
44
|
+
print(f"error: --bind must be host:port, got {args.bind!r}", file=sys.stderr)
|
|
45
|
+
return 2
|
|
46
|
+
try:
|
|
47
|
+
bind_port = int(bind_port_s)
|
|
48
|
+
allow = AllowList(args.allow)
|
|
49
|
+
config = ProxyConfig(
|
|
50
|
+
allow=allow,
|
|
51
|
+
bind_host=bind_host,
|
|
52
|
+
bind_port=bind_port,
|
|
53
|
+
connect_timeout_s=args.connect_timeout,
|
|
54
|
+
)
|
|
55
|
+
except ValueError as exc:
|
|
56
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
57
|
+
return 2
|
|
58
|
+
|
|
59
|
+
server = EgressProxyServer(config)
|
|
60
|
+
|
|
61
|
+
async def run() -> None:
|
|
62
|
+
try:
|
|
63
|
+
await server.serve()
|
|
64
|
+
except asyncio.CancelledError:
|
|
65
|
+
await server.close()
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
asyncio.run(run())
|
|
69
|
+
except KeyboardInterrupt:
|
|
70
|
+
pass
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
sys.exit(main())
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Allow-listing CONNECT forward proxy (v1: HTTPS tunneling only).
|
|
2
|
+
|
|
3
|
+
The proxy accepts only ``CONNECT host:port`` requests, checks the target
|
|
4
|
+
against an explicit allow-list, dials, and pipes bytes bidirectionally.
|
|
5
|
+
Everything else fails closed:
|
|
6
|
+
|
|
7
|
+
- target not on the list → 403
|
|
8
|
+
- non-CONNECT method → 405 (no plain-HTTP forwarding in v1)
|
|
9
|
+
- malformed request line / headers → 400
|
|
10
|
+
- unreachable target → 502
|
|
11
|
+
|
|
12
|
+
TLS is NOT intercepted: the proxy sees the CONNECT target only, which is
|
|
13
|
+
exactly the metadata the host allow-list needs. v1 scope per PARITY_TODO
|
|
14
|
+
0.4.2 — no TLS interception, no plain-HTTP forwarding.
|
|
15
|
+
|
|
16
|
+
Bounds that keep the security boundary tight:
|
|
17
|
+
|
|
18
|
+
- request head capped at ``MAX_HEAD_BYTES`` (16 KiB) → 431 over cap
|
|
19
|
+
- CONNECT dial bounded by ``connect_timeout_s``
|
|
20
|
+
- an empty allow-list is a configuration error, not "open" — constructing
|
|
21
|
+
``AllowList([])`` raises ``ValueError`` (fail loud, never silently open)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import logging
|
|
28
|
+
import re
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
MAX_HEAD_BYTES = 16 * 1024
|
|
34
|
+
_DEFAULT_CONNECT_TIMEOUT_S = 10.0
|
|
35
|
+
#: Ports implied by a bare `host` allow entry — mirrors the Seatbelt
|
|
36
|
+
#: profile semantics in docs/spec/safety.md so the two layers agree.
|
|
37
|
+
_BARE_HOST_PORTS = frozenset({443, 80})
|
|
38
|
+
|
|
39
|
+
_ENTRY_RE = re.compile(
|
|
40
|
+
r"^(?P<host>[A-Za-z0-9._-]+|\[[0-9a-fA-F:]+\])(?::(?P<port>\d{1,5}))?$"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class AllowEntry:
|
|
46
|
+
host: str
|
|
47
|
+
ports: frozenset[int] # empty frozenset is never stored; see parse
|
|
48
|
+
|
|
49
|
+
def allows(self, host: str, port: int) -> bool:
|
|
50
|
+
return self.host == host.lower() and port in self.ports
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_allow_entry(raw: str) -> AllowEntry:
|
|
54
|
+
"""Parse one `host` or `host:port` entry. Bare hosts allow 443 and 80.
|
|
55
|
+
|
|
56
|
+
Raises ValueError on anything malformed — a bad entry must never
|
|
57
|
+
silently widen or narrow the list.
|
|
58
|
+
"""
|
|
59
|
+
text = raw.strip()
|
|
60
|
+
m = _ENTRY_RE.match(text)
|
|
61
|
+
if not m:
|
|
62
|
+
raise ValueError(f"invalid allow entry {raw!r}: expected host or host:port")
|
|
63
|
+
host = m.group("host").lower()
|
|
64
|
+
if host.startswith("[") and host.endswith("]"):
|
|
65
|
+
host = host[1:-1]
|
|
66
|
+
port_s = m.group("port")
|
|
67
|
+
if port_s is None:
|
|
68
|
+
return AllowEntry(host, frozenset(_BARE_HOST_PORTS))
|
|
69
|
+
port = int(port_s)
|
|
70
|
+
if not 1 <= port <= 65535:
|
|
71
|
+
raise ValueError(f"invalid allow entry {raw!r}: port out of range")
|
|
72
|
+
return AllowEntry(host, frozenset({port}))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AllowList:
|
|
76
|
+
"""Closed set of allowed CONNECT targets. Empty input is an error."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, entries: list[str]):
|
|
79
|
+
if not entries:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
"allow-list is empty: an egress proxy with no entries is "
|
|
82
|
+
"either a mistake or should not be run at all"
|
|
83
|
+
)
|
|
84
|
+
self._entries = tuple(parse_allow_entry(e) for e in entries)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def entries(self) -> tuple[AllowEntry, ...]:
|
|
88
|
+
return self._entries
|
|
89
|
+
|
|
90
|
+
def allows(self, host: str, port: int) -> bool:
|
|
91
|
+
host = host.lower()
|
|
92
|
+
return any(e.allows(host, port) for e in self._entries)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class ProxyConfig:
|
|
97
|
+
allow: AllowList # required — fail-closed by construction
|
|
98
|
+
bind_host: str = "127.0.0.1"
|
|
99
|
+
bind_port: int = 8899
|
|
100
|
+
connect_timeout_s: float = _DEFAULT_CONNECT_TIMEOUT_S
|
|
101
|
+
|
|
102
|
+
def __post_init__(self) -> None:
|
|
103
|
+
if self.allow is None:
|
|
104
|
+
raise ValueError("ProxyConfig.allow is required (fail-closed)")
|
|
105
|
+
# 0 is valid: ephemeral bind (tests, supervised spawns that read the
|
|
106
|
+
# port back from `bound_port`).
|
|
107
|
+
if not 0 <= self.bind_port <= 65535:
|
|
108
|
+
raise ValueError(f"bind_port out of range: {self.bind_port}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class EgressProxyServer:
|
|
112
|
+
"""Asyncio CONNECT proxy. `await serve()` runs until cancelled."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, config: ProxyConfig):
|
|
115
|
+
self.config = config
|
|
116
|
+
self._server: asyncio.AbstractServer | None = None
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def bound_port(self) -> int:
|
|
120
|
+
if self._server and self._server.sockets:
|
|
121
|
+
return int(self._server.sockets[0].getsockname()[1])
|
|
122
|
+
return self.config.bind_port
|
|
123
|
+
|
|
124
|
+
async def serve(self) -> None:
|
|
125
|
+
self._server = await asyncio.start_server(
|
|
126
|
+
self._handle,
|
|
127
|
+
self.config.bind_host,
|
|
128
|
+
self.config.bind_port,
|
|
129
|
+
)
|
|
130
|
+
logger.info(
|
|
131
|
+
"egress proxy on %s:%s (%d allow entries)",
|
|
132
|
+
self.config.bind_host,
|
|
133
|
+
self.bound_port,
|
|
134
|
+
len(self.config.allow.entries),
|
|
135
|
+
)
|
|
136
|
+
async with self._server:
|
|
137
|
+
await self._server.serve_forever()
|
|
138
|
+
|
|
139
|
+
async def close(self) -> None:
|
|
140
|
+
if self._server:
|
|
141
|
+
self._server.close()
|
|
142
|
+
await self._server.wait_closed()
|
|
143
|
+
self._server = None
|
|
144
|
+
|
|
145
|
+
# ---- connection handling -------------------------------------------
|
|
146
|
+
|
|
147
|
+
async def _handle(
|
|
148
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
149
|
+
) -> None:
|
|
150
|
+
try:
|
|
151
|
+
head = await self._read_head(reader)
|
|
152
|
+
if head is None:
|
|
153
|
+
await self._reply(writer, 431, "Request Header Fields Too Large")
|
|
154
|
+
return
|
|
155
|
+
parsed = self._parse_request_line(head)
|
|
156
|
+
if parsed is None:
|
|
157
|
+
await self._reply(writer, 400, "Bad Request")
|
|
158
|
+
return
|
|
159
|
+
method, host, port = parsed
|
|
160
|
+
if method != "CONNECT":
|
|
161
|
+
# Checked before authority validity: a GET with a weird
|
|
162
|
+
# target is still a 405, not a 400.
|
|
163
|
+
await self._reply(writer, 405, "Method Not Allowed")
|
|
164
|
+
return
|
|
165
|
+
if host is None or port is None:
|
|
166
|
+
await self._reply(writer, 400, "Bad Request")
|
|
167
|
+
return
|
|
168
|
+
if not self.config.allow.allows(host, port):
|
|
169
|
+
logger.info("deny %s:%d (not on allow-list)", host, port)
|
|
170
|
+
await self._reply(writer, 403, "Forbidden")
|
|
171
|
+
return
|
|
172
|
+
try:
|
|
173
|
+
upstream_r, upstream_w = await asyncio.wait_for(
|
|
174
|
+
asyncio.open_connection(host, port),
|
|
175
|
+
timeout=self.config.connect_timeout_s,
|
|
176
|
+
)
|
|
177
|
+
except (OSError, asyncio.TimeoutError):
|
|
178
|
+
await self._reply(writer, 502, "Bad Gateway")
|
|
179
|
+
return
|
|
180
|
+
await self._reply(writer, 200, "Connection Established")
|
|
181
|
+
await self._tunnel(reader, writer, upstream_r, upstream_w)
|
|
182
|
+
except (ConnectionResetError, BrokenPipeError, asyncio.IncompleteReadError):
|
|
183
|
+
pass # client or upstream hung up mid-stream — normal teardown
|
|
184
|
+
finally:
|
|
185
|
+
try:
|
|
186
|
+
writer.close()
|
|
187
|
+
await writer.wait_closed()
|
|
188
|
+
except OSError:
|
|
189
|
+
pass # peer already gone
|
|
190
|
+
|
|
191
|
+
async def _read_head(self, reader: asyncio.StreamReader) -> bytes | None:
|
|
192
|
+
"""Read up to the blank line ending the request head; None = over cap."""
|
|
193
|
+
data = b""
|
|
194
|
+
while b"\r\n\r\n" not in data:
|
|
195
|
+
chunk = await reader.read(min(4096, MAX_HEAD_BYTES - len(data) + 4))
|
|
196
|
+
if not chunk:
|
|
197
|
+
return data if data else b""
|
|
198
|
+
data += chunk
|
|
199
|
+
if len(data) > MAX_HEAD_BYTES:
|
|
200
|
+
return None
|
|
201
|
+
return data
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _parse_request_line(head: bytes) -> tuple[str, str | None, int | None] | None:
|
|
205
|
+
"""Split the request line. `(method, None, None)` means the method is
|
|
206
|
+
fine but the authority is not a CONNECT target — caller maps that to
|
|
207
|
+
405-before-400 ordering."""
|
|
208
|
+
try:
|
|
209
|
+
first = head.split(b"\r\n", 1)[0].decode("ascii")
|
|
210
|
+
except UnicodeDecodeError:
|
|
211
|
+
return None
|
|
212
|
+
parts = first.split(" ")
|
|
213
|
+
if len(parts) != 3 or not parts[2].startswith("HTTP/"):
|
|
214
|
+
return None
|
|
215
|
+
method, authority = parts[0].upper(), parts[1]
|
|
216
|
+
if ":" not in authority:
|
|
217
|
+
return (method, None, None)
|
|
218
|
+
host, _, port_s = authority.rpartition(":")
|
|
219
|
+
try:
|
|
220
|
+
port = int(port_s)
|
|
221
|
+
except ValueError:
|
|
222
|
+
return (method, None, None)
|
|
223
|
+
if not host or not 1 <= port <= 65535:
|
|
224
|
+
return (method, None, None)
|
|
225
|
+
return method, host.lower(), port
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
async def _reply(writer: asyncio.StreamWriter, code: int, reason: str) -> None:
|
|
229
|
+
writer.write(f"HTTP/1.1 {code} {reason}\r\ncontent-length: 0\r\n\r\n".encode())
|
|
230
|
+
await writer.drain()
|
|
231
|
+
|
|
232
|
+
async def _tunnel(
|
|
233
|
+
self,
|
|
234
|
+
client_r: asyncio.StreamReader,
|
|
235
|
+
client_w: asyncio.StreamWriter,
|
|
236
|
+
upstream_r: asyncio.StreamReader,
|
|
237
|
+
upstream_w: asyncio.StreamWriter,
|
|
238
|
+
) -> None:
|
|
239
|
+
async def pipe(src: asyncio.StreamReader, dst: asyncio.StreamWriter) -> None:
|
|
240
|
+
while True:
|
|
241
|
+
chunk = await src.read(64 * 1024)
|
|
242
|
+
if not chunk:
|
|
243
|
+
break
|
|
244
|
+
dst.write(chunk)
|
|
245
|
+
await dst.drain()
|
|
246
|
+
if dst.can_write_eof():
|
|
247
|
+
dst.write_eof()
|
|
248
|
+
|
|
249
|
+
up = asyncio.create_task(pipe(client_r, upstream_w))
|
|
250
|
+
down = asyncio.create_task(pipe(upstream_r, client_w))
|
|
251
|
+
try:
|
|
252
|
+
await asyncio.wait({up, down}, return_when=asyncio.FIRST_COMPLETED)
|
|
253
|
+
finally:
|
|
254
|
+
for t in (up, down):
|
|
255
|
+
t.cancel()
|
|
256
|
+
upstream_w.close()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: steerable-egress-proxy
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Steerable optional component — local allow-listing CONNECT egress proxy. Gives per-host egress control on platforms whose sandbox can only pin ports (Seatbelt) or only drop the network namespace (bwrap): confine the sidecar to localhost:<proxy> and let the proxy own the host list.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# steerable-egress-proxy
|
|
9
|
+
|
|
10
|
+
Optional Steerable component: a local, allow-listing `CONNECT` egress proxy.
|
|
11
|
+
|
|
12
|
+
## Why it exists
|
|
13
|
+
|
|
14
|
+
The sidecar's OS sandboxes cannot do per-host egress on their own:
|
|
15
|
+
macOS Seatbelt degrades hostnames to ports (`*:443`), and Linux bwrap can
|
|
16
|
+
only drop the whole network namespace. The remedy on both is the same —
|
|
17
|
+
confine the sidecar to `localhost:<proxy port>` and let this proxy own the
|
|
18
|
+
host list. See `docs/spec/safety.md` ("Egress allow-list") for the full
|
|
19
|
+
threat model.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
steerable-egress-proxy --bind 127.0.0.1:8899 \
|
|
25
|
+
--allow api.deepseek.com \
|
|
26
|
+
--allow localhost:11434
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- Only `CONNECT host:port` is served (HTTPS tunneling). Plain-HTTP
|
|
30
|
+
forwarding and TLS interception are deliberately out of v1 scope.
|
|
31
|
+
- Bare `host` entries allow ports 443 and 80, mirroring the Seatbelt
|
|
32
|
+
profile semantics so the two layers agree.
|
|
33
|
+
- Fail-closed by construction: an empty allow-list is a startup error,
|
|
34
|
+
not "open"; targets off the list get `403`; non-CONNECT gets `405`.
|
|
35
|
+
- Request heads are capped at 16 KiB; upstream dials time out after 10s.
|
|
36
|
+
|
|
37
|
+
Wire-up with the sandbox: set the sidecar's egress allow-list to
|
|
38
|
+
`localhost:8899` only, and point the sidecar's HTTP stack at the proxy
|
|
39
|
+
(`HTTPS_PROXY=http://127.0.0.1:8899` — httpx honors it). The sandbox then
|
|
40
|
+
pins the process to the proxy and the proxy enforces the host list.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/steerable_egress_proxy/__init__.py
|
|
4
|
+
src/steerable_egress_proxy/__main__.py
|
|
5
|
+
src/steerable_egress_proxy/proxy.py
|
|
6
|
+
src/steerable_egress_proxy.egg-info/PKG-INFO
|
|
7
|
+
src/steerable_egress_proxy.egg-info/SOURCES.txt
|
|
8
|
+
src/steerable_egress_proxy.egg-info/dependency_links.txt
|
|
9
|
+
src/steerable_egress_proxy.egg-info/entry_points.txt
|
|
10
|
+
src/steerable_egress_proxy.egg-info/top_level.txt
|
|
11
|
+
tests/test_proxy.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
steerable_egress_proxy
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Tests for the allow-listing CONNECT egress proxy.
|
|
2
|
+
|
|
3
|
+
Tunnel tests use a real loopback echo server as the CONNECT target — the
|
|
4
|
+
proxy's job is byte plumbing plus the allow-list gate, and both are only
|
|
5
|
+
honestly tested over real sockets.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
from steerable_egress_proxy import (
|
|
14
|
+
AllowList,
|
|
15
|
+
EgressProxyServer,
|
|
16
|
+
ProxyConfig,
|
|
17
|
+
parse_allow_entry,
|
|
18
|
+
)
|
|
19
|
+
from steerable_egress_proxy.__main__ import main as cli_main
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _start_echo() -> tuple[asyncio.AbstractServer, int]:
|
|
23
|
+
async def echo(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
24
|
+
while True:
|
|
25
|
+
data = await reader.read(4096)
|
|
26
|
+
if not data:
|
|
27
|
+
break
|
|
28
|
+
writer.write(data)
|
|
29
|
+
await writer.drain()
|
|
30
|
+
writer.close()
|
|
31
|
+
|
|
32
|
+
server = await asyncio.start_server(echo, "127.0.0.1", 0)
|
|
33
|
+
port = int(server.sockets[0].getsockname()[1])
|
|
34
|
+
return server, port
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _start_proxy(allow: list[str]) -> tuple[EgressProxyServer, asyncio.Task, int]:
|
|
38
|
+
server = EgressProxyServer(
|
|
39
|
+
ProxyConfig(allow=AllowList(allow), bind_host="127.0.0.1", bind_port=0)
|
|
40
|
+
)
|
|
41
|
+
task = asyncio.create_task(server.serve())
|
|
42
|
+
# Let serve() bind before we read the port.
|
|
43
|
+
for _ in range(100):
|
|
44
|
+
if server._server is not None:
|
|
45
|
+
break
|
|
46
|
+
await asyncio.sleep(0.01)
|
|
47
|
+
return server, task, server.bound_port
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _connect(
|
|
51
|
+
proxy_port: int, authority: str
|
|
52
|
+
) -> tuple[bytes, asyncio.StreamReader, asyncio.StreamWriter]:
|
|
53
|
+
reader, writer = await asyncio.open_connection("127.0.0.1", proxy_port)
|
|
54
|
+
writer.write(f"CONNECT {authority} HTTP/1.1\r\nhost: {authority}\r\n\r\n".encode())
|
|
55
|
+
await writer.drain()
|
|
56
|
+
head = await reader.readuntil(b"\r\n\r\n")
|
|
57
|
+
return head, reader, writer
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _raw_request(proxy_port: int, payload: bytes) -> bytes:
|
|
61
|
+
reader, writer = await asyncio.open_connection("127.0.0.1", proxy_port)
|
|
62
|
+
writer.write(payload)
|
|
63
|
+
await writer.drain()
|
|
64
|
+
head = await reader.readuntil(b"\r\n\r\n")
|
|
65
|
+
writer.close()
|
|
66
|
+
return head
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---- allow-list parsing ---------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_bare_host_allows_443_and_80():
|
|
73
|
+
entry = parse_allow_entry("api.deepseek.com")
|
|
74
|
+
assert entry.allows("api.deepseek.com", 443)
|
|
75
|
+
assert entry.allows("api.deepseek.com", 80)
|
|
76
|
+
assert not entry.allows("api.deepseek.com", 22)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_host_port_entry_is_exact():
|
|
80
|
+
entry = parse_allow_entry("localhost:11434")
|
|
81
|
+
assert entry.allows("localhost", 11434)
|
|
82
|
+
assert not entry.allows("localhost", 443)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_entry_matching_is_case_insensitive():
|
|
86
|
+
entry = parse_allow_entry("API.Example.COM:8443")
|
|
87
|
+
assert entry.allows("api.example.com", 8443)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.parametrize(
|
|
91
|
+
"raw",
|
|
92
|
+
["", ":", "host:", "host:0", "host:65536", "host:abc", "bad host:443", "h:o:s:t"],
|
|
93
|
+
)
|
|
94
|
+
def test_malformed_entries_raise(raw):
|
|
95
|
+
with pytest.raises(ValueError):
|
|
96
|
+
parse_allow_entry(raw)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_empty_allow_list_fails_loud():
|
|
100
|
+
with pytest.raises(ValueError, match="empty"):
|
|
101
|
+
AllowList([])
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_proxy_config_requires_allow_list():
|
|
105
|
+
# No default: constructing without `allow` is a type error; a None
|
|
106
|
+
# smuggled past the type checker still fails loud.
|
|
107
|
+
with pytest.raises((TypeError, ValueError)):
|
|
108
|
+
ProxyConfig(allow=None) # type: ignore[arg-type]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---- live tunneling -------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def test_allowed_connect_tunnels_bytes_both_ways():
|
|
115
|
+
echo, echo_port = await _start_echo()
|
|
116
|
+
proxy, task, proxy_port = await _start_proxy([f"127.0.0.1:{echo_port}"])
|
|
117
|
+
try:
|
|
118
|
+
head, reader, writer = await _connect(proxy_port, f"127.0.0.1:{echo_port}")
|
|
119
|
+
assert head.startswith(b"HTTP/1.1 200")
|
|
120
|
+
writer.write(b"ping-through-proxy\n")
|
|
121
|
+
await writer.drain()
|
|
122
|
+
assert await reader.readline() == b"ping-through-proxy\n"
|
|
123
|
+
writer.close()
|
|
124
|
+
finally:
|
|
125
|
+
await proxy.close()
|
|
126
|
+
task.cancel()
|
|
127
|
+
echo.close()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def test_denied_host_gets_403_and_no_dial():
|
|
131
|
+
proxy, task, proxy_port = await _start_proxy(["127.0.0.1:1"])
|
|
132
|
+
try:
|
|
133
|
+
head = await _raw_request(proxy_port, b"CONNECT 10.9.8.7:443 HTTP/1.1\r\n\r\n")
|
|
134
|
+
assert head.startswith(b"HTTP/1.1 403")
|
|
135
|
+
finally:
|
|
136
|
+
await proxy.close()
|
|
137
|
+
task.cancel()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def test_denied_port_gets_403():
|
|
141
|
+
echo, echo_port = await _start_echo()
|
|
142
|
+
proxy, task, proxy_port = await _start_proxy(["127.0.0.1:1"])
|
|
143
|
+
try:
|
|
144
|
+
head = await _raw_request(
|
|
145
|
+
proxy_port, f"CONNECT 127.0.0.1:{echo_port} HTTP/1.1\r\n\r\n".encode()
|
|
146
|
+
)
|
|
147
|
+
assert head.startswith(b"HTTP/1.1 403")
|
|
148
|
+
finally:
|
|
149
|
+
await proxy.close()
|
|
150
|
+
task.cancel()
|
|
151
|
+
echo.close()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def test_non_connect_method_gets_405():
|
|
155
|
+
proxy, task, proxy_port = await _start_proxy(["127.0.0.1:80"])
|
|
156
|
+
try:
|
|
157
|
+
head = await _raw_request(
|
|
158
|
+
proxy_port, b"GET http://example.com/ HTTP/1.1\r\n\r\n"
|
|
159
|
+
)
|
|
160
|
+
assert head.startswith(b"HTTP/1.1 405")
|
|
161
|
+
finally:
|
|
162
|
+
await proxy.close()
|
|
163
|
+
task.cancel()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def test_malformed_request_gets_400():
|
|
167
|
+
proxy, task, proxy_port = await _start_proxy(["127.0.0.1:80"])
|
|
168
|
+
try:
|
|
169
|
+
head = await _raw_request(proxy_port, b"garbage\r\n\r\n")
|
|
170
|
+
assert head.startswith(b"HTTP/1.1 400")
|
|
171
|
+
finally:
|
|
172
|
+
await proxy.close()
|
|
173
|
+
task.cancel()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def test_oversized_head_gets_431():
|
|
177
|
+
proxy, task, proxy_port = await _start_proxy(["127.0.0.1:80"])
|
|
178
|
+
try:
|
|
179
|
+
reader, writer = await asyncio.open_connection("127.0.0.1", proxy_port)
|
|
180
|
+
writer.write(
|
|
181
|
+
b"CONNECT 127.0.0.1:80 HTTP/1.1\r\nx: " + b"a" * 32768 + b"\r\n\r\n"
|
|
182
|
+
)
|
|
183
|
+
await writer.drain()
|
|
184
|
+
head = await reader.readuntil(b"\r\n\r\n")
|
|
185
|
+
assert head.startswith(b"HTTP/1.1 431")
|
|
186
|
+
writer.close()
|
|
187
|
+
finally:
|
|
188
|
+
await proxy.close()
|
|
189
|
+
task.cancel()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def test_unreachable_allowed_target_gets_502():
|
|
193
|
+
# Port 1 is allowed by the list but nothing listens there.
|
|
194
|
+
proxy, task, proxy_port = await _start_proxy(["127.0.0.1:1"])
|
|
195
|
+
try:
|
|
196
|
+
head = await _raw_request(proxy_port, b"CONNECT 127.0.0.1:1 HTTP/1.1\r\n\r\n")
|
|
197
|
+
assert head.startswith(b"HTTP/1.1 502")
|
|
198
|
+
finally:
|
|
199
|
+
await proxy.close()
|
|
200
|
+
task.cancel()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---- CLI ------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_cli_without_allow_exits_loud(capsys):
|
|
207
|
+
assert cli_main(["--bind", "127.0.0.1:0"]) == 2
|
|
208
|
+
assert "empty" in capsys.readouterr().err
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_cli_with_bad_bind_exits_loud(capsys):
|
|
212
|
+
assert cli_main(["--bind", "no-port", "--allow", "x:443"]) == 2
|
|
213
|
+
assert "--bind" in capsys.readouterr().err
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_cli_with_bad_allow_entry_exits_loud(capsys):
|
|
217
|
+
assert cli_main(["--allow", "bad entry:443"]) == 2
|
|
218
|
+
assert "invalid allow entry" in capsys.readouterr().err
|