
volto
This manual covers volto, a MASQUE proxy server written in Rust. One QUIC connection from the client carries TCP through classic CONNECT (RFC 9114 §4.4) and UDP through CONNECT-UDP (RFC 9298, HTTP Datagrams per RFC 9297), both dispatched by the :protocol pseudo-header. It terminates TLS itself, runs unattended on a small Linux host, reloads certificates and credentials on SIGHUP, and ships as a static binary. It is built to interoperate with Surge’s masque policy.
Install
One line on a fresh Ubuntu host downloads the newest release for the architecture, verifies it against SHA256SUMS, creates the system user, generates a self-signed certificate and a password, writes /etc/volto/config.toml, installs the systemd unit and starts it:
curl -fsSL https://raw.githubusercontent.com/vcarus/volto/main/script/deploy.sh |
sudo bash -s -- --enable-timer --username yourname
It finishes by printing a ready-to-paste client policy line. Every flag, the certificate options and the tarball route are in the deployment chapter.
This manual
- Configuration — every key, its default, and what it costs to change.
- Deployment — building, certificates (ACME DNS-01 or self-signed plus pinning), releases and rollback, systemd, firewall, fd budget, reloads, relays, fail2ban.
- Architecture — how a request becomes a tunnel, the in-tree HTTP/3 layer, why quinn-proto is patched, and what the tests assert.
Reference
- API documentation — the crate’s rustdoc, rebuilt from
mainon every push. - Source repository — the code, the issue tracker, and the tracked copy of these pages.
- Releases — static musl binaries for
x86_64andaarch64, withSHA256SUMS. - Security policy — how to report a vulnerability privately.
Configuration
volto reads one TOML file, named with --config, or -c for short:
volto --config /etc/volto/config.toml
volto -c /etc/volto/config.toml
Only [server] is required; every other section and key has a default. Unknown
keys are an error at startup rather than being silently ignored, so a typo fails
loudly — in every table, not only in [server]. That also makes a config file
forward-only, which matters when rolling a release back; see
version compatibility. A commented reference file
ships as script/config.example.toml.
--check-config answers whether a given binary can read a given file without
starting anything; see
checking a file.
SIGHUP re-reads the file. A file that fails to parse or validate is rejected
whole and the running configuration keeps serving; see
deployment.md.
[server]
| Key | Type | Default | Meaning |
|---|---|---|---|
listen | string | required | UDP address to listen on, e.g. "0.0.0.0:443". QUIC is UDP; there is no TCP listener |
cert | path | required | PEM certificate chain, leaf first |
key | path | required | PEM private key (PKCS#8, PKCS#1 or SEC1) |
alpn | array of strings | ["h3"] | ALPN identifiers to advertise, in preference order. Change only for interop debugging. A list that does not offer h3 starts a server no client can reach — HTTP/3 is the only protocol volto serves, so TLS ends every handshake with no_application_protocol, an alert that says nothing about why — and volto warns about it at startup |
shutdown_grace | seconds | 5 | How long established tunnels may finish after SIGTERM. Range 0..3600, where 0 closes every tunnel at once. Kept short because a client that keeps using a connection after GOAWAY (Surge does) has its new requests fail for the whole drain. systemd’s TimeoutStopSec must be larger — and the ceiling is there because the value is a bound: a drain longer than an hour has outlived any service manager’s patience, so it would only replace the graceful ending with a SIGKILL |
[auth]
| Key | Type | Default | Meaning |
|---|---|---|---|
users | array of tables | [] | List of { username, password }. An empty list disables authentication and makes this an open proxy; volto warns at startup when that is the case |
A username may not contain a colon (RFC 7617), and may not be longer than 32
bytes — the length a user-id is carried at in a log line, and therefore the
length authentication failures are bucketed under, so a longer name could never
have its failures cleared by its own success. Credentials are compared in
constant time. Both Proxy-Authorization (preferred) and Authorization are
accepted, because Surge’s manual does not say which it sends; a failed check is
answered with 407 and Proxy-Authenticate: Basic.
[limits]
| Key | Type | Default | Meaning |
|---|---|---|---|
udp_session_timeout | seconds | 180 | Idle timeout for a UDP session, where idle means no packet crossed the proxy in either direction: a payload reaching the target or the target answering re-arms it, while bytes that complete nothing — a capsule still being assembled or skipped, packets a budget or a full queue dropped — do not, so a peer cannot hold a session’s socket and buffers open by dripping. RFC 9298 §3.1 says a proxy SHOULD NOT go below 120 (volto warns if you do), and the ceiling is 3600. Also bounds each write in a half-closed TCP tunnel’s surviving direction: one that does not complete within it cuts the tunnel, while a half-closed tunnel parked in a read is left alone (see the architecture doc) |
max_targets_per_conn | integer | 256 | Concurrent tunnels on one QUIC connection, TCP and UDP sharing the budget. Beyond it, requests get 503 with Proxy-Status: volto; error=connection_limit_reached. Range 1..65536, the same ceiling max_streams_bidi has and for the same reason: one tunnel is one bidirectional stream, and every tunnel also costs a file descriptor |
max_connections | integer | 256 | Simultaneously open QUIC connections; 0 removes the limit and never evicts. At the cap a new connection takes the slot of the oldest connection that has never had a request pass the credentials check — closed with H3_NO_ERROR and logged with reason=evicted — so a peer that keeps handshaking without ever authenticating cannot hold the server shut. Only a newcomer whose address QUIC has validated may evict: at the cap an unvalidated one is answered with a Retry (RFC 9000 §8.1) and takes no slot, which costs a spoofed Initial the flood it was for. A client that has connected before pays nothing — it returns a NEW_TOKEN token (RFC 9000 §8.1.3) and is already validated — so the extra round trip falls on first contact, on a token older than two weeks, and on the first reconnection after a restart or SIGHUP, and only while the server is full. Only when every live connection has authenticated is the newcomer refused during the handshake, before any per-connection state exists here |
connect_timeout | seconds | 10 | Budget for reaching a target; 0 disables it, and the ceiling is 3600. Spent twice per request and separately — once on name resolution, once on the whole list of addresses it resolved to — so a request holds its tunnel slot for at most twice this before any byte flows. A lookup that runs out answers 504 with Proxy-Status: volto; error=dns_timeout, a connect that runs out answers 504 with error=connection_timeout |
ip_family_preference | string | "ipv4" | Which address family a resolved target name is tried on first: ipv4, ipv6 or system (the resolver’s own RFC 6724 order). Applies to both tunnel kinds |
max_streams_bidi | integer | 1024 | Concurrent bidirectional streams per connection — one per tunnel — once a request on that connection has passed the credentials check. It is not what the handshake advertises: a connection is accepted on a fixed allowance of INITIAL_BIDI_STREAMS = 16 (in quic.rs) and granted this value in a single step by its first authenticated request, so a peer that has proved nothing can neither hold a thousand request streams open nor draw a refusal on each. A client that opens more than 16 tunnels before its first one is answered finds the seventeenth held — QUIC backpressure, not an error — until that answer comes back, which is a wait it was already having. An operator who configures fewer than 16 gets what they configured, before authentication as well as after it. Range 1..65536, and the ceiling is there because of where the cost falls: a stream slot is reserved for every unit of the credit when the allowance is granted, not when a stream is opened, so the value is work paid in one go by the connection’s first authenticated request rather than by every handshake — milliseconds at the ceiling on the dev host and whole seconds at a million, which is why the range stops where it does. quinn’s own default of 100 runs out during ordinary browsing. The peer’s unidirectional streams are not a key: they are fixed by MAX_PEER_UNI_STREAMS = 16 (in quic.rs), where HTTP/3 needs exactly three — the control stream and the QPACK encoder/decoder pair, RFC 9114 §6.2 and RFC 9204 §4.2. Exceeding a transport parameter is a QUIC-level failure with no HTTP-level explanation attached, which is why there is margin rather than three |
max_idle_timeout | seconds | 60 | How long a connection may go without traffic before it is closed. Range 1..3600. It also bounds every application-level wait that precedes a tunnel, whatever the connection’s authentication state: the QUIC/TLS handshake, the HTTP/3 handshake, the read of each peer unidirectional stream’s type, the read of a request stream’s HEADERS, and every refusal this proxy writes. A separate bound of twice this applies only while a connection has never had a request pass the credentials check — counted from the handshake and never re-armed by a new stream, so it bounds the connection rather than a pause in it — and is lifted for good once a request authenticates, so a client that keeps an idle connection between requests is unaffected |
keep_alive_interval | seconds | 20 | Keep-alive period; 0 switches it off. Must be strictly less than max_idle_timeout / 2, or startup and reload fail |
initial_mtu | bytes | 1200 | Size of the first QUIC packets — a UDP payload size, not an IP packet size. Range 1200..1452. Below 1200 is an error (RFC 9000 §14) rather than a silent round-up; above 1452 is an error too, because an Ethernet frame leaves 1452 bytes of payload over IPv6 (1472 over IPv4) and quinn applies initial_mtu with no ceiling of its own — so a handshake sent in packets no path carries leaves the server unreachable with nothing to fall back to. That failure mode is what separates this key from mtu_upper_bound: this value is sent blind, before any feedback channel exists to correct it |
mtu_discovery | bool | true | Probe for a larger path MTU (RFC 8899 DPLPMTUD). false stops the upward search, so packets stay at initial_mtu — except that quinn’s black-hole detector still runs and can drop them to the 1200-byte floor for the rest of the connection, with nothing to bring them back up. Slower, but predictable |
mtu_upper_bound | bytes | 1452 | Ceiling for the MTU discovery search — a UDP payload size like initial_mtu. Range initial_mtu..1472. The default is the value safe over both IPv4 and IPv6 on Ethernet; an operator who has measured their path (ping -M do, tracepath) can claim what IPv4 leaves above that, at most 1472. Safe to overshoot, unlike initial_mtu: a size is only adopted after a probe of that size is acknowledged, and a lost probe is retried then abandoned without counting as congestion, so a bound above what the path carries costs a few PINGs and nothing else. No effect (and a startup warning) when mtu_discovery is off |
congestion_control | string | "bbr" | QUIC congestion controller: bbr, cubic or newreno |
initial_rtt_ms | milliseconds | 333 | Round-trip time assumed before the first measurement. Range 10..10000 |
socket_recv_buffer | bytes | 2097152 | UDP socket receive buffer to request when the socket is created; 0 leaves the operating system’s own value alone. Capped by net.core.rmem_max, and volto warns at startup when it was capped |
socket_send_buffer | bytes | 2097152 | The same on the way out, capped by net.core.wmem_max |
Notes that matter in practice
The fd budget is one number split in two. Every tunnel costs one file
descriptor, so max_targets_per_conn and systemd’s LimitNOFILE are two halves
of the same budget. What volto compares against RLIMIT_NOFILE at startup is
max_connections × max_targets_per_conn plus FD_HEADROOM = 64 descriptors
of headroom for the listening socket, the request streams and a certificate
reload — 65600 for the defaults, which the shipped unit’s LimitNOFILE=131072
has room for. Raise either limit past that point and LimitNOFILE has to go up
with it, or the startup warning fires.
A CONNECT-UDP session costs memory as well as a descriptor, and only the
descriptors are checked at startup. Each session holds three buffers for its
whole life: a 64 KiB receive buffer for the packets it reads off its target
socket — it cannot be smaller, because a recv into a short buffer truncates the
packet silently — an inbound datagram queue of 64 entries, each at most the
1472-byte max_udp_payload_size this server advertises, so about 92 KiB, and the
capsule decoder on the request stream, which buffers one DATAGRAM capsule’s value
until all of it has arrived and so tops out around 78 KiB. That is roughly
236 KiB per session, 59 MiB per connection at max_targets_per_conn = 256, and
about 14.7 GiB across a server saturated at both defaults. Lowering either limit
lowers it proportionally. It is a ceiling rather than a resting size: the queue
is only full while a client sends faster than the proxy forwards, and the capsule
buffer only fills for a client that leaves a capsule unfinished.
A TCP tunnel costs less, not nothing. Its relay buffer starts at
RELAY_BUF_SIZE = 16 KiB, and settles on a single RELAY_BLOCK_SIZE = 64 KiB
block once the tunnel has relayed anything: reads are cut from one block until
too little of it is left to offer a full-sized window, and that is also why the
first 16 KiB is let go after the first read. So the saturation product for TCP
is max_connections × max_targets_per_conn × 64 KiB = 4 GiB at the defaults,
beside the 9.8 GiB of the UDP one. What a tunnel holds beyond that one block is
bounded by quinn’s per-connection send window (SEND_WINDOW = 10 MB): the
pieces cut from a block share it, and each is held until the segment carrying it
has been acknowledged, so the block outlives them all.
Every tunnel also holds its request’s header fields. volto advertises
MAX_FIELD_SECTION_SIZE = 65536 in its SETTINGS, and a client is entitled to
send a request that large; decoded, it costs roughly that much again — about 77
KiB measured — and it is kept for the whole life of the tunnel the request
opened, not just until the target has been named. At the defaults that is
another 19 MiB per connection and about 4.8 GiB across the server, on top of the
tunnel figures above. Real clients send a few hundred bytes of headers, so this
is a ceiling reached only by a client that chooses to fill the advertised limit;
it is listed because nothing else in this section accounts for it.
connect_timeout is spent per request, not per connection. Without it a
target that silently drops SYNs holds a tunnel slot and its file descriptor for
as long as the operating system keeps retrying — around two minutes on Linux —
so a handful of black-holed addresses during ordinary browsing can spend a
connection’s whole max_targets_per_conn on tunnels that will never open. No
attacker is needed for that. A reload carries a new value to connections
accepted from then on, and each request those connections make gets the budget
afresh; connections already open keep the value they were accepted with, like
the rest of the per-connection policy. Set it to 0 only to hand the wait back
to the operating system — and know what that hands back: a client that resets a
request stream while its target is still being dialled does not cancel the dial,
so with the budget off the slot stays spent until the kernel gives up. volto
warns at startup when the budget is off.
connect_timeout bounds the answer, not the resolver. A lookup that runs
out of budget is answered 504 immediately, but the getaddrinfo call behind it
cannot be cancelled and keeps its thread until the system resolver gives up. The
server bounds that separately: every connection has a name-lookup slot reserved
for it that nothing else can take, plus a capped share of a server-wide
allowance, so a client aiming at names that never resolve cannot stop anyone
else’s names from resolving. Nothing is configurable there, and nothing changes
on the wire — the refusals are the same 504 dns_timeout they always were.
max_connections also sizes the blocking thread pool, at startup only. The
pool is given a thread for every reserved lookup slot the budget can hand out,
plus the shared allowance and headroom; threads are created on demand and reaped
when idle. A reload that raises max_connections takes effect for new
connections but does not resize the pool, which needs a restart.
max_streams_bidi, max_idle_timeout, keep_alive_interval, initial_mtu,
mtu_discovery, congestion_control and initial_rtt_ms are QUIC transport
settings and apply to new connections only. A reload carries them to
connections accepted from then on; connections already open keep what they
negotiated at handshake time, because QUIC cannot renegotiate transport
parameters. ip_family_preference is not a transport parameter, but it is
snapshotted the same way: a connection resolves every target with the preference
that was in force when it was accepted.
max_streams_bidi is the one of them that also moves within a connection’s
life, when its first authenticated request raises the allowance from 16 to the
configured value. That is not renegotiation — the raise travels as a
MAX_STREAMS frame, which QUIC allows at any time and only ever upwards — and it
makes the key no more reloadable than the rest: what a connection is raised to is
the value that was in force when it was accepted, not whatever a later reload
set. A reload during a connection’s unauthenticated window changes nothing about
that connection either.
[server].listen is startup-only. A reload carrying a new value for it is
accepted — the rest of the file still applies, because the usual sender of
SIGHUP is a renewal hook that rewrites the whole file and refusing the reload
over one key would be worse than ignoring it — but the socket does not move.
The reload says so, naming both addresses, so an operator who did mean to move
it is not left reading a successful reload that quietly did not apply:
WARN volto::quic: server.listen changed, but a reload cannot move the listening
socket; the server is still bound where it started. Restart to apply it.
bound=0.0.0.0:443 configured=0.0.0.0:8443
The two socket buffer keys are startup-only, not reloadable at all. They are
applied to the UDP socket when it is created, and a reload does not rebind that
socket — changing them needs a restart, the same as [server].listen. Each
request is capped by the host: net.core.rmem_max / net.core.wmem_max on
Linux, kern.ipc.maxsockbuf on macOS, and a host may fail the request outright
rather than clamping it. volto warns at startup, naming the sysctl, whenever it
got less than it asked for, and comes up on the operating system’s default
either way. 0 asks for nothing and leaves that default in place. The reason
these keys exist at all is that quinn never calls setsockopt itself, so a
server that does not ask gets net.core.rmem_default — around 208 KiB —
however high rmem_max has been raised; see
UDP socket buffers.
keep_alive_interval is validated as strictly below half the idle timeout,
not at most half. At exactly half, losing a single keep-alive packet is enough
for the connection to time out. This pairing is what keeps a NAT mapping alive
across an idle period; see
running behind a UDP relay.
congestion_control should usually stay on BBR. Over a long, lossy path a
loss-based controller (cubic, newreno) reads every dropped packet as congestion
and collapses the window — downloads stall to near zero while a co-located TCP
proxy, which the kernel runs on BBR, is unaffected. BBR models bandwidth and RTT
instead. Switch to cubic only on a clean path, or as a fallback.
Path MTU discovery reports what it found in the connection close line. The
INFO ... connection closed and WARN ... connection closed with error lines
carry mtu=, the largest UDP payload the sender settled on for that path, and
mtu_black_holes=, how many times quinn’s black-hole detector pushed it back to
the floor during the connection, next to rtt_ms= and remote_now=. Both are
reports, not knobs. A mtu= still at initial_mtu when a long-lived connection
ends means the DPLPMTUD probes were never acknowledged, which is what a path
that black-holes large packets looks like from here, and the case
mtu_discovery = false exists for; anything above initial_mtu is discovery
having done its job. The counter tells a fall-back apart from a path that never
got there: the detector is a heuristic over loss bursts, and full-size packets
lost to ordinary congestion during a bulk transfer look the same to it as a
path that stopped carrying them, after which the connection sends packets at
the 1200-byte floor for a one-minute cooldown before probing again. A
non-zero count on a path where other connections settle above the floor is
therefore that heuristic firing, not the path changing.
Those lines also report what the connection carried. Alongside rtt_ms=
and mtu= they carry tunnels=, how many requests on that connection were
granted a tunnel slot — TCP CONNECT and CONNECT-UDP draw on the same budget, and
a request turned away before the slot (407, a malformed message, the tunnel
limit itself) is not counted, while a destination the policy rejects and a
target that could not be reached both are, since the slot is taken before the
target is judged — and four transport counters. tx_bytes= and
rx_bytes= are UDP-level byte counts: everything this server put on or took off
the wire for that connection, QUIC and HTTP/3 framing, retransmissions,
acknowledgements and padding included. They are neither tunnel payload, which is
always smaller, nor bytes the peer acknowledged, since a packet is counted when
it is sent whether or not it arrived; read them as how much this connection
moved through the host, not as an accounting figure. sent_packets= and
lost_packets= are reported together because a loss rate needs both, and a
single count on its own says nothing about the path. dropped_datagrams= is
this server’s own doing rather than the path’s: inbound HTTP Datagrams the
connection’s router dropped on purpose — an unknown Context ID, a Quarter
Stream ID no session claims, a session whose inbound queue was full, or a
datagram cut short of its Context ID — each of which the RFCs require or permit
to be silent where it happens, leaving this total as their only
production-visible trace. All of them come from one
snapshot taken as the connection ends, so they cost nothing while it is running.
initial_rtt_ms seeds the handshake retransmission timers. Until the first
ACK arrives there is no RTT sample, and a lost handshake packet waits roughly
three times this value before it is resent. The default of 333 comes from
RFC 9002 and is deliberately conservative. On a known path, set 1.5–2× the RTT
that volto’s connection logs report as rtt_ms — a measured ~90 ms path wants
about 150 — which cuts the worst-case handshake stall from about a second to a
few hundred milliseconds. The example configuration in script/ — and therefore
every install derived from it — ships 150 for that reason; the compiled-in
fallback used when the key is absent stays at 333. Keep the margin: a value
below the real RTT makes the timer fire early and retransmit packets that were
never lost.
The two MTU keys are shipped tuned as well. The example configuration in
script/ ships initial_mtu = 1242 and mtu_upper_bound = 1464 as live keys,
and the installer substitutes only the listen address, the certificate paths
and the user — so every install derived from it runs above the compiled-in 1200
and 1452 that apply when the keys are absent. 1242 keeps the handshake inside a
1270-byte IPv4 packet: under the 1280 bytes any practical path carries, and
below what Chromium (1250) and quic-go (1280) send everywhere. Over IPv6 the
same packets are 1290 bytes, past that guarantee, so put it back to 1200 if
clients reach the server over IPv6 — this is the key that is sent blind, and a
size the path cannot carry kills the connection with nothing to fall back to.
1464 is what an IPv4 uplink behind a 1492-byte first-hop IP MTU (one
PPPoE-sized deduction) leaves, and it is a ceiling for a search rather than a
size that gets sent, so overshooting costs a probe and nothing else; 1472,
clean Ethernet over IPv4, is the most volto accepts. Neither value is a
measurement of your path: measure with ping -M do before raising either,
and lower initial_mtu on the first sign that a handshake is not getting
through.
ip_family_preference decides which half of a dual-stack target is tried
first, and it is an operator’s call rather than the resolver’s. getaddrinfo
sorts its answers by RFC 6724, which puts a global IPv6 address ahead of every
IPv4 one whenever the host has a usable IPv6 route — the right answer for a host
that is only a client of the internet, and the wrong one for a proxy whose IPv6
egress is tunnelled or worse peered than its native IPv4, which is a common
shape on a VPS. volto therefore defaults to ipv4. A TCP tunnel would otherwise
spend the whole IPv6 connect attempt before IPv4 is tried, and a CONNECT-UDP
session would not recover at all: its socket is connected to the first address
that has a route, and nothing later revisits that choice. Set ipv6 when the
host’s IPv6 path is the better one — native IPv6 with tunnelled or NATed IPv4 —
and system to hand the ordering back to the resolver, which on glibc can then
be shaped through gai.conf. The ordering is a stable partition, so whatever
RFC 6724 decided within a family still stands; a target that resolves to one
family, an IP literal above all, is unaffected by any of the three.
[security]
| Key | Type | Default | Meaning |
|---|---|---|---|
allow_private_networks | bool | false | Allow tunnels to address space RFC 6890 marks special-purpose: “this host on this network” (0.0.0.0/8), loopback, RFC 1918, link-local, shared address space (100.64.0.0/10), IETF protocol assignments (192.0.0.0/24), benchmarking (198.18.0.0/15 and 2001:2::/48), 6to4 relay anycast (192.88.99.0/24), reserved (240.0.0.0/4), the documentation ranges, ULA, ORCHID (2001:10::/28), the deprecated site-local fec0::/10, the deprecated IPv4-compatible ::/96 (stacks that still honour it route ::127.0.0.1 to loopback, which would otherwise be a second way around the IPv4 rules), 2001:db8::/32 and 100::/64. Keep it off on a public deployment |
denied_ports | array of integers | [25] | Target ports refused regardless of address, answered with 403. Do not add 53 (see below) |
unanswered_packet_budget | integer | 64 | Packets a UDP session may send before its target has answered; 0 disables the mitigation |
max_auth_failures | integer | 5 | Authentication failures tolerated on one connection before it is dropped; 0 disables it. One failure is one credential value tried and refused, so a single request may spend more than one. Failures are counted in buckets — one per configured user-id that is guessed at, one shared by every user-id that is not configured, one for the requests that named nobody — and the connection goes when the total across them reaches this value. A request that authenticates clears its own user’s bucket and the credential-less one, so failures cannot add up over the life of a working connection; it clears nothing else, so a peer holding one valid credential cannot buy back its guesses at a second user’s password by interleaving a good request, and a scan for user-ids that do not exist is never cleared by anything |
expected_sni | array of strings | [] | Host names this server answers to. An empty list answers to any name, which is the default and what every release before this one did. A non-empty list turns on the SNI gate: a handshake whose ClientHello does not name one of these hosts is dropped at the socket before the QUIC layer sees it, so a port scan of this address gets nothing back — no Version Negotiation packet, no CONNECTION_CLOSE, no TLS alert. Matched name for name, ASCII case-insensitive, with one trailing root dot ignored; no wildcards and no suffix matching, and a name with an empty label (a leading dot, two dots in a row, or a second trailing dot) or an IP address literal is refused at startup, because RFC 6066 section 3 does not permit either in a server_name and no conforming client would send one. Every client must then send the name as SNI (in Surge, the sni= parameter), or it sees the port as closed with no error anywhere but this server’s debug log — so change this key and the clients together. A name the certificate does not cover draws a warning at startup and on reload, because a typo here is otherwise indistinguishable from a dead server. See below |
- This whole section is snapshotted per connection, at accept. A reload
applies it to connections accepted after it, and a connection already open
keeps the rules it was accepted with for its whole life, tunnels opened on it
later included, so a tightened
allow_private_networksor a new entry indenied_portsreaches a client that is holding a connection open only when that connection ends. Usesystemctl restart voltowhen the tightening has to apply to everything at once; the same reasoning and the same remedy as for credentials, worked through under Reloading in deployment.md. - Addresses are normalized before matching, so neither
::ffff:127.0.0.1nor::127.0.0.1gets pastallow_private_networks = false. - IPv6 transition addresses are judged by the IPv4 address they carry, because
that is what a host routing them actually reaches: the well-known NAT64 prefix
(
64:ff9b::/96), 6to4 (2002::/16) and Teredo (2001::/32). So64:ff9b::7f00:1is refused as the 127.0.0.1 it is, while64:ff9b::808:808is reachable as 8.8.8.8. The local-use NAT64 prefix64:ff9b:1::/48is judged differently: its operator picks where the IPv4 address sits inside it and RFC 8215 §5 forbids a reader assuming a layout, so the whole prefix counts as private and followsallow_private_networks. - Multicast, broadcast and the unspecified address are never dialled,
regardless of that setting. They are amplification primitives, not
destinations. What the client is told about the unspecified address is a
separate question — see the note on blackholed names under
[log]. - This host’s own addresses are never dialled either, at any port and
regardless of that setting, because the reason is a different one: a tunnel
to an address this machine carries reaches the machine’s own services with the
proxy’s own source address, which is the privilege escalation RFC 9298 §7
names. Loopback is the exception, and it is the exception the RFC makes too:
it lists “localhost” as a class of its own, and here that class is the private
one above, off by default and opened by
allow_private_networks. So a target is refused with403anddestination_ip_prohibitedwhen it resolves to an address of this host other than loopback, whether that address is public, on the LAN, or the one the server listens on. - What that rule does not cover, said rather than left to be discovered.
With
allow_private_networks = truea client can still reach this server’s own listener through127.0.0.1, exactly as it can reach every other loopback service the operator opened: loopback is the private class above, and that switch is what decides it. And behind a relay the address a client dials the proxy on is the relay’s, not one any interface here carries, so a target naming that address is judged like any other public address and it is the relay rather than this rule that stands in the way. - UDP/53 must stay reachable. Surge’s UDP availability test is a DNS query
through the tunnel, so denying port 53 makes Surge report the policy as broken.
volto warns if 53 appears in
denied_ports. unanswered_packet_budgetstops a client using the proxy as a reflector or a port scanner (RFC 9298 §7). The first reply from the target lifts the limit for the rest of the session, so it only bites on one-way floods. The default is generous on purpose: handshakes that legitimately need several packets before the first reply must not break. The connection carries a total of its own,CONNECTION_UNANSWERED_MULTIPLIER= 8 times this value (512 packets at the default), spent by every session on it that sends into silence. A session whose target answers gives back what that session spent, and nothing else does, so opening a new session no longer restores the allowance, a long-lived connection of short answered sessions (DNS through the tunnel) spends none of it, and a session that finds the total spent is closed rather than left running and muted.0switches both halves off.max_auth_failuresis not a rate limit and not a ban. It raises the cost of guessing from “one handshake, then unlimited attempts” to “one handshake per N attempts”, without any cross-connection state to keep or evict. What it counts is credential values rather than requests: every value that is tried and refused costs one failure, so a request carrying a guess under each of the two accepted field names spends two. A request carrying more than two credential values is answered400before any of them is tried, and costs nothing, because two is all that accepting both field names asks for. N is therefore a number of guesses whatever shape they arrive in. Pair it with fail2ban for actual banning — see deployment.md.
The SNI gate
expected_sni is the one key here that changes what an outsider sees rather than
what a client may reach. With it set, the UDP port stops answering anybody who
cannot name the host: the check runs on the datagram itself, under the QUIC
layer, and a handshake that fails it is discarded rather than refused. There is
no reply of any kind, so a scan of the address finds a closed port.
What it is for is a deployment whose address is not the name — volto behind a relay, where the port is on an IP that nothing else advertises. What it is not is traffic obfuscation: it says nothing about what a connection looks like once one is open, and it does not stop a probe that already knows the name.
Turning it on is a two-sided change. Set it to the name on the certificate,
and make sure every client sends that name as SNI. Surge does when the policy
line names the host; when the policy points at a relay’s IP instead, that is the
sni= parameter (with server-cert-verify-name=), which such a deployment
already needs. A client that sends the wrong name — or none — gets silence, and
silence is indistinguishable from the server being down, so the two halves belong
in the same change window.
Some details worth knowing before relying on it:
- A name is checked twice. The socket-level check reads the ClientHello out of the client’s first Initial packet, which is where a ClientHello normally fits whole. One that is split across several packets is deliberately let through — refusing a first flight before its extensions have arrived would make a large ClientHello unreachable — and is stopped a layer up instead, by a certificate resolver that declines to present a certificate for a name it does not know. That second refusal is a TLS alert rather than silence, which is the one case where a probe learns something.
- Reloadable.
SIGHUPapplies a new list to handshakes from then on; connections already open are unaffected, exactly like the rest of the file. - Not a certificate selector. There is still one certificate and one key. Naming several hosts means volto answers to all of them with the same certificate, so they all have to be on it.
- A packet that names nobody can still draw an acknowledgement. The check refuses an Initial whose Destination Connection ID is under the eight bytes RFC 9000 §7.2 requires of a client’s first packet, since no later one is that shape either and quinn applies the same floor before decrypting; what it passes, it passes to quinn, which acknowledges an Initial carrying an ack-eliciting frame whether or not a name is in it. Sending one means building a QUIC Initial packet on purpose, which is a probe aimed at this server rather than a scan of the address.
- Stateless resets are not covered. A short-header packet for a connection
this server does not hold can still draw one (RFC 9000 §10.3), because telling
a live connection’s packets from a stranger’s needs state the gate does not
have, and filtering on the address instead would break the connection migration
a phone behind a relay’s NAT depends on. The residue is narrow: the packet has
to be at least 22 bytes, start with the bits
01, and carry eight bytes that pass the endpoint’s own keyed connection-ID check. A scanner’s probe does not. - Rejected names. A name that no ClientHello could carry is refused at
startup rather than accepted and never matched — an empty string, a wildcard, a
name over 253 bytes, anything outside the ASCII letters, digits,
-,.and_an A-label is made of. The failure mode this avoids is a gate that starts cleanly and silently refuses every client.
[log]
| Key | Type | Default | Meaning |
|---|---|---|---|
level | string | "info" | A bare level (trace/debug/info/warn/error) or a directive list such as "volto=debug,quinn=info". RUST_LOG overrides it |
keylog | bool | false | Write TLS secrets to the file named by SSLKEYLOGFILE. Debugging only; volto warns while it is on |
At debug, every inbound request is logged with its method, path, :protocol
and header lines. Credential values are replaced with
<scheme> <redacted N bytes>, but the header names are kept — that is what makes
the log usable for confirming which authorization header a client actually sends.
A keylog file decrypts every session through the proxy, including sessions already recorded. Turn it off and delete the file when you are done.
Under the shipped unit it writes nowhere. ProtectSystem=strict with
ReadOnlyPaths=/etc/volto and no ReadWritePaths= leaves the service no
writable path but its own PrivateTmp=yes directory, and rustls reports the
failure to open once and carries on, so the operator gets a server that started,
a startup warning saying the keylog is on, and no file. Give it a directory and
a path with a drop-in:
# /etc/systemd/system/volto.service.d/keylog.conf
[Service]
Environment=SSLKEYLOGFILE=/var/lib/volto/keylog
ReadWritePaths=/var/lib/volto
after sudo install -d -o volto -g volto -m 0700 /var/lib/volto, then
systemctl daemon-reload and restart. Remove the drop-in and the directory
together with keylog = false when the session is over: the file is every
secret the proxy has negotiated since it was created.
Under systemd, volto prefixes each line with a syslog priority (<3> for ERROR,
<4> for WARN, <6> for INFO, <7> for DEBUG and TRACE). journald parses that
prefix, strips it, and files the record with the matching PRIORITY, so
journalctl -u volto -p warning selects what it says it does instead of matching
everything. The prefix appears only when systemd sets JOURNAL_STREAM, so
running volto in a terminal prints the same lines it always did, and the shipped
unit needs no extra setting (SyslogLevelPrefix= already defaults to true).
Every line at info or above carries a log_id field, eight lowercase letters
and digits. It names one statement in the source, is assigned once and is never
reused, and it stays the same when that line is reworded, so it is what a
runbook or a journal filter should match on. The message text is not covered by
the compatibility promise below; the id is the stable half of the pair.
One refusal is deliberately quieter than its neighbours, in the log and on the
wire. A target whose every resolved address is 0.0.0.0 or :: is a name a
filtering resolver has blackholed: that decision belongs to the resolver, not to
volto, so it is logged at INFO and answered with a 200 whose stream is closed
immediately — the client sees a tunnel that opened and died, which is what a
blocked name looks like through a transport that has no way to explain itself.
Answering 403 instead would invite the client to blame the proxy for an ad
blocker’s decision. A target that resolves to loopback, private or mixed
addresses is a refusal volto really did make: it stays a WARN and a 403 with
Proxy-Status: …; error=destination_ip_prohibited, because that is what a probe
for internal services looks like from here.
That warning, and the one a request refused for reaching max_targets_per_conn
writes, are reported on a doubling schedule per connection: the 1st, 2nd, 4th,
8th and so on, each carrying refusals= with the running total, and the ones in
between at DEBUG. Both are refusals a client can repeat as fast as it can open
request streams, and journald’s rate limiting counts lines, so one line per
request would let a peer decide how much of the journal is left to record
anything else in — including the lines about it. A scan of every port on a host
is 17 warnings rather than 65535, the first arrives as promptly as it ever did,
and the last one says how large the scan was.
Checking a file without starting the server
volto --check-config --config /etc/volto/config.toml
reads the file, validates it and exits. A file that is good exits 0 with one
line on stdout; a file that is not exits non-zero with the reason on stderr —
the same error, word for word, that the service would print at startup, because
it is the same code path. Settings that are legal but worth a word ([auth].users
empty, log.keylog on) are printed to stderr as warnings and do not change the
exit status: they describe a server that runs, not one that refuses to.
Nothing is bound, started or written, so this needs no privilege beyond reading
the file it is given — which on a host set up by install-selfsigned.sh still
means root or sudo -u volto, since /etc/volto is 0750 volto:volto.
What it covers is what startup does with the file before it becomes a
server: TOML syntax, every table’s refusal of a key it does not know, and every
range and cross-field rule (keep_alive_interval against max_idle_timeout,
mtu_upper_bound against initial_mtu, and the rest) — including that
server.cert and server.key exist and are files.
What it does not cover is what only the running service can answer: whether
the listen address is free, whether the certificate and key parse and form a
usable pair, and whether RLIMIT_NOFILE leaves room for max_connections ×
max_targets_per_conn. That last one is a property of the systemd unit rather
than of the file, so answering it from a shell would answer a different
question than the one that matters. A file that passes can still fail to serve
for one of those reasons; a file that fails will not start at all.
The reason the flag exists is the section below: it is how
script/deploy.sh asks a release it is
about to install whether it can read the file this host already has, before it
swaps the binary.
Collecting a support bundle
volto --diagnostics --config /etc/volto/config.toml
prints, to stdout, everything an issue about a host would otherwise be a series
of questions about, and exits 0. In order: the version of the binary that
printed it; the configuration file’s path and every table of it as this binary
parsed it, after defaults, so [limits] and [security] are the values the
server would actually run on rather than the subset the file happens to name;
the warnings --check-config prints; the descriptor limits of the process
that ran the command, soft and hard, since the hard limit is what says whether
a soft one that is too low can be raised here at all or needs the unit changed,
and on Linux the same pair for the running service, read from /proc; the four
net.core UDP buffer sysctls named under UDP socket
buffers, read from /proc/sys on Linux and
reported as unavailable on any other platform; and uname -srm.
Passwords are redacted. Every [auth].users entry prints its password as
<redacted>, by the same guard that keeps one out of the error a malformed file
produces, so the output is safe to paste into an issue. Read it before pasting
anyway: the rest of the configuration is there in full, host names and listen
address included.
The descriptor limits are this command’s, not the service’s. Run from an SSH
shell the two RLIMIT_NOFILE lines are that shell’s, which is how 1024 and
1048576 came to be recorded for two hosts running the service at 131072. The
line under them says so, and on Linux the section then prints the running
service’s own figures, read from /proc/<pid>/limits:
[file descriptors]
RLIMIT_NOFILE soft = 1024
RLIMIT_NOFILE hard = 1048576
these two are this process's own limits, not the service's (the service's are in /proc/<MainPID>/limits)
service pid 5312 Max open files soft = 131072
service pid 5312 Max open files hard = 131072
The service is found by walking /proc, so no unit name is assumed and nothing
is asked of systemctl: a process matches when its comm is volto, or when
its exe link resolves to the binary this command is running. The name alone
is enough because the link is readable only with privilege, reads (deleted)
once the deploy script has replaced the file under the running service, and is
a different path when this command is run from a freshly unpacked tarball.
Every match is printed with its own pid,
so a second volto started by hand is visible rather than folded into one answer.
A host with no such process prints one line saying so, and a file that cannot be
read prints one line saying that; neither changes the exit status. On any other
platform the section says these figures are Linux only. To read them by hand,
systemctl show -p MainPID volto gives the pid for /proc/<MainPID>/limits.
Nothing is bound, connected or resolved, nothing is written, and no journal is
read, so this asks for no more privilege than reading the configuration file
does. The two flags refuse to be combined: --check-config and --diagnostics
answer different questions and neither is the obvious winner, so a command line
naming both is rejected rather than one of them ignored.
Version compatibility
An unknown key is refused, and the file is refused whole rather than the one key: nothing else in it takes effect, so this is a startup failure and not a warning. Two consequences, and they are not symmetric.
Upgrading is always safe. A key a later release adds takes its documented
default when the file does not mention it, and nothing ever rewrites
/etc/volto/config.toml — not script/deploy.sh on an update, not
install-selfsigned.sh on a re-run. No key has ever been renamed or removed, so
a file written for any earlier release still loads.
Rolling back is not. A file that names a key the older binary does not know stops it from starting at all, which is the one moment that costs the most: a rollback is only ever run when something is already wrong. The failure looks like this in the journal, and it names the file, the line and the column but not the key — a parse error redacts every quoted segment of the parser’s message, because the same message can quote a password, and a key is quoted the same way:
Error: failed to parse config file /etc/volto/config.toml at line 181, column 1: unknown field `<redacted>`
The line number is the thread to pull: comment that key out and start the service. To find out before the restart instead of after it, ask the binary you are about to install:
/path/to/old/volto --check-config --config /etc/volto/config.toml
which is what script/deploy.sh does on your behalf — but only for a release
that knows the flag. Rolling back past the release that introduced it leaves you
with the table below and the line number in the journal.
| Key | Introduced in |
|---|---|
[limits].connect_timeout | v0.2.6 |
[limits].socket_recv_buffer, [limits].socket_send_buffer | v0.2.8 |
[limits].ip_family_preference | v0.2.9 |
[limits].mtu_upper_bound | v0.4.5 |
[security].expected_sni | v0.9.0 |
Everything else has been there since v0.1.0. mtu_upper_bound is the one that
bites in practice, because the shipped example sets it and every install made by
install-selfsigned.sh is derived from that example — so a host first installed
at v0.4.5 or later carries it whether or not anyone chose it.
Why the rejection stays. Ignoring unknown keys would make rollback a
non-event, and it would also make one typo dangerous rather than merely wrong.
Every misspelled key falls back to a default; for [auth].users that default is
the empty list, and an empty user list is an open proxy. A configuration that
silently drops its credentials is a worse failure than a service that refuses to
start, so the refusal stands and the rollback cost is paid in this section
instead.
What is stable from v1.0.0
Configuration keys, their defaults and the command-line arguments are stable within 1.x. A key that exists keeps its name and its meaning, a documented default is not changed under a running deployment, and a key is removed only after at least one minor release has warned about it at startup. A new key keeps taking its documented default when the file does not mention it, which is what makes the upgrade half of this section a no-op.
Two things are deliberately outside that promise. Log line shapes are not an
interface: fields are added, reworded and moved between levels as the
operational picture changes, and a parser built on them is built on something
that moves. Neither is the volto library API: the crate is a library so that
the tests and the fuzz targets can reach the parsers and the bounds directly,
and every item it exposes exists for them.
A minimal file
[server]
listen = "0.0.0.0:443"
cert = "/etc/volto/fullchain.pem"
key = "/etc/volto/privkey.pem"
[auth]
users = [{ username = "user1", password = "…" }]
Deployment
Target platform is a Linux host with systemd; the development host is macOS and the test suite is expected to pass on both.
A stock Ubuntu 24.04 image already carries everything the release path needs:
curl, tar, sha256sum, openssl and systemd itself. ufw is not among
them and neither script requires it — the installer notices its absence and says
so instead of failing. Building on the host instead of downloading a release is
what adds requirements, and they are named under building.
Building
Rust 1.95 or newer. Cargo.lock is committed, and quinn-proto is redirected
by a [patch.crates-io] stanza to a commit carrying an MTU fix no release has
yet (see
architecture.md), so
build with the lockfile and do not run cargo update:
cargo build --release --locked # target/release/volto
On Debian or Ubuntu that needs build-essential and pkg-config. Compiling on
the server itself is the simplest route; the dependency stack is pure Rust apart
from the C code aws-lc-rs builds, so cross-compiling works as well:
rustup target add x86_64-unknown-linux-musl
cargo build --release --locked --target x86_64-unknown-linux-musl
Cross-compiling to musl needs a C toolchain for aws-lc-rs (musl-tools for the
x86_64 target, a cross toolchain for aarch64). The non-FIPS build used here
ships pre-generated bindings, so no cmake, bindgen or Go is involved – a C
compiler is enough. The release workflow builds both
targets with cross; the resulting static
binaries are attached to each tagged release together with a SHA256SUMS file.
Each archive unpacks to the binary, LICENSE, README.md, script/ and
docs/ – so the installer runs straight out of it, and the pages both scripts
name when they refuse to install are on the host rather than only on the web.
Certificates
Two paths. Pick one.
ACME with DNS-01
The right choice when you own a domain and want clients to need no extra configuration. When the domain’s A record points at a UDP relay rather than at the server, HTTP-01 and TLS-ALPN-01 cannot validate — they are answered at the address the record names — so DNS-01 is the only usable challenge:
sudo apt install -y certbot python3-certbot-dns-cloudflare # or your provider's plugin
sudo certbot certonly \
--dns-cloudflare --dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
--key-type ecdsa \
-d example.com
--key-type ecdsa is not only a performance preference. Until a client’s address
is validated, QUIC lets a server send at most three times the bytes it received
(RFC 9000 §8.1, roughly a 3600-byte budget). An RSA chain can exceed that and
cost the handshake an extra round trip; an ECDSA chain fits comfortably.
The symlinks under /etc/letsencrypt/live/ are readable by root only, so rather
than pointing volto at them, have the renewal hook copy the files into
/etc/volto and reload:
sudo tee /etc/letsencrypt/renewal-hooks/deploy/volto.sh >/dev/null <<'EOF'
#!/bin/sh
set -e
install -o volto -g volto -m 0644 /etc/letsencrypt/live/example.com/fullchain.pem /etc/volto/fullchain.pem
install -o volto -g volto -m 0640 /etc/letsencrypt/live/example.com/privkey.pem /etc/volto/privkey.pem
systemctl reload volto
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/volto.sh
sudo /etc/letsencrypt/renewal-hooks/deploy/volto.sh # run it once by hand
Self-signed with fingerprint pinning
The right choice for a handful of your own devices when maintaining a domain and DNS-01 credentials is not worth it. The client verifies one specific certificate by its SHA-256 fingerprint instead of a chain.
Start from a release tarball; nothing has to be built on the host. The archives
are named volto-<version>-<target>.tar.gz for the two published targets,
x86_64-unknown-linux-musl and aarch64-unknown-linux-musl — which is what
uname -m already answers on a Linux host:
version=1.0.2 # whatever the releases page shows
name="volto-${version}-$(uname -m)-unknown-linux-musl"
base="https://github.com/vcarus/volto/releases/download/v${version}"
curl -fsSLO "$base/$name.tar.gz"
curl -fsSLO "$base/SHA256SUMS"
sha256sum --ignore-missing -c SHA256SUMS
tar xzf "$name.tar.gz"
cd "$name"
sudo script/install-selfsigned.sh --binary ./volto
--ignore-missing is what lets that check pass at all: SHA256SUMS lists the
archive for both architectures and you downloaded one, so without it the other
is reported as FAILED open or read and sha256sum exits 1. It still fails, as
it should, when the archive it can read does not match.
From a checkout instead; --binary is unnecessary there, because its default is
exactly what the build produces:
cargo build --release --locked
sudo script/install-selfsigned.sh
The installer creates the volto system user, installs the binary, generates an
EC P-256 certificate valid for ten years, derives /etc/volto/config.toml from
the shipped example with a random password, installs and starts the systemd unit,
opens the port in ufw if it is active, and prints the fingerprint, the expiry
date and a pasteable Surge policy line. It asks for the certificate name when
neither --sni nor $SNI is set and a terminal is attached; everything else has
a default:
sudo script/install-selfsigned.sh \
--sni volto.internal \
--port 443 \
--username yourname \
--password 'or let it generate one'
The binary it installs is ./target/release/volto unless -b, --binary PATH
names another; that is how deploy.sh points a first install at the binary it
unpacked from a release tarball rather than at a local build. Each option that
takes a value can come from the environment instead — BINARY, SNI, PORT,
USERNAME, PASSWORD — and --help lists the full set.
A username may not contain a colon (RFC 7617) and may not be longer than 32
bytes (see configuration.md), and neither a username
nor a password may contain ", \, | or &: the first two cannot be
written into the generated TOML string, and the other two are metacharacters of
the substitution that writes it. The installer refuses them up front rather than
installing something other than what was asked for. Everything else printable is
accepted, * and . included, and a generated password never runs into this.
Before the generated config.toml is installed, the binary being installed is
asked whether it can load it — the same volto --check-config question
deploy.sh puts to a release on the update path. A no ends the run with the
binary’s own message and nothing written, so the cause can be fixed and the
installer re-run on a host it left untouched. A binary too old to know the flag
is not asked, and the install goes ahead as it always did.
Re-running is safe: an existing config file, certificate or user is kept.
--force regenerates the certificate only — it never rewrites config.toml, so
hand edits survive. Regenerating changes the fingerprint, and every client then
has to be updated.
Generating the certificate by hand instead:
sudo openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout /etc/volto/key.pem -out /etc/volto/cert.pem \
-days 3650 -nodes \
-subj "/CN=volto.internal" \
-addext "subjectAltName=DNS:volto.internal" \
-addext "basicConstraints=critical,CA:FALSE" \
-addext "keyUsage=critical,digitalSignature,keyEncipherment" \
-addext "extendedKeyUsage=serverAuth"
sudo chown volto:volto /etc/volto/cert.pem /etc/volto/key.pem
sudo chmod 0644 /etc/volto/cert.pem
sudo chmod 0640 /etc/volto/key.pem
openssl x509 -in /etc/volto/cert.pem -noout -fingerprint -sha256 # for the client
openssl x509 -in /etc/volto/cert.pem -noout -enddate # note the expiry
The SAN is what gets matched — a bare CN has not been accepted for years — so it must be present even though the name is fictional. The name need not resolve: the client connects to an address and uses this only as the SNI and the name to match.
What pinning changes, and why each point matters:
- The fingerprint is the trust anchor. Generate it on the server, and carry it to each client yourself. If it arrives over a channel someone else can rewrite, pinning buys nothing — an attacker supplies their own fingerprint.
- The private key never leaves the machine. Not into backups, not into chat.
- There is no revocation. If the key may have leaked, the only remedy is
regenerating (
--force) and updating the fingerprint on every client by hand. - Track the expiry date yourself. A pinning client usually skips validity and hostname checks, so an expired certificate may fail silently and only surface after a client update. The installer prints the date; put it in a calendar.
- Never use
skip-cert-verifyinstead of pinning. It disables server identity entirely, and Basic credentials are sent on every request — a man in the middle collects the username and password on first use.
Deploying from releases
script/deploy.sh turns the release page into the
deployment mechanism: it resolves the newest release (or the one given with
--tag), downloads the tarball for the host’s architecture, verifies it
against the release’s SHA256SUMS, and then does whichever of three things the
host needs:
- No install yet (
/etc/volto/config.tomlabsent): it runs the bundledinstall-selfsigned.shfor the full first-time setup, so the same flags and environment variables apply (--sni,--port,--username,--password). - Older version installed: it asks the release it is about to install
whether it can load this host’s
/etc/volto/config.tomland stops if it cannot (see before the swap); otherwise it keeps the current binary at/usr/local/bin/volto.prevand the current unit at/etc/systemd/system/volto.service.prev, swaps in the new one, refreshes the systemd unit and restarts. If the service is not running a few seconds later, both are restored, systemd is reloaded, the service is restarted, and the script fails loudly. The unit is covered because it is refreshed on every update and carries the hardening directives an older host is most likely to reject. - Already on that version, with the config and the unit in place: it exits
without touching anything. The presence checks are part of the deal —
deleting
/etc/volto/config.tomland re-running is the supported way to regenerate it (the certificate and the system user survive, so the fingerprint does not change). What decides this is the version string and nothing else, so a binary that reports the wanted version is left in place whatever its bytes are;--forcebelow is how it gets replaced.
--dry-run prints which of the three a run would pick and stops before doing
anything about it. It needs an explicit --tag, since resolving “latest” is a
network call, and it is how tests/it_deploy.rs exercises the decision without
root, systemd or a download.
--force takes the download, verify, install and restart path even when the
installed version already matches and the config and unit are both in place. It
needs --tag for the same reason --dry-run does: a reinstall is about the
bytes of one named release. Nothing else about the path changes, the rollback
guard included, and neither /etc/volto/config.toml nor the certificate is
rewritten. A dry run reports the same decision as dry-run: would reinstall v0.4.7. The case it is for is a release deleted and published again under the
same tag: the convergence check compares version strings, so a host that already
reports that version keeps the bytes of the first upload until a run is forced.
The script is also its own bootstrap. On a bare host, pipe it straight from the
repository and let it do the downloading — everything it installs comes out of
the checksum-verified release tarball, the piped copy only steers. With stdin
being a pipe it never prompts, so give --sni (and friends) explicitly or
accept their defaults:
curl -fsSL https://raw.githubusercontent.com/vcarus/volto/main/script/deploy.sh |
sudo bash -s -- --enable-timer --sni volto.internal --port 443 \
--username yourname --password 'or let it generate one'
Omitting --password is also fine, and usually better: the installer generates
18 random bytes — 144 bits, 24 characters — and prints the result in the final
Surge policy line. --username is independent, so naming the user still leaves
the password generated.
The no-op path is what makes it safe to run on a schedule:
sudo script/deploy.sh --enable-timer
installs the script as /usr/local/sbin/volto-deploy plus a systemd timer that
re-runs it daily (OnCalendar=daily, randomized by up to an hour, Persistent=
so a powered-off day is caught up). Every later deploy refreshes the installed
copy of the script from the release it just verified, so the timer keeps pace
with the repository. journalctl -u volto-deploy.service shows what each run
did; a failed update leaves the timer unit in a failed state, which is the
signal to go look.
Before the swap
On every update, and on a rollback above all, the script asks the binary it is about to install whether it can load the configuration this host already has:
volto --check-config --config /etc/volto/config.toml
If the answer is no, nothing is installed, the running service is not touched, and the run fails with the candidate’s own message — file, line and column — on stderr. Asking before the swap is the only time the answer is worth having: afterwards the service is already down, and the guard below has restored the release you were trying to leave.
Whether the candidate can be asked at all is decided by looking for the flag in
its own --help, not by running it and reading how it fails, so a release from
before the flag existed is never mistaken for a bad configuration. Such a
release simply goes unchecked — which means a rollback past the release that
introduced --check-config is exactly as unguarded as it always was, and gets
the advisory below instead.
Rolling back
Rolling back is the same flow pinned to an older release:
sudo volto-deploy --tag v0.1.0
Add --force when the host already reports the version being pinned to. The
convergence check compares version strings, so without it that run is a no-op
and the binary on the host stays where it is.
Before pinning anything older than v0.4.5, comment mtu_upper_bound out of
/etc/volto/config.toml: every install carries that key, no earlier release
knows it, and a volto refuses the whole file rather than the one key it cannot
read.
It is also the one flow nobody rehearses, so the script says the two things that bite on the way back before it does anything about them.
The config file goes back with nothing. The script never rewrites
/etc/volto/config.toml or the certificate — both belong to
install-selfsigned.sh’s first run and to you afterwards — and an older volto
refuses a key it does not know, refusing the whole file rather than the key,
so the service does not start at all. mtu_upper_bound is the one that bites in
practice: it reached the shipped example in v0.4.5, and every install is derived
from that example. The startup error names the file, the line and the column;
comment that key out and start the service, or comment out everything
introduced after the target release beforehand. See
version compatibility for which key
arrived when. On a rollback the check above catches this first, when the target
release is new enough to be asked.
The script’s own guardrail does not help here, and reads backwards if you are not expecting it: when the newly installed binary is not running a few seconds later, the previous binary and the previous unit are both restored, which on a rollback is the release you were trying to leave.
--tag is not a pin. The script carries no version pin of its own; it
converges on whatever the newest published release is, in either direction. So
a rollback left alone is undone by the next timer tick, within a day. Hold it
with
sudo systemctl disable --now volto-deploy.timer
or, if the release itself is the problem for every host, delete it from the releases page — that rolls every host back on its own next tick and needs no per-host action at all.
systemd
The shipped unit is script/masque.service. Manual
installation:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin volto
sudo install -m 0755 target/release/volto /usr/local/bin/volto
sudo install -d -o volto -g volto -m 0750 /etc/volto
sudo install -o volto -g volto -m 0640 script/config.example.toml /etc/volto/config.toml
sudo install -m 0644 script/masque.service /etc/systemd/system/volto.service
sudo systemctl daemon-reload
sudo systemctl enable volto
enable without --now on purpose: the service is not started yet. Then edit
/etc/volto/config.toml: set the cert/key paths and set [auth].users.
An empty user list means no authentication at all, and the password the example
ships is a placeholder published in this repository and in every release
tarball, so a server started before this edit accepts a credential anybody can
read. The server warns about both at startup and refuses neither, so the order
of these two steps is what keeps the port shut until the file is right.
Start it once that edit is made:
sudo systemctl start volto
The unit runs as a fixed system user rather than with DynamicUser=yes on
purpose: the private key must be readable by this service and nothing else,
which needs a stable owner to grant it to (chown volto:volto, mode 0640).
AmbientCapabilities=CAP_NET_BIND_SERVICE is what allows binding a low port
without root, and the rest of the unit is standard systemd hardening —
ProtectSystem=strict with ReadOnlyPaths=/etc/volto, a @system-service
syscall filter, no new privileges.
RUST_LOG overrides the configured log level without editing the config:
# /etc/systemd/system/volto.service.d/debug.conf
[Service]
Environment=RUST_LOG=volto=debug,quinn=info
Log lines carry a syslog priority when systemd is reading them, so journald’s own severity filter works rather than needing a text search:
journalctl -u volto -p warning --since -24h
Every line at info or above also carries a log_id field: eight lowercase
letters and digits naming that one statement, fixed for the life of the line
even if its wording changes. A runbook or an alert should match on the id rather
than on the words, which a later release may rewrite. This selects the warning a
connection that ended badly writes, and nothing else:
journalctl -u volto --since -24h | grep log_id=9pds6tk6
See [log] for the rest of the line format.
When a problem needs reporting rather than reading, volto --diagnostics --config /etc/volto/config.toml prints the version, the parsed configuration
with passwords redacted, descriptor limits, the UDP buffer sysctls and the
kernel, in one paste. The two RLIMIT_NOFILE lines are those of the process
that ran the command, so from an SSH shell they are the shell’s and not the
service’s. The bundle says so on the line under them, and on Linux it prints the
service’s own Max open files values as well, read from /proc/<pid>/limits of
every running volto process it finds. To read that by hand, systemctl show -p MainPID volto gives the pid. See collecting a support
bundle.
Firewall
QUIC is UDP. This is the single most common reason for “it works locally but the client cannot connect”:
sudo ufw allow 443/udp # only if ufw is installed and active
sudo ss -lunp | grep 443 # confirm volto is actually listening on UDP
A stock Ubuntu image has no ufw, so that first line is a command not found
rather than a missing rule — the installer says as much in its own words, “no
active ufw detected — open UDP 443 yourself if a firewall is in the way”. Open
the port wherever this host actually filters, if it filters at all.
A cloud provider’s security group needs the same rule, on UDP.
File-descriptor budget
Each tunnel — TCP or UDP — costs one descriptor, and one client multiplexes many
onto a single QUIC connection. The quota is per connection, so the number the
process has to have descriptors for is the product, plus a fixed 64 for the
listening socket, the request streams, stdio and the certificate a SIGHUP
re-reads:
limits.max_connections × limits.max_targets_per_conn + 64
The shipped defaults make that 256 × 256 + 64 = 65600, and the shipped unit sets
LimitNOFILE=131072, so a stock install has room over. The drop-in below is for
operators who raise either limit past that point: at max_connections = 512 the
number needed is 512 × 256 + 64 = 131136, past what the unit grants, and clients
at their quotas can then consume every descriptor the process has, leaving none
for the listening socket, for the certificate a SIGHUP re-reads, or for
anything else. Fd exhaustion is not a crash here — a tunnel whose socket()
fails is refused with a 500 and Proxy-Status: volto; error=proxy_internal_error,
one request at a time, and the tunnels already running are untouched — but it is
a degradation that hits every connection at once. That error type is what
distinguishes it from an unreachable destination: it is RFC 9209’s “internal
error unrelated to the origin”, so a burst of them in a client’s logs points at
this host’s descriptor budget rather than at the targets, and it carries no
next-hop because nothing was contacted. The same answer covers the other ways
a host can run out — no kernel buffer, no ephemeral port left to bind.
volto compares that number against RLIMIT_NOFILE at startup and warns when it
does not fit, which is worth heeding rather than silencing. Take the headroom in
whichever direction suits the host: raise LimitNOFILE, or lower
limits.max_connections, which is also the knob that bounds memory.
# /etc/systemd/system/volto.service.d/nofile.conf
[Service]
LimitNOFILE=262144
UDP socket buffers
On a high-bandwidth path the kernel’s UDP socket buffers are where packets are dropped first, and two different sysctls decide how big they are. Only one of them is a ceiling:
net.core.rmem_default/wmem_defaultis what a socket gets when the application never asks — about 208 KiB on a stock Linux.net.core.rmem_max/wmem_maxis the most an application may request. Raising it does nothing at all for a program that does not ask.
volto asks. limits.socket_recv_buffer and limits.socket_send_buffer are
requested when the socket is created — DEFAULT_SOCKET_RECV_BUFFER = 2 MiB and
DEFAULT_SOCKET_SEND_BUFFER = 2 MiB by default — so on this server the ceiling
is the sysctl that matters:
sudo sysctl -w net.core.rmem_max=4194304
sudo sysctl -w net.core.wmem_max=4194304
Sized so the default request fits with room to spare; put the same lines in
/etc/sysctl.d/ to survive a reboot. When the kernel caps the request instead,
volto warns at startup and names the sysctl to raise, so a host that never had
these touched says so in its own log rather than dropping packets quietly.
Two readings that look wrong and are not. Linux reports a granted buffer as
double the size — the accounting includes per-packet overhead — so a satisfied
2 MiB request shows as rb 4194304 in ss -uanpm, and that same number is what
the startup line prints as so_rcvbuf. And both keys take effect only when the
socket is created: a reload does not rebind it, so changing them needs a
restart. 0 hands the size back to the operating system.
Reloading
systemctl reload volto sends SIGHUP. volto re-reads the configuration file and
applies it to connections accepted from then on: a renewed certificate, a changed
user list, a raised or lowered limits.max_connections, changed transport
parameters. Established connections keep the
configuration they were accepted with — a tunnel’s rules must not change
mid-transfer, and QUIC cannot renegotiate transport parameters anyway.
That matters when the change is meant to revoke access: a client that still holds
an established connection keeps working on the old credentials, and keep-alives
routinely hold one open across long idle periods. Use systemctl restart volto
rather than reload when the old credentials must stop working — a restart
closes those connections after the shutdown grace period (see
Graceful shutdown) instead of leaving them running.
A reload is all-or-nothing. Parsing, validation and certificate loading all happen before anything is swapped in, so there is no state where a new certificate is paired with an old user list. If the file is broken, volto logs the error and keeps running on the previous configuration; it never exits. That property is the point: the process sending this signal is usually a renewal hook running unattended, and a typo must not become an outage.
Graceful shutdown
SIGTERM stops the endpoint from accepting new connections, sends GOAWAY on the
established ones and waits for their tunnels to finish, up to
server.shutdown_grace (default 5 s). The default is short on purpose: a
client that keeps using a connection after GOAWAY instead of opening a new one
— Surge does — has every new request fail until the drain ends, so a long
grace period trades a longer outage for every new request against finishing the
transfers already in flight. Raise it if long transfers matter more to you than
a few seconds of failed requests at each restart. Keep systemd’s
TimeoutStopSec comfortably above whatever you choose — the shipped unit uses
45 — so systemd does not send SIGKILL mid-drain. An hour is the most that can
be configured: the grace period is the bound the drain is built around, and a
value past that would only hand the ending back to SIGKILL.
The GOAWAY carries an identifier, and it is a promise in both directions.
Requests below it were already accepted and are still served during the drain,
tunnel and all, even if the client only finishes sending them after the signal.
Requests at or past it are rejected with H3_REQUEST_REJECTED, which tells the
client they were not processed and may be retried on another connection.
Sending the GOAWAY is itself bounded by limits.max_idle_timeout, because a
peer decides when a write to it completes: one that grants no flow-control
window on the control stream would otherwise hold that connection’s drain open
for the whole grace period, and with it the process. A connection whose peer will
not take the frame within that bound drains without one and is closed when its
tunnels end, exactly as it would have been otherwise.
A SIGHUP that arrives once the drain has begun is refused and logged: the listener has been closed by then, and reopening it to accept handshakes that are seconds from being closed again would be worse than doing nothing. Reload before you stop, not during.
Running behind a UDP relay
volto needs no special configuration to sit behind a plain layer-4 UDP forwarder, because TLS terminates only at volto itself. The relay moves opaque UDP packets and holds no key material. Three things are worth knowing:
-
Keep the relay’s UDP conntrack timeout above the keep-alive interval. On Linux
nf_conntrack_udp_timeoutdefaults to 30 seconds. volto sends keep-alives every 20 seconds by default, which refreshes the mapping in time; if your relay expires entries faster, lowerkeep_alive_intervalwell under that value and lowermax_idle_timeoutwith it (the keep-alive must stay below half the idle timeout, and volto refuses to start otherwise). -
Issue certificates with DNS-01 when the domain’s A record points at the relay — the other challenge types validate against that address and cannot reach volto; see ACME with DNS-01 above.
-
Point the client at the relay’s address, and the certificate name at the domain. In Surge that is
sni=plusserver-cert-verify-name=:volto = masque, 203.0.113.10, 443, sni=example.com, server-cert-verify-name=example.com, username=user1, password=…
One consequence to plan for: every client then reaches volto from the relay’s address, so per-IP banning at the server cannot distinguish them — see fail2ban below.
A second one: the rule that refuses a tunnel to one of this host’s own addresses
cannot see the relay’s, because no interface here carries it, so a target that
names the relay is judged like any other public address. See
[security].
fail2ban
A failed authentication logs one stable WARN line carrying the source address:
WARN ... authentication failed ... remote=203.0.113.7:5678 username="user1" reason="credentials rejected"
/etc/fail2ban/filter.d/volto.conf:
[Definition]
failregex = ^.*authentication failed.*remote=<HOST>:\d+.*$
ignoreregex =
/etc/fail2ban/jail.d/volto.conf:
[volto]
enabled = true
backend = systemd
journalmatch = _SYSTEMD_UNIT=volto.service
filter = volto
maxretry = 10
findtime = 10m
bantime = 1h
# QUIC is UDP: the action has to ban the UDP port.
action = iptables[name=volto, port=443, protocol=udp]
Check first that the logged address actually distinguishes clients. Any NAT
in the path rewrites it: behind a UDP relay every client appears with the relay’s
address, and a server behind carrier-grade NAT can see all inbound traffic
rewritten to one gateway address. Verify by connecting yourself and comparing
remote= against your own public address. If they do not match, or if every
connection shares one address, banning by IP bans everyone — drop fail2ban and
rely on the connection-level limit instead, or run the ban on the machine
closest to the internet that still sees real client addresses.
That connection-level limit needs no configuration and is unaffected by topology:
after security.max_auth_failures failures (default 5) the whole QUIC connection
is closed, so an attacker pays for a full QUIC and TLS handshake every N guesses.
A failure is one credential value tried and refused rather than one refused
request: a request may carry credentials under either of the two accepted field
names, both are charged, and a request carrying more than two credential values
is answered 400 without any of them being tried. So a guess costs a guess
however many the attacker packs into one request.
Architecture
MASQUE is not one protocol but a set of IETF specifications that put proxy tunnels on top of HTTP/3. A classic HTTP proxy gives you one TCP tunnel per connection; here one QUIC connection multiplexes arbitrarily many TCP and UDP tunnels, and looks like ordinary HTTP/3 traffic from the outside.
CONNECT-UDP (RFC 9298) UDP proxying semantics
classic CONNECT (RFC 9114 §4.4) TCP proxying semantics
HTTP Datagrams + Capsules (RFC 9297)
Extended CONNECT (RFC 9220)
HTTP/3 (RFC 9114) + QPACK (RFC 9204)
QUIC (RFC 9000) + DATAGRAM frames (RFC 9221)
TLS 1.3 (RFC 8446 / RFC 9001) over UDP
RFC 9298 stands on 9297 and 9220; 9297 stands on 9221. The TCP path needs none of them — only RFC 9114 itself.
The path of a request
quic.rs owns the quinn endpoint. Transport parameters come from [limits], so
the stream cap, idle timeout, keep-alive, MTU settings, congestion controller and
initial RTT are configuration rather than constants; they apply to connections
accepted from then on, including across a SIGHUP reload. The stream cap is the
one of them that does not reach the handshake as itself; what an unauthenticated
connection is worth is further down this section.
The UDP socket underneath is bound here rather than by quinn::Endpoint::server,
which is a bare std::net::UdpSocket::bind and never sets SO_RCVBUF/SO_SNDBUF
— a server that does not ask keeps net.core.rmem_default whatever rmem_max
says. limits.socket_recv_buffer / socket_send_buffer are requested at that
moment and the granted sizes read back, so a capped request becomes a startup
warning rather than silent packet loss. Being a property of the socket rather
than of a connection, these two are the only [limits] keys a reload cannot
change.
The SNI gate
gate.rs sits between that socket and quinn. It is an AsyncUdpSocket wrapper,
so it sees every datagram before the QUIC layer does, and it is off unless
[security] expected_sni names at least one host — an empty list, the shipped
default, passes everything through untouched.
It exists because quinn’s endpoint is conservative but not silent. A long-header
packet naming a version it does not speak draws a Version Negotiation packet; an
Initial whose Destination Connection ID is shorter than the eight bytes RFC 9000
§7.2 requires draws a CONNECTION_CLOSE; a short-header packet for no live
connection can draw a Stateless Reset. All three are correct answers to a QUIC
peer, and all three are answers to a port scan. With the gate open, a datagram
that would draw one is discarded first.
The judgement is made on the first packet in the datagram, which is enough for
the whole of it: RFC 9000 §12.2 both lets a receiver “route based on the
information in the first packet contained in a UDP datagram” and forbids a sender
from coalescing packets with different connection IDs into one. A short header passes — the gate
holds no connection state to judge it with. A long header naming a version other
than 1 is refused. A long header that is not an Initial passes, because quinn
drops those for an unknown connection without a word. An Initial in a datagram
below 1200 bytes passes for the same reason: RFC 9000 §14.1 has the server
discard it, and quinn does, silently. What is left is an Initial that could start
a connection. Its Destination Connection ID is judged first, on the header alone
and before any decryption: RFC 9000 §7.2 gives a client’s first Initial a
Destination Connection ID of at least eight bytes, every later Initial of an
admitted handshake is addressed by the eight bytes this endpoint chose, and so is
the one a Retry supplies, so nothing legitimate is shorter. quinn applies the
same floor ahead of its own decryption, in early_validate_first_packet. A
shorter one is refused here, which is the CONNECTION_CLOSE above never sent.
The packet is then opened: the keys for it are derived from the Destination
Connection ID in the packet itself (RFC 9001 §5.2), so no connection state is
needed to read it. Its CRYPTO frames are assembled from offset zero,
the ClientHello is parsed, and the server_name extension (RFC 6066 §3) is
compared with the configured list — ASCII case-insensitively, with a trailing
root dot ignored, name for name and no wildcards.
A refused datagram is not removed from the receive batch, because one buffer can hold a whole GRO run described by a single stride. It is left in place with its Destination Connection ID length byte overwritten by a value larger than the 20 bytes RFC 9000 §17.2 allows, which quinn’s own packet decoder rejects before it reads the version, the type or anything else — and rejects by returning rather than by answering.
Two deviations from a SHOULD are deliberate and are the point of the feature.
RFC 9000 §5.2.2 says a server “SHOULD send a Version Negotiation packet” for a
large enough packet naming an unsupported version; with the gate open it sends
nothing. RFC 6066 §3 says a server that does not recognise the name “SHOULD take
one of two actions: either abort the handshake by sending a fatal-level
unrecognized_name(112) alert or continue the handshake”; the socket-level check
does neither, and the second check below aborts with a different alert.
That second check is in tls.rs. The gate reads only what arrives in the first
Initial packet, and a ClientHello large enough to be split across several of them
is passed through on purpose — refusing a first flight whose extensions have not
arrived would make a large ClientHello unreachable. Such a handshake is stopped
by a ResolvesServerCert that declines to present a certificate for a name that
is not on the list, which rustls turns into a fatal access_denied alert. It is
not a certificate selector: there is still one certificate and one key, and
naming several hosts means answering to all of them with the same certificate.
Stateless resets are deliberately left uncovered — see D106 and the configuration reference for what that leaves visible and why filtering on the address instead would break connection migration. So is the acknowledgement quinn returns for an Initial the gate passes that carries an ack-eliciting frame and no name — a PING on its own, or a ClientHello the gate could not finish reading — because refusing those means refusing a large first flight, which this design will not do. None of this is traffic obfuscation, which stays a non-goal: the gate hides that a service is here from somebody who does not know the name to ask for, and says nothing about what a connection looks like once one is open.
Each accepted connection is handed to h3api::Connection::handshake, which must
advertise both SETTINGS_ENABLE_CONNECT_PROTOCOL (0x08) and
SETTINGS_H3_DATAGRAM (0x33). Surge checks for both and disconnects if either
is missing; this is the first thing to suspect when a client drops immediately
after a successful TLS handshake. The frame also carries
SETTINGS_MAX_FIELD_SECTION_SIZE (MAX_FIELD_SECTION_SIZE = 64 KiB), both
QPACK settings as zero, and one reserved “grease” identifier of RFC 9114
§7.2.4.1’s 0x1f * N + 0x21 form, which that section says endpoints SHOULD send
so peers keep exercising the rule that unknown identifiers are ignored.
conn.rs runs the accept loop and dispatches each request stream on the
:protocol pseudo-header, after authenticating it:
- absent, method CONNECT →
tunnel/tcp.rs connect-udp→tunnel/udp.rs- anything else → 501
Authentication deliberately runs before routing, so an unauthenticated client
gets 407 rather than learning from a 501 which :protocol values this server
implements — but after the connection-specific-field check described below, so
a peer that sends one of those fields gets 400 whether or not it has
credentials.
That 501 is a real one for every :protocol value, not only the ones this
server has heard of. The token is carried through as the bytes that arrived, so
connect-ip, webtransport and anything else are answered with the status
RFC 9220 asks for and logged under the name the client actually sent, rather
than being refused as malformed before anything can look at them.
The routing above and the per-connection context both live in tunnel/mod.rs,
beside the target resolution the two tunnel kinds share. Two concerns that are
neither are files of their own: tunnel/status.rs holds every refusal and the
RFC 9209 Proxy-Status vocabulary refusals are written in, and
tunnel/quota.rs the per-connection tunnel budget and the guards that spend it.
Every wait that stands between a packet arriving and a tunnel opening is
bounded, whatever the connection’s authentication state, and two limits share
the job. All but two of those waits are bounded by limits.max_idle_timeout:
the QUIC/TLS handshake in quic.rs, the HTTP/3 handshake and the read of each
peer unidirectional stream’s type in h3/connection.rs, the read of a request
stream’s HEADERS in Resolver::resolve, and every response this server writes
before a tunnel starts relaying — 400, 403, 407, 431, 501 and the 5xx family,
the 200 that closes on the spot, and the 200 that opens a tunnel — in
Stream::respond_within. The two exceptions are resolving the target’s name and
connecting to it, which draw on limits.connect_timeout instead: the lookup
gets that budget once, the connect gets it once for the whole list of addresses
rather than per address, and 0 hands both waits back to the operating system.
The idle-timeout bound is needed because the keep-alive PINGs this server sends
are answered by the peer’s QUIC stack with no application ever involved, so the
transport’s own idle timer never fires on a peer that is present but says
nothing; the response writes need it because a peer that grants no flow-control
window never takes even the fifty-odd bytes of a 407. A tunnel’s own 200 is
bounded like the rest: the pumps that would otherwise end the wait do not exist
until that write returns, and a lapsed one resets the stream and drops the
target socket the request had already opened, with a reset rather than a FIN.
One further bound applies until a request on a connection has passed the
credentials check: twice limits.max_idle_timeout for one request to
authenticate. It is armed once at the handshake and never re-armed by a new
stream, so what it bounds is the connection rather than a pause in it; twice, so
that the transport’s own idle timeout stays the first thing to fire on a peer
that has simply gone away. The clock is read on each pass as well as awaited,
because awaiting it alone is not enough: a timeout only reports a lapsed
deadline on a poll where the work underneath it was not already finished, so a
peer with another request stream always queued — one opened and finished empty
costs it nothing and the server nothing but a stream error — was never measured
against the deadline at all. Without it a peer that finishes the QUIC handshake
and then says nothing holds a max_connections slot for as long as it keeps its
socket open. It is lifted for the life of the connection the moment one request
authenticates, so a client reusing an idle connection between requests is
untouched. A lapsed connection bound closes the connection with H3_NO_ERROR,
which is not an error and is logged as the idle ending it is; a lapsed HEADERS
bound resets that one stream with H3_REQUEST_INCOMPLETE and a lapsed response
write resets it with H3_REQUEST_CANCELLED, leaving everything else on the
connection running.
That bound is on time. The second one on an unauthenticated connection is on
size, and it is a transport parameter: the handshake advertises
INITIAL_BIDI_STREAMS = 16 concurrent bidirectional streams rather than the
configured limits.max_streams_bidi, and the first request to pass the
credentials check raises the connection to the configured value in one step.
Before it, a peer that has proved nothing holds at most 16 request streams — 16
parked request tasks and 16 refusals to write, where 1024 of each were free —
and its whole HEADERS buffering is bounded twice over by the same number, since
16 field sections at the MAX_FIELD_SECTION_SIZE = 64 KiB per-frame cap are
exactly the HEADERS_BUFFER_BUDGET = 1 MiB budget below. Running into the
allowance is backpressure rather than an error: the peer’s own stack holds the
stream, and closing streams returns credit before authentication as after it. A
client that fires more than 16 CONNECTs at once therefore waits for the raise,
which happens before its first request has resolved a name or opened a socket,
so what it waits is the round trip it was already waiting for that first answer.
An operator who configures fewer than 16 gets what they configured throughout:
the clamp only ever lowers.
That bound holds one connection; it does not bound how many of the
limits.max_connections slots unauthenticated peers hold between them, and a
peer that completes a handshake about once a second and never sends a credential
would fill every one of them legitimately, each slot bounded, all of them
replaced. So at the cap a new connection does not simply lose: the endpoint
takes the slot of the oldest connection that has never authenticated and admits
the newcomer, and only refuses when every live connection has authenticated. The
evicted connection is closed with H3_NO_ERROR — nothing about it failed — and
logged like the other idle endings, with reason=evicted; one whose QUIC
handshake had not finished has already been accepted at the transport layer, so
what its peer receives is a CONNECTION_CLOSE carrying APPLICATION_ERROR and
an empty reason rather than the CONNECTION_REFUSED a refusal sends. A
connection that has authenticated is never a candidate, and below the cap
nothing of this runs. There is no sub-quota for unauthenticated connections
because it would not help: a legitimate client is unauthenticated at accept time
too, so it would be squeezed by exactly the pool it is trying to join.
Evicting on the word of an unverified source address would be worse than the
problem: a spoofed Initial costs an attacker one datagram and no return path, so
a flood of them would empty the roster and start a TLS server flight per packet.
At the cap, a newcomer whose address QUIC has not yet validated is therefore
answered with a Retry (RFC 9000 §8.1) — it takes no slot and no crypto — and
only the newcomer that comes back with the token may evict anybody. The extra
round trip is charged to an address the server holds no token from: quinn’s
bloom feature is on, so the server sends NEW_TOKEN frames on every connection
it validates (RFC 9000 §8.1.3) and a client that kept one comes back already
validated and evicts without a Retry — first contact, a token past its two-week
lifetime, or a token sealed before the last restart or SIGHUP are what pay.
Below the cap no Retry is sent at all and the handshake is what it always was.
Target address selection
Both tunnel kinds resolve their target through one function in tunnel/mod.rs,
which is also where the resolved list is ordered by
limits.ip_family_preference — before the destination policy filters it and
before either tunnel dials anything, so the two cannot disagree about which
family goes first. The default is IPv4-first, deliberately unlike getaddrinfo, which
orders by RFC 6724 and so puts global IPv6 ahead of IPv4 on any host with an
IPv6 route. That ordering is an operator policy on a proxy rather than a
resolver detail: the TCP path walks the list in order, so the non-preferred
family costs a full connect attempt, and the CONNECT-UDP path has no failover at
all — connecting a UDP socket only asks the kernel for a route, so the first
address with one wins outright. The reorder is a stable partition, leaving
RFC 6724’s ordering within each family intact; system opts out of it entirely.
The name lookup budget
Name resolution is the one place in this server where a request reaches a
resource it cannot give back on demand. tokio::net::lookup_host runs
getaddrinfo on the runtime’s blocking thread pool, and that call cannot be
cancelled: when limits.connect_timeout expires the client is answered, but the
thread stays in the resolver until the stub gives up on its own — five seconds
per attempt on a stock resolv.conf, tens of seconds when the nameserver
answers nothing at all. The pool is process-wide, so without a bound one
client’s black-holed names park threads that every other connection’s lookups
need, including names that would have resolved from /etc/hosts without a query.
So the pool is rationed in two tiers, in net.rs:
- One reserved lookup slot per connection, which nothing else can take. No number of hostile connections can starve a connection outright; at worst its lookups run one at a time — a lookup queued for the shared allowance keeps its claim on the reserved slot too, and takes it the moment it frees.
- A server-wide allowance on top of that, capped per connection, so ordinary use resolves a page’s worth of tunnels in parallel while no single connection can hold more than its share.
The permit is dropped by the blocking task rather than by the request that asked
for it — a permit released when the client is answered would say a thread is
free while it is still inside the resolver, which is exactly how a client could
park the pool one connect_timeout at a time. An IP literal never reaches the
resolver and never takes a permit.
The blocking pool is sized from limits.max_connections at startup for the same
reason: it has a thread for every slot the budget can hand out, plus headroom.
Threads are created on demand and reaped when idle, so this is a ceiling rather
than an allocation. Because it is fixed when the process starts, a SIGHUP that
raises max_connections does not resize it.
TCP tunnels
tunnel/tcp.rs resolves :authority explicitly — not implicitly through
TcpStream::connect((host, port)), because the destination policy has to see the
resolved address — answers 200, and then runs two independent pumps with full
RFC 9114 §4.4 half-close semantics:
| Event | Behaviour |
|---|---|
| client finishes the stream | shutdown(Write) on the target socket, not a full close |
| target EOF | finish the send side of the response stream |
| target RST or error | reset the stream with H3_CONNECT_ERROR |
| client resets the stream, or stops reading it | close the TCP connection with a reset, and cancel the direction the client left alone with H3_REQUEST_CANCELLED |
Collapsing this into “one side closes, everything closes” breaks every protocol
that depends on half-close. The abnormal paths coordinate through a sticky
watch teardown channel so that neither pump can strand the other.
While both directions are live, each pump is the other’s watchdog and nothing in
a tunnel is on a timer: whatever one pump notices — a reset stream, a target
that fails — raises a teardown, and that teardown is what ends the other’s wait.
The first two rows above are the endings that take the watchdog away, because
they end a direction without a teardown. So from the moment one direction has
ended cleanly, each write in the surviving direction is bounded by [limits] udp_session_timeout — the same knob a CONNECT-UDP session’s idle bound comes
from — and a write that does not complete within one of those cuts the tunnel:
the request stream is reset (H3_REQUEST_CANCELLED when it is the client that
stopped reading after its own FIN, H3_CONNECT_ERROR when it is the target that
stopped reading after its own EOF) and the target socket is closed abortively —
though on the client-FIN path the proxy’s clean FIN has already reached the
target, and a kernel that does not reset from FIN_WAIT_2 (Linux) closes silently
behind it, so only the resources are reclaimed there. Every write gets its own
budget, so a client that keeps reading may take hours to drain a download after
its FIN — but progress alone does not save a write that never finishes, which
puts a floor of roughly one RELAY_BLOCK_SIZE = 64 KiB relay chunk per budget
under how slowly a half-closed download may be drained (about one 1.4 KB piece
per budget in the other direction). A tunnel whose two directions are both still
open is never on a timer at all, however long a write parks.
The bound covers the parked write half of the problem and only that. A
half-closed tunnel whose surviving direction is parked in a read — no bytes
in either direction — is deliberately not put on a timer, because a target that
has taken the client’s FIN may legitimately take minutes to answer and cutting it
would break the half-closes this proxy exists to carry; what limits those is
capacity rather than time, max_connections × max_targets_per_conn sockets.
Without the write bound, a half-closed tunnel whose surviving peer stopped taking
bytes held the target socket, its file descriptor and the tunnel slot until the
QUIC connection ended — which this server’s own keep-alives can postpone
indefinitely.
Not on a timer is not unwatched. Such a tunnel still ends the moment the client
says it wants no more of it: the response direction watches for the client’s
STOP_SENDING — the other half of what dropping a request stream sends — even
while there is nothing to write, so a client that half-closed and then walked
away releases the target socket and its tunnel slot at once rather than at the
end of the QUIC connection. Only a client that is still waiting for an answer
keeps a quiet half-closed tunnel alive.
The last row cancels both directions because RFC 9114 §4.4 asks for it: “If the stream is reset or reading is aborted by the client, a proxy SHOULD perform the same operation on the other direction in order to ensure that both directions of the stream are cancelled.” Without it a client that reset only its sending side had the response direction finished with a clean FIN, so a truncated response read as a complete one, and a client that sent STOP_SENDING had its own sending side stopped with code 0 rather than with an HTTP/3 code.
UDP tunnels
tunnel/udp.rs parses the RFC 9298 well-known path template. Surge has no URI
template parameter, so it uses the default template the RFC defines for exactly
that case:
https://$PROXY_HOST:$PROXY_PORT/.well-known/masque/udp/{target_host}/{target_port}/
Parsing is deliberately lenient: percent-decoding, an optional trailing slash, a
port in 1..=65535 and an empty query. IPv6 literals arrive without brackets,
with the colons escaped (2001:db8::42 → 2001%3Adb8%3A%3A42) per RFC 9298
§3; the bracketed form is also accepted.
Two ordering rules that are easy to get wrong:
- Answer 2xx immediately — do not wait for the target to send anything. UDP is connectionless and the proxy cannot know whether the target is reachable.
- But resolve DNS first. When
target_hostis a name, resolution MUST complete before the response; a failure is refused with 502 andProxy-Status: volto; error=dns_error(RFC 9209). “Immediate” means “without waiting for the target”, not “without resolving”.
The target socket is connected, so packets from anywhere else are dropped by the
kernel. Sessions have their own idle timeout (default
DEFAULT_UDP_SESSION_TIMEOUT = 180 s; RFC 9298 §3.1 asks for at least 120),
where idle means no packet crossed the proxy: the clock is re-armed by a payload
reaching the target or the target answering, never by bytes alone, so a peer
dripping fragments of a capsule that never completes is reclaimed on schedule.
As on the connection bound above, that clock is read on each pass and not only
awaited: a peer with another payload always queued — the ones the amplification
cap drops re-arm nothing, and cost it only bandwidth — kept the session’s wait
finishing on its own and the deadline was never measured against it. Closing the
socket must also close the request stream.
Datagram routing
Each connection routes inbound datagrams to per-session channels from its own
background task, in src/h3/connection.rs — the same task that reads the peer’s
unidirectional streams, since both belong to the connection and both end with
it. A peer may hold MAX_PEER_UNI_STREAMS = 16 of those open at once (a
transport parameter rather than a configuration key) where HTTP/3 needs three,
and each one’s type has to arrive within an idle timeout. Routing is per
request stream, which is why it lives in the HTTP/3 layer: a session claims
its Quarter Stream ID by asking its stream for a DatagramReceiver and holds
the claim exactly as long as it holds that receiver, so a session that ends —
however it ends — takes its routing entry with it. Sending is the other half and
stays outside: a UDP session writes its datagrams straight onto the
quinn::Connection.
Two fields decide where a packet goes, and getting either subtly wrong is silent:
- Quarter Stream ID = stream ID ÷ 4 (RFC 9297 §2.1), not the stream ID. Client-initiated bidirectional stream IDs are always multiples of four, so the low two bits carry no information and dividing saves encoding space. The old draft term “Flow Identifier” is gone, and there is no separate mapping table. A value no session owns is dropped, since a session can close with packets still in flight — but a value that cannot be a stream ID at all (above 2⁶⁰-1, or a datagram too short to parse one) closes the whole QUIC connection with H3_DATAGRAM_ERROR (0x33), which RFC 9297 §2.1 states as a MUST.
- Context ID, a varint at the head of the payload (RFC 9298 §5).
0means a raw UDP payload; anything else is an extension. An unknown Context ID is dropped silently — never a connection error — and so is a truncated one, which no requirement covers.
Every such drop — an unknown Context ID, a truncated one, an unclaimed Quarter
Stream ID, or a session’s inbound queue already full — is counted, and the
total is reported as dropped_datagrams= on the connection’s closing log line,
since the drops themselves are required to be silent.
Get either wrong and the symptom is “the handshake succeeds, the tunnel is
established, and not one packet gets through”, with no error anywhere. The
encoder and decoder are hand-rolled in src/datagram.rs — about thirty lines,
which is why no dependency is carried for them.
Routing uses a bounded channel and try_send: a session that is not draining
its queue loses packets, which is correct UDP behaviour, instead of blocking the
routing task and starving every other session on the connection. A CONNECT (TCP)
tunnel never claims a receiver, so it never appears in the table at all.
The capsule stream
The request stream of a CONNECT-UDP tunnel is not an empty stream. Its body is a
sequence of capsules (RFC 9297 §3), each Type varint + Length varint + Value,
handled by src/capsule.rs as an incremental TLV decoder:
- Requests and responses carry
Capsule-Protocol: ?1; a response using capsules must not carryContent-Length,Content-TypeorTransfer-Encoding. - Unknown capsule types are skipped by reading the length and discarding the value edge-to-edge, with no accumulation — a peer declaring a 2⁶²-byte capsule costs no memory. A truncated capsule is a malformed message.
- The DATAGRAM capsule (type 0x00) is the reliable fallback channel for when QUIC datagrams are unavailable. It is a fallback, not a downgrade: a target packet too large for a QUIC datagram is dropped rather than re-routed through the capsule stream, which would break end-to-end unreliability and defeat the inner path-MTU discovery.
Treating the stream bytes as opaque is a worse trap than the Quarter Stream ID: the decoder desynchronizes on the first capsule that arrives.
RFC 9297 §2.1.1 also forbids sending QUIC datagrams before the peer has
advertised SETTINGS_H3_DATAGRAM. The flag is one shared atomic rather than a
value each session snapshots: the task that reads the peer’s control stream
writes it and every session reads it once per packet, so a session opened before
the SETTINGS arrived moves onto QUIC datagrams the moment they do. The module
comment on src/h3/connection.rs records what the sampled version cost.
The HTTP/3 layer
HTTP/3 is implemented in src/h3 rather than taken from a crate. What it covers
is what a CONNECT proxy needs, and it is stated in full:
- Framing (RFC 9114 §7) — an incremental decoder that hands DATA payload on
in the chunks quinn delivered it in, buffers every other frame because none of
them can be acted on piecewise, and skips an unknown frame type by its declared
length without allocating for it. A frame’s type is judged before its
length: the decoder knows which of the three kinds of stream it is reading —
the peer’s control stream, a request stream, or a request stream whose CONNECT
has been answered — and a frame type that may not appear there is a connection
error at any size. The number of reserved or unknown frames a request stream
may make the decoder skip before its request arrives is bounded too (RFC 9114
§10.5): grease and padding are legitimate (§7.2.8), but a flood of them — zero-
length frames included, which no size bound would catch — is refused for
H3_EXCESSIVE_LOAD. The limit is generous, per stream, and answered with a bare reset rather than a 431, since no field section was ever too large. - QPACK (RFC 9204) against the static table, including Huffman decoding (RFC 7541 Appendix B).
- The control stream — SETTINGS, GOAWAY, and the rules about what may appear
there, read by a task of its own for as long as the connection lasts. RFC 9114
§6.2.1 makes a control stream that is closed at any point a connection error,
and that holds in both directions: a peer that finishes its own, and a peer
that stops this endpoint’s with STOP_SENDING, are each answered with a
CONNECTION_CLOSE carrying
H3_CLOSED_CRITICAL_STREAM. - Request validation — RFC 9114 §4.1.2, §4.3 and §4.4 plus RFC 8441 §4: the point at which a field section becomes a request or is refused as malformed.
- The message vocabulary — a status, a method, a request and a list of field
lines, in
src/h3/message.rs. These were thehttpcrate’s until decision D78; carrying it meant every decoded field was validated and copied a second time into aHeaderName/HeaderValue, and the pseudo-headers were folded into a URI that the tunnels then took apart again. A field section is now decoded once, validated once, and carried in one type, and the crate is gone from the dependency tree.
What it leaves out, deliberately:
- The QPACK dynamic table.
SETTINGS_QPACK_MAX_TABLE_CAPACITYandSETTINGS_QPACK_BLOCKED_STREAMSare both advertised as 0, which RFC 9204 §3.2.3 and §2.1.2 make binding on the peer’s encoder. A field line referencing a dynamic entry is therefore a protocol violation answered withQPACK_DECOMPRESSION_FAILED, not a gap in the implementation — and the eviction and head-of-line blocking that go with a dynamic table are gone with it. The peer’s encoder and decoder streams are still read, instruction by instruction: with no table, an insertion, a capacity above zero, a Section Acknowledgment or an Insert Count Increment is the connection error RFC 9204 §3.2.2, §4.3.1, §4.4.1 and §4.4.3 make it, and only a zero capacity and a Stream Cancellation pass. - Huffman encoding. A response here is a status line and at most two short fields; compressing it would save a handful of bytes per tunnel. Decoding is implemented because a client’s request arrives Huffman-coded.
- Server push and WebTransport. A push stream from a client is a connection
error either way (RFC 9114 §6.2.2), and
webtransportis a:protocolthis server answers 501 to. The push bookkeeping frames are still judged: every CANCEL_PUSH names a push this server never promised (RFC 9114 §7.2.3), and a MAX_PUSH_ID may not shrink (§7.2.7) — bothH3_ID_ERROR.
A connection error is a quinn::Connection::close carrying the HTTP/3 code,
which is exactly what RFC 9114 §8 defines one to be. Nothing has to be
propagated between tasks: closing the connection makes every operation on it
fail on its own, so only the reason is recorded on the way past — quinn
overwrites its own stored reason with “closed locally” — and read back by the
accept loop.
Which frame types may appear is decided first, and from the frame header alone.
SETTINGS, GOAWAY, CANCEL_PUSH, MAX_PUSH_ID and PUSH_PROMISE on a request stream
are a connection error of type H3_FRAME_UNEXPECTED whatever length they declare
(RFC 9114 §7.2.3–§7.2.7, and §4.1 for GOAWAY, whose “wrong stream” rule §7.2.6
states for a client only). Once a CONNECT has been answered, RFC 9114 §4.4
narrows the stream to DATA and unknown types, so a HEADERS frame there is a
connection error too. Ordering these ahead of the size checks is what keeps a
431 from being the answer to something that is not a field section, and what
stops a frame the peer was never allowed to send from being charged for.
Field sections are bounded at MAX_FIELD_SECTION_SIZE = 64 KiB, advertised as
SETTINGS_MAX_FIELD_SECTION_SIZE so a peer that respects SETTINGS never sends
more, and enforced on receipt so one that does not gets no further. The frame
layer refuses an oversized non-DATA frame from its declared length, before a
byte is allocated for it.
That bound is per frame, and a peer may open as many streams as its transport
parameters allow. What one connection may hold in frames it has announced but
not finished is therefore bounded separately, at HEADERS_BUFFER_BUDGET = 1 MiB
summed over its request streams: a frame is charged for the length it announced
the moment the decoder commits to buffering it, and the charge is returned when
the frame completes or when the stream carrying it ends. The request that would
take a connection past that is refused with 431 and stopped with
H3_EXCESSIVE_LOAD, which is the same answer a single oversized field section
gets; the connection and its tunnels are untouched. A CONNECT request with
credentials is a few hundred bytes, so every request stream the transport allows
could be mid-HEADERS at once and still be using a tenth of the budget — what
reaches it is sixteen field sections of the largest advertised size in flight
together, and the seventeenth of those is what is refused. Sixteen streams at
once is also the whole of what an unauthenticated connection may hold, so a peer
that has proved nothing can meet this budget and never overshoot it: the 431
belongs to a connection past the credentials check, and against such a
connection’s 1024 streams it is the only thing standing. Only frames that are
legal where they arrived are counted, since the type verdict comes first: a peer
cannot hold the budget with PUSH_PROMISE frames on request streams, nor with
field sections announced on tunnels that may carry none. The peer’s control
stream is not counted in it either: there is one of it per connection, it
buffers one frame at a time, and nothing on it can be refused stream by stream.
One choice here is worth stating, though it is within the RFC: a malformed
request is answered with a bare 400 and a clean stream close rather than a
reset wherever the fault is visible at the tunnel layer – a CONNECT-UDP request
carrying Content-Length or Content-Type (RFC 9297 §3.2), or any request
carrying one of the connection-specific fields RFC 9114 §4.2 forbids
(Proxy-Connection, Keep-Alive, Transfer-Encoding, Upgrade). RFC 9114
§4.1.2 lets a server “send an HTTP response indicating the error prior to
closing or resetting the stream”, and it_tcp and it_udp pin the 400. The
Connection field itself is refused at decode time with H3_MESSAGE_ERROR,
earlier still. The four-field check runs before the credentials check rather
than after it, unlike the routing it precedes: a message carrying one is
malformed whoever sent it, and judging it afterwards answered that MUST with a
407 to an unauthenticated peer and a 400 to an authenticated one.
A peer that closes its QPACK encoder or decoder stream is treated as
H3_CLOSED_CRITICAL_STREAM, the connection error RFC 9204 §4.2 requires, and
both endings count: a clean finish and a reset alike. The one exemption is a
connection that has already ended, where the closure is the connection going
away rather than a fault the peer committed. That is the exemption the control
stream makes for the same race.
HTTP Datagrams are hand-rolled in src/datagram.rs. That started as a way around
h3-datagram 0.0.2, which tagged every datagram with Quarter Stream ID 0 and so
misrouted every session after the first on a connection; it is simply ours now —
about thirty lines of varint work with no dependency behind them.
Why quinn-proto is patched (temporary)
Cargo.toml carries a [patch.crates-io] stanza pointing quinn-proto at a
commit on upstream’s 0.11.x branch. quinn-proto’s MTU black-hole detector treats
an ordinary congestion loss burst during a bulk transfer as proof that the path
has stopped carrying the current MTU, so a download makes the connection fall
back to the 1200-byte floor — and on the 0.11.x branch every further loss burst
at the floor re-triggers the detector and pushes the next probe out again, so the
MTU stays there for as long as the transfer lasts. That branch was also missing
two comparison fixes that upstream had landed on main only.
The pinned commit, 48455d3, is the quinn-proto-0.11.17 tag plus five fixes
(and two unrelated backports the branch picked up in between: ACK frames
bundled into DATAGRAM/STREAM packets, and a minimum-RTT path statistic).
Three are in quinn-proto/src/connection/mtud.rs, brought to 0.11.x by
quinn-rs/quinn#2799:
- “Fix some comparisons in the black hole detector” and “Relax MTU discovery state assertion”, backported from quinn-rs/quinn#2400.
- “proto: treat an equal-size delivery as evidence against a preceding loss
burst”. The actual bug is
#2791 and the fix
#2792, merged to
main.
The fourth is in quinn-proto/src/connection/datagrams.rs: the
outgoing-datagram eviction loop subtracted an evicted datagram’s size from the
buffered-bytes counter twice, so sustained eviction underflows the counter and
ends in a panic inside send_datagram while the connection lock is held. The
bug is #2805 and the fix
#2806, landed on 0.11.x.
The fifth is in quinn-proto/src/endpoint.rs: a datagram carrying an
unsupported QUIC version elicited a Version Negotiation packet whatever its
size, where RFC 9000 Section 5.2.2 says servers MUST drop the small ones. A
spoofed 15-byte probe therefore got a larger reply, an amplification vector
aimed squarely at the port scanner the SNI gate exists to defeat. The fix is
#2822 on main, backported to
0.11.x by #2823.
No 0.11.x release carries these yet, which is why the stanza pins a commit rather than a version. Because that commit’s version is the upstream tag, anything true of quinn-proto 0.11.17 is true here except those five fixes.
Exit condition: drop the stanza as soon as a quinn-proto 0.11.x release
includes the fix, then cargo update -p quinn-proto and remove the CI audit
workaround described below. Two things do not work while it is in place:
- Dependabot does not touch git dependencies, so it cannot bump this one. The
pinned commit is moved by hand whenever quinn-proto ships a security or
hardening release, and moving it is forced if a
quinnrelease raises its quinn-proto requirement above 0.11.17. - cargo-audit skips lockfile entries whose source is not the default registry, so
the patched entry would fall out of RustSec coverage. The
auditjob in.github/workflows/ci.ymlworks around this by scanning a copy ofCargo.lockwith that onesourceline rewritten back to the registry — which is sound precisely because the version is the upstream tag.
The h3api facade
src/h3api.rs is the facade over src/h3: the only module allowed to name a
type from it, so that everything else — conn, quic, the tunnels — sees
bytes and quinn types plus the handful of names it exports. The facade began
as insulation from a 0.0.x dependency’s API churn and was kept on its own terms:
it is the list of what a proxy actually asks of HTTP/3, and it is short enough to
read in one sitting.
The message types — Request, Status, Fields — are re-exported through it
rather than wrapped in something else. The rule the facade enforces is about
where a type is named, not about hiding it: nothing outside src/h3 reaches
into that module, so this one file still says everything the rest of the crate
may assume about HTTP/3.
One gotcha it cannot hide: h3api::Connection::handshake consumes the
quinn::Connection, so clone it first if you also need to send datagrams on
it. Receiving them needs no clone — the HTTP/3 connection routes them itself.
Testing
Integration tests assert on-wire behaviour, not internal state: status codes,
response headers, and in it_settings the actual bytes the server writes on its
control stream. That is what makes a dependency bump reviewable — the tests
describe the protocol, so they still mean something after the implementation
underneath changes.
Two tests are load-bearing beyond their names:
- The multi-session concurrent CONNECT-UDP test is the regression baseline for Quarter-Stream-ID-class bugs. Three sessions on one connection, each verified to receive its own traffic. Never weaken it.
it_migrationrebinds the client endpoint mid-tunnel and asserts that existing tunnels survive the address change and that new ones can still be opened, which pins QUIC connection migration against an accidentalmigration(false)or an upstream regression. What makes migration work behind NAT at all is quinn’s non-zero-length server connection IDs (8 bytes by default) — the property RFC 9308 §9 strongly recommends for exactly this reason — so any future endpoint tuning must not shorten them to zero.
it_stress keeps a heavy tier behind #[ignore] (500 concurrent tunnels, 10000
setup/teardown rounds) and a light tier in the default run that catches slot
leaks by shrinking the quota until a leak turns into an observable 503.
These pages are checked too. it_docs reads docs/ as text and asserts three
things a machine can settle: that the configuration keys documented here and the
ones script/config.example.toml ships are one set, and that the documented set
still parses as a Config; that every constant a page quotes in the
`IDENT` = value notation holds the value the crate compiles; and that every
docs/<page>.md#<anchor> reference in src/, in tests/ and in these pages
themselves names a heading that exists. What no gate can check is whether a
sentence is true, so that half stays a review question.
Test infrastructure lives in tests/common/: an in-process server plus
self-signed certificates from rcgen, so no test needs a fixture on disk, and
the HTTP/3 client in tests/common/h3client.rs that drives it.
One thing that suite cannot do is disagree with itself: that client is built on
the same codec as the server, so a misreading of the framing or of QPACK is one
both ends share. The interop CI job closes that gap by starting a real
volto process and driving it with two clients that share no code, no QUIC
stack and no reading of the RFCs with it or with each other. Go’s
masque-go on quic-go speaks CONNECT-UDP
over the RFC 9298 default URI template, with authentication on and the server’s
certificate trusted rather than skipped; it asserts multi-round echo on one
session, three concurrent sessions on a single QUIC connection receiving only
their own traffic, Proxy-Status on a refusal, and a 407 when credentials are
omitted. Python’s aioquic covers what
masque-go cannot: the plain CONNECT (TCP) tunnel — round trips, two concurrent
tunnels, and the RFC 9114 §4.4 half-close — plus a datagram carrying an unknown
Context ID, which RFC 9298 requires to be dropped silently and which only a
client that writes its own Context ID prefix can send. The clients live in
tests/interop/ (a Go module) and tests/interop/aioquic/ (a Python script),
so cargo test neither sees nor needs them.
A third independent implementation judges the field-section encoding in
particular, and its verdicts are checked in rather than re-run: the differential
oracle in tests/interop/difforacle/ puts generated and mutated field sections
through this server’s QPACK and Huffman decoders and through ls-qpack (via
pylsqpack), comparing both the accept/reject verdict and the decoded fields.
It needs a pinned Python environment and so runs on demand like the fuzz
targets, but what it settled lives in tests/it_diff_oracle.rs and is
re-checked on every cargo test: the five inputs the two implementations
answer differently, none of them a fault here, and ls-qpack’s own copy of the
RFC 9204 static table, entry by entry. That table is the one thing the in-tree
tests cannot check for themselves, since src/h3/qpack.rs transcribes RFC 9204
Appendix A by hand and its unit test can only compare that transcription with
itself.
Every hand-written parser that reads bytes a peer chose also has a
coverage-guided fuzz target under fuzz/, run on demand on a nightly toolchain
in a Linux container rather than in CI – which only type-checks them, since
fuzz/ is a workspace of its own and its targets reach items that are public
only because they ask for them, so a rename in src/ would otherwise break the
fuzzers silently between runs. fuzz/README.md maps each target to the parser
it covers and has the invocation. Beside the five codecs — the frame
state machine, QPACK, Huffman, the capsule parser and the datagram codec — that
means the request validator that reaches RFC 9114 §4.1.2’s malformed verdict,
the two parsers that turn a client-named target into a host and a port, and the
credentials field, which is the one attacker-shaped byte string read before
anything has authenticated. The property tests in tests/it_props.rs and
tests/it_fuzz.rs state the same kinds of invariants on stable Rust, so they
are what CI runs on every push.