steerable-egress-proxy 0.3.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.
- steerable_egress_proxy/__init__.py +20 -0
- steerable_egress_proxy/__main__.py +75 -0
- steerable_egress_proxy/proxy.py +256 -0
- steerable_egress_proxy-0.3.0.dist-info/METADATA +40 -0
- steerable_egress_proxy-0.3.0.dist-info/RECORD +8 -0
- steerable_egress_proxy-0.3.0.dist-info/WHEEL +5 -0
- steerable_egress_proxy-0.3.0.dist-info/entry_points.txt +2 -0
- steerable_egress_proxy-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -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,8 @@
|
|
|
1
|
+
steerable_egress_proxy/__init__.py,sha256=cWEeWjO9VxS2ar8PsbiAUhs7E-fpeZ3Rz72A91U5M64,492
|
|
2
|
+
steerable_egress_proxy/__main__.py,sha256=gMwqOQ7XPLnnUMR9MOkg0qFUiEu3ztuE06QbA91oTCI,2070
|
|
3
|
+
steerable_egress_proxy/proxy.py,sha256=IW0WMbjOBztqQZui1MqJO1GxExkLvsNGgaE4sjAclKo,9469
|
|
4
|
+
steerable_egress_proxy-0.3.0.dist-info/METADATA,sha256=uE-hp5-PrD7ZSBPxc9h07mVoa5e2JohAr10R8iL2S0A,1794
|
|
5
|
+
steerable_egress_proxy-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
steerable_egress_proxy-0.3.0.dist-info/entry_points.txt,sha256=HVK6cSmCj9sZx8uhlO6GiMnq1VcQ4wwd2qZr4Eu6ceE,80
|
|
7
|
+
steerable_egress_proxy-0.3.0.dist-info/top_level.txt,sha256=_MpXoKraWMC_SI5PcvoptFodbc5gXXRYzhqcxqiF7c8,23
|
|
8
|
+
steerable_egress_proxy-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
steerable_egress_proxy
|