Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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]

KeyTypeDefaultMeaning
listenstringrequiredUDP address to listen on, e.g. "0.0.0.0:443". QUIC is UDP; there is no TCP listener
certpathrequiredPEM certificate chain, leaf first
keypathrequiredPEM private key (PKCS#8, PKCS#1 or SEC1)
alpnarray 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_graceseconds5How 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]

KeyTypeDefaultMeaning
usersarray 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]

KeyTypeDefaultMeaning
udp_session_timeoutseconds180Idle 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_conninteger256Concurrent 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_connectionsinteger256Simultaneously 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_timeoutseconds10Budget 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_preferencestring"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_bidiinteger1024Concurrent 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_timeoutseconds60How 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_intervalseconds20Keep-alive period; 0 switches it off. Must be strictly less than max_idle_timeout / 2, or startup and reload fail
initial_mtubytes1200Size 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_discoverybooltrueProbe 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_boundbytes1452Ceiling 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_controlstring"bbr"QUIC congestion controller: bbr, cubic or newreno
initial_rtt_msmilliseconds333Round-trip time assumed before the first measurement. Range 10..10000
socket_recv_bufferbytes2097152UDP 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_bufferbytes2097152The 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]

KeyTypeDefaultMeaning
allow_private_networksboolfalseAllow 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_portsarray of integers[25]Target ports refused regardless of address, answered with 403. Do not add 53 (see below)
unanswered_packet_budgetinteger64Packets a UDP session may send before its target has answered; 0 disables the mitigation
max_auth_failuresinteger5Authentication 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_sniarray 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_networks or a new entry in denied_ports reaches a client that is holding a connection open only when that connection ends. Use systemctl restart volto when 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.1 nor ::127.0.0.1 gets past allow_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). So 64:ff9b::7f00:1 is refused as the 127.0.0.1 it is, while 64:ff9b::808:808 is reachable as 8.8.8.8. The local-use NAT64 prefix 64:ff9b:1::/48 is 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 follows allow_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 with 403 and destination_ip_prohibited when 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 = true a client can still reach this server’s own listener through 127.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_budget stops 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. 0 switches both halves off.
  • max_auth_failures is 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 answered 400 before 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. SIGHUP applies 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]

KeyTypeDefaultMeaning
levelstring"info"A bare level (trace/debug/info/warn/error) or a directive list such as "volto=debug,quinn=info". RUST_LOG overrides it
keylogboolfalseWrite 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.

KeyIntroduced in
[limits].connect_timeoutv0.2.6
[limits].socket_recv_buffer, [limits].socket_send_bufferv0.2.8
[limits].ip_family_preferencev0.2.9
[limits].mtu_upper_boundv0.4.5
[security].expected_sniv0.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 = "…" }]