Skip to content

Configuration

Configuration is read from $XDG_CONFIG_HOME/hawse/ (~/.config/hawse/ by default), falling back to /etc/hawse/ when the file is not there. --config PATH and the HAWSE_CONFIG environment variable override the location. Relative key paths resolve against the directory containing the config file. When neither directory has a config, keys are written to /etc/hawse/ if running as root and to $XDG_CONFIG_HOME/hawse/ otherwise.

Server settings and their defaults:

listen = "[::]:4433"
bind = "::"
key = "server.key"
dynamic_ports = "40000-41000"

The server answers on the listen port twice: UDP for QUIC, and TCP for the fallback transport. Both have to be free at startup and reachable through the firewall — the server refuses to start if it cannot bind the TCP side, rather than come up with the fallback silently missing. Set transport.tcp_fallback = false on a server whose clients all use QUIC: it then binds the port on UDP only, and a client set to prefer = "tcp" cannot connect. Public ports bound for clients are TCP or UDP, as each service asks. hawse server --listen ADDR overrides listen for that run.

congestion selects the controller, on either end, for the data that end sends. The server defaults to cubic, the client to bbr:

[transport]
congestion = "bbr" # or "cubic"

The choice matters most on a path with a long round trip and some random loss, where cubic shrinks its window on every packet the path loses and bbr does not. Measurements has both measured over a real path, and bench/ measures them on the path you have.

bind is the address those public ports listen on. The default answers on every interface. Set it to 127.0.0.1 when a reverse proxy on the same host is the only thing that should reach them:

bind = "127.0.0.1"
[clients.nas]
key = "ed25519:AAAA..."
ports = ["8096"]

A client may override the server-wide value with its own bind, so one server can keep some services behind a proxy and publish others directly.

allow limits a service to visitors whose address falls in one of the listed networks. The server turns everyone else away before the client hears of them: a TCP visitor sees its connection closed, a UDP packet is dropped. For UDP the source address is whatever the packet claims, so a forged packet naming an allowed network still reaches the service. Set it on the service in client.toml:

[expose.postgres]
local = "127.0.0.1:5432"
port = 5432
allow = ["203.0.113.0/24", "2001:db8::/48"]

The same key on a [clients.NAME] table in server.toml caps what that client may publish. A service then admits only the addresses both lists cover, or all of the server’s list when the service names none, and a service whose list shares nothing with the server’s fails to bind as not granted. Without either list a service admits everyone.

proxy_protocol = true on a service sends a PROXY protocol v2 header ahead of each visitor’s bytes, naming the visitor’s address and the public address it reached, so a service that logs or limits by address sees the visitor instead of the client. The service has to expect the header, or it reads it as the start of the request. On a UDP service the header goes in front of every datagram the service receives, and its replies carry none. Where the server answers on every interface, a UDP service’s header names the address the client dialed as the public one, whichever of the server’s addresses the visitor reached.

A service behind a reverse proxy on the server, with bind = "127.0.0.1" as above, sees every visitor arrive from the proxy, so its allow list and its PROXY header describe the proxy, not the visitor.

limits.streams_per_client caps how many streams one client’s connection may carry: the control stream, one per visitor connection and one per UDP service. It defaults to 4096, and a visitor that arrives when every stream is taken is reset. A client accepts 4096 streams from the server, so a value above 4097 does nothing more. On the TCP fallback, yamux promises every stream 256 KiB of receive window, so the default lets a session hold about 1 GiB of unread data. Lower it on a server with little memory, but not below the number of visitor connections a client carries at once. limits.udp_sessions_per_service caps how many visitors one UDP service keeps track of at once, and defaults to 4096. Past it the visitor that has been quiet longest is forgotten, and any visitor is forgotten after 60 s of silence; its next packet starts a new session, which the local service sees arrive from a new port.

limits.auth_failures_per_minute, 30 by default, limits how often one address may fail to authenticate: a key the server does not know, even one whose connection never sends its greeting, or a TCP handshake that fails. An IPv6 address counts together with the rest of its /64. Past the limit the server refuses that address’s connections before the TLS or QUIC handshake, and lets one more try through every two seconds at the default. Clients sharing an address, behind carrier-grade NAT for instance, share its limit. The server tracks at most 16384 addresses; while that many are failing at once, new ones go unlimited, and the log says so. 0 turns the limit off.

quic_retry = true makes a QUIC client prove its address with one extra round trip before the server spends anything on a handshake. It also lets a failed QUIC handshake count against the limit above; without it only failures after the handshake do, since an unproven source address can be forged.

Client settings: server and server_key are required, key defaults to client.key, and each [expose.NAME] table needs a local address.

transport.prefer chooses how the client reaches the server:

[transport]
prefer = "auto" # or "quic", or "tcp"

auto is the default. It dials QUIC, and when QUIC has not connected after 2 seconds it dials the fallback beside it and takes whichever connects first; if QUIC fails sooner, the fallback is dialed at once. quic never falls back. tcp selects the fallback outright, which carries every stream over one TLS connection multiplexed with yamux — for networks that block outbound UDP. A session that lands on the fallback stays there until it ends, and the client logs a warning when it does; the next connection tries QUIC first again. The 2 seconds are measured, so that one lost packet does not move a session onto the fallback: see Measurements.

A visitor’s connection that is cut short ends in a connection reset, on either transport: when the local service resets it, when nothing is listening on local, or when the tunnel itself drops. One that finished ends as the service ended it. On the fallback that takes a marker written into every stream, at a small cost to a request competing with heavy transfers, which Measurements puts a figure on.

A UDP service needs /udp on both ends: port = "51820/udp" or "any/udp" in the client’s [expose.NAME] table, and a grant such as "51820/udp" in the server’s ports. Each payload crosses the tunnel as one QUIC datagram when it fits. One that does not fit — a full-size packet from a WireGuard tunnel at its default MTU is one — goes over a reliable stream instead, where packets arrive in order and a lost one delays those behind it. Setting MTU = 1370 on both WireGuard peers keeps their packets inside a datagram on a path with the usual 1500-byte MTU: payloads up to 1412 bytes crossed as datagrams, and 1370 leaves room for hawse’s own header to grow as a service sees more visitors. A service with proxy_protocol sends the visitor’s address in every packet, 7 bytes for an IPv4 visitor and 19 for IPv6, so its payloads leave a datagram that much sooner. On the fallback every payload takes that stream. hawse never holds a UDP sender back: when the tunnel cannot keep up, packets are dropped, as on any congested path. On the fallback each UDP service’s traffic from visitor to service takes one stream, which caps it well below what QUIC carries; Measurements has the loss and jitter measured on both. The service end line in the client’s log counts packets dropped from a full datagram queue as queue_full.

Give a UDP service’s local as a literal address such as 127.0.0.1:51820. A hostname is looked up again for every new visitor, only its first address is tried, and while a lookup runs every UDP service on the connection waits. The client holds one socket for each visitor seen in the last 60 s, up to 4096 per service, and past that the visitor quiet longest is dropped to make room, so a busy or publicly reachable UDP service needs a descriptor limit to match, such as LimitNOFILE= under systemd. On a server with several addresses, set bind to the one visitors use: a reply from a wildcard bind leaves from whichever address the route picks.

Defaults for the other [transport] settings, on the server:

[transport]
idle_timeout = "30s"
stream_window = "8MiB"
connection_window = "64MiB"
buffer = "16KiB"
tcp_fallback = true

The client uses the same values except stream_window = "2MiB" and connection_window = "16MiB", and has no tcp_fallback.

The rest of [transport] is not honoured equally by the two. Both use idle_timeout (on TCP it is the quiet time before the kernel starts probing), connection_window and buffer. stream_window and congestion are QUIC’s alone: yamux guarantees every stream 256 KiB and grows it only into the connection window’s slack, so there is no per-stream knob to set, and TCP’s congestion control belongs to the kernel — so on the fallback both settings are accepted, validated and then ignored.

--threads sets the number of worker threads and defaults to the number of CPUs.