Aether's proxy feature lets any connected principal reach a sibling service's HTTP API tunneled through the existing gRPC stream. You get encryption, identity propagation, OBO authority delegation, ACL enforcement, and audit logging with no extra infrastructure.
Two capabilities ship today:
| Feature | What it does |
|---|---|
| REST proxy | Tunnels a complete HTTP request/response pair over the Aether stream. Good for standard REST APIs (JSON, binary uploads, etc.). |
| TCP/WebSocket/UDP tunnels | Open a raw byte-stream tunnel between two Aether principals. TCP, WebSocket, and UDP backends are all implemented in the terminator sidecar — see Tunnels. |
Why not just call the backend directly? Three reasons:
- Zero network exposure. The backend never needs an outbound route or an open port visible to the caller. Both sides are behind the Aether gateway.
- Identity and OBO for free. Every proxied request carries the caller's
verified identity and any On-Behalf-Of grant. The sidecar mints
X-Auth-*headers from the same library as the auth-proxy, so the backend's auth logic is unchanged. - Audit for free. Every proxied request writes
proxy_http_routed(orproxy_http_failed) audit events with full grant lineage, resolved instance, and byte counts — no custom instrumentation needed.
┌─────────────────┐ ┌────────────────────┐ ┌─────────────────────┐
│ Caller-side │ │ Aether Gateway │ │ Service-side │
│ initiator │ │ (router only — │ │ terminator sidecar │
│ sidecar │ │ no HTTP term.) │ │ sv::memorylayer │
│ │ │ │ │ ::default │
│ localhost:8888 │ gRPC │ │ gRPC │ │
│ HTTP listener │◄────►│ ProxyHttp* / │◄────►│ X-Auth-* mint lib │
│ (curl, legacy) │ │ Tunnel* routes │ │ ┌────────────────┐ │
│ │ │ │ │ │ HTTP localhost │ │
│ X-Auth-* mint │ └────────────────────┘ │ └──────┬─────────┘ │
│ lib (optional) │ │ │ │
└────────┬────────┘ └─────────┼───────────┘
│ │
│ (or in-process via SDK adapter) ▼
┌────────┴────────┐ ┌─────────────────────┐
│ Caller agent │ ┌────────────────────┐ │ memorylayer │
│ Go/Py/TS SDK │ gRPC │ Aether Gateway │ │ (HTTP server) │
│ proxy_http() │◄────►│ │ └─────────────────────┘
└─────────────────┘ └────────────────────┘
The gateway is a pure router. It never terminates HTTP or TCP. All proxying happens at endpoints — either via a sidecar binary or in-process via SDK adapters. This is a hard architectural rule; it keeps the gateway's security boundary simple and audit accounting straightforward.
Proxy requests target a service principal topic, not an arbitrary agent or user. Two forms are accepted:
| Form | Example | Meaning |
|---|---|---|
| Specific instance | sv::memorylayer::default |
Route to exactly this connected instance. Returns SIDECAR_UNAVAILABLE if offline. |
| Wildcard | sv::memorylayer |
Route to any healthy connected instance of this implementation. |
The bare sv::{impl} wildcard is only accepted inside ProxyHttpRequest
and TunnelOpen. Plain SendMessage to a bare sv::impl topic is still
rejected as malformed.
- The gateway checks its local
identityIndexfirst (O(1), prefers the same gateway instance that holds the service connection). - Falls back to a cluster-wide Redis lock scan, filtering out instances whose lock TTL has decayed below 5 s (considered unhealthy).
- Picks uniformly at random from the survivor set.
REST proxy: resolution is per-request — each ProxyHttpRequest may land
on a different instance.
Tunnels: TunnelOpen resolves once and pins the result in Redis.
Subsequent TunnelData / TunnelClose frames are always routed to the same
instance (sticky).
The proxy-sidecar binary is composed of three independent surfaces. Each is
gated by an enabled: true flag in its YAML section; any combination can run
together in one process over a single shared gateway connection (one Aether
identity, one lock).
| Surface | When to use |
|---|---|
terminator |
Expose a local HTTP/TCP/WS/UDP backend to the Aether network. |
initiator |
Forward local HTTP traffic to a remote service topic (no SDK required). |
relay |
Give a credential-free sandbox process a filtered view of the gateway over UDS. |
To run multiple surfaces in one process, enable each section. Sandboxes
typically enable both terminator and relay so the same sidecar receives
inbound requests for the local service AND mediates the sandbox's outbound
gRPC traffic. See proxy-sandbox.md and
server/configs/proxy-sidecar.sandbox.example.yaml for the full pattern.
The terminator connects to the Aether gateway as the service principal
(sv::{impl}::{spec}), receives incoming ProxyHttpRequest envelopes, and
forwards them to a local HTTP backend.
Use this when you want to expose an existing HTTP service over Aether without modifying its code.
# proxy-sidecar.yaml — terminator surface
gateway:
address: gateway.example.com:50051
insecure: false
api_key_path: /etc/aether/sidecar.key
tls:
cert_file: /etc/aether/tls/cert.pem
key_file: /etc/aether/tls/key.pem
ca_file: /etc/aether/tls/ca.pem
# Identity registered with the gateway.
service:
implementation: memorylayer
specifier: default
# Tenant ID injected as X-Auth-Tenant-ID (header_mode: strict | both).
tenant_id: prod-tenant
terminator:
enabled: true
backends:
- name: default
kind: http
url: http://localhost:61001
allow_paths:
- "/v1/*"
- "/healthz"
allow_methods:
- GET
- POST
- PUT
- DELETE
max_body_bytes: 10485760 # 10 MiB
idle_timeout_ms: 30000
header_mode: strict # strict | passthrough | both
logging:
level: info
format: jsonThe initiator exposes a local HTTP listener. Requests to that listener are
translated into ProxyHttpRequest envelopes sent to the configured target
topic. Unmodified callers (curl, legacy scripts, third-party tools) redirect
by changing only a base URL — no code changes, no Aether SDK required.
Use this when you cannot modify the caller but can change its target hostname.
# proxy-sidecar.yaml — initiator surface
gateway:
address: gateway.example.com:50051
insecure: false
api_key_path: /etc/aether/sidecar.key
initiator:
enabled: true
listen:
bind: localhost:8888 # local port your caller points at
target:
topic: sv::memorylayer::default # or wildcard: sv::memorylayer
logging:
level: info
format: jsonThe relay binds a local gRPC server (UDS or TCP) that an untrusted sandbox process dials with no credentials. The relay injects the sidecar's own API key and identity before forwarding each envelope to the real gateway, enforcing an operation allow-list and a target-topic clamp.
Use this when you spawn short-lived agents or tool-runner sandboxes that must not hold API keys or TLS certificates.
# proxy-sidecar.yaml — relay surface (standalone)
gateway:
address: gateway.example.com:50051
api_key_path: /etc/aether/sidecar.key
service:
implementation: toolrunner
specifier: default
relay:
enabled: true
listen: unix:///run/aether.sock # sandbox dials this socket
identity_override: enforce # discard sandbox-claimed identity
allowed_ops:
profile: sandbox-default # SendMessage, ProgressReport, KVOperation
target_topic_clamp:
mode: reject
allowed_targets:
- "ag.prod-workspace.orchestrator.*"
logging:
level: info
format: jsonEnabling terminator.enabled: true alongside relay.enabled: true runs both
surfaces over the same gateway connection. See
proxy-sandbox.md and
server/configs/proxy-sidecar.sandbox.example.yaml for a fully annotated
example.
The kind field in each backend entry controls which protocol the terminator
uses to reach the local service:
| Kind | Protocol | Notes |
|---|---|---|
http |
HTTP/1.1 | Default. Supports allow_paths, allow_methods, header_mode. |
tcp |
Raw TCP | url is host:port or tcp://host:port. Default max_bytes: 100 MiB per tunnel. |
ws |
WebSocket | url is ws:// or wss://. Same flow-control as TCP. |
udp |
UDP datagram | url is host:port or udp://host:port. Default max_datagram_bytes: 1400. |
Controls how the terminator handles Authorization / X-Auth-* headers
before forwarding to the local backend.
| Value | Behaviour |
|---|---|
strict |
Strip Authorization and all X-Auth-* from the inbound request. Mint fresh X-Auth-* headers from the OBO grant in ProxyHttpRequest.authorization. Default; recommended for most services. |
passthrough |
Forward caller-supplied headers unchanged. No minting. Use only when the backend has its own auth layer and you trust the caller's headers. |
both |
Mint X-Auth-* headers AND preserve caller-supplied headers. Minted values override any collisions. |
The proxy transport adapters let you redirect an existing HTTP client to Aether with a single line. The host portion of the URL is ignored — only the path and query string are forwarded.
import httpx
from scitrera_aether_client.httpx_transport import AetherHTTPXTransport
# aether_client is your connected sync AetherClient
transport = AetherHTTPXTransport(aether_client, "sv::memorylayer::default")
with httpx.Client(transport=transport) as http:
resp = http.get("http://ignored/v1/memories/abc")Async variant:
from scitrera_aether_client.httpx_transport import AetherAsyncHTTPXTransport
transport = AetherAsyncHTTPXTransport(aether_client, "sv::memorylayer::default")
async with httpx.AsyncClient(transport=transport) as http:
resp = await http.get("http://ignored/v1/memories/abc")import requests
from scitrera_aether_client.requests_adapter import AetherRequestsAdapter
session = requests.Session()
session.mount("aether+sv://", AetherRequestsAdapter(aether_client))
# Specific instance (impl + specifier separated by --)
session.get("aether+sv://memorylayer--default/v1/memories/abc")
# Wildcard / load-balanced (any healthy memorylayer instance)
session.get("aether+sv://memorylayer/v1/memories/abc")The -- delimiter in the netloc maps to :: in the Aether topic
(memorylayer--default → sv::memorylayer::default). If impl or specifier
contains a literal --, URL-encode it as %2D%2D. ProxyError surfaces as
requests.exceptions.ConnectionError.
import (
"net/http"
"github.com/scitrera/aether/sdk/go/aether"
)
// agentClient is your connected *aether.BaseClient (or any principal client)
rt := &aether.AetherRoundTripper{
Client: agentClient,
Target: "sv::memorylayer::default",
}
httpClient := &http.Client{Transport: rt}
resp, err := httpClient.Get("http://ignored/v1/memories/abc")OBO authorization from a Go context.Context is forwarded automatically:
ctx = aether.WithOBOAuthorization(ctx, authContext)
req, _ = http.NewRequestWithContext(ctx, "GET", "http://ignored/v1/memories/abc", nil)
resp, err = httpClient.Do(req)import {AetherFetchTransport} from "@scitrera/aether-client";
// agentClient is your connected AetherClient
const transport = new AetherFetchTransport(agentClient, "sv::memorylayer::default");
const resp = await transport.fetch("/v1/memories/abc");AetherFetchTransport.fetch() accepts the same signature as the Web Fetch API
(string | URL | Request, optional RequestInit). The hostname and protocol
of the URL are ignored.
The gateway applies the standard ACL check before routing a proxy envelope.
The caller must have send permission for the target service principal's
workspace, just as with SendMessage.
On-Behalf-Of (OBO) authorization is carried inside
ProxyHttpRequest.authorization (AuthorizationContext):
| Field | Purpose |
|---|---|
authority_mode |
"direct" — caller acts as itself; "obo" — caller acts on behalf of subject. |
subject.principal_type / subject.principal_id |
The end-user or downstream principal being acted for. |
grant_id |
ID of a pre-established authority grant. |
The terminator sidecar resolves the OBO context and mints X-Auth-* headers
using the same server/pkg/identityheaders library used by the auth-proxy.
This is the single source of truth for identity header minting — the
backend sees identical headers regardless of whether the request arrived via
the auth-proxy path or the proxy-sidecar path.
Configured under the proxy key in the gateway config (all have sensible
defaults).
| Config field | Default | Description |
|---|---|---|
proxy.max_request_body_bytes |
8 MiB | Maximum total body size for a single inline ProxyHttpRequest envelope. Bodies larger than this are streamed as ProxyHttpBodyChunk frames and bounded only by the per-backend max_body_bytes (default 10 MiB). |
proxy.max_concurrent_tunnels_per_workspace |
256 | Gateway-wide live-tunnel ceiling per workspace. |
proxy.max_tunnel_bytes |
0 (unlimited) | Cumulative byte cap per tunnel session. |
Bodies that exceed 256 KB are automatically split into ProxyHttpBodyChunk
frames by the SDK adapters, routed through the gateway via a per-request pin,
and reassembled by the terminator before dispatch. The same chunked-streaming
mechanism applies in the response direction. This is transparent to
application code; the effective request-body ceiling is the per-backend
max_body_bytes.
Every proxy operation writes to the audit log.
Event (operation) |
When it fires |
|---|---|
proxy_http_routed |
Request successfully forwarded to the target sidecar. |
proxy_http_failed |
Request could not be delivered (ACL denied, sidecar offline, payload too large, etc.). |
proxy_http_stream_closed |
Streaming response finished (clean EOF, idle timeout, byte cap, or caller cancel). Only fires for stream_response_indefinitely=true requests. |
tunnel_opened |
TunnelOpen successfully established and pinned. |
tunnel_open_failed |
TunnelOpen rejected (ACL, quota, no healthy instance). |
tunnel_closed |
Tunnel torn down (client close, stream disconnect, or pin expiry). |
Each event includes:
- Caller identity — the principal that sent the proxy envelope.
- Resolved target — the concrete
sv::{impl}::{spec}after wildcard resolution (never the bare wildcard form). - Grant lineage —
grant_idandauthority_modefromAuthorizationContext, enabling full OBO chain reconstruction. - Byte counts — request and response body sizes (for
proxy_http_routed); cumulative bytes transferred (fortunnel_closed).
ProxyError.kind values returned in ProxyHttpResponse.error:
| Kind | Meaning | Retry? |
|---|---|---|
UNKNOWN |
Catch-all for unclassified errors. | No |
DIAL_FAILED |
Sidecar could not connect to the local backend. Check the backend is running and backends[].url is correct. |
No (check config) |
TIMEOUT |
Request exceeded timeout_ms. |
Yes, if idempotent |
UPSTREAM_RESET |
Backend closed the connection mid-response. | No |
ACL_DENIED |
Caller does not have permission to reach this target. | No |
SIDECAR_UNAVAILABLE |
No healthy service instance for the target topic. | Yes, after backoff |
PAYLOAD_TOO_LARGE |
Body exceeds proxy.max_request_body_bytes. |
No (reduce payload) |
DECODE_FAILED |
Gateway could not decode the proxy envelope. | No |
Retry policy: the gateway never retries on the caller's behalf. SDK
adapters (AetherRoundTripper, AetherHTTPXTransport, AetherFetchTransport)
do not retry automatically. Callers may retry TIMEOUT and
SIDECAR_UNAVAILABLE on idempotent requests; other kinds indicate a
configuration or permission issue and should not be retried.
TCP, WebSocket, and UDP tunnel support is proto-complete and gateway-routed
(TunnelOpen / TunnelData / TunnelClose / TunnelAck messages exist in
api/proto/aether.proto and are dispatched by server/internal/gateway/routing_proxy.go).
What is shipped:
- Gateway wildcard resolution and tunnel-pin stickiness (Redis-pinned per
tunnel_id) - Sidecar TCP, WebSocket (
ws:///wss://), and UDP backends withTunnelAckflow control - End-to-end TCP echo integration tests (see proxy-quickstart.md)
Future work:
- Go SDK
TunnelDial, TypeScript SDKtunnelDial, Pythontunnel_dial— higher-level SDK surface (raw proto access works today)
The TunnelOpen wire format includes a session_token field reserved for
tunnel resume across stream reconnects (not yet implemented).
Tunnels differ from REST proxy in one key way: they are sticky. Once
TunnelOpen resolves a wildcard to a concrete instance, all subsequent frames
for that tunnel_id go to the same instance for the lifetime of the tunnel.
Tunnels are torn down on stream disconnect.
A single terminator sidecar can serve multiple local HTTP (or tunnel) backends.
The sidecar walks the backends list and selects a backend using one of two
strategies.
HTTP backends — first-match-by-ACL (implicit)
When ProxyHttpRequest.backend_name is absent the sidecar walks backends in
declaration order and picks the first entry whose allow_paths glob and
allow_methods list both match the incoming request. The last backend in the
list is conventionally a catch-all (allow_paths: ["/*"]).
HTTP backends — explicit backend_name
When ProxyHttpRequest.backend_name is set the sidecar looks up that backend
by BackendConfig.Name directly and skips the glob walk. The named backend's
allow_paths / allow_methods still apply (defence in depth) — backend_name
selects which ACL to consult, it does not bypass it.
BackendConfig.Name is operator-visible only (logs, metrics) unless
backend_name is set explicitly by the caller.
Tunnel backends — first-match-by-remote_hint (implicit)
For TunnelOpen, the sidecar matches remote_hint against each backend's
allow_remote_hints glob list in order. remote_hint is a caller-supplied
string that identifies the intended tunnel target (e.g. "db-primary",
"cache-1"). The first backend whose glob matches wins.
Tunnel backends — explicit backend_name
Set TunnelOpen.backend_name to bypass the glob walk. The named backend's
allow_remote_hints still applies.
The recommended pattern is to declare backends from most-specific to least-specific and terminate with a catch-all:
backends:
- name: api-v1
kind: http
url: http://localhost:61001
allow_paths: [ "/v1/*", "/healthz" ]
allow_methods: [ GET, POST ]
- name: api-v2
kind: http
url: http://localhost:61002
allow_paths: [ "/v2/*" ]
allow_methods: [ GET, POST, PUT, PATCH, DELETE ]
- name: default # catch-all — matches anything not claimed above
kind: http
url: http://localhost:61003
allow_paths: [ "/*" ]
allow_methods: [ GET, POST, PUT, PATCH, DELETE ]A full three-backend example with inline comments is in
server/configs/proxy-sidecar.multi-backend.example.yaml.
Callers do not need to set backend_name to benefit from path-prefix routing;
the first-match-by-ACL rule handles it automatically based on the request path.
Python — httpx
import httpx
from scitrera_aether_client.httpx_transport import AetherHTTPXTransport
transport = AetherHTTPXTransport(aether_client, "sv::memorylayer::default")
with httpx.Client(transport=transport) as http:
# Path "/v1/memories/abc" → sidecar matches api-v1 backend (first match)
resp_v1 = http.get("http://ignored/v1/memories/abc")
# Path "/v2/memories/abc" → sidecar matches api-v2 backend
resp_v2 = http.get("http://ignored/v2/memories/abc")Go
rt := &aether.AetherRoundTripper{
Client: agentClient,
Target: "sv::memorylayer::default",
}
httpClient := &http.Client{Transport: rt}
// "/v1/..." → api-v1 backend (first-match-by-ACL, implicit)
resp, err := httpClient.Get("http://ignored/v1/memories/abc")
// "/v2/..." → api-v2 backend
resp, err = httpClient.Get("http://ignored/v2/memories/abc")TypeScript
const transport = new AetherFetchTransport(agentClient, "sv::memorylayer::default");
// "/v1/..." → api-v1 backend (first-match-by-ACL, implicit)
const respV1 = await transport.fetch("/v1/memories/abc");
// "/v2/..." → api-v2 backend
const respV2 = await transport.fetch("/v2/memories/abc");When the caller needs to target a specific backend regardless of path, set
backend_name directly on the proto request. This is currently a Go-only
low-level API (the higher-level SDK adapters do not expose backend_name yet):
import (
"net/http"
pb "github.com/scitrera/aether/api/proto"
"github.com/scitrera/aether/sdk/go/aether"
)
// Build the ProxyHttpRequest manually to set backend_name.
req := &pb.ProxyHttpRequest{
RequestId: agentClient.NextRequestID(),
TargetTopic: "sv::memorylayer::default",
Method: http.MethodGet,
Path: "/v1/memories/abc",
BackendName: "api-v1", // explicit — skips glob walk on the sidecar
}remote_hint is a caller-supplied string passed in TunnelOpen that the
sidecar matches against each backend's allow_remote_hints glob list. It lets
a single terminator expose multiple tunnel backends (e.g. distinct databases or
cache nodes) while the operator controls which callers can reach which target
via ACL.
Example sidecar config for two tunnel backends:
backends:
- name: db-primary
kind: tcp
url: tcp://db-primary.internal:5432
allow_remote_hints:
- "db-primary"
- "db-*"
- name: cache
kind: tcp
url: tcp://cache.internal:6379
allow_remote_hints:
- "cache-*"A caller sets remote_hint: "db-primary" in TunnelOpen; the sidecar selects
the db-primary backend automatically. To override the glob walk, set
TunnelOpen.backend_name = "db-primary" explicitly (the named backend's
allow_remote_hints still applies).
| Transport | Implicit (first-match) | Explicit backend_name |
|---|---|---|
| REST proxy (HTTP) | All SDKs | Go proto API (typed BackendName field) |
| Tunnels | All SDKs (via remote_hint) |
Go proto API (typed BackendName field) |
Higher-level SDK surface for backend_name (Python proxy_http, TS
AetherFetchTransport) is planned for a future release.
By default the terminator buffers the entire backend response body before
forwarding it to the caller. For SSE feeds, long-poll endpoints, or any
response where the backend sends data incrementally, set
stream_response_indefinitely = true on the ProxyHttpRequest.
When enabled:
- The terminator begins forwarding
ProxyHttpBodyChunkframes to the caller as soon as the backend produces bytes — no buffering. timeout_mson the request governs time-to-first-byte only. After the first byte arrives, the connection stays open until one of the streaming termination conditions below fires.- The stream terminates when:
- The backend sends EOF (clean
fin=trueframe). - No bytes flow for
stream_idle_timeout_msmilliseconds — default 30 s (streamIdleTimeoutDefaultconstant interminator.go). - Total response bytes exceed
max_response_body_bytes— default 0 (unlimited). Set a non-zero value to cap runaway streams. - The caller's gRPC stream disconnects.
- The backend sends EOF (clean
Each termination emits a proxy_http_stream_closed audit event with the
final byte count and close reason.
| Field | Type | Default | Purpose |
|---|---|---|---|
stream_response_indefinitely |
bool |
false |
Opt-in to the streaming path. If false, the buffered path is used. |
stream_idle_timeout_ms |
int64 |
30 000 ms | Idle-byte deadline. Set 0 to use the default. |
max_response_body_bytes |
int64 |
0 (no cap) | Hard byte cap across the full stream. Triggers PAYLOAD_TOO_LARGE. |
The AetherHTTPXTransport does not expose these fields directly today. Use
the low-level proxy_http / proxy_http_async helpers with a hand-built
ProxyHttpRequest to opt in:
import asyncio
from scitrera_aether_client.proxy import proxy_http_async
import aether_pb2 as pb
req = pb.ProxyHttpRequest(
request_id=client.next_request_id(),
target_topic="sv::memorylayer::default",
method="GET",
path="/v1/events/stream",
stream_response_indefinitely=True,
stream_idle_timeout_ms=60_000, # 60 s idle deadline
)
async for chunk in proxy_http_async(client, req):
print(chunk)Standard ACL grants allow broad send permission to a target topic. Two
finer-grained resource scopes let operators restrict which HTTP paths or
tunnel targets a given grant may reach.
Set the resource_scope key proxy_path on an AuthorityGrant to restrict
which backend + HTTP method + path combinations the grant holder may invoke.
Pattern grammar: <backend_glob>::<method_glob> <path_glob>
<backend_glob>— matched against the resolvedBackendConfig.Name(e.g.api-v1,*,api-*).<method_glob>— matched against the HTTP method (upper-cased). Use*for any method.<path_glob>— matched against the request path viapath.Matchsemantics.
A literal "*" pattern (no ::) is shorthand for "match anything".
If the proxy_path key is absent from the grant's resource scope, all paths
are allowed (no restriction). This is backward-compatible — existing grants
without this key are unaffected.
Examples:
| Pattern | Allows |
|---|---|
* |
Any backend, method, and path |
api-v1::GET /v1/* |
GET under /v1/ on the api-v1 backend only |
*::GET /healthz |
GET /healthz on any backend |
api-*::* /v2/* |
Any method under /v2/ on any api-* backend |
The _default backend name is used when the request arrives via the
auth-proxy path (not the proxy sidecar), keeping grant patterns portable
between the two components.
Implementation: server/pkg/identityheaders.MatchProxyPath /
ResourceTypeProxyPath = "proxy_path".
Set the resource_scope key tunnel_target on an AuthorityGrant to
restrict which backend + protocol + remote-hint combinations the grant holder
may open a tunnel to.
Pattern grammar: <backend_glob>::<protocol_glob> <remote_hint_glob>
<backend_glob>— matched against the resolvedBackendConfig.Name.<protocol_glob>— matched against the tunnel protocol (tcp,udp,ws, always lower-cased).<remote_hint_glob>— matched against the caller-suppliedremote_hintstring inTunnelOpen.
A literal "*" pattern is shorthand for "match anything". An absent
tunnel_target key means all tunnels are allowed.
Examples:
| Pattern | Allows |
|---|---|
* |
Any backend, protocol, and remote hint |
db-primary::tcp db-primary |
TCP tunnel to db-primary hint on the db-primary backend |
*::tcp prod-*:5432 |
TCP tunnel to any prod-*:5432 hint on any backend |
cache::tcp cache-* |
TCP tunnel to any cache-* hint on the cache backend |
Implementation: server/pkg/identityheaders.MatchTunnelTarget /
ResourceTypeTunnelTarget = "tunnel_target".
The terminator sidecar supports zero-downtime backend reconfiguration via
SIGHUP. Send the signal to the running process and it will:
- Re-read the YAML file that was passed via
--configat startup. - Validate the new config (same rules as startup validation).
- Atomically swap the backend slices under a write lock — in-flight requests keep their originally-captured backend reference and finish naturally.
- Log a
"terminator: config reloaded"info message with old/new backend counts.
kill -HUP $(pidof proxy-sidecar)
# or, if you know the PID:
kill -HUP <pid>What you can change on reload:
- Add, remove, or reconfigure any backend (HTTP, TCP, WS, UDP).
- Change
allow_paths,allow_methods,header_mode,max_body_bytes, etc. - Change gateway credentials or TLS paths (takes effect on the next reconnect, not the current live connection).
What is rejected on reload:
| Condition | Behaviour |
|---|---|
| Config file not found or unreadable | Log error, keep old config |
| Invalid YAML or failed validation | Log error, keep old config |
Mode change (terminator → initiator) |
Log error, keep old config |
| Second SIGHUP while reload is in progress | Silently dropped (no queuing) |
In-flight tunnel safety: Tunnels that are alive at the time of the swap hold a direct pointer to their backend struct, which is not freed until all references drop. New tunnels after the swap will route against the new backend list; existing tunnels are unaffected.
Dev-defaults mode: When the sidecar starts with --dev and no config file
is found, cfgPath is empty and SIGHUP is a no-op (the error is logged). To
enable live reload, always provide an explicit --config path.
When the caller and the target sidecar are connected to the same gateway instance, the gateway can deliver bulk data-plane bytes directly between the two gRPC streams in-process, skipping the RabbitMQ round-trip. This materially cuts per-frame latency and load on the message broker for the common single-gateway / co-located deployments.
Which envelopes take the bypass
Only data-plane envelopes — bytes-only payloads that are not audited per-frame — are eligible:
| Envelope | Bypass eligible? |
|---|---|
TunnelData |
Yes |
TunnelAck |
Yes |
ProxyHttpBodyChunk |
Yes |
TunnelOpen |
No — always RMQ |
TunnelClose |
No — always RMQ |
ProxyHttpRequest (header) |
No — always RMQ |
ProxyHttpResponse (header) |
No — always RMQ |
ProxyError |
No — always RMQ |
Audit invariant. Control-plane envelopes carry the audit signal
(tunnel_opened, proxy_http_routed, etc.). They ALWAYS travel through
RabbitMQ regardless of co-location, so audit emission is preserved exactly
as in the cross-gateway case. The bypass affects byte routing only.
Backpressure parity. When the target session's outbound delivery buffer is full, the bypass falls back to RMQ rather than stalling routing. This matches the existing slow-reader behavior of the RMQ fan-out path: a slow sidecar absorbs pressure via the broker's persisted log, not by blocking the routing goroutine.
Configuration
Enabled by default. To disable (e.g. emergency rollback during an incident):
quotas:
proxy:
local_bypass_enabled: falseOr via environment override (no restart needed for new connections):
export AETHER_PROXY_LOCAL_BYPASS_DISABLED=1Observability
The Prometheus counter aether_proxy_local_bypass_total{envelope_type, result}
exposes per-envelope outcomes:
| Result | Meaning |
|---|---|
hit |
Bypass succeeded; bytes delivered locally without touching RMQ. |
rmq_fallback |
Target not connected to this gateway; routed via RMQ. |
full_buffer |
Target's deliveryCh was full; routed via RMQ to avoid stalling. |
disabled |
Bypass turned off by config or env override. |
For the broader roadmap of routing-layer optimizations and the design rationale behind the single-node bypass, see proxy-architecture-roadmap.md.
Two additional headers are stamped on every proxied request that reaches a backend via the terminator sidecar or the auth-proxy direct path. They let sandboxed backends discover who called them — so they can call back — without a separate lookup.
| Header | Value | Trust |
|---|---|---|
X-Aether-Caller-Topic |
Sender's principal topic (e.g. ag.ws.myagent.spec or a user/service ID) |
Server-stamped — trustworthy |
X-Aether-Caller-Subject |
OBO grant subject's ID when authority_mode=on_behalf_of; absent in direct mode |
Server-stamped — trustworthy |
Source of truth:
- Terminator sidecar path:
X-Aether-Caller-Topicis taken from thex-aether-actor-topicfield that the gateway stamps on everyProxyHttpRequestenvelope before forwarding.X-Aether-Caller-Subjectis derived from the resolved OBO authority (after grant validation). - Auth-proxy direct path:
X-Aether-Caller-Topicequals the authenticated principal's ID.X-Aether-Caller-Subjectequals the OBO subject's ID when a grant was resolved; absent otherwise.
Both components use server/pkg/identityheaders.MintInto to stamp these
headers, so the wire format is identical regardless of which path the request
arrived through.
Spoofing prevention: Both headers are in the X-Aether-* namespace and
are stripped by identityheaders.StripInbound on every inbound request before
any trusted minting occurs. Clients cannot inject these headers.
The proxy sidecar can enable both the terminator and relay surfaces in one process to create a secure sandbox environment: an untrusted spawned process (LLM agent, tool runner, ephemeral container) dials a local Unix Domain Socket with no credentials. The sidecar injects its own API key and identity, enforces a strict operation allow-list and target-topic clamp, and forwards permitted envelopes upstream.
The sandbox holds zero credentials. The UDS path (/run/aether.sock by
convention) gates access by filesystem permissions. If the sandbox is
compromised, the attacker gains only what the relay's allow-list and topic clamp
permit — not free access to the gateway.
See proxy-sandbox.md for the full deployment pattern,
UDS path convention, SDK config snippets, spawn-time grant scopes
(proxy_path, tunnel_target), and the annotated example YAML at
server/configs/proxy-sidecar.sandbox.example.yaml.
- proxy-quickstart.md — running the sidecar, integration tests, and auth-proxy regression suite
- proxy-cutover.md — production rollout criteria, SLOs, rollback steps, and runbook
- proxy-load-test-results.md — routing-layer benchmark results and scope caveats
- proxy-architecture-roadmap.md — phased plan for proxy/tunnel routing-layer evolution
- proxy-sandbox.md — sandbox deployment pattern (relay+terminator, UDS, grant scopes)