Tunnels
The HTTP/3 client guide sends an HTTP/3 request over SCION. This page is for
the traffic that is not an HTTP/3 request: a protocol client of your own, a library that takes a
java.net.Socket, or an OkHttp stack you already have. All of them travel through a CONNECT
tunnel on the same client.
The page builds on the guide. It uses the client the guide builds, the same test network, and the same sample app.
What a tunnel is
openTunnel asks the client for a byte stream to a host:port. The client resolves host to a
SCION address, as it does for the host of a request URL, and opens an HTTP/3 connection to the
server at that address. That server is the tunnel server. It accepts a CONNECT request for the
host:port, opens a TCP connection to that port, and passes bytes between the two connections. The
tunnel is your end of that byte stream. It travels on the HTTP/3 connection your requests use, and
it has no framing of its own.
The tunnel server decides which hosts and ports it forwards a tunnel to. The client verifies the
certificate of the tunnel server against host, as it does for a request URL.
A tunnel server on one port
The client dials the tunnel server on the port of the tunnel. A tunnel to chat.example.org:5222
thus opens an HTTP/3 connection to port 5222. Some tunnel servers listen on one port for all their
targets, for example on port 443. For such a tunnel server, use openTunnel(via, target). The
client dials via and sends target in the CONNECT request:
val tunnel = client.openTunnel(
via = ScionHttp3Authority("chat.example.org", 443),
target = ScionHttp3Authority("chat.example.org", 5222),
)
The tunnel server has the SCION address of the target host, so via usually has the same host as
target. The client resolves the host of via. Thus a dnsOverride applies to the host of via.
The client does not resolve target. The tunnel server gets it unchanged.
The client does not verify the certificate of the tunnel server at via. A tunnel server on one
port, such as a PathGuard WebGateway, cannot show a certificate for each of its targets.
Authenticate target inside the tunnel. With OkHttp, use an https URL. With your own protocol
client, run TLS over the tunnel. The client keeps the connection to via apart from its verified
connections, so a request never uses it.
ScionTunnelSocket and ScionTunnelSocketFactory take a viaPort. With it, each socket opens its
tunnel with openTunnel(via, target). via is the host of the endpoint on viaPort, and the
endpoint of the socket is target:
val sockets = ScionTunnelSocketFactory(client, viaPort = 443)
How a SCION host is named
The client resolves a host name with its own resolver, which reads the TSAR records of the host.
Resolve a host name with TSAR explains these records. The
platform's resolver, which InetAddress and OkHttp's default Dns use, reads A and AAAA records
only. A host that exists on SCION alone has none, so a platform lookup fails for it. Nothing on this
page needs a platform lookup: every API below takes the host name and hands it to the client, which
resolves it. A dnsOverride replaces the TSAR lookup for one host, for a tunnel as for a request.
The test network's server is also a tunnel server, with two behaviours. A tunnel to localhost is
an echo: every byte written into it comes back. A tunnel to http.invalid reaches an HTTP/1.1
server inside the tunnel.
The byte stream
openTunnel returns a ScionHttp3Tunnel. The sample writes a message into the echo and reads
back what comes out:
/** Sends [message] through a tunnel to the echo and reads back what comes out. */
suspend fun echo(message: String): String =
client.openTunnel(echoHost, port).use { tunnel ->
tunnel.write(message.encodeToByteArray())
// The peer sees the end of the stream. The echo ends its side in turn.
tunnel.shutdownWrite()
val received = ByteArrayOutputStream()
while (true) {
val chunk = tunnel.read(4096)
if (chunk.isEmpty()) break
received.write(chunk)
}
received.toString(Charsets.UTF_8.name())
}
Four things about the stream matter:
- The two directions are independent. One coroutine can read while another writes.
shutdownWriteends your direction only. The peer sees the end of the stream, and your reads stay open until the peer ends its direction. A read then returns an empty array.- One read returns at most one frame. A payload the transport split arrives over several reads, so read in a loop until you have what you expect.
- Cancellation closes the tunnel. A read or a write that is cancelled mid-flight may have lost the bytes it just read or written part of its data. The stream is not usable after it.
Close the tunnel when you are done with it. Closing resets the stream. A call still in flight ends
with TunnelClosed.
A java.net.Socket
ScionTunnelSocket is a java.net.Socket whose bytes travel through a tunnel. It is for a protocol
library that takes a socket and knows nothing about SCION:
suspend fun echoThroughSocket(message: String): String =
withContext(Dispatchers.IO) {
ScionTunnelSocket(client).use { socket ->
socket.connect(InetSocketAddress.createUnresolved(echoHost, port))
socket.getOutputStream().write(message.encodeToByteArray())
socket.shutdownOutput()
socket.getInputStream().readBytes().decodeToString()
}
}
connect opens the tunnel to the endpoint's host name and port. A socket with a viaPort
dials the tunnel server on that port, as
A tunnel server on one port explains. Use
InetSocketAddress.createUnresolved, so that no platform lookup happens. That lookup fails for a
host that exists on SCION alone, as How a SCION host is named
explains. A resolved address works too: the socket takes the host name from it and ignores the IP
address.
The socket keeps the Socket contract that a library expects:
- Its streams block the calling thread. Do not use one on the main thread.
soTimeoutbounds a read and a passed deadline arrives asSocketTimeoutException. The socket stays usable after it.shutdownOutputisshutdownWriteon the tunnel.closefrom another thread ends a blocked read withSocketException.- The socket options, such as
tcpNoDelayandkeepAlive, can be set and read, but have no effect. There is no TCP connection to apply them to.
A tunnel failure arrives as an IOException.
OkHttp
ScionTunnelSocketFactory is a javax.net.SocketFactory that creates tunnel sockets over one
client. OkHttp takes it and needs one more setting:
private val okHttp =
OkHttpClient
.Builder()
.socketFactory(ScionTunnelSocketFactory(client))
// OkHttp resolves the host before it asks for a socket. A host that exists on SCION
// alone has no A record.
.dns(
object : Dns {
override fun lookup(hostname: String): List<InetAddress> {
if (hostname != HTTP_HOST) throw UnknownHostException(hostname)
return listOf(InetAddress.getByAddress(hostname, byteArrayOf(0, 0, 0, 0)))
}
},
).build()
/** `GET /hello` with OkHttp, as HTTP/1.1 inside a tunnel. */
suspend fun fetchWithOkHttp(): HelloScion.Reply =
withContext(Dispatchers.IO) {
val request = Request.Builder().url("http://$HTTP_HOST:$port/hello").build()
okHttp.newCall(request).execute().use { response ->
HelloScion.Reply(response.code, response.body?.string().orEmpty())
}
}
OkHttp resolves the host of the URL through its Dns before it asks the factory for a socket and
then connects the socket to the address it got. The default Dns is the platform's resolver, which
fails for a host that exists on SCION alone, as How a SCION host is named
explains. The Dns above returns a placeholder address that keeps the host name. The socket takes
the name from it and the tunnel carries the name to the gateway, which is where the SCION lookup
happens.
The Dns refuses every host that is not meant for SCION. The two settings belong together: this
factory never connects to the placeholder, but a platform socket would connect to 0.0.0.0 on the
device itself. Set both on one OkHttp client. A client derived with newBuilder() inherits both.
Every connection in OkHttp's pool is one tunnel. OkHttp opens one when it needs one and reuses it
for the next request to the same host, as it does with a TCP connection. Requests over the tunnel
are HTTP/1.1. The library sends an https URL over TLS through the tunnel with OkHttp's own TLS
stack and trust settings.
The sample uses an http URL to the test network's plain HTTP/1.1 host. The library's e2e tests
fetch an https URL through the same socket with OkHttp's TLS stack over the tunnel. The sample's
OkHttp client, its socket factory, and the client under them are built once and kept.
Errors
The tunnel arms of ScionHttp3Exception add to the arms the guide lists:
TunnelRefusedis aCONNECTthe tunnel server answered with a status other than 2xx. It carries the status.isRetryableis true for a 5xx status and false for a 4xx status.TunnelResetis a tunnel the peer reset while it was open.TunnelDisconnectedis a tunnel that ended because the connection under it went away, for example when the network changed. Open a new one.TunnelClosedis a call on a tunnel your app already closed, or a write aftershutdownWrite.
On a ScionTunnelSocket, and so in OkHttp, all of them arrive as an IOException. Its cause is
the ScionHttp3Exception.
Try it
Start the test network as the guide describes, run the sample app, and press one of the three tunnel buttons. Echo through a tunnel and Echo through a socket show the message the app sent, back from the echo. Fetch with OkHttp shows:
200
world
That is GET /hello, as HTTP/1.1 inside a CONNECT tunnel, over SCION.
Where to go next
- The library README —
bindings/android/scion-http3-android/README.mdis the reference for the tunnel and socket API. - The sample —
Tunnels.ktis the class this page is built from.