From 095356a17d6a841d460f82453c837e8e3b4b3b45 Mon Sep 17 00:00:00 2001 From: uartnet <140632163+uartnet@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:43:38 +0200 Subject: [PATCH 1/5] feat(edge): support region-aware tunnels --- README.md | 2 + docs/CONFIGURATION.md | 8 + proto/rstream.proto | 4 +- src/rstream/_proto/rstream_pb2.py | 92 +++++----- src/rstream/_proto/rstream_pb2.pyi | 12 +- src/rstream/api.py | 93 +++++++++- src/rstream/client.py | 48 ++++- src/rstream/config.py | 169 +++++++++++++++++- src/rstream/control.py | 8 + src/rstream/engine_api.py | 1 + src/rstream/protocol.py | 7 + src/rstream/tunnel.py | 6 +- src/rstream/types.py | 2 + tests/integration/test_runtime_fake_engine.py | 141 ++++++++++++++- tests/unit/test_api.py | 115 +++++++++++- tests/unit/test_config.py | 142 +++++++++++++++ tests/unit/test_protocol.py | 4 + tests/unit/test_tunnel.py | 38 ++++ 18 files changed, 819 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 7662aa5..5c2c82d 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ Configuration is resolved in this order: | `RSTREAM_MTLS_CERT_FILE` | Client certificate file for mTLS authentication. | | `RSTREAM_MTLS_KEY_FILE` | Client private key file for mTLS authentication. | | `RSTREAM_API_URL` | Control plane API URL for managed project discovery. | +| `RSTREAM_REGION` | Authorized region to select for a managed project. | +| `RSTREAM_CONTROL_PLANE_HEADERS` | Additional Control plane request headers encoded as a JSON object. | `RSTREAM_ENGINE_ADDRESS` is also accepted for compatibility with older local SDK workflows. Prefer `RSTREAM_ENGINE` in new code. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 9760af5..3b1d0f8 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -28,12 +28,20 @@ Set `RSTREAM_CONFIG` to use another file. | `RSTREAM_MTLS_CERT_FILE` | mTLS client certificate path. | | `RSTREAM_MTLS_KEY_FILE` | mTLS client key path. | | `RSTREAM_API_URL` | Control plane API URL for managed project discovery. | +| `RSTREAM_REGION` | Authorized region to select for a managed project. | +| `RSTREAM_CONTROL_PLANE_HEADERS` | Additional Control plane request headers encoded as a JSON object. | | `RSTREAM_TUNNEL_TRANSPORT` | `auto`, `tls`, or `quic`. Python maps `auto` to TLS and rejects explicit `quic`. | | `RSTREAM_QUIC_TRANSPORT` | Legacy selector. Prefer `RSTREAM_TUNNEL_TRANSPORT`. | The SDK also accepts `RSTREAM_ENGINE_ADDRESS` for compatibility with older local C++ SDK workflows. Prefer `RSTREAM_ENGINE` in new code. +Region selection requires a managed project endpoint and cannot be combined +with an explicit engine override. Control plane headers may satisfy a separate +deployment access layer. Authentication, forwarding, and hop-by-hop headers are +reserved; malformed values and case-insensitive duplicates are rejected before +network I/O. + ## Config file ```yaml diff --git a/proto/rstream.proto b/proto/rstream.proto index c6a9c76..12d23db 100644 --- a/proto/rstream.proto +++ b/proto/rstream.proto @@ -14,7 +14,7 @@ extend google.protobuf.FieldOptions { string access = 51234; } -option (protocol_version) = "1.4.3"; +option (protocol_version) = "1.4.4"; package rstream.io_rstrm.protobuf; @@ -128,6 +128,7 @@ message TunnelProperties { google.protobuf.UInt32Value port = 23 [(access) = "read-write"]; google.protobuf.BoolValue upstream_tls = 24 [(access) = "read-write"]; google.protobuf.BoolValue datagram_guaranteed_delivery = 25 [(access) = "read-write"]; + google.protobuf.BoolValue allow_cross_region_routing = 26 [(access) = "read-write"]; } // When a client opens a new control channel to the server @@ -210,6 +211,7 @@ message ProxyConnReq { string stream_id = 2; google.protobuf.StringValue secret = 3; IpAddress source_ip = 4; + google.protobuf.StringValue proxy_endpoint = 5; } // Client's response to a 'ConnectionInitReq'. The client can refuse the connection by providing an error. diff --git a/src/rstream/_proto/rstream_pb2.py b/src/rstream/_proto/rstream_pb2.py index 9342a59..0296de7 100644 --- a/src/rstream/_proto/rstream_pb2.py +++ b/src/rstream/_proto/rstream_pb2.py @@ -27,14 +27,14 @@ from google.protobuf import wrappers_pb2 as google_dot_protobuf_dot_wrappers__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rrstream.proto\x12\x19rstream.io_rstrm.protobuf\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"/\n\tIpAddress\x12\x0c\n\x02v4\x18\x01 \x01(\x07H\x00\x12\x0c\n\x02v6\x18\x02 \x01(\x0cH\x00\x42\x06\n\x04\x61\x64\x64r\"j\n\x05\x45rror\x12\x32\n\x04\x63ode\x18\x01 \x01(\x0e\x32$.rstream.io_rstrm.protobuf.ErrorCode\x12-\n\x07message\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.StringValue\"\xd5\x02\n\rClientDetails\x12+\n\x05\x61gent\x18\x01 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12-\n\x07\x63hannel\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12-\n\x07version\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12(\n\x02os\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12*\n\x04\x61rch\x18\x05 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12+\n\x05token\x18\x06 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12\x36\n\x10protocol_version\x18\x07 \x01(\x0b\x32\x1c.google.protobuf.StringValue\"\xd2\x02\n\rServerDetails\x12+\n\x05\x61gent\x18\x01 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12-\n\x07\x63hannel\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12-\n\x07version\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12*\n\x04plan\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12.\n\x08provider\x18\x05 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12,\n\x06region\x18\x06 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12,\n\x06update\x18\x07 \x01(\x0b\x32\x1c.google.protobuf.StringValue\"\xc0\x0c\n\x10TunnelProperties\x12\x37\n\x02id\x18\x01 \x01(\x0b\x32\x1c.google.protobuf.StringValueB\r\x92\x82\x19\tread-only\x12@\n\rcreation_date\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.TimestampB\r\x92\x82\x19\tread-only\x12:\n\x04name\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.StringValueB\x0e\x92\x82\x19\nread-write\x12:\n\x04type\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.StringValueB\x0e\x92\x82\x19\nread-write\x12;\n\x07publish\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.BoolValueB\x0e\x92\x82\x19\nread-write\x12>\n\x08protocol\x18\x06 \x01(\x0b\x32\x1c.google.protobuf.StringValueB\x0e\x92\x82\x19\nread-write\x12W\n\x06labels\x18\x07 \x03(\x0b\x32\x37.rstream.io_rstrm.protobuf.TunnelProperties.LabelsEntryB\x0e\x92\x82\x19\nread-write\x12\x1d\n\x05geoip\x18\x08 \x03(\tB\x0e\x92\x82\x19\nread-write\x12#\n\x0btrusted_ips\x18\t \x03(\tB\x0e\x92\x82\x19\nread-write\x12;\n\x04host\x18\n \x01(\x0b\x32\x1c.google.protobuf.StringValueB\x0f\x18\x01\x92\x82\x19\tread-only\x12>\n\x08tls_mode\x18\x0b \x01(\x0b\x32\x1c.google.protobuf.StringValueB\x0e\x92\x82\x19\nread-write\x12!\n\ttls_alpns\x18\x0c \x03(\tB\x0e\x92\x82\x19\nread-write\x12\x45\n\x0ftls_min_version\x18\r \x01(\x0b\x32\x1c.google.protobuf.StringValueB\x0e\x92\x82\x19\nread-write\x12#\n\x0btls_ciphers\x18\x0e \x03(\tB\x0e\x92\x82\x19\nread-write\x12=\n\tmtls_auth\x18\x0f \x01(\x0b\x32\x1a.google.protobuf.BoolValueB\x0e\x92\x82\x19\nread-write\x12G\n\x0fmtls_cacert_pem\x18\x10 \x01(\x0b\x32\x1c.google.protobuf.StringValueB\x10\x18\x01\x92\x82\x19\nread-write\x12\x42\n\x0chttp_version\x18\x11 \x01(\x0b\x32\x1c.google.protobuf.StringValueB\x0e\x92\x82\x19\nread-write\x12\x42\n\x0chttp_use_tls\x18\x12 \x01(\x0b\x32\x1a.google.protobuf.BoolValueB\x10\x18\x01\x92\x82\x19\nread-write\x12>\n\ntoken_auth\x18\x13 \x01(\x0b\x32\x1a.google.protobuf.BoolValueB\x0e\x92\x82\x19\nread-write\x12@\n\x0crstream_auth\x18\x14 \x01(\x0b\x32\x1a.google.protobuf.BoolValueB\x0e\x92\x82\x19\nread-write\x12\x42\n\x0e\x63hallenge_mode\x18\x15 \x01(\x0b\x32\x1a.google.protobuf.BoolValueB\x0e\x92\x82\x19\nread-write\x12>\n\x08hostname\x18\x16 \x01(\x0b\x32\x1c.google.protobuf.StringValueB\x0e\x92\x82\x19\nread-write\x12:\n\x04port\x18\x17 \x01(\x0b\x32\x1c.google.protobuf.UInt32ValueB\x0e\x92\x82\x19\nread-write\x12@\n\x0cupstream_tls\x18\x18 \x01(\x0b\x32\x1a.google.protobuf.BoolValueB\x0e\x92\x82\x19\nread-write\x12P\n\x1c\x64\x61tagram_guaranteed_delivery\x18\x19 \x01(\x0b\x32\x1a.google.protobuf.BoolValueB\x0e\x92\x82\x19\nread-write\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"Y\n\x15OpenControlChannelReq\x12@\n\x0e\x63lient_details\x18\x01 \x01(\x0b\x32(.rstream.io_rstrm.protobuf.ClientDetails\"\xf3\x01\n\x15OpenControlChannelRsp\x12\x41\n\x02ok\x18\x01 \x01(\x0b\x32\x33.rstream.io_rstrm.protobuf.OpenControlChannelRsp.OkH\x00\x12\x31\n\x05\x65rror\x18\x02 \x01(\x0b\x32 .rstream.io_rstrm.protobuf.ErrorH\x00\x1aY\n\x02Ok\x12\x11\n\tclient_id\x18\x01 \x01(\t\x12@\n\x0eserver_details\x18\x02 \x01(\x0b\x32(.rstream.io_rstrm.protobuf.ServerDetailsB\t\n\x07payload\"\x18\n\x16\x43loseControlChannelReq\"\x18\n\x16\x43loseControlChannelRsp\"k\n\rOpenTunnelReq\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x46\n\x11tunnel_properties\x18\x02 \x01(\x0b\x32+.rstream.io_rstrm.protobuf.TunnelProperties\"\xab\x01\n\rOpenTunnelRsp\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12H\n\x11tunnel_properties\x18\x02 \x01(\x0b\x32+.rstream.io_rstrm.protobuf.TunnelPropertiesH\x00\x12\x31\n\x05\x65rror\x18\x03 \x01(\x0b\x32 .rstream.io_rstrm.protobuf.ErrorH\x00\x42\t\n\x07payload\"#\n\x0e\x43loseTunnelReq\x12\x11\n\ttunnel_id\x18\x01 \x01(\t\"#\n\x0e\x43loseTunnelRsp\x12\x11\n\ttunnel_id\x18\x01 \x01(\t\"\x9b\x01\n\x0cProxyConnReq\x12\x11\n\ttunnel_id\x18\x01 \x01(\t\x12\x11\n\tstream_id\x18\x02 \x01(\t\x12,\n\x06secret\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12\x37\n\tsource_ip\x18\x04 \x01(\x0b\x32$.rstream.io_rstrm.protobuf.IpAddress\"R\n\x0cProxyConnRsp\x12\x11\n\tstream_id\x18\x01 \x01(\t\x12/\n\x05\x65rror\x18\x02 \x01(\x0b\x32 .rstream.io_rstrm.protobuf.Error\"\x8d\x01\n\x08ProxyReq\x12@\n\x0e\x63lient_details\x18\x01 \x01(\x0b\x32(.rstream.io_rstrm.protobuf.ClientDetails\x12\x11\n\tstream_id\x18\x02 \x01(\t\x12,\n\x08zero_rtt\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.BoolValue\";\n\x08ProxyRsp\x12/\n\x05\x65rror\x18\x01 \x01(\x0b\x32 .rstream.io_rstrm.protobuf.Error\"\xc9\x01\n\tStreamReq\x12@\n\x0e\x63lient_details\x18\x01 \x01(\x0b\x32(.rstream.io_rstrm.protobuf.ClientDetails\x12\x16\n\x0etunnel_id_name\x18\x02 \x01(\t\x12,\n\x08zero_rtt\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.BoolValue\x12\x34\n\x10\x64\x61tagram_channel\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.BoolValue\"^\n\tStreamRsp\x12\x13\n\tstream_id\x18\x01 \x01(\tH\x00\x12\x31\n\x05\x65rror\x18\x02 \x01(\x0b\x32 .rstream.io_rstrm.protobuf.ErrorH\x00\x42\t\n\x07payload\"Z\n\x14\x44\x61tagramChannelClose\x12\x11\n\tstream_id\x18\x01 \x01(\t\x12/\n\x05\x65rror\x18\x02 \x01(\x0b\x32 .rstream.io_rstrm.protobuf.Error\"\x0b\n\tHeartbeat\" \n\rServerMessage\x12\x0f\n\x07message\x18\x01 \x01(\t\"\xcc\t\n\x07Message\x12T\n\x18open_control_channel_req\x18\x01 \x01(\x0b\x32\x30.rstream.io_rstrm.protobuf.OpenControlChannelReqH\x00\x12T\n\x18open_control_channel_rsp\x18\x02 \x01(\x0b\x32\x30.rstream.io_rstrm.protobuf.OpenControlChannelRspH\x00\x12V\n\x19\x63lose_control_channel_req\x18\x03 \x01(\x0b\x32\x31.rstream.io_rstrm.protobuf.CloseControlChannelReqH\x00\x12V\n\x19\x63lose_control_channel_rsp\x18\x04 \x01(\x0b\x32\x31.rstream.io_rstrm.protobuf.CloseControlChannelRspH\x00\x12\x43\n\x0fopen_tunnel_req\x18\x05 \x01(\x0b\x32(.rstream.io_rstrm.protobuf.OpenTunnelReqH\x00\x12\x43\n\x0fopen_tunnel_rsp\x18\x06 \x01(\x0b\x32(.rstream.io_rstrm.protobuf.OpenTunnelRspH\x00\x12\x45\n\x10\x63lose_tunnel_req\x18\x07 \x01(\x0b\x32).rstream.io_rstrm.protobuf.CloseTunnelReqH\x00\x12\x45\n\x10\x63lose_tunnel_rsp\x18\x08 \x01(\x0b\x32).rstream.io_rstrm.protobuf.CloseTunnelRspH\x00\x12\x41\n\x0eproxy_conn_req\x18\t \x01(\x0b\x32\'.rstream.io_rstrm.protobuf.ProxyConnReqH\x00\x12\x41\n\x0eproxy_conn_rsp\x18\n \x01(\x0b\x32\'.rstream.io_rstrm.protobuf.ProxyConnRspH\x00\x12\x38\n\tproxy_req\x18\x0b \x01(\x0b\x32#.rstream.io_rstrm.protobuf.ProxyReqH\x00\x12\x38\n\tproxy_rsp\x18\x0c \x01(\x0b\x32#.rstream.io_rstrm.protobuf.ProxyRspH\x00\x12:\n\nstream_req\x18\r \x01(\x0b\x32$.rstream.io_rstrm.protobuf.StreamReqH\x00\x12:\n\nstream_rsp\x18\x0e \x01(\x0b\x32$.rstream.io_rstrm.protobuf.StreamRspH\x00\x12\x39\n\theartbeat\x18\x0f \x01(\x0b\x32$.rstream.io_rstrm.protobuf.HeartbeatH\x00\x12\x42\n\x0eserver_message\x18\x10 \x01(\x0b\x32(.rstream.io_rstrm.protobuf.ServerMessageH\x00\x12Q\n\x16\x64\x61tagram_channel_close\x18\x11 \x01(\x0b\x32/.rstream.io_rstrm.protobuf.DatagramChannelCloseH\x00\x42\t\n\x07payload*\xb5\x03\n\tErrorCode\x12\x1a\n\x16\x45RROR_CODE_UNSPECIFIED\x10\x00\x12\x1c\n\x17\x45RROR_CODE_UNAUTHORIZED\x10\xe8\x07\x12\x1f\n\x1a\x45RROR_CODE_INVALID_REQUEST\x10\xd0\x0f\x12(\n#ERROR_CODE_PROTOCOL_VERSION_MISSING\x10\xda\x0f\x12(\n#ERROR_CODE_PROTOCOL_VERSION_INVALID\x10\xe4\x0f\x12-\n(ERROR_CODE_PROTOCOL_VERSION_INCOMPATIBLE\x10\xee\x0f\x12 \n\x1b\x45RROR_CODE_TUNNEL_NOT_FOUND\x10\xb8\x17\x12\x1e\n\x19\x45RROR_CODE_INVALID_STREAM\x10\xa0\x1f\x12%\n ERROR_CODE_FEATURE_NOT_AVAILABLE\x10\x88\'\x12#\n\x1e\x45RROR_CODE_SERVICE_UNAVAILABLE\x10\xf0.\x12\"\n\x1d\x45RROR_CODE_CAPACITY_EXHAUSTED\x10\xd8\x36\x12\x18\n\x13\x45RROR_CODE_INTERNAL\x10\xa8\x46:8\n\x10protocol_version\x12\x1c.google.protobuf.FileOptions\x18\xb8\x8e\x03 \x01(\t:/\n\x06\x61\x63\x63\x65ss\x12\x1d.google.protobuf.FieldOptions\x18\xa2\x90\x03 \x01(\tB\x0fZ\x04./pb\xc2\xf3\x18\x05\x31.4.3b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rrstream.proto\x12\x19rstream.io_rstrm.protobuf\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"/\n\tIpAddress\x12\x0c\n\x02v4\x18\x01 \x01(\x07H\x00\x12\x0c\n\x02v6\x18\x02 \x01(\x0cH\x00\x42\x06\n\x04\x61\x64\x64r\"j\n\x05\x45rror\x12\x32\n\x04\x63ode\x18\x01 \x01(\x0e\x32$.rstream.io_rstrm.protobuf.ErrorCode\x12-\n\x07message\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.StringValue\"\xd5\x02\n\rClientDetails\x12+\n\x05\x61gent\x18\x01 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12-\n\x07\x63hannel\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12-\n\x07version\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12(\n\x02os\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12*\n\x04\x61rch\x18\x05 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12+\n\x05token\x18\x06 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12\x36\n\x10protocol_version\x18\x07 \x01(\x0b\x32\x1c.google.protobuf.StringValue\"\xd2\x02\n\rServerDetails\x12+\n\x05\x61gent\x18\x01 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12-\n\x07\x63hannel\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12-\n\x07version\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12*\n\x04plan\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12.\n\x08provider\x18\x05 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12,\n\x06region\x18\x06 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12,\n\x06update\x18\x07 \x01(\x0b\x32\x1c.google.protobuf.StringValue\"\x90\r\n\x10TunnelProperties\x12\x37\n\x02id\x18\x01 \x01(\x0b\x32\x1c.google.protobuf.StringValueB\r\x92\x82\x19\tread-only\x12@\n\rcreation_date\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.TimestampB\r\x92\x82\x19\tread-only\x12:\n\x04name\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.StringValueB\x0e\x92\x82\x19\nread-write\x12:\n\x04type\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.StringValueB\x0e\x92\x82\x19\nread-write\x12;\n\x07publish\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.BoolValueB\x0e\x92\x82\x19\nread-write\x12>\n\x08protocol\x18\x06 \x01(\x0b\x32\x1c.google.protobuf.StringValueB\x0e\x92\x82\x19\nread-write\x12W\n\x06labels\x18\x07 \x03(\x0b\x32\x37.rstream.io_rstrm.protobuf.TunnelProperties.LabelsEntryB\x0e\x92\x82\x19\nread-write\x12\x1d\n\x05geoip\x18\x08 \x03(\tB\x0e\x92\x82\x19\nread-write\x12#\n\x0btrusted_ips\x18\t \x03(\tB\x0e\x92\x82\x19\nread-write\x12;\n\x04host\x18\n \x01(\x0b\x32\x1c.google.protobuf.StringValueB\x0f\x18\x01\x92\x82\x19\tread-only\x12>\n\x08tls_mode\x18\x0b \x01(\x0b\x32\x1c.google.protobuf.StringValueB\x0e\x92\x82\x19\nread-write\x12!\n\ttls_alpns\x18\x0c \x03(\tB\x0e\x92\x82\x19\nread-write\x12\x45\n\x0ftls_min_version\x18\r \x01(\x0b\x32\x1c.google.protobuf.StringValueB\x0e\x92\x82\x19\nread-write\x12#\n\x0btls_ciphers\x18\x0e \x03(\tB\x0e\x92\x82\x19\nread-write\x12=\n\tmtls_auth\x18\x0f \x01(\x0b\x32\x1a.google.protobuf.BoolValueB\x0e\x92\x82\x19\nread-write\x12G\n\x0fmtls_cacert_pem\x18\x10 \x01(\x0b\x32\x1c.google.protobuf.StringValueB\x10\x18\x01\x92\x82\x19\nread-write\x12\x42\n\x0chttp_version\x18\x11 \x01(\x0b\x32\x1c.google.protobuf.StringValueB\x0e\x92\x82\x19\nread-write\x12\x42\n\x0chttp_use_tls\x18\x12 \x01(\x0b\x32\x1a.google.protobuf.BoolValueB\x10\x18\x01\x92\x82\x19\nread-write\x12>\n\ntoken_auth\x18\x13 \x01(\x0b\x32\x1a.google.protobuf.BoolValueB\x0e\x92\x82\x19\nread-write\x12@\n\x0crstream_auth\x18\x14 \x01(\x0b\x32\x1a.google.protobuf.BoolValueB\x0e\x92\x82\x19\nread-write\x12\x42\n\x0e\x63hallenge_mode\x18\x15 \x01(\x0b\x32\x1a.google.protobuf.BoolValueB\x0e\x92\x82\x19\nread-write\x12>\n\x08hostname\x18\x16 \x01(\x0b\x32\x1c.google.protobuf.StringValueB\x0e\x92\x82\x19\nread-write\x12:\n\x04port\x18\x17 \x01(\x0b\x32\x1c.google.protobuf.UInt32ValueB\x0e\x92\x82\x19\nread-write\x12@\n\x0cupstream_tls\x18\x18 \x01(\x0b\x32\x1a.google.protobuf.BoolValueB\x0e\x92\x82\x19\nread-write\x12P\n\x1c\x64\x61tagram_guaranteed_delivery\x18\x19 \x01(\x0b\x32\x1a.google.protobuf.BoolValueB\x0e\x92\x82\x19\nread-write\x12N\n\x1a\x61llow_cross_region_routing\x18\x1a \x01(\x0b\x32\x1a.google.protobuf.BoolValueB\x0e\x92\x82\x19\nread-write\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"Y\n\x15OpenControlChannelReq\x12@\n\x0e\x63lient_details\x18\x01 \x01(\x0b\x32(.rstream.io_rstrm.protobuf.ClientDetails\"\xf3\x01\n\x15OpenControlChannelRsp\x12\x41\n\x02ok\x18\x01 \x01(\x0b\x32\x33.rstream.io_rstrm.protobuf.OpenControlChannelRsp.OkH\x00\x12\x31\n\x05\x65rror\x18\x02 \x01(\x0b\x32 .rstream.io_rstrm.protobuf.ErrorH\x00\x1aY\n\x02Ok\x12\x11\n\tclient_id\x18\x01 \x01(\t\x12@\n\x0eserver_details\x18\x02 \x01(\x0b\x32(.rstream.io_rstrm.protobuf.ServerDetailsB\t\n\x07payload\"\x18\n\x16\x43loseControlChannelReq\"\x18\n\x16\x43loseControlChannelRsp\"k\n\rOpenTunnelReq\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x46\n\x11tunnel_properties\x18\x02 \x01(\x0b\x32+.rstream.io_rstrm.protobuf.TunnelProperties\"\xab\x01\n\rOpenTunnelRsp\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12H\n\x11tunnel_properties\x18\x02 \x01(\x0b\x32+.rstream.io_rstrm.protobuf.TunnelPropertiesH\x00\x12\x31\n\x05\x65rror\x18\x03 \x01(\x0b\x32 .rstream.io_rstrm.protobuf.ErrorH\x00\x42\t\n\x07payload\"#\n\x0e\x43loseTunnelReq\x12\x11\n\ttunnel_id\x18\x01 \x01(\t\"#\n\x0e\x43loseTunnelRsp\x12\x11\n\ttunnel_id\x18\x01 \x01(\t\"\xd1\x01\n\x0cProxyConnReq\x12\x11\n\ttunnel_id\x18\x01 \x01(\t\x12\x11\n\tstream_id\x18\x02 \x01(\t\x12,\n\x06secret\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12\x37\n\tsource_ip\x18\x04 \x01(\x0b\x32$.rstream.io_rstrm.protobuf.IpAddress\x12\x34\n\x0eproxy_endpoint\x18\x05 \x01(\x0b\x32\x1c.google.protobuf.StringValue\"R\n\x0cProxyConnRsp\x12\x11\n\tstream_id\x18\x01 \x01(\t\x12/\n\x05\x65rror\x18\x02 \x01(\x0b\x32 .rstream.io_rstrm.protobuf.Error\"\x8d\x01\n\x08ProxyReq\x12@\n\x0e\x63lient_details\x18\x01 \x01(\x0b\x32(.rstream.io_rstrm.protobuf.ClientDetails\x12\x11\n\tstream_id\x18\x02 \x01(\t\x12,\n\x08zero_rtt\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.BoolValue\";\n\x08ProxyRsp\x12/\n\x05\x65rror\x18\x01 \x01(\x0b\x32 .rstream.io_rstrm.protobuf.Error\"\xc9\x01\n\tStreamReq\x12@\n\x0e\x63lient_details\x18\x01 \x01(\x0b\x32(.rstream.io_rstrm.protobuf.ClientDetails\x12\x16\n\x0etunnel_id_name\x18\x02 \x01(\t\x12,\n\x08zero_rtt\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.BoolValue\x12\x34\n\x10\x64\x61tagram_channel\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.BoolValue\"^\n\tStreamRsp\x12\x13\n\tstream_id\x18\x01 \x01(\tH\x00\x12\x31\n\x05\x65rror\x18\x02 \x01(\x0b\x32 .rstream.io_rstrm.protobuf.ErrorH\x00\x42\t\n\x07payload\"Z\n\x14\x44\x61tagramChannelClose\x12\x11\n\tstream_id\x18\x01 \x01(\t\x12/\n\x05\x65rror\x18\x02 \x01(\x0b\x32 .rstream.io_rstrm.protobuf.Error\"\x0b\n\tHeartbeat\" \n\rServerMessage\x12\x0f\n\x07message\x18\x01 \x01(\t\"\xcc\t\n\x07Message\x12T\n\x18open_control_channel_req\x18\x01 \x01(\x0b\x32\x30.rstream.io_rstrm.protobuf.OpenControlChannelReqH\x00\x12T\n\x18open_control_channel_rsp\x18\x02 \x01(\x0b\x32\x30.rstream.io_rstrm.protobuf.OpenControlChannelRspH\x00\x12V\n\x19\x63lose_control_channel_req\x18\x03 \x01(\x0b\x32\x31.rstream.io_rstrm.protobuf.CloseControlChannelReqH\x00\x12V\n\x19\x63lose_control_channel_rsp\x18\x04 \x01(\x0b\x32\x31.rstream.io_rstrm.protobuf.CloseControlChannelRspH\x00\x12\x43\n\x0fopen_tunnel_req\x18\x05 \x01(\x0b\x32(.rstream.io_rstrm.protobuf.OpenTunnelReqH\x00\x12\x43\n\x0fopen_tunnel_rsp\x18\x06 \x01(\x0b\x32(.rstream.io_rstrm.protobuf.OpenTunnelRspH\x00\x12\x45\n\x10\x63lose_tunnel_req\x18\x07 \x01(\x0b\x32).rstream.io_rstrm.protobuf.CloseTunnelReqH\x00\x12\x45\n\x10\x63lose_tunnel_rsp\x18\x08 \x01(\x0b\x32).rstream.io_rstrm.protobuf.CloseTunnelRspH\x00\x12\x41\n\x0eproxy_conn_req\x18\t \x01(\x0b\x32\'.rstream.io_rstrm.protobuf.ProxyConnReqH\x00\x12\x41\n\x0eproxy_conn_rsp\x18\n \x01(\x0b\x32\'.rstream.io_rstrm.protobuf.ProxyConnRspH\x00\x12\x38\n\tproxy_req\x18\x0b \x01(\x0b\x32#.rstream.io_rstrm.protobuf.ProxyReqH\x00\x12\x38\n\tproxy_rsp\x18\x0c \x01(\x0b\x32#.rstream.io_rstrm.protobuf.ProxyRspH\x00\x12:\n\nstream_req\x18\r \x01(\x0b\x32$.rstream.io_rstrm.protobuf.StreamReqH\x00\x12:\n\nstream_rsp\x18\x0e \x01(\x0b\x32$.rstream.io_rstrm.protobuf.StreamRspH\x00\x12\x39\n\theartbeat\x18\x0f \x01(\x0b\x32$.rstream.io_rstrm.protobuf.HeartbeatH\x00\x12\x42\n\x0eserver_message\x18\x10 \x01(\x0b\x32(.rstream.io_rstrm.protobuf.ServerMessageH\x00\x12Q\n\x16\x64\x61tagram_channel_close\x18\x11 \x01(\x0b\x32/.rstream.io_rstrm.protobuf.DatagramChannelCloseH\x00\x42\t\n\x07payload*\xb5\x03\n\tErrorCode\x12\x1a\n\x16\x45RROR_CODE_UNSPECIFIED\x10\x00\x12\x1c\n\x17\x45RROR_CODE_UNAUTHORIZED\x10\xe8\x07\x12\x1f\n\x1a\x45RROR_CODE_INVALID_REQUEST\x10\xd0\x0f\x12(\n#ERROR_CODE_PROTOCOL_VERSION_MISSING\x10\xda\x0f\x12(\n#ERROR_CODE_PROTOCOL_VERSION_INVALID\x10\xe4\x0f\x12-\n(ERROR_CODE_PROTOCOL_VERSION_INCOMPATIBLE\x10\xee\x0f\x12 \n\x1b\x45RROR_CODE_TUNNEL_NOT_FOUND\x10\xb8\x17\x12\x1e\n\x19\x45RROR_CODE_INVALID_STREAM\x10\xa0\x1f\x12%\n ERROR_CODE_FEATURE_NOT_AVAILABLE\x10\x88\'\x12#\n\x1e\x45RROR_CODE_SERVICE_UNAVAILABLE\x10\xf0.\x12\"\n\x1d\x45RROR_CODE_CAPACITY_EXHAUSTED\x10\xd8\x36\x12\x18\n\x13\x45RROR_CODE_INTERNAL\x10\xa8\x46:8\n\x10protocol_version\x12\x1c.google.protobuf.FileOptions\x18\xb8\x8e\x03 \x01(\t:/\n\x06\x61\x63\x63\x65ss\x12\x1d.google.protobuf.FieldOptions\x18\xa2\x90\x03 \x01(\tB\x0fZ\x04./pb\xc2\xf3\x18\x05\x31.4.4b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'rstream_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None - _globals['DESCRIPTOR']._serialized_options = b'Z\004./pb\302\363\030\0051.4.3' + _globals['DESCRIPTOR']._serialized_options = b'Z\004./pb\302\363\030\0051.4.4' _globals['_TUNNELPROPERTIES_LABELSENTRY']._loaded_options = None _globals['_TUNNELPROPERTIES_LABELSENTRY']._serialized_options = b'8\001' _globals['_TUNNELPROPERTIES'].fields_by_name['id']._loaded_options = None @@ -87,8 +87,10 @@ _globals['_TUNNELPROPERTIES'].fields_by_name['upstream_tls']._serialized_options = b'\222\202\031\nread-write' _globals['_TUNNELPROPERTIES'].fields_by_name['datagram_guaranteed_delivery']._loaded_options = None _globals['_TUNNELPROPERTIES'].fields_by_name['datagram_guaranteed_delivery']._serialized_options = b'\222\202\031\nread-write' - _globals['_ERRORCODE']._serialized_start=5452 - _globals['_ERRORCODE']._serialized_end=5889 + _globals['_TUNNELPROPERTIES'].fields_by_name['allow_cross_region_routing']._loaded_options = None + _globals['_TUNNELPROPERTIES'].fields_by_name['allow_cross_region_routing']._serialized_options = b'\222\202\031\nread-write' + _globals['_ERRORCODE']._serialized_start=5586 + _globals['_ERRORCODE']._serialized_end=6023 _globals['_IPADDRESS']._serialized_start=143 _globals['_IPADDRESS']._serialized_end=190 _globals['_ERROR']._serialized_start=192 @@ -98,45 +100,45 @@ _globals['_SERVERDETAILS']._serialized_start=645 _globals['_SERVERDETAILS']._serialized_end=983 _globals['_TUNNELPROPERTIES']._serialized_start=986 - _globals['_TUNNELPROPERTIES']._serialized_end=2586 - _globals['_TUNNELPROPERTIES_LABELSENTRY']._serialized_start=2541 - _globals['_TUNNELPROPERTIES_LABELSENTRY']._serialized_end=2586 - _globals['_OPENCONTROLCHANNELREQ']._serialized_start=2588 - _globals['_OPENCONTROLCHANNELREQ']._serialized_end=2677 - _globals['_OPENCONTROLCHANNELRSP']._serialized_start=2680 - _globals['_OPENCONTROLCHANNELRSP']._serialized_end=2923 - _globals['_OPENCONTROLCHANNELRSP_OK']._serialized_start=2823 - _globals['_OPENCONTROLCHANNELRSP_OK']._serialized_end=2912 - _globals['_CLOSECONTROLCHANNELREQ']._serialized_start=2925 - _globals['_CLOSECONTROLCHANNELREQ']._serialized_end=2949 - _globals['_CLOSECONTROLCHANNELRSP']._serialized_start=2951 - _globals['_CLOSECONTROLCHANNELRSP']._serialized_end=2975 - _globals['_OPENTUNNELREQ']._serialized_start=2977 - _globals['_OPENTUNNELREQ']._serialized_end=3084 - _globals['_OPENTUNNELRSP']._serialized_start=3087 - _globals['_OPENTUNNELRSP']._serialized_end=3258 - _globals['_CLOSETUNNELREQ']._serialized_start=3260 - _globals['_CLOSETUNNELREQ']._serialized_end=3295 - _globals['_CLOSETUNNELRSP']._serialized_start=3297 - _globals['_CLOSETUNNELRSP']._serialized_end=3332 - _globals['_PROXYCONNREQ']._serialized_start=3335 - _globals['_PROXYCONNREQ']._serialized_end=3490 - _globals['_PROXYCONNRSP']._serialized_start=3492 - _globals['_PROXYCONNRSP']._serialized_end=3574 - _globals['_PROXYREQ']._serialized_start=3577 - _globals['_PROXYREQ']._serialized_end=3718 - _globals['_PROXYRSP']._serialized_start=3720 - _globals['_PROXYRSP']._serialized_end=3779 - _globals['_STREAMREQ']._serialized_start=3782 - _globals['_STREAMREQ']._serialized_end=3983 - _globals['_STREAMRSP']._serialized_start=3985 - _globals['_STREAMRSP']._serialized_end=4079 - _globals['_DATAGRAMCHANNELCLOSE']._serialized_start=4081 - _globals['_DATAGRAMCHANNELCLOSE']._serialized_end=4171 - _globals['_HEARTBEAT']._serialized_start=4173 - _globals['_HEARTBEAT']._serialized_end=4184 - _globals['_SERVERMESSAGE']._serialized_start=4186 - _globals['_SERVERMESSAGE']._serialized_end=4218 - _globals['_MESSAGE']._serialized_start=4221 - _globals['_MESSAGE']._serialized_end=5449 + _globals['_TUNNELPROPERTIES']._serialized_end=2666 + _globals['_TUNNELPROPERTIES_LABELSENTRY']._serialized_start=2621 + _globals['_TUNNELPROPERTIES_LABELSENTRY']._serialized_end=2666 + _globals['_OPENCONTROLCHANNELREQ']._serialized_start=2668 + _globals['_OPENCONTROLCHANNELREQ']._serialized_end=2757 + _globals['_OPENCONTROLCHANNELRSP']._serialized_start=2760 + _globals['_OPENCONTROLCHANNELRSP']._serialized_end=3003 + _globals['_OPENCONTROLCHANNELRSP_OK']._serialized_start=2903 + _globals['_OPENCONTROLCHANNELRSP_OK']._serialized_end=2992 + _globals['_CLOSECONTROLCHANNELREQ']._serialized_start=3005 + _globals['_CLOSECONTROLCHANNELREQ']._serialized_end=3029 + _globals['_CLOSECONTROLCHANNELRSP']._serialized_start=3031 + _globals['_CLOSECONTROLCHANNELRSP']._serialized_end=3055 + _globals['_OPENTUNNELREQ']._serialized_start=3057 + _globals['_OPENTUNNELREQ']._serialized_end=3164 + _globals['_OPENTUNNELRSP']._serialized_start=3167 + _globals['_OPENTUNNELRSP']._serialized_end=3338 + _globals['_CLOSETUNNELREQ']._serialized_start=3340 + _globals['_CLOSETUNNELREQ']._serialized_end=3375 + _globals['_CLOSETUNNELRSP']._serialized_start=3377 + _globals['_CLOSETUNNELRSP']._serialized_end=3412 + _globals['_PROXYCONNREQ']._serialized_start=3415 + _globals['_PROXYCONNREQ']._serialized_end=3624 + _globals['_PROXYCONNRSP']._serialized_start=3626 + _globals['_PROXYCONNRSP']._serialized_end=3708 + _globals['_PROXYREQ']._serialized_start=3711 + _globals['_PROXYREQ']._serialized_end=3852 + _globals['_PROXYRSP']._serialized_start=3854 + _globals['_PROXYRSP']._serialized_end=3913 + _globals['_STREAMREQ']._serialized_start=3916 + _globals['_STREAMREQ']._serialized_end=4117 + _globals['_STREAMRSP']._serialized_start=4119 + _globals['_STREAMRSP']._serialized_end=4213 + _globals['_DATAGRAMCHANNELCLOSE']._serialized_start=4215 + _globals['_DATAGRAMCHANNELCLOSE']._serialized_end=4305 + _globals['_HEARTBEAT']._serialized_start=4307 + _globals['_HEARTBEAT']._serialized_end=4318 + _globals['_SERVERMESSAGE']._serialized_start=4320 + _globals['_SERVERMESSAGE']._serialized_end=4352 + _globals['_MESSAGE']._serialized_start=4355 + _globals['_MESSAGE']._serialized_end=5583 # @@protoc_insertion_point(module_scope) diff --git a/src/rstream/_proto/rstream_pb2.pyi b/src/rstream/_proto/rstream_pb2.pyi index 7993821..6d4241e 100644 --- a/src/rstream/_proto/rstream_pb2.pyi +++ b/src/rstream/_proto/rstream_pb2.pyi @@ -96,7 +96,7 @@ class ServerDetails(_message.Message): def __init__(self, agent: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., channel: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., version: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., plan: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., provider: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., region: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., update: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ...) -> None: ... class TunnelProperties(_message.Message): - __slots__ = ("id", "creation_date", "name", "type", "publish", "protocol", "labels", "geoip", "trusted_ips", "host", "tls_mode", "tls_alpns", "tls_min_version", "tls_ciphers", "mtls_auth", "mtls_cacert_pem", "http_version", "http_use_tls", "token_auth", "rstream_auth", "challenge_mode", "hostname", "port", "upstream_tls", "datagram_guaranteed_delivery") + __slots__ = ("id", "creation_date", "name", "type", "publish", "protocol", "labels", "geoip", "trusted_ips", "host", "tls_mode", "tls_alpns", "tls_min_version", "tls_ciphers", "mtls_auth", "mtls_cacert_pem", "http_version", "http_use_tls", "token_auth", "rstream_auth", "challenge_mode", "hostname", "port", "upstream_tls", "datagram_guaranteed_delivery", "allow_cross_region_routing") class LabelsEntry(_message.Message): __slots__ = ("key", "value") KEY_FIELD_NUMBER: _ClassVar[int] @@ -129,6 +129,7 @@ class TunnelProperties(_message.Message): PORT_FIELD_NUMBER: _ClassVar[int] UPSTREAM_TLS_FIELD_NUMBER: _ClassVar[int] DATAGRAM_GUARANTEED_DELIVERY_FIELD_NUMBER: _ClassVar[int] + ALLOW_CROSS_REGION_ROUTING_FIELD_NUMBER: _ClassVar[int] id: _wrappers_pb2.StringValue creation_date: _timestamp_pb2.Timestamp name: _wrappers_pb2.StringValue @@ -154,7 +155,8 @@ class TunnelProperties(_message.Message): port: _wrappers_pb2.UInt32Value upstream_tls: _wrappers_pb2.BoolValue datagram_guaranteed_delivery: _wrappers_pb2.BoolValue - def __init__(self, id: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., creation_date: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., name: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., type: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., publish: _Optional[_Union[_wrappers_pb2.BoolValue, _Mapping]] = ..., protocol: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., labels: _Optional[_Mapping[str, str]] = ..., geoip: _Optional[_Iterable[str]] = ..., trusted_ips: _Optional[_Iterable[str]] = ..., host: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., tls_mode: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., tls_alpns: _Optional[_Iterable[str]] = ..., tls_min_version: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., tls_ciphers: _Optional[_Iterable[str]] = ..., mtls_auth: _Optional[_Union[_wrappers_pb2.BoolValue, _Mapping]] = ..., mtls_cacert_pem: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., http_version: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., http_use_tls: _Optional[_Union[_wrappers_pb2.BoolValue, _Mapping]] = ..., token_auth: _Optional[_Union[_wrappers_pb2.BoolValue, _Mapping]] = ..., rstream_auth: _Optional[_Union[_wrappers_pb2.BoolValue, _Mapping]] = ..., challenge_mode: _Optional[_Union[_wrappers_pb2.BoolValue, _Mapping]] = ..., hostname: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., port: _Optional[_Union[_wrappers_pb2.UInt32Value, _Mapping]] = ..., upstream_tls: _Optional[_Union[_wrappers_pb2.BoolValue, _Mapping]] = ..., datagram_guaranteed_delivery: _Optional[_Union[_wrappers_pb2.BoolValue, _Mapping]] = ...) -> None: ... + allow_cross_region_routing: _wrappers_pb2.BoolValue + def __init__(self, id: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., creation_date: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., name: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., type: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., publish: _Optional[_Union[_wrappers_pb2.BoolValue, _Mapping]] = ..., protocol: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., labels: _Optional[_Mapping[str, str]] = ..., geoip: _Optional[_Iterable[str]] = ..., trusted_ips: _Optional[_Iterable[str]] = ..., host: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., tls_mode: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., tls_alpns: _Optional[_Iterable[str]] = ..., tls_min_version: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., tls_ciphers: _Optional[_Iterable[str]] = ..., mtls_auth: _Optional[_Union[_wrappers_pb2.BoolValue, _Mapping]] = ..., mtls_cacert_pem: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., http_version: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., http_use_tls: _Optional[_Union[_wrappers_pb2.BoolValue, _Mapping]] = ..., token_auth: _Optional[_Union[_wrappers_pb2.BoolValue, _Mapping]] = ..., rstream_auth: _Optional[_Union[_wrappers_pb2.BoolValue, _Mapping]] = ..., challenge_mode: _Optional[_Union[_wrappers_pb2.BoolValue, _Mapping]] = ..., hostname: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., port: _Optional[_Union[_wrappers_pb2.UInt32Value, _Mapping]] = ..., upstream_tls: _Optional[_Union[_wrappers_pb2.BoolValue, _Mapping]] = ..., datagram_guaranteed_delivery: _Optional[_Union[_wrappers_pb2.BoolValue, _Mapping]] = ..., allow_cross_region_routing: _Optional[_Union[_wrappers_pb2.BoolValue, _Mapping]] = ...) -> None: ... class OpenControlChannelReq(_message.Message): __slots__ = ("client_details",) @@ -216,16 +218,18 @@ class CloseTunnelRsp(_message.Message): def __init__(self, tunnel_id: _Optional[str] = ...) -> None: ... class ProxyConnReq(_message.Message): - __slots__ = ("tunnel_id", "stream_id", "secret", "source_ip") + __slots__ = ("tunnel_id", "stream_id", "secret", "source_ip", "proxy_endpoint") TUNNEL_ID_FIELD_NUMBER: _ClassVar[int] STREAM_ID_FIELD_NUMBER: _ClassVar[int] SECRET_FIELD_NUMBER: _ClassVar[int] SOURCE_IP_FIELD_NUMBER: _ClassVar[int] + PROXY_ENDPOINT_FIELD_NUMBER: _ClassVar[int] tunnel_id: str stream_id: str secret: _wrappers_pb2.StringValue source_ip: IpAddress - def __init__(self, tunnel_id: _Optional[str] = ..., stream_id: _Optional[str] = ..., secret: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., source_ip: _Optional[_Union[IpAddress, _Mapping]] = ...) -> None: ... + proxy_endpoint: _wrappers_pb2.StringValue + def __init__(self, tunnel_id: _Optional[str] = ..., stream_id: _Optional[str] = ..., secret: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ..., source_ip: _Optional[_Union[IpAddress, _Mapping]] = ..., proxy_endpoint: _Optional[_Union[_wrappers_pb2.StringValue, _Mapping]] = ...) -> None: ... class ProxyConnRsp(_message.Message): __slots__ = ("stream_id", "error") diff --git a/src/rstream/api.py b/src/rstream/api.py index 3c15ce2..2f227fd 100644 --- a/src/rstream/api.py +++ b/src/rstream/api.py @@ -6,7 +6,12 @@ from dataclasses import dataclass from urllib.parse import quote, urljoin -from rstream.config import DEFAULT_API_URL, normalize_engine_address +from rstream.config import ( + DEFAULT_API_URL, + _normalize_control_plane_headers, + _normalize_region, + normalize_engine_address, +) from rstream.errors import ConfigurationError, RuntimeError @@ -17,6 +22,16 @@ class TokenCredentials: token: str +@dataclass(frozen=True) +class TunnelsProjectRegionalEndpoint: + """Regional engine endpoint authorized for a managed project.""" + + provider: str + region: str + domain: str + engine_port: int + + @dataclass(frozen=True) class TunnelsProject: """Managed tunnels project metadata required for engine resolution.""" @@ -26,6 +41,8 @@ class TunnelsProject: url: str | None domain: str engine_port: int + placement: str = "regional" + regional_endpoints: tuple[TunnelsProjectRegionalEndpoint, ...] = () class RstreamAPIClient: @@ -35,9 +52,13 @@ def __init__( self, *, api_url: str = DEFAULT_API_URL, + control_plane_headers: Mapping[str, str] | None = None, credentials: TokenCredentials | None = None, ) -> None: self.api_url = api_url.rstrip("/") + self.control_plane_headers = dict( + _normalize_control_plane_headers(control_plane_headers) + ) self.credentials = credentials async def resolve_tunnels_project(self, endpoint: str) -> TunnelsProject: @@ -66,7 +87,7 @@ async def request_json(self, path: str) -> Mapping[str, object]: code="ERR_RSTREAM_INVALID_API_PATH", ) url = urljoin(f"{self.api_url}/", path.lstrip("/")) - headers: dict[str, str] = {} + headers = dict(self.control_plane_headers) if self.credentials is not None: headers["Authorization"] = f"Bearer {self.credentials.token}" async with httpx.AsyncClient(follow_redirects=False, timeout=15) as client: @@ -85,7 +106,41 @@ async def request_json(self, path: str) -> Mapping[str, object]: return {str(key): item for key, item in value.items()} -def engine_from_project(project: TunnelsProject) -> str: +def engine_from_project(project: TunnelsProject, region: str | None = None) -> str: + requested = _normalize_region(region) + if requested is not None: + matches = tuple( + endpoint + for endpoint in project.regional_endpoints + if endpoint.region.strip().lower() == requested + ) + if not matches: + available = sorted( + { + endpoint.region.strip().lower() + for endpoint in project.regional_endpoints + if endpoint.region.strip() + } + ) + suffix = f" Available regions: {', '.join(available)}." if available else "" + raise ConfigurationError( + f"Region '{requested}' is not available for this project.{suffix}", + code="ERR_RSTREAM_REGION_UNAVAILABLE", + ) + if len(matches) > 1: + raise ConfigurationError( + f"Region '{requested}' is ambiguous for this project.", + code="ERR_RSTREAM_REGION_AMBIGUOUS", + ) + selected = matches[0] + engine = f"{project.endpoint}.{selected.domain}:{selected.engine_port}" + normalized = normalize_engine_address(engine) + if normalized is None: + raise RuntimeError( + "Failed to normalize managed project regional engine address.", + code="ERR_RSTREAM_ENGINE_RESOLUTION", + ) + return normalized if project.endpoint and project.domain: engine = f"{project.endpoint}.{project.domain}:{project.engine_port or 443}" normalized = normalize_engine_address(engine) @@ -110,6 +165,7 @@ def _project_from_json(data: Mapping[str, object]) -> TunnelsProject: endpoint = _string_required(data, "endpoint") domain = _string_required(data, "domain") engine_port = _int_required(data, "enginePort") + placement = data.get("placement") url = data.get("url") return TunnelsProject( id=project_id, @@ -117,9 +173,40 @@ def _project_from_json(data: Mapping[str, object]) -> TunnelsProject: url=url if isinstance(url, str) else None, domain=domain, engine_port=engine_port, + placement=placement if isinstance(placement, str) else "regional", + regional_endpoints=_regional_endpoints_from_json(data.get("regionalEndpoints")), ) +def _regional_endpoints_from_json( + value: object, +) -> tuple[TunnelsProjectRegionalEndpoint, ...]: + if value is None: + return () + if not isinstance(value, list): + raise RuntimeError( + "Control plane response has invalid 'regionalEndpoints'.", + code="ERR_RSTREAM_API_INVALID_RESPONSE", + ) + endpoints: list[TunnelsProjectRegionalEndpoint] = [] + for item in value: + if not isinstance(item, Mapping): + raise RuntimeError( + "Control plane response has invalid 'regionalEndpoints'.", + code="ERR_RSTREAM_API_INVALID_RESPONSE", + ) + normalized = {str(key): entry for key, entry in item.items()} + endpoints.append( + TunnelsProjectRegionalEndpoint( + provider=_string_required(normalized, "provider"), + region=_string_required(normalized, "region"), + domain=_string_required(normalized, "domain"), + engine_port=_int_required(normalized, "enginePort"), + ) + ) + return tuple(endpoints) + + def _string_required(data: Mapping[str, object], key: str) -> str: value = data.get(key) if isinstance(value, str) and value.strip(): diff --git a/src/rstream/client.py b/src/rstream/client.py index 9f4a54b..2339da3 100644 --- a/src/rstream/client.py +++ b/src/rstream/client.py @@ -59,6 +59,7 @@ def __init__( api_url: str | None = None, config_path: str | None = None, context: str | None = None, + control_plane_headers: Mapping[str, str] | None = None, engine: str | None = None, connect_timeout: float = 15.0, heartbeat: bool = True, @@ -67,6 +68,7 @@ def __init__( no_token: bool | None = None, project_endpoint: str | None = None, read_config_file: bool = True, + region: str | None = None, require_token: bool = False, token: str | None = None, tls: TLSOptions | None = None, @@ -76,6 +78,7 @@ def __init__( api_url=api_url, config_path=config_path, context=context, + control_plane_headers=control_plane_headers, engine=engine, connect_timeout=connect_timeout, heartbeat=heartbeat, @@ -84,6 +87,7 @@ def __init__( no_token=no_token, project_endpoint=project_endpoint, read_config_file=read_config_file, + region=region, require_token=require_token, token=token, tls=tls, @@ -101,6 +105,7 @@ def from_env( api_url: str | None = None, config_path: str | None = None, context: str | None = None, + control_plane_headers: Mapping[str, str] | None = None, engine: str | None = None, connect_timeout: float = 15.0, heartbeat: bool = True, @@ -109,6 +114,7 @@ def from_env( no_token: bool | None = None, project_endpoint: str | None = None, read_config_file: bool = True, + region: str | None = None, require_token: bool = False, token: str | None = None, tls: TLSOptions | None = None, @@ -118,6 +124,7 @@ def from_env( api_url=api_url, config_path=config_path, context=context, + control_plane_headers=control_plane_headers, engine=engine, connect_timeout=connect_timeout, heartbeat=heartbeat, @@ -126,6 +133,7 @@ def from_env( no_token=no_token, project_endpoint=project_endpoint, read_config_file=read_config_file, + region=region, require_token=require_token, token=token, tls=tls, @@ -380,7 +388,24 @@ async def _open_proxy_connection( request: pb.ProxyConnReq, ) -> RstreamStream: token = request.secret.value if request.HasField("secret") else None - reader, writer = await self._dial_engine(engine, resolved) + proxy_engine = engine + if request.HasField("proxy_endpoint"): + if token is None or not token.strip(): + raise ProtocolError( + "Engine did not provide credentials for the redirected stream.", + code="ERR_RSTREAM_PROTOCOL", + ) + proxy_engine = request.proxy_endpoint.value.strip() + if not proxy_engine: + raise ProtocolError( + "Engine returned an empty proxy endpoint.", + code="ERR_RSTREAM_PROTOCOL", + ) + reader, writer = await self._dial_engine( + proxy_engine, + resolved, + use_explicit_server_name=proxy_engine == engine, + ) try: await write_message( writer, @@ -410,8 +435,14 @@ async def _dial_engine( self, engine: str, resolved: ResolvedClientOptions, + *, + use_explicit_server_name: bool = True, ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: - ssl_context, server_hostname = create_ssl_context(engine, resolved.tls) + ssl_context, server_hostname = create_ssl_context( + engine, + resolved.tls, + use_explicit_server_name=use_explicit_server_name, + ) host, port = _split_engine(engine) return await _wait_for_operation( asyncio.open_connection( @@ -425,7 +456,7 @@ async def _dial_engine( ) async def _resolve_engine(self, resolved: ResolvedClientOptions) -> str: - if resolved.engine is not None: + if resolved.region is None and resolved.engine is not None: return resolved.engine if resolved.project_endpoint is None: raise ConfigurationError( @@ -437,9 +468,10 @@ async def _resolve_engine(self, resolved: ResolvedClientOptions) -> str: ) project = await RstreamAPIClient( api_url=resolved.api_url, + control_plane_headers=resolved.control_plane_headers, credentials=credentials, ).resolve_tunnels_project(resolved.project_endpoint) - return engine_from_project(project) + return engine_from_project(project, resolved.region) async def _resolve_token( self, @@ -479,6 +511,7 @@ def with_options( api_url: str | None = None, config_path: str | None = None, context: str | None = None, + control_plane_headers: Mapping[str, str] | None = None, engine: str | None = None, connect_timeout: float | None = None, heartbeat: bool | None = None, @@ -487,6 +520,7 @@ def with_options( no_token: bool | None = None, project_endpoint: str | None = None, read_config_file: bool | None = None, + region: str | None = None, require_token: bool | None = None, token: str | None = None, tls: TLSOptions | None = None, @@ -497,6 +531,11 @@ def with_options( api_url=api_url if api_url is not None else options.api_url, config_path=config_path if config_path is not None else options.config_path, context=context if context is not None else options.context, + control_plane_headers=( + control_plane_headers + if control_plane_headers is not None + else options.control_plane_headers + ), engine=engine if engine is not None else options.engine, connect_timeout=( connect_timeout @@ -525,6 +564,7 @@ def with_options( if read_config_file is not None else options.read_config_file ), + region=region if region is not None else options.region, require_token=( require_token if require_token is not None else options.require_token ), diff --git a/src/rstream/config.py b/src/rstream/config.py index b689ce1..7b6bd04 100644 --- a/src/rstream/config.py +++ b/src/rstream/config.py @@ -39,6 +39,7 @@ class ClientOptions: api_url: str | None = None config_path: str | None = None context: str | None = None + control_plane_headers: Mapping[str, str] | None = None engine: str | None = None connect_timeout: float = 15.0 heartbeat: bool = True @@ -47,6 +48,7 @@ class ClientOptions: no_token: bool | None = None project_endpoint: str | None = None read_config_file: bool = True + region: str | None = None require_token: bool = False tls: TLSOptions | None = None token: str | None = None @@ -59,6 +61,7 @@ class ResolvedClientOptions: """Fully resolved options used by the runtime client.""" api_url: str + control_plane_headers: Mapping[str, str] engine: str | None connect_timeout: float heartbeat: bool @@ -66,6 +69,7 @@ class ResolvedClientOptions: operation_timeout: float no_token: bool project_endpoint: str | None + region: str | None tls: TLSOptions | None token: str | None zero_rtt: bool @@ -105,6 +109,7 @@ class _TransportConfig: class _EnvironmentConfig: api_url: str auth: _AuthConfig | None = None + headers: Mapping[str, str] = field(default_factory=dict) transport: _TransportConfig | None = None @@ -115,6 +120,7 @@ class _ContextConfig: auth: _AuthConfig | None = None engine: str | None = None project_endpoint: str | None = None + region: str | None = None transport: _TransportConfig | None = None @@ -128,9 +134,11 @@ class _ConfigFile: @dataclass(frozen=True) class _ResolvedConfig: api_url: str + control_plane_headers: Mapping[str, str] = field(default_factory=dict) context_engine: str | None = None engine: str | None = None project_endpoint: str | None = None + region: str | None = None tls: TLSOptions | None = None token: str | None = None tunnel_transport: str | None = None @@ -141,15 +149,33 @@ class _EnvSettings: api_url: str | None = None config_path: str | None = None context: str | None = None + control_plane_headers: Mapping[str, str] = field(default_factory=dict) engine: str | None = None mtls_cert: str | None = None mtls_key: str | None = None + region: str | None = None token: str | None = None tunnel_transport: str | None = None use_quic: bool | None = None _DNS_LABEL_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$", re.I) +_HEADER_NAME_PATTERN = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") +_RESERVED_CONTROL_PLANE_HEADERS = { + "authorization", + "connection", + "content-length", + "cookie", + "forwarded", + "host", + "keep-alive", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", +} def default_config_path() -> str: @@ -203,9 +229,27 @@ async def resolve_client_options(options: ClientOptions) -> ResolvedClientOption "operation_timeout must be positive.", code="ERR_RSTREAM_INVALID_TIMEOUT", ) + region = _normalize_region( + _first_defined(options.region, env.region, config.region) + ) + explicit_engine = _normalize_optional(_first_defined(options.engine, env.engine)) + project_endpoint = _normalize_optional( + _first_defined(options.project_endpoint, config.project_endpoint) + ) + if region is not None and explicit_engine is not None: + raise ConfigurationError( + "Region selection cannot be combined with an explicit engine override.", + code="ERR_RSTREAM_REGION_ENGINE_CONFLICT", + ) + if region is not None and project_endpoint is None: + raise ConfigurationError( + "Managed project endpoint is required for region selection.", + code="ERR_RSTREAM_PROJECT_ENDPOINT_REQUIRED", + ) return ResolvedClientOptions( api_url=_first_defined(options.api_url, env.api_url, config.api_url) or DEFAULT_API_URL, + control_plane_headers=dict(config.control_plane_headers), engine=normalize_engine_address( _first_defined( options.engine, @@ -221,9 +265,8 @@ async def resolve_client_options(options: ClientOptions) -> ResolvedClientOption no_token=options.no_token if options.no_token is not None else token is None and not _tls_has_client_certificate(tls), - project_endpoint=_normalize_optional( - _first_defined(options.project_endpoint, config.project_endpoint) - ), + project_endpoint=project_endpoint, + region=region, tls=tls, token=token, zero_rtt=options.zero_rtt, @@ -234,11 +277,13 @@ async def resolve_client_options(options: ClientOptions) -> ResolvedClientOption def create_ssl_context( engine: str, options: TLSOptions | None, + *, + use_explicit_server_name: bool = True, ) -> tuple[ssl.SSLContext, str | None]: parsed_host = engine.split(":", 1)[0] server_name = ( options.server_name - if options is not None and options.server_name + if use_explicit_server_name and options is not None and options.server_name else parsed_host ) context = ssl.create_default_context(cafile=options.ca_file if options else None) @@ -296,13 +341,32 @@ def normalize_engine_address(engine: str | None) -> str | None: return f"{hostname.lower()}{f':{parsed.port}' if parsed.port else ''}" +def _normalize_region(region: str | None) -> str | None: + normalized = _normalize_optional(region) + if normalized is None or normalized.lower() == "auto": + return None + value = normalized.lower() + if len(value) > 64 or not re.fullmatch( + r"[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?", value + ): + raise ConfigurationError( + "Region can only contain letters, numbers, dots, underscores, or hyphens.", + code="ERR_RSTREAM_INVALID_REGION", + ) + return value + + def _resolve_config( options: ClientOptions, env: _EnvSettings, ) -> _ResolvedConfig: if not options.read_config_file: return _ResolvedConfig( - api_url=_first_defined(options.api_url, env.api_url) or DEFAULT_API_URL + api_url=_first_defined(options.api_url, env.api_url) or DEFAULT_API_URL, + control_plane_headers=_merge_control_plane_headers( + env.control_plane_headers, + options.control_plane_headers, + ), ) config_path = ( _normalize_optional(_first_defined(options.config_path, env.config_path)) @@ -365,9 +429,15 @@ def _resolve_config( tunnel_transport = _transport_mode_from_config(environment_transport) return _ResolvedConfig( api_url=api_url, + control_plane_headers=_merge_control_plane_headers( + environment.headers if environment else None, + env.control_plane_headers, + options.control_plane_headers, + ), context_engine=context.engine if context else None, engine=explicit_engine, project_endpoint=context.project_endpoint if context else None, + region=context.region if context else None, tls=tls, token=token, tunnel_transport=tunnel_transport, @@ -380,6 +450,9 @@ def _read_env() -> _EnvSettings: api_url=_normalize_api_url(os.getenv("RSTREAM_API_URL")), config_path=_normalize_optional(os.getenv("RSTREAM_CONFIG")), context=_normalize_optional(os.getenv("RSTREAM_CONTEXT")), + control_plane_headers=_control_plane_headers_from_json( + os.getenv("RSTREAM_CONTROL_PLANE_HEADERS") + ), engine=_normalize_optional( _first_defined( os.getenv("RSTREAM_ENGINE"), @@ -388,6 +461,7 @@ def _read_env() -> _EnvSettings: ), mtls_cert=_normalize_optional(os.getenv("RSTREAM_MTLS_CERT_FILE")), mtls_key=_normalize_optional(os.getenv("RSTREAM_MTLS_KEY_FILE")), + region=_normalize_optional(os.getenv("RSTREAM_REGION")), token=_normalize_optional(os.getenv("RSTREAM_AUTHENTICATION_TOKEN")), tunnel_transport=_normalize_optional(os.getenv("RSTREAM_TUNNEL_TRANSPORT")), use_quic=None if legacy_quic is None else legacy_quic == "1", @@ -442,6 +516,7 @@ def _context_config(value: Mapping[str, object]) -> _ContextConfig: auth=_auth_config(value.get("auth")), engine=_normalize_optional(_string(value.get("engine"))), project_endpoint=_normalize_optional(_string(value.get("projectEndpoint"))), + region=_normalize_optional(_string(value.get("region"))), transport=_transport_config(value.get("transport")), ) @@ -450,6 +525,7 @@ def _environment_config(value: Mapping[str, object]) -> _EnvironmentConfig: return _EnvironmentConfig( api_url=_normalize_api_url(_string(value.get("apiUrl"))) or "", auth=_auth_config(value.get("auth")), + headers=_control_plane_headers_config(value.get("headers")), transport=_transport_config(value.get("transport")), ) @@ -666,6 +742,89 @@ def _merge_tls_options( ) +def _merge_control_plane_headers( + *sources: Mapping[str, str] | None, +) -> Mapping[str, str]: + merged: dict[str, str] = {} + for source in sources: + merged.update(_normalize_control_plane_headers(source)) + return merged + + +def _control_plane_headers_from_json(value: str | None) -> Mapping[str, str]: + normalized = _normalize_optional(value) + if normalized is None: + return {} + try: + parsed: object = json.loads(normalized) + except json.JSONDecodeError as error: + raise ConfigurationError( + "RSTREAM_CONTROL_PLANE_HEADERS must be a JSON object of string values.", + code="ERR_RSTREAM_INVALID_CONFIG", + ) from error + return _control_plane_headers_config(parsed) + + +def _control_plane_headers_config(value: object) -> Mapping[str, str]: + if value is None: + return {} + if not isinstance(value, Mapping): + raise ConfigurationError( + "Control plane headers must be an object of string values.", + code="ERR_RSTREAM_INVALID_CONFIG", + ) + headers: dict[str, str] = {} + for name, header_value in value.items(): + if not isinstance(name, str) or not isinstance(header_value, str): + raise ConfigurationError( + "Control plane headers must be an object of string values.", + code="ERR_RSTREAM_INVALID_CONFIG", + ) + headers[name] = header_value + return _normalize_control_plane_headers(headers) + + +def _normalize_control_plane_headers( + headers: Mapping[str, str] | None, +) -> Mapping[str, str]: + normalized: dict[str, str] = {} + for raw_name, value in (headers or {}).items(): + if not isinstance(raw_name, str) or not isinstance(value, str): + raise ConfigurationError( + "Control plane headers must contain string names and values.", + code="ERR_RSTREAM_INVALID_CONFIG", + ) + name = raw_name.strip() + lower_name = name.lower() + if not _HEADER_NAME_PATTERN.fullmatch(name): + raise ConfigurationError( + f"Invalid control plane header name '{raw_name}'.", + code="ERR_RSTREAM_INVALID_CONFIG", + ) + if lower_name in _RESERVED_CONTROL_PLANE_HEADERS or lower_name.startswith( + "x-forwarded-" + ): + raise ConfigurationError( + f"Control plane header '{raw_name}' is reserved.", + code="ERR_RSTREAM_INVALID_CONFIG", + ) + if "\r" in value or "\n" in value: + raise ConfigurationError( + f"Control plane header '{raw_name}' has an invalid value.", + code="ERR_RSTREAM_INVALID_CONFIG", + ) + canonical_name = "-".join( + part[:1].upper() + part[1:].lower() for part in name.split("-") + ) + if canonical_name in normalized: + raise ConfigurationError( + f"Duplicate control plane header '{canonical_name}'.", + code="ERR_RSTREAM_INVALID_CONFIG", + ) + normalized[canonical_name] = value + return normalized + + def _validate_token_expiry(token: str) -> None: parts = token.split(".") if len(parts) < 2: diff --git a/src/rstream/control.py b/src/rstream/control.py index d92dc83..bde525d 100644 --- a/src/rstream/control.py +++ b/src/rstream/control.py @@ -108,6 +108,7 @@ async def create_tunnel( hostname: str | None = None, port: int | None = None, upstream_tls: bool | None = None, + allow_cross_region_routing: bool | None = None, auth: TunnelAuth | None = None, ) -> BytestreamTunnel: explicit_options = CreateTunnelOptions( @@ -130,6 +131,7 @@ async def create_tunnel( hostname=hostname, port=port, upstream_tls=upstream_tls, + allow_cross_region_routing=allow_cross_region_routing, auth=auth, ) if options is not None and explicit_options != CreateTunnelOptions(): @@ -368,6 +370,11 @@ def _finish(self) -> None: def _normalize_bytestream_options(options: CreateTunnelOptions) -> TunnelProperties: + if options.allow_cross_region_routing is not None and options.protocol != "tcp": + raise RuntimeError( + "Cross-region routing policy requires protocol='tcp'.", + code="ERR_RSTREAM_INVALID_TUNNEL", + ) if options.port is not None and options.protocol != "tcp": raise RuntimeError( "A published port requires protocol='tcp'.", @@ -444,4 +451,5 @@ def _normalize_bytestream_options(options: CreateTunnelOptions) -> TunnelPropert hostname=options.hostname, port=options.port, upstream_tls=options.upstream_tls, + allow_cross_region_routing=options.allow_cross_region_routing, ) diff --git a/src/rstream/engine_api.py b/src/rstream/engine_api.py index abb2ab9..2d01570 100644 --- a/src/rstream/engine_api.py +++ b/src/rstream/engine_api.py @@ -175,6 +175,7 @@ def tunnel_properties_from_json(data: Mapping[str, object]) -> TunnelProperties: datagram_guaranteed_delivery=_optional_bool( data, "datagram_guaranteed_delivery" ), + allow_cross_region_routing=_optional_bool(data, "allow_cross_region_routing"), ) diff --git a/src/rstream/protocol.py b/src/rstream/protocol.py index 991083f..ab6881a 100644 --- a/src/rstream/protocol.py +++ b/src/rstream/protocol.py @@ -155,6 +155,10 @@ def tunnel_properties_to_pb(properties: TunnelProperties) -> pb.TunnelProperties result.datagram_guaranteed_delivery.CopyFrom( BoolValue(value=properties.datagram_guaranteed_delivery) ) + if properties.allow_cross_region_routing is not None: + result.allow_cross_region_routing.CopyFrom( + BoolValue(value=properties.allow_cross_region_routing) + ) return result @@ -206,6 +210,9 @@ def tunnel_properties_from_pb(properties: pb.TunnelProperties) -> TunnelProperti datagram_guaranteed_delivery=wrapper_bool( optional_field(properties, "datagram_guaranteed_delivery", BoolValue) ), + allow_cross_region_routing=wrapper_bool( + optional_field(properties, "allow_cross_region_routing", BoolValue) + ), ) diff --git a/src/rstream/tunnel.py b/src/rstream/tunnel.py index cdc9728..81c3c02 100644 --- a/src/rstream/tunnel.py +++ b/src/rstream/tunnel.py @@ -67,8 +67,12 @@ async def accept(self) -> RstreamStream: async def close(self) -> None: if self._closed: + await self.wait_forwarders_closed() return - await self._control.close_tunnel(self.id) + try: + await self._control.close_tunnel(self.id) + finally: + await self.wait_forwarders_closed() async def forward_to(self, host: str, port: int) -> None: _validate_local_endpoint(host, port) diff --git a/src/rstream/types.py b/src/rstream/types.py index 0ae9c74..78bd4b1 100644 --- a/src/rstream/types.py +++ b/src/rstream/types.py @@ -50,6 +50,7 @@ class TunnelProperties: port: int | None = None upstream_tls: bool | None = None datagram_guaranteed_delivery: bool | None = None + allow_cross_region_routing: bool | None = None @dataclass(frozen=True) @@ -75,6 +76,7 @@ class CreateTunnelOptions: hostname: str | None = None port: int | None = None upstream_tls: bool | None = None + allow_cross_region_routing: bool | None = None auth: TunnelAuth | None = None diff --git a/tests/integration/test_runtime_fake_engine.py b/tests/integration/test_runtime_fake_engine.py index c5eaef1..0688031 100644 --- a/tests/integration/test_runtime_fake_engine.py +++ b/tests/integration/test_runtime_fake_engine.py @@ -38,8 +38,13 @@ async def test_published_tcp_options_and_local_validation(tmp_path: Path) -> Non client = client_for(engine) async with await client.connect() as control: - tunnel = await control.create_tunnel(protocol="tcp", port=10042) + tunnel = await control.create_tunnel( + protocol="tcp", + port=10042, + allow_cross_region_routing=True, + ) assert tunnel.forwarding_address == "test.localhost:10042 (tcp)" + assert tunnel.properties.allow_cross_region_routing is True with pytest.raises( rstream.RstreamRuntimeError, match="do not accept" ) as exc: @@ -48,6 +53,14 @@ async def test_published_tcp_options_and_local_validation(tmp_path: Path) -> Non hostname="ssh.example.test", ) assert exc.value.code == "ERR_RSTREAM_INVALID_TUNNEL" + with pytest.raises( + rstream.RstreamRuntimeError, match="requires protocol='tcp'" + ) as exc: + await control.create_tunnel( + protocol="http", + allow_cross_region_routing=True, + ) + assert exc.value.code == "ERR_RSTREAM_INVALID_TUNNEL" assert engine.open_tunnel_requests == 1 @@ -212,6 +225,88 @@ async def test_proxy_connection_delivery_round_trip(tmp_path: Path) -> None: ) +@pytest.mark.asyncio +async def test_proxy_connection_can_dial_ingress_engine(tmp_path: Path) -> None: + ca = trustme.CA() + async with ( + await FakeEngine.start(tmp_path, name="owner", ca=ca) as owner, + await FakeEngine.start(tmp_path, name="ingress", ca=ca) as ingress, + ): + client = client_for(owner, token="owner-pat", zero_rtt=False) + async with await client.connect() as control: + tunnel = await control.create_tunnel() + response_waiter = asyncio.create_task( + owner.request_proxy_connection( + tunnel.id, + "stream_direct_1", + proxy_endpoint=ingress.address, + ) + ) + app_stream = await asyncio.wait_for(tunnel.accept(), timeout=1) + ingress_stream = await ingress.next_proxy_stream() + response = await response_waiter + assert not response.proxy_conn_rsp.HasField("error") + assert owner.proxy_requests == [] + assert ingress.proxy_requests == [("stream_direct_1", False)] + assert ingress.proxy_tokens == ["stream-secret"] + app_stream.write(b"ping") + await app_stream.drain() + assert await ingress_stream.readexactly(4) == b"ping" + app_stream.close() + ingress_stream.close() + await asyncio.gather( + app_stream.wait_closed(), + ingress_stream.wait_closed(), + return_exceptions=True, + ) + + +@pytest.mark.asyncio +async def test_proxy_redirect_without_stream_secret_is_rejected(tmp_path: Path) -> None: + ca = trustme.CA() + async with ( + await FakeEngine.start(tmp_path, name="owner", ca=ca) as owner, + await FakeEngine.start(tmp_path, name="ingress", ca=ca) as ingress, + ): + client = client_for(owner) + async with await client.connect() as control: + tunnel = await control.create_tunnel() + response = await owner.request_proxy_connection( + tunnel.id, + "stream_missing_secret", + proxy_endpoint=ingress.address, + include_secret=False, + ) + assert response.proxy_conn_rsp.HasField("error") + assert "credentials" in response.proxy_conn_rsp.error.message.value + assert owner.proxy_requests == [] + assert ingress.proxy_requests == [] + + +@pytest.mark.asyncio +async def test_proxy_redirect_with_empty_stream_secret_is_rejected( + tmp_path: Path, +) -> None: + ca = trustme.CA() + async with ( + await FakeEngine.start(tmp_path, name="owner", ca=ca) as owner, + await FakeEngine.start(tmp_path, name="ingress", ca=ca) as ingress, + ): + client = client_for(owner) + async with await client.connect() as control: + tunnel = await control.create_tunnel() + response = await owner.request_proxy_connection( + tunnel.id, + "stream_empty_secret", + proxy_endpoint=ingress.address, + secret="", + ) + assert response.proxy_conn_rsp.HasField("error") + assert "credentials" in response.proxy_conn_rsp.error.message.value + assert owner.proxy_requests == [] + assert ingress.proxy_requests == [] + + @pytest.mark.asyncio async def test_proxy_handshake_timeout_is_reported_to_engine( tmp_path: Path, @@ -262,13 +357,15 @@ def client_for( engine: FakeEngine, *, operation_timeout: float = 1, + token: str | None = None, zero_rtt: bool = False, ) -> rstream.Client: return rstream.Client( engine=engine.address, - no_token=True, + no_token=token is None, operation_timeout=operation_timeout, read_config_file=False, + token=token, tls=rstream.TLSOptions( ca_file=str(engine.ca_file), server_name="localhost", @@ -298,6 +395,7 @@ def __init__( self.open_tunnel_requests = 0 self.stream_requests: list[tuple[str, bool]] = [] self.proxy_requests: list[tuple[str, bool]] = [] + self.proxy_tokens: list[str | None] = [] self._control_writer: asyncio.StreamWriter | None = None self._pending_proxy_responses: asyncio.Queue[pb.Message] = asyncio.Queue() self._proxy_streams: asyncio.Queue[rstream.RstreamStream] = asyncio.Queue() @@ -307,13 +405,19 @@ def __init__( self._closed_event = asyncio.Event() @classmethod - async def start(cls, tmp_path: Path) -> FakeEngine: - ca = trustme.CA() - cert = ca.issue_cert("localhost", "127.0.0.1") - ca_file = tmp_path / "ca.pem" - cert_file = tmp_path / "cert.pem" - key_file = tmp_path / "key.pem" - ca.cert_pem.write_to_path(ca_file) + async def start( + cls, + tmp_path: Path, + *, + name: str = "engine", + ca: trustme.CA | None = None, + ) -> FakeEngine: + certificate_authority = ca or trustme.CA() + cert = certificate_authority.issue_cert("localhost", "127.0.0.1") + ca_file = tmp_path / f"{name}-ca.pem" + cert_file = tmp_path / f"{name}-cert.pem" + key_file = tmp_path / f"{name}-key.pem" + certificate_authority.cert_pem.write_to_path(ca_file) cert.cert_chain_pems[0].write_to_path(cert_file) cert.private_key_pem.write_to_path(key_file) ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) @@ -360,12 +464,22 @@ async def request_proxy_connection( self, tunnel_id: str, stream_id: str, + *, + proxy_endpoint: str | None = None, + include_secret: bool = True, + secret: str = "stream-secret", ) -> pb.Message: writer = self._control_writer assert writer is not None message = pb.Message() message.proxy_conn_req.tunnel_id = tunnel_id message.proxy_conn_req.stream_id = stream_id + if proxy_endpoint is not None: + message.proxy_conn_req.proxy_endpoint.CopyFrom( + StringValue(value=proxy_endpoint) + ) + if include_secret: + message.proxy_conn_req.secret.CopyFrom(StringValue(value=secret)) await write_message(writer, message) return await asyncio.wait_for(self._pending_proxy_responses.get(), timeout=1) @@ -465,6 +579,10 @@ async def _handle_open_tunnel( response.open_tunnel_rsp.tunnel_properties.port.CopyFrom( request.tunnel_properties.port ) + if request.tunnel_properties.HasField("allow_cross_region_routing"): + response.open_tunnel_rsp.tunnel_properties.allow_cross_region_routing.CopyFrom( + request.tunnel_properties.allow_cross_region_routing + ) await write_message(writer, response) async def _handle_close_tunnel( @@ -519,6 +637,11 @@ async def _handle_proxy( ) -> None: zero_rtt = request.HasField("zero_rtt") and request.zero_rtt.value self.proxy_requests.append((request.stream_id, zero_rtt)) + self.proxy_tokens.append( + request.client_details.token.value + if request.client_details.HasField("token") + else None + ) if self._consume("next_proxy_hang"): await self._hold() if not zero_rtt: diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index a1dd512..3c5389d 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -1,14 +1,16 @@ from __future__ import annotations +import httpx import pytest from rstream.api import ( RstreamAPIClient, TunnelsProject, + TunnelsProjectRegionalEndpoint, _project_from_json, engine_from_project, ) -from rstream.errors import RstreamRuntimeError +from rstream.errors import ConfigurationError, RstreamRuntimeError def test_engine_from_project_uses_endpoint_domain_and_port() -> None: @@ -48,6 +50,55 @@ def test_engine_from_project_rejects_unresolvable_project() -> None: engine_from_project(project) +def test_engine_from_project_selects_only_authorized_regions() -> None: + project = TunnelsProject( + id="project_123", + endpoint="abc123", + url="abc123.global.example.test:443", + domain="global.example.test", + engine_port=443, + placement="global", + regional_endpoints=( + TunnelsProjectRegionalEndpoint( + provider="aws", + region="eu-west-3", + domain="eu.example.test", + engine_port=8443, + ), + TunnelsProjectRegionalEndpoint( + provider="aws", + region="us-east-1", + domain="us.example.test", + engine_port=443, + ), + ), + ) + + assert engine_from_project(project, "US-EAST-1") == "abc123.us.example.test:443" + with pytest.raises(ConfigurationError, match="Available regions"): + engine_from_project(project, "ap-southeast-1") + + +def test_engine_from_project_rejects_ambiguous_regions() -> None: + endpoint = TunnelsProjectRegionalEndpoint( + provider="aws", + region="eu-west-3", + domain="eu.example.test", + engine_port=443, + ) + project = TunnelsProject( + id="project_123", + endpoint="abc123", + url=None, + domain="global.example.test", + engine_port=443, + regional_endpoints=(endpoint, endpoint), + ) + + with pytest.raises(ConfigurationError, match="ambiguous"): + engine_from_project(project, "eu-west-3") + + def test_project_from_json_normalizes_control_plane_payload() -> None: project = _project_from_json( { @@ -56,6 +107,15 @@ def test_project_from_json_normalizes_control_plane_payload() -> None: "url": "engine.example.test:443", "domain": "tunnels.rstream.io", "enginePort": 9443, + "placement": "global", + "regionalEndpoints": [ + { + "provider": "aws", + "region": "eu-west-3", + "domain": "eu.example.test", + "enginePort": 8443, + } + ], } ) @@ -64,6 +124,8 @@ def test_project_from_json_normalizes_control_plane_payload() -> None: assert project.url == "engine.example.test:443" assert project.domain == "tunnels.rstream.io" assert project.engine_port == 9443 + assert project.placement == "global" + assert project.regional_endpoints[0].region == "eu-west-3" @pytest.mark.parametrize( @@ -95,3 +157,54 @@ async def test_api_client_rejects_absolute_remote_path() -> None: with pytest.raises(RstreamRuntimeError, match="relative absolute path"): await client.request_json("//evil.example.test/project") + + +@pytest.mark.asyncio +async def test_api_client_sends_control_plane_headers_without_following_redirects( + monkeypatch: pytest.MonkeyPatch, +) -> None: + observed: dict[str, object] = {} + + class FakeAsyncClient: + def __init__(self, *, follow_redirects: bool, timeout: int) -> None: + observed["follow_redirects"] = follow_redirects + observed["timeout"] = timeout + + async def __aenter__(self) -> FakeAsyncClient: + return self + + async def __aexit__( + self, + exc_type: object, + exc_value: object, + traceback: object, + ) -> None: + return None + + async def get( + self, + url: str, + *, + headers: dict[str, str], + ) -> httpx.Response: + observed["url"] = url + observed["headers"] = headers + return httpx.Response(200, json={"ok": True}) + + monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient) + client = RstreamAPIClient( + control_plane_headers={"x-vercel-protection-bypass": "test-secret"} + ) + + assert await client.request_json("/api/test") == {"ok": True} + assert observed == { + "follow_redirects": False, + "headers": {"X-Vercel-Protection-Bypass": "test-secret"}, + "timeout": 15, + "url": "https://rstream.io/api/test", + } + + +def test_api_client_rejects_reserved_control_plane_headers() -> None: + with pytest.raises(ConfigurationError, match="reserved"): + RstreamAPIClient(control_plane_headers={"Authorization": "secret"}) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 071cb80..3d1e767 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -7,6 +7,8 @@ from rstream.config import ( ClientOptions, + TLSOptions, + create_ssl_context, normalize_engine_address, resolve_client_options, ) @@ -62,6 +64,18 @@ def test_normalize_engine_address_rejects_scheme() -> None: normalize_engine_address("https://example.test") +def test_redirected_engine_uses_its_hostname_for_tls() -> None: + options = TLSOptions(server_name="owner.example.test") + _, owner_server_name = create_ssl_context("owner.example.test:443", options) + _, ingress_server_name = create_ssl_context( + "ingress.example.test:443", + options, + use_explicit_server_name=False, + ) + assert owner_server_name == "owner.example.test" + assert ingress_server_name == "ingress.example.test" + + def test_resolve_config_file_context(tmp_path: Path) -> None: config = tmp_path / "config.yaml" config.write_text( @@ -91,6 +105,62 @@ def test_resolve_config_file_context(tmp_path: Path) -> None: assert resolved.token == "local-token" +def test_region_resolution_follows_option_environment_and_context_precedence( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = tmp_path / "config.yaml" + config.write_text( + """ +version: 1 +defaults: + context: + name: global +contexts: + - name: global + engine: project.global.example.test:443 + projectEndpoint: project + region: eu-west-3 +""".strip(), + encoding="utf-8", + ) + monkeypatch.setenv("RSTREAM_REGION", "us-east-1") + resolved = asyncio.run( + resolve_client_options(ClientOptions(config_path=str(config), no_token=True)) + ) + assert resolved.region == "us-east-1" + explicit = asyncio.run( + resolve_client_options( + ClientOptions( + config_path=str(config), + no_token=True, + region="EU-CENTRAL-1", + ) + ) + ) + assert explicit.region == "eu-central-1" + monkeypatch.delenv("RSTREAM_REGION") + context = asyncio.run( + resolve_client_options(ClientOptions(config_path=str(config), no_token=True)) + ) + assert context.region == "eu-west-3" + + +def test_region_resolution_rejects_direct_engine_override() -> None: + with pytest.raises(ConfigurationError, match="explicit engine override"): + asyncio.run( + resolve_client_options( + ClientOptions( + engine="engine.example.test:443", + no_token=True, + project_endpoint="project", + read_config_file=False, + region="eu-west-3", + ) + ) + ) + + def test_resolve_config_rejects_invalid_yaml(tmp_path: Path) -> None: config = tmp_path / "config.yaml" config.write_text("contexts: [", encoding="utf-8") @@ -191,3 +261,75 @@ def test_invalid_tunnel_transport_is_rejected() -> None: ) ) ) + + +def test_control_plane_headers_merge_config_environment_and_options( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = tmp_path / "config.yaml" + config.write_text( + """ +version: 1 +defaults: + context: + name: local +environments: + - apiUrl: https://rstream.io + headers: + X-Environment: config + X-Shared: config +contexts: + - name: local + apiUrl: https://rstream.io + engine: engine.test:443 +""".strip(), + encoding="utf-8", + ) + monkeypatch.setenv( + "RSTREAM_CONTROL_PLANE_HEADERS", + '{"X-Runtime":"environment","X-Shared":"environment"}', + ) + resolved = asyncio.run( + resolve_client_options( + ClientOptions( + config_path=str(config), + control_plane_headers={ + "X-Explicit": "option", + "X-Shared": "option", + }, + no_token=True, + ) + ) + ) + + assert resolved.control_plane_headers == { + "X-Environment": "config", + "X-Explicit": "option", + "X-Runtime": "environment", + "X-Shared": "option", + } + + +@pytest.mark.parametrize( + "value", + [ + "not-json", + "[]", + '{"X-Test":1}', + '{"Authorization":"secret"}', + '{"X-Forwarded-Host":"example.test"}', + '{"Bad Header":"value"}', + '{"X-Test":"first","x-test":"second"}', + ], +) +def test_control_plane_headers_environment_rejects_invalid_values( + value: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("RSTREAM_CONTROL_PLANE_HEADERS", value) + + with pytest.raises(ConfigurationError, match=r"(?i)control plane header|HEADERS"): + asyncio.run( + resolve_client_options(ClientOptions(read_config_file=False, no_token=True)) + ) diff --git a/tests/unit/test_protocol.py b/tests/unit/test_protocol.py index 87860f7..70db37c 100644 --- a/tests/unit/test_protocol.py +++ b/tests/unit/test_protocol.py @@ -33,6 +33,7 @@ def test_tunnel_properties_round_trip() -> None: hostname="api.example.test", port=443, datagram_guaranteed_delivery=True, + allow_cross_region_routing=True, ) decoded = tunnel_properties_from_pb(tunnel_properties_to_pb(properties)) @@ -48,6 +49,7 @@ def test_tunnel_properties_round_trip() -> None: assert decoded.hostname == "api.example.test" assert decoded.port == 443 assert decoded.datagram_guaranteed_delivery is True + assert decoded.allow_cross_region_routing is True def test_published_tcp_properties_round_trip() -> None: @@ -56,12 +58,14 @@ def test_published_tcp_properties_round_trip() -> None: publish=True, protocol="tcp", port=10042, + allow_cross_region_routing=False, ) decoded = tunnel_properties_from_pb(tunnel_properties_to_pb(properties)) assert decoded.protocol == "tcp" assert decoded.port == 10042 + assert decoded.allow_cross_region_routing is False def test_message_encoding_prefixes_payload_length() -> None: diff --git a/tests/unit/test_tunnel.py b/tests/unit/test_tunnel.py index fcd8b79..923523f 100644 --- a/tests/unit/test_tunnel.py +++ b/tests/unit/test_tunnel.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass, field from typing import cast @@ -81,6 +82,33 @@ async def test_tunnel_close_delegates_to_control_channel() -> None: assert control.closed_tunnels == ["tun_123"] +@pytest.mark.asyncio +async def test_tunnel_close_waits_for_forwarders() -> None: + control = _ClosingControl() + tunnel = BytestreamTunnel(control, TunnelProperties(id="tun_123")) + control.tunnel = tunnel + started = asyncio.Event() + stopped = asyncio.Event() + + async def forwarder() -> None: + started.set() + try: + await asyncio.Future() + finally: + await asyncio.sleep(0) + stopped.set() + + task = asyncio.create_task(forwarder()) + tunnel._forward_tasks.add(task) + task.add_done_callback(tunnel._forward_tasks.discard) + await started.wait() + + await tunnel.close() + + assert stopped.is_set() + assert task.done() + + @pytest.mark.asyncio async def test_tunnel_accepts_delivered_stream() -> None: stream = _stream_double() @@ -141,6 +169,16 @@ async def close_tunnel(self, tunnel_id: str) -> None: self.closed_tunnels.append(tunnel_id) +@dataclass +class _ClosingControl(_Control): + tunnel: BytestreamTunnel | None = None + + async def close_tunnel(self, tunnel_id: str) -> None: + await super().close_tunnel(tunnel_id) + assert self.tunnel is not None + self.tunnel.on_close() + + class _WriterDouble: def __init__(self) -> None: self._closing = False From 7626e8dc0f815182393d423a09add57333673c31 Mon Sep 17 00:00:00 2001 From: uartnet <140632163+uartnet@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:47:38 +0200 Subject: [PATCH 2/5] fix(edge): allow cross-region routing for every protocol --- src/rstream/control.py | 5 ----- tests/integration/test_runtime_fake_engine.py | 15 ++++++--------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/rstream/control.py b/src/rstream/control.py index bde525d..ce16792 100644 --- a/src/rstream/control.py +++ b/src/rstream/control.py @@ -370,11 +370,6 @@ def _finish(self) -> None: def _normalize_bytestream_options(options: CreateTunnelOptions) -> TunnelProperties: - if options.allow_cross_region_routing is not None and options.protocol != "tcp": - raise RuntimeError( - "Cross-region routing policy requires protocol='tcp'.", - code="ERR_RSTREAM_INVALID_TUNNEL", - ) if options.port is not None and options.protocol != "tcp": raise RuntimeError( "A published port requires protocol='tcp'.", diff --git a/tests/integration/test_runtime_fake_engine.py b/tests/integration/test_runtime_fake_engine.py index 0688031..9bf521e 100644 --- a/tests/integration/test_runtime_fake_engine.py +++ b/tests/integration/test_runtime_fake_engine.py @@ -53,16 +53,13 @@ async def test_published_tcp_options_and_local_validation(tmp_path: Path) -> Non hostname="ssh.example.test", ) assert exc.value.code == "ERR_RSTREAM_INVALID_TUNNEL" - with pytest.raises( - rstream.RstreamRuntimeError, match="requires protocol='tcp'" - ) as exc: - await control.create_tunnel( - protocol="http", - allow_cross_region_routing=True, - ) - assert exc.value.code == "ERR_RSTREAM_INVALID_TUNNEL" + http_tunnel = await control.create_tunnel( + protocol="http", + allow_cross_region_routing=True, + ) + assert http_tunnel.properties.allow_cross_region_routing is True - assert engine.open_tunnel_requests == 1 + assert engine.open_tunnel_requests == 2 @pytest.mark.asyncio From fbdd5926d78ac1f7be96358d481553f3a9bf57ca Mon Sep 17 00:00:00 2001 From: uartnet <140632163+uartnet@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:48:12 +0200 Subject: [PATCH 3/5] build(deps): update Python dependencies --- docs/TUNNELS.md | 6 ++++-- pyproject.toml | 40 +++++++++++++++++++++------------------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/docs/TUNNELS.md b/docs/TUNNELS.md index 5c6eb44..edd1858 100644 --- a/docs/TUNNELS.md +++ b/docs/TUNNELS.md @@ -188,8 +188,10 @@ async with rstream.Client.from_env() as client: hostname = await client.generate_stable_hostname() async with await client.connect() as control: tunnel = await control.create_tunnel( - protocol="http", http_version="http/1.1", - publish=True, hostname=hostname, + protocol="http", + http_version="http/1.1", + publish=True, + hostname=hostname, ) ``` diff --git a/pyproject.toml b/pyproject.toml index 5f3c7b7..0529793 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,35 +25,37 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "protobuf>=6.33.4,<7", - "PyYAML>=6.0.2,<7", + "protobuf>=7.35.1,<8", + "PyYAML>=6.0.3,<7", ] [project.optional-dependencies] -api = ["httpx>=0.27,<1"] +api = ["httpx>=0.28.1,<1"] asgi = ["h11>=0.16,<1"] -realtime = ["httpx>=0.27,<1", "websockets>=13,<16"] +realtime = ["httpx>=0.28.1,<1", "websockets>=16.1.1,<17"] wsgi = ["h11>=0.16,<1"] examples = [ - "aiohttp>=3.10,<4", - "django>=5.1,<6", - "fastapi>=0.110,<1", - "flask>=3.0,<4", + "aiohttp>=3.14.3,<4", + "django>=5.2.15,<6; python_version < '3.12'", + "django>=6.0.7,<7; python_version >= '3.12'", + "fastapi>=0.139.2,<1", + "flask>=3.1.3,<4", "h11>=0.16,<1", ] dev = [ - "aiohttp>=3.10,<4", - "build>=1.2,<2", - "django>=5.1,<6", - "fastapi>=0.110,<1", - "flask>=3.0,<4", - "httpx>=0.27,<1", - "mypy>=1.13,<2", - "pytest>=8.3,<9", - "pytest-asyncio>=0.24,<1", - "ruff>=0.8,<1", + "aiohttp>=3.14.3,<4", + "build>=1.5,<2", + "django>=5.2.15,<6; python_version < '3.12'", + "django>=6.0.7,<7; python_version >= '3.12'", + "fastapi>=0.139.2,<1", + "flask>=3.1.3,<4", + "httpx>=0.28.1,<1", + "mypy>=2.3,<3", + "pytest>=9.1.1,<10", + "pytest-asyncio>=1.4,<2", + "ruff>=0.16,<1", "trustme>=1.2,<2", - "websockets>=13,<16", + "websockets>=16.1.1,<17", ] [project.urls] From b46829d97e05403cb5c4b24260a8701bc1f9b509 Mon Sep 17 00:00:00 2001 From: uartnet <140632163+uartnet@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:10:15 +0200 Subject: [PATCH 4/5] docs: organize internal documentation --- CONTRIBUTING.md | 2 +- README.md | 8 ++++---- docs/{CONFIGURATION.md => 001-configuration.md} | 0 docs/{TUNNELS.md => 002-tunnels.md} | 0 docs/{WEBHOOKS.md => 003-webhooks.md} | 0 docs/{TESTING.md => 004-testing.md} | 0 docs/{TEST_MATRIX.md => 005-test-matrix.md} | 0 docs/{GITHUB_SETUP.md => 006-github-setup.md} | 2 +- docs/README.md | 10 ++++++++++ 9 files changed, 16 insertions(+), 6 deletions(-) rename docs/{CONFIGURATION.md => 001-configuration.md} (100%) rename docs/{TUNNELS.md => 002-tunnels.md} (100%) rename docs/{WEBHOOKS.md => 003-webhooks.md} (100%) rename docs/{TESTING.md => 004-testing.md} (100%) rename docs/{TEST_MATRIX.md => 005-test-matrix.md} (100%) rename docs/{GITHUB_SETUP.md => 006-github-setup.md} (99%) create mode 100644 docs/README.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 50a68aa..f0423c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,7 +57,7 @@ Use the smallest test that exercises the behavior: - framework example tests when changing sample integrations; - opt-in e2e tests for real engines and managed environments. -See [docs/TESTING.md](docs/TESTING.md) for the e2e matrix. +See [docs/004-testing.md](docs/004-testing.md) for the e2e matrix. ## Generated code diff --git a/README.md b/README.md index 5c2c82d..f2cc410 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Configuration is resolved in this order: `RSTREAM_ENGINE_ADDRESS` is also accepted for compatibility with older local SDK workflows. Prefer `RSTREAM_ENGINE` in new code. -See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) for supported YAML fields and +See [docs/001-configuration.md](docs/001-configuration.md) for supported YAML fields and error behavior. ## FastAPI tunnel @@ -226,7 +226,7 @@ async def handle_webhook(request: Request) -> None: `event.id` is suitable for idempotency. Keep the raw request body unchanged when verifying the signature. -See [docs/WEBHOOKS.md](docs/WEBHOOKS.md) for the payload shape and headers. +See [docs/003-webhooks.md](docs/003-webhooks.md) for the payload shape and headers. ## Examples @@ -261,7 +261,7 @@ Real-engine tests are opt-in: RSTREAM_PYTHON_E2E=1 pytest tests/e2e ``` -See [docs/TESTING.md](docs/TESTING.md) for local-engine and managed-environment +See [docs/004-testing.md](docs/004-testing.md) for local-engine and managed-environment test commands. ## Repository setup and release @@ -271,7 +271,7 @@ secret for normal pull request checks. Release automation uses release-please an requires the maintainer-managed `RELEASE_PLEASE_TOKEN` secret plus the `CI_ALLOWED_ACTOR` repository variable. -See [docs/GITHUB_SETUP.md](docs/GITHUB_SETUP.md) before creating or publishing +See [docs/006-github-setup.md](docs/006-github-setup.md) before creating or publishing the repository. ## License diff --git a/docs/CONFIGURATION.md b/docs/001-configuration.md similarity index 100% rename from docs/CONFIGURATION.md rename to docs/001-configuration.md diff --git a/docs/TUNNELS.md b/docs/002-tunnels.md similarity index 100% rename from docs/TUNNELS.md rename to docs/002-tunnels.md diff --git a/docs/WEBHOOKS.md b/docs/003-webhooks.md similarity index 100% rename from docs/WEBHOOKS.md rename to docs/003-webhooks.md diff --git a/docs/TESTING.md b/docs/004-testing.md similarity index 100% rename from docs/TESTING.md rename to docs/004-testing.md diff --git a/docs/TEST_MATRIX.md b/docs/005-test-matrix.md similarity index 100% rename from docs/TEST_MATRIX.md rename to docs/005-test-matrix.md diff --git a/docs/GITHUB_SETUP.md b/docs/006-github-setup.md similarity index 99% rename from docs/GITHUB_SETUP.md rename to docs/006-github-setup.md index 602f280..d2b299b 100644 --- a/docs/GITHUB_SETUP.md +++ b/docs/006-github-setup.md @@ -103,6 +103,6 @@ pytest python -m build ``` -Also run at least one real-engine e2e command from [TESTING.md](TESTING.md) +Also run at least one real-engine e2e command from [TESTING.md](004-testing.md) against a local or managed engine and record the command in the release notes or PR description. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..761dbb2 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,10 @@ +# Python SDK documentation + +Read these documents in order: + +1. [Configuration](001-configuration.md) documents SDK configuration. +2. [Tunnels](002-tunnels.md) documents tunnel APIs and behavior. +3. [Webhooks](003-webhooks.md) documents webhook support. +4. [Testing](004-testing.md) describes local and CI validation. +5. [Test matrix](005-test-matrix.md) defines supported runtime combinations. +6. [GitHub setup](006-github-setup.md) documents repository CI configuration. From 1f7d26a7de428e6e6a32c8fac523858626927f85 Mon Sep 17 00:00:00 2001 From: uartnet <140632163+uartnet@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:21:10 +0200 Subject: [PATCH 5/5] fix(api): align project routing metadata --- src/rstream/api.py | 5 ++--- tests/unit/test_api.py | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/rstream/api.py b/src/rstream/api.py index 2f227fd..6044854 100644 --- a/src/rstream/api.py +++ b/src/rstream/api.py @@ -41,7 +41,7 @@ class TunnelsProject: url: str | None domain: str engine_port: int - placement: str = "regional" + routing: str regional_endpoints: tuple[TunnelsProjectRegionalEndpoint, ...] = () @@ -165,7 +165,6 @@ def _project_from_json(data: Mapping[str, object]) -> TunnelsProject: endpoint = _string_required(data, "endpoint") domain = _string_required(data, "domain") engine_port = _int_required(data, "enginePort") - placement = data.get("placement") url = data.get("url") return TunnelsProject( id=project_id, @@ -173,7 +172,7 @@ def _project_from_json(data: Mapping[str, object]) -> TunnelsProject: url=url if isinstance(url, str) else None, domain=domain, engine_port=engine_port, - placement=placement if isinstance(placement, str) else "regional", + routing=_string_required(data, "routing"), regional_endpoints=_regional_endpoints_from_json(data.get("regionalEndpoints")), ) diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 3c5389d..98b69f7 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -20,6 +20,7 @@ def test_engine_from_project_uses_endpoint_domain_and_port() -> None: url=None, domain="tunnels.rstream.io", engine_port=9443, + routing="regional", ) assert engine_from_project(project) == "abc123.tunnels.rstream.io:9443" @@ -32,6 +33,7 @@ def test_engine_from_project_falls_back_to_url() -> None: url="engine.example.test:443", domain="", engine_port=443, + routing="regional", ) assert engine_from_project(project) == "engine.example.test:443" @@ -44,6 +46,7 @@ def test_engine_from_project_rejects_unresolvable_project() -> None: url=None, domain="", engine_port=443, + routing="regional", ) with pytest.raises(RstreamRuntimeError, match="Failed to resolve"): @@ -57,7 +60,7 @@ def test_engine_from_project_selects_only_authorized_regions() -> None: url="abc123.global.example.test:443", domain="global.example.test", engine_port=443, - placement="global", + routing="global", regional_endpoints=( TunnelsProjectRegionalEndpoint( provider="aws", @@ -92,6 +95,7 @@ def test_engine_from_project_rejects_ambiguous_regions() -> None: url=None, domain="global.example.test", engine_port=443, + routing="global", regional_endpoints=(endpoint, endpoint), ) @@ -107,7 +111,7 @@ def test_project_from_json_normalizes_control_plane_payload() -> None: "url": "engine.example.test:443", "domain": "tunnels.rstream.io", "enginePort": 9443, - "placement": "global", + "routing": "global", "regionalEndpoints": [ { "provider": "aws", @@ -124,7 +128,7 @@ def test_project_from_json_normalizes_control_plane_payload() -> None: assert project.url == "engine.example.test:443" assert project.domain == "tunnels.rstream.io" assert project.engine_port == 9443 - assert project.placement == "global" + assert project.routing == "global" assert project.regional_endpoints[0].region == "eu-west-3" @@ -141,6 +145,13 @@ def test_project_from_json_normalizes_control_plane_payload() -> None: "endpoint": "abc", "domain": "example.test", "enginePort": 70_000, + "routing": "regional", + }, + { + "id": "project", + "endpoint": "abc", + "domain": "example.test", + "enginePort": 443, }, ], )