Skip to content

Commit 0f3e62b

Browse files
whummerclaude
andcommitted
Add hook-based TCP proxy with gateway integration
- Extensions implement tcp_connection_matcher() to claim connections - Monkeypatch HTTPChannel to inspect initial bytes before HTTP processing - First matching extension wins and traffic routes to its backend - TCP traffic multiplexed through main gateway port (4566) with HTTP - Provide matcher helpers: create_prefix_matcher, create_signature_matcher, combine_matchers - No central protocol registry - extensions define their own matchers Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c7677bd commit 0f3e62b

File tree

4 files changed

+650
-1
lines changed

4 files changed

+650
-1
lines changed

utils/localstack_extensions/utils/docker.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from rolo.proxy import Proxy
2020
from rolo.routing import RuleAdapter, WithHost
2121
from werkzeug.datastructures import Headers
22+
from twisted.internet import reactor
23+
from twisted.protocols.portforward import ProxyFactory
2224

2325
LOG = logging.getLogger(__name__)
2426

@@ -30,6 +32,9 @@ class ProxiedDockerContainerExtension(Extension):
3032
3133
Requests may potentially use HTTP2 with binary content as the protocol (e.g., gRPC over HTTP2).
3234
To ensure proper routing of requests, subclasses can define the `http2_ports`.
35+
36+
For services requiring raw TCP proxying (e.g., native database protocols), use the `tcp_ports`
37+
parameter to enable transparent TCP forwarding to the container.
3338
"""
3439

3540
name: str
@@ -41,7 +46,7 @@ class ProxiedDockerContainerExtension(Extension):
4146
host: str | None
4247
"""
4348
Optional host on which to expose the container endpoints.
44-
Can be either a static hostname, or a pattern like `<regex("(.+\.)?"):subdomain>myext.<domain>`
49+
Can be either a static hostname, or a pattern like `<regex("(.+\\.)?"):subdomain>myext.<domain>`
4550
"""
4651
path: str | None
4752
"""Optional path on which to expose the container endpoints."""
@@ -59,6 +64,22 @@ class ProxiedDockerContainerExtension(Extension):
5964
"""Callable that returns the target port for a given request, for routing purposes"""
6065
http2_ports: list[int] | None
6166
"""List of ports for which HTTP2 proxy forwarding into the container should be enabled."""
67+
tcp_ports: list[int] | None
68+
"""
69+
List of container ports for raw TCP proxying through the gateway.
70+
Enables transparent TCP forwarding for protocols that don't use HTTP (e.g., native DB protocols).
71+
72+
When tcp_ports is set, the extension must implement tcp_connection_matcher() to identify
73+
its traffic by inspecting initial connection bytes.
74+
"""
75+
76+
tcp_connection_matcher: Callable[[bytes], bool] | None
77+
"""
78+
Optional function to identify TCP connections belonging to this extension.
79+
80+
Called with initial connection bytes (up to 512 bytes) to determine if this extension
81+
should handle the connection. Return True to claim the connection, False otherwise.
82+
"""
6283

6384
def __init__(
6485
self,
@@ -71,6 +92,7 @@ def __init__(
7192
health_check_fn: Callable[[], None] | None = None,
7293
request_to_port_router: Callable[[Request], int] | None = None,
7394
http2_ports: list[int] | None = None,
95+
tcp_ports: list[int] | None = None,
7496
):
7597
self.image_name = image_name
7698
if not container_ports:
@@ -84,6 +106,7 @@ def __init__(
84106
self.health_check_fn = health_check_fn
85107
self.request_to_port_router = request_to_port_router
86108
self.http2_ports = http2_ports
109+
self.tcp_ports = tcp_ports
87110
self.main_port = self.container_ports[0]
88111
self.container_host = get_addressable_container_host()
89112

@@ -106,6 +129,53 @@ def update_gateway_routes(self, router: http.Router[http.RouteHandler]):
106129
self.container_host, port, self.should_proxy_request
107130
)
108131

132+
# set up raw TCP proxies with protocol detection
133+
if self.tcp_ports:
134+
self._setup_tcp_protocol_routing()
135+
136+
def _setup_tcp_protocol_routing(self):
137+
"""
138+
Set up TCP routing on the LocalStack gateway for this extension.
139+
140+
This method patches the gateway's HTTP protocol handler to intercept TCP
141+
connections and allow this extension to claim them via tcp_connection_matcher().
142+
This enables multiple TCP protocols to share the main gateway port (4566).
143+
144+
Uses monkeypatching to intercept dataReceived() before HTTP processing.
145+
"""
146+
from localstack_extensions.utils.tcp_protocol_router import (
147+
patch_gateway_for_tcp_routing,
148+
register_tcp_extension,
149+
)
150+
151+
# Get the connection matcher from the extension
152+
matcher = getattr(self, "tcp_connection_matcher", None)
153+
if not matcher:
154+
LOG.warning(
155+
f"Extension {self.name} has tcp_ports but no tcp_connection_matcher(). "
156+
"TCP routing will not work without a matcher."
157+
)
158+
return
159+
160+
# Apply gateway patches (only happens once globally)
161+
patch_gateway_for_tcp_routing()
162+
163+
# Register this extension for TCP routing
164+
# Use first port as the default target port
165+
target_port = self.tcp_ports[0] if self.tcp_ports else self.main_port
166+
167+
register_tcp_extension(
168+
extension_name=self.name,
169+
matcher=matcher,
170+
backend_host=self.container_host,
171+
backend_port=target_port,
172+
)
173+
174+
LOG.info(
175+
f"Registered TCP extension {self.name} -> "
176+
f"{self.container_host}:{target_port} on gateway"
177+
)
178+
109179
@abstractmethod
110180
def should_proxy_request(self, headers: Headers) -> bool:
111181
"""Define whether a request should be proxied, based on request headers."""
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""
2+
Helper functions for creating TCP connection matchers.
3+
4+
This module provides utilities for extensions to create custom matchers
5+
that identify their TCP connections from initial bytes.
6+
"""
7+
8+
import logging
9+
from typing import Callable
10+
11+
LOG = logging.getLogger(__name__)
12+
13+
# Type alias for matcher functions
14+
ConnectionMatcher = Callable[[bytes], bool]
15+
16+
17+
def create_prefix_matcher(prefix: bytes) -> ConnectionMatcher:
18+
"""
19+
Create a matcher that matches a specific byte prefix.
20+
21+
Args:
22+
prefix: The byte prefix to match
23+
24+
Returns:
25+
A matcher function
26+
27+
Example:
28+
# Match Redis RESP protocol
29+
matcher = create_prefix_matcher(b"*")
30+
"""
31+
32+
def matcher(data: bytes) -> bool:
33+
return data.startswith(prefix)
34+
35+
return matcher
36+
37+
38+
def create_signature_matcher(
39+
signature: bytes, offset: int = 0
40+
) -> ConnectionMatcher:
41+
"""
42+
Create a matcher that matches bytes at a specific offset.
43+
44+
Args:
45+
signature: The byte sequence to match
46+
offset: The offset where the signature should appear
47+
48+
Returns:
49+
A matcher function
50+
51+
Example:
52+
# Match PostgreSQL protocol version at offset 4
53+
matcher = create_signature_matcher(b"\\x00\\x03\\x00\\x00", offset=4)
54+
"""
55+
56+
def matcher(data: bytes) -> bool:
57+
if len(data) < offset + len(signature):
58+
return False
59+
return data[offset : offset + len(signature)] == signature
60+
61+
return matcher
62+
63+
64+
def create_custom_matcher(check_func: Callable[[bytes], bool]) -> ConnectionMatcher:
65+
"""
66+
Create a matcher from a custom checking function.
67+
68+
Args:
69+
check_func: Function that takes bytes and returns bool
70+
71+
Returns:
72+
A matcher function
73+
74+
Example:
75+
def is_my_protocol(data):
76+
return len(data) > 10 and data[5] == 0xFF
77+
78+
matcher = create_custom_matcher(is_my_protocol)
79+
"""
80+
return check_func
81+
82+
83+
def combine_matchers(*matchers: ConnectionMatcher) -> ConnectionMatcher:
84+
"""
85+
Combine multiple matchers with OR logic.
86+
87+
Returns True if any matcher returns True.
88+
89+
Args:
90+
*matchers: Variable number of matcher functions
91+
92+
Returns:
93+
A combined matcher function
94+
95+
Example:
96+
# Match either of two custom protocols
97+
matcher1 = create_prefix_matcher(b"PROTO1")
98+
matcher2 = create_prefix_matcher(b"PROTO2")
99+
combined = combine_matchers(matcher1, matcher2)
100+
"""
101+
102+
def combined(data: bytes) -> bool:
103+
return any(matcher(data) for matcher in matchers)
104+
105+
return combined
106+
107+
108+
# Export all functions
109+
__all__ = [
110+
"ConnectionMatcher",
111+
"create_prefix_matcher",
112+
"create_signature_matcher",
113+
"create_custom_matcher",
114+
"combine_matchers",
115+
]

0 commit comments

Comments
 (0)