Skip to content

Embedded client TCP tunnel support - #51

Open
jagerman wants to merge 11 commits into
session-foundation:devfrom
jagerman:embedded-tcp-tunnel
Open

Embedded client TCP tunnel support#51
jagerman wants to merge 11 commits into
session-foundation:devfrom
jagerman:embedded-tcp-tunnel

Conversation

@jagerman

Copy link
Copy Markdown
Member

This PR adds TCP tunnel support for embedded clients. It deliberately only implements embedded -> full client tunnels (no embedded-to-embedded, client-to-embedded, or embedded-to-relay). An embedded device as a TCP recipient could be useful, but isn't worth creating an API for until there is an actual use case, and realistically the only thing you would access on a relay is the storage server, which is available over quic (UDP) already.

Also fixes a minor potential issue in the UDP tunnel mapping if the actual tunnel construction raised an exception that would leave a stale entry.

This relies on

This also includes a minor vaguely related commits:

  • changes the default client log level to *=info, quic=warning because quic info is too chatty; this also fixes an unexpected behaviour where level=tcp=debug in the config file was ignoring the default and just setting everything to info. (Now whatever you specify in the config applies on top of the default).

Requires session-foundation/libquic#15 (for null crypto).

map_udp_remote_port auto-vivified the _udp_handles entry and incremented
holders before constructing the socket, so a throwing constructor left an
entry behind with a holder that nothing could release: the exception
propagates out, no claim is returned, and the count never comes back down.
A later call for the same remote:port then saw a null socket, counted to
two, and constructed again.

Construct the socket first and only insert and count once it exists.
TCPHandle listened on IPv4 INADDR_ANY, which exposed every mapped tunnel
port to the local network and contradicted the documented contract of
handing back a port on [::1]; it also passed sizeof(sockaddr) as the
socklen for a sockaddr_in.  Bind [::1] via quic::Address instead.

Close handling could not distinguish an orderly shutdown from a failure:
both EOF and error closed the whole stream, which truncates a transfer
silently in the EOF case.  A clean EOF now sends FIN and keeps receiving,
so a half-close stays half-closed; an error closes the stream with an
error code, distinguishing "could not connect at all" from "connection
broke" so the far end can tell them apart; and a received FIN shuts down
the write side only once queued output has drained.

Backpressure now uses the stream watermark and pause/resume APIs, which
did not exist when the previous chunking loop was written.  A bufferevent
output buffer grows without limit and so signals nothing by itself, so
the stream is paused once the application falls behind and resumed from
the write callback.

Also removes the dead surface (make_client, the empty _init_client, the
undefined on_stream_data declaration, a forward declaration of a class
that does not exist) and reworks TCPHandle::connect, which took a raw
event_base and a source address it used as the destination, was IPv4
only, and never installed bufferevent callbacks at all.
The tunnel was 230 lines of struct sitting in session.cpp with its
accepting half disabled behind #if 0, referencing APIs that no longer
exist (set_stream_data_cb, and a Router::loop member access missing its
call parens), so it could not have compiled if simply re-enabled.

The accepting side now reads the two-byte destination port from the head
of each stream and connects to the tun address.  Handing the stream to a
TCPConnection installs a new data callback, which cannot be done from
inside the callback being replaced -- that assigns over the std::function
currently executing -- so the connect is deferred to the next loop
iteration and anything arriving meanwhile is buffered and written ahead
of the socket's own traffic.  Only a client with a tun device can
terminate a stream locally, so only such a client listens.

The tunnel is now built on first use rather than in both Session
constructors, which gave every session a QUIC endpoint it would most
likely never use.

Connections are tracked by an id of our own rather than by stream id: a
stream opened past the peer's stream limit is pending and has no id
assigned yet, and those placeholder ids are not distinct, so keying on
them silently dropped connections once more than 32 were open at once.

The connection is capped at the QUIC minimum packet size, raises the
stream limit well above the default of 32 (one stream per TCP
connection), and holds itself open with keep-alives so that a merely
idle TCP connection is not killed; the connection is instead torn down a
minute after its last stream goes away, so a dormant tunnel costs no
path traffic.
Only a client with a tun device can terminate a tunnelled stream into a
local TCP connection, so an initiator has no way to tell whether a
mapping it makes could ever carry anything.  Advertise the capability so
it can refuse up front instead of waiting to fail.

This takes a new bit rather than reusing 1.0.x's QUIC_TUNNEL (1 << 1),
which advertised the opposite role -- "I am embedded, so reach me via a
tunnel" -- and was therefore set by exactly the clients that cannot
accept one.  Bit 0 (1.0.x EXIT) is likewise still set in the wild.

Session::remote_accepts_tcp reports it as an optional<bool> because
"no client contact yet" and "remote says no" call for different
behaviour from a caller: the former is worth attempting, the latter
never will be.
The TCP counterpart of map_udp_remote_port, sharing its holder-count
semantics: asking for a remote and port that is already mapped hands
back the same listener, and the mapping is torn down by the last holder
to release it.

The listener lives here rather than on the session because it has to
outlive session churn -- the tunnel beneath it can be reset by a dead
path, an idle teardown, or a session timing out, none of which should
change the port an application was handed.  Each accepted connection
re-obtains the session, as the UDP receive path does per datagram, so a
mapping re-establishes itself on next use.

Releasing the last holder closes the connections established through the
mapping as well as the listener: holding a claim is what keeps a tunnel
alive, so nothing is left running once the last one goes.

A remote already known not to accept tunnelled TCP is refused without
binding a port, and re-checked per connection, since a mapping may have
been made before its client contact arrived.
Mirrors establish_udp, including its three-outcome contract and the
RAII claim that keeps the mapping alive: an application holds a
tcp_tunnel for as long as it wants the tunnel and lets it go when it
does not, and dropping the last claim closes the listening port and the
connections made through it.

Two differences from UDP.  A remote whose client contact says it does
not accept tunnelled TCP is refused up front, and gets its own
tunnel_failure value when that only becomes apparent after the mapping
is made, since "will never work" and "not reachable right now" call for
different responses.  And suggested_mtu is nullopt, because segment
sizes are not an application's to choose.
Replaces the commented-out sketch with a real establish_tcp call, and
makes SIGUSR2 re-open the tunnel so that releasing and re-establishing
can be tested by hand.
A full client defaulted to a global info level, at which quic logs a
line per stream open and close -- noise for a client tunnelling TCP,
where that is one pair of lines per connection.  Default to
"*=info, quic=warn" instead.

Setting a level in config replaced that default outright, so naming a
single category ("tcp=debug") silently discarded the rest of it.
Category settings are cumulative, so appending is enough: naming only
categories now adjusts the defaults, while naming a global level resets
everything before it and so still replaces them.
Rewrites the 2021 design proposal into a description of what exists:
the supported direction and what is deliberately not supported, the
wire details, packet sizing, idle handling, backpressure, close
semantics, and the remaining gaps.
@jagerman
jagerman force-pushed the embedded-tcp-tunnel branch from 6f1ee06 to 964aa50 Compare August 14, 2026 03:33
The inner connection carried nothing the session layer had not already
encrypted end to end and the path layer onion-encrypted, and its peer is
authenticated by the session itself, so QUIC's own crypto bought a second
AEAD pass over every byte and a TLS handshake before any of it could
flow.  It was standing on a hardcoded well-known keypair to get that far.

Use libquic's DangerouslyUnencryptedCreds instead, which needs libquic
1.9.0.

Both ends must be doing this -- such a connection cannot talk to an
ordinary QUIC endpoint -- which is what the TCP_TUNNEL capability flag
already ensures.
Two cases the harness could not previously reach.

establish_tcp() hands back the bound port synchronously and documents
that a connection made before the session is up is held rather than
refused, but every test connected only after on_established had fired.
With SR_TCP_EARLY set the tunnel is opened before anything has
established a session and connected to immediately, which is the case
that documents.

A target may now carry several comma-separated ports, mapping each to
the same remote, so that multiple mappings sharing one session (and one
tunnel connection) get covered.
@jagerman
jagerman force-pushed the embedded-tcp-tunnel branch from 964aa50 to c0fbaab Compare August 14, 2026 03:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant