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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ steerable-egress-proxy = steerable_egress_proxy.__main__:main
@@ -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