Skip to content

Hysteria 2 server and client

This recipe builds a complete Hysteria 2 deployment with etemenanki-app at both ends. The server listens on UDP port 443, gives each user their own credential, relays UDP as well as TCP, hides its QUIC packets with Salamander obfuscation and answers probers with an ordinary web page. The client runs on your own machine, offers a SOCKS5 port to your browser and applications, and sends their traffic to the server. The same server also accepts the upstream Hysteria client, which the second half of the page configures.

Read it when you want a working pair to start from. The Hysteria 2 protocol page is the reference for every key, limit and timeout; this page explains the choices the recipe makes and the mistakes that break a deployment.

flowchart LR
    A["Browser or application"] -->|"SOCKS5 on 127.0.0.1:1080"| B["Client: socks-in"]
    B --> C["Client: hy2-out"]
    C -->|"QUIC on UDP 443, Salamander"| D["Server: hy2-in"]
    U["Upstream hysteria client"] -->|"QUIC on UDP 443, Salamander"| D
    D --> E["direct"]
    E --> F["Internet"]
    D -->|"private addresses"| G["block"]
  • The server has one hysteria2 inbound on UDP port 443, a freedom outbound called direct, and a blackhole outbound called block that keeps clients away from the server’s own private networks.
  • The client has a socks inbound on 127.0.0.1:1080 and a hysteria2 outbound that every connection goes through, except connections to private addresses, which go out directly.
  • All proxied TCP connections and UDP packets from one client share one QUIC connection to the server. The client authenticates once, when that connection is made.
  • A server with a public IP address and a DNS name that points to it. This page uses proxy.example.com.
  • etemenanki-app installed on the server and on the client machine, as described in Install.
  • A TLS certificate for proxy.example.com whose subjectAltName lists that name. A certificate from a public CA, such as Let’s Encrypt, works with every client without extra settings. For a self-signed certificate, see Using a self-signed certificate.
  • Nothing else on the server using UDP port 443. A web server on TCP 443 is fine, because the two ports are separate, but a web server with HTTP/3 enabled also holds UDP 443.

Both files pass etemenanki-app --test. Replace the name, the two user passwords and the obfuscation key before you use them.

/etc/etemenanki/config.toml (server)
# A Hysteria 2 server on UDP port 443 with two users, UDP relay, Salamander
# obfuscation and an HTML masquerade page, relaying everything directly.
# Replace the certificate paths, the passwords and the obfuscation key.
[log]
level = "info"
[[inbound]]
tag = "hy2-in"
protocol = "hysteria2"
listen = "0.0.0.0" # the default, 127.0.0.1, is unreachable from outside
port = 443 # a UDP port: open 443/udp in the firewall
[inbound.settings]
# The certificate must carry a subjectAltName for the name clients dial.
cert_file = "/etc/etemenanki/tls/fullchain.pem"
key_file = "/etc/etemenanki/tls/privkey.pem"
# Clients authenticate with the string "user:pass", for example
# "alice:replace-with-a-long-random-password".
users = [
{ user = "alice", pass = "replace-with-a-long-random-password" },
{ user = "bob", pass = "replace-with-another-long-random-password" },
]
# UDP relay is off by default.
udp = true
udp_idle_timeout = 60
# Every client needs the same obfs and obfs_password.
obfs = "salamander"
obfs_password = "replace-with-a-long-random-obfs-key"
# What a prober, a browser or a client with a wrong credential receives.
[inbound.settings.masquerade]
status = 404
body = "<html><body><h1>404 Not Found</h1></body></html>\n"
content_type = "text/html; charset=utf-8"
[[outbound]]
tag = "direct"
protocol = "freedom"
[[outbound]]
tag = "block"
protocol = "blackhole"
[route]
default = "direct"
# Keep clients away from the server's own private networks.
[[route.rule]]
outbound = "block"
cidr = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "fc00::/7", "::1/128"]
  1. Put the certificate in place. The server example reads /etc/etemenanki/tls/fullchain.pem and /etc/etemenanki/tls/privkey.pem. fullchain.pem holds the leaf certificate first, followed by the intermediates. Give the private key the owner root:etemenanki and mode 0640, as Running etemenanki-app describes for the systemd service user.

  2. Generate the secrets. The recipe needs one password per user and one obfuscation key shared by the server and all clients. Any random string works; for example:

    Terminal window
    openssl rand -base64 24 # run once per user password, and once for obfs_password

    Put the values in users and obfs_password on the server. On the client, password is user:pass for the user it connects as, and obfs_password is the server’s key, unchanged.

  3. Check both files. --test builds everything a start would build, including loading the certificate, but binds no port:

    Terminal window
    etemenanki-app --test -c /etc/etemenanki/config.toml
    Configuration OK.

    It catches a missing certificate file, a key that does not belong to the certificate, a mistyped key and the other configuration errors listed under Troubleshooting. It checks each file on its own, so it cannot catch a firewall that drops the packets, or a credential or obfuscation key that differs between the server and the client.

  4. Open UDP port 443. See Open the UDP port below.

  5. Start the server. Under the systemd unit from Running etemenanki-app, run systemctl start etemenanki. When the listener is up, the log shows:

    INFO etemenanki_app::instance: inbound hy2-in listening on udp 0.0.0.0:443

    ss -ulpn 'sport = :443' on the server should now list the process.

  6. Start the client and send a request through it.

    Terminal window
    etemenanki-app -c config.toml
    curl -x socks5h://127.0.0.1:1080 https://example.com/

    The client connects to the server only when the first flow arrives, so a wrong credential, key or certificate shows up in the client’s log at the first request, not at startup.

Hysteria 2 runs over QUIC, which is UDP. A firewall rule or cloud security group that allows TCP 443 does not let a single Hysteria 2 packet through. Allow UDP 443 inbound on the host firewall and in any provider firewall in front of the server:

Terminal window
ufw allow 443/udp

When the port is blocked, the client sees no answer at all. Its log shows a timeout, not an authentication error:

WARN etemenanki_protocols::hysteria::slot: hysteria2: connect failed: hysteria2: no address answered (203.0.113.10:443: timed out)

Binding port 443 needs root or the CAP_NET_BIND_SERVICE capability, which the systemd unit on Running etemenanki-app grants. Without it, the server fails to start with failed to start: inbound hy2-in bind udp 0.0.0.0:443 failed: Permission denied (os error 13).

The server’s inbound takes the common tag, listen and port keys from Inbounds, and its Hysteria 2 keys in [inbound.settings]. This table lists the keys the recipe sets; the protocol page lists all of them.

Key Type Default Recipe value Why
listen string "127.0.0.1" "0.0.0.0" The default is reachable only from the server itself.
port u16 none, required 443 A UDP port. It does not collide with a TCP inbound on 443.
cert_file, key_file path none, required /etc/etemenanki/tls/… QUIC always uses TLS 1.3; there is no plaintext mode.
users array of tables none two entries One credential per user, sent as user:pass. The alternative is a single shared password.
udp bool false true Without it, the server tells clients it does not relay UDP.
udp_idle_timeout u64 60 60 Seconds of silence, in both directions, after which a UDP session is closed. Accepted range 2 to 600, and only with udp = true.
obfs string (enum) none "salamander" The only accepted value, exact and lower case.
obfs_password string none a random key At least 4 bytes, identical on every client.
masquerade table Go’s plain 404 page not found an HTML 404 page The response to every request that is not a valid authentication.

The [inbound.settings] and [inbound.settings.masquerade] tables reject unknown keys, so a misspelling stops the config instead of starting a server without the feature you asked for.

Without a credential, a Hysteria 2 server looks like an HTTP/3 web server. Every request that is not a valid authentication gets the same fixed response: a browser asking for /, a prober, and a client with the wrong credential all see the masquerade page. The recipe serves a small HTML page with status 404:

[inbound.settings.masquerade]
status = 404
body = "<html><body><h1>404 Not Found</h1></body></html>\n"
content_type = "text/html; charset=utf-8"

Any key you leave out keeps its default. status accepts 100 to 999 except 233, which is the status Hysteria 2 uses for a successful authentication; status = 233 fails with inbound hy2-in: hysteria2: 233 is the authentication success status and cannot be used for the masquerade. The masquerade is a fixed response: it cannot serve files or proxy another site.

With Salamander on, a client without the right obfuscation key cannot complete a QUIC handshake, so it never gets as far as the masquerade page.

The client’s outbound uses the common server and port keys, described on Outbounds, and its Hysteria 2 keys in [outbound.settings]:

Key Type Default Recipe value Why
server string none, required "proxy.example.com" The server’s name or IP address. Without server_name, it is also the name the certificate must match.
port u16 none, required 443 The server’s UDP port. A single port: there is no port range.
password string none, required "alice:…" The credential. user:pass for a server with users, the bare password for a server with password.
obfs, obfs_password string none same as the server They must match the server exactly.
server_name string the value of server not set Set it when server is an IP address and the certificate names a domain.
ca_file path none not set Extra trusted CA certificates, for a self-signed or private-CA certificate.

The outbound has no [outbound.stream] block and no TLS table: server_name, ca_file and allow_insecure live in [outbound.settings]. The SOCKS inbound has UDP ASSOCIATE on by default, so SOCKS5 clients can send UDP through the tunnel as well; the server relays it because it has udp = true.

If the client should connect by IP address, keep the name for the certificate check:

[[outbound]]
tag = "hy2-out"
protocol = "hysteria2"
server = "203.0.113.10"
port = 443
[outbound.settings]
password = "alice:replace-with-a-long-random-password"
server_name = "proxy.example.com"
obfs = "salamander"
obfs_password = "replace-with-a-long-random-obfs-key"

A Hysteria 2 client sends a single string, called auth in the upstream client and password in etemenanki-app, once per QUIC connection. How the server reads that string depends on which key its inbound has:

Server [inbound.settings] has etemenanki-app client password Upstream client auth Server’s rule
users = [{ user = "alice", pass = "s3cret" }] "alice:s3cret" alice:s3cret Split at the first :. The name is compared ignoring ASCII case, the password exactly.
password = "s3cret" "s3cret" s3cret The whole string must equal password.

Consequences worth knowing:

  • Alice:s3cret also works, because user names are lower-cased (ASCII only) before the comparison. For the same reason, two users whose names differ only in case are refused: hysteria2: two users share a name once lower-cased.
  • A password may contain :, a user name may not. alice:pa:ss authenticates as alice with password pa:ss. A user containing : fails with hysteria2: a username cannot contain ':' — it separates the two on the wire.
  • The two forms do not mix. A server with users rejects a bare password, and a server with password compares the whole alice:s3cret string against its password. Setting both keys fails with password and users cannot both be set; a credential would have two answers.
  • email is accepted but has no visible effect here. A users entry may carry an email, which replaces the user name as the identity attached to that user’s flows. etemenanki-app does not log that identity, count traffic by it or route on it.
  • Empty values are refused. A users entry with an empty user or pass fails with hysteria2: a user needs both a name and a password.

A wrong credential gets the masquerade response, not a distinct error. The etemenanki-app client logs the status it received:

WARN etemenanki_protocols::hysteria::slot: hysteria2: connect failed: hysteria2: no address answered (203.0.113.10:443: hysteria2 authentication rejected with status 404 Not Found)

The status in that line is the masquerade status, so a server with a custom masquerade shows its own code there.

Salamander scrambles every UDP packet with a key derived from obfs_password, so that the packets no longer look like QUIC. It is not encryption, which QUIC’s own TLS already provides. Both ends apply it to every packet, including the first handshake packet, so the two ends must agree exactly:

Server Client Result
salamander, key K salamander, key K Works.
salamander, key K salamander, another key No answer: the client times out.
salamander no obfs No answer: the client times out.
no obfs salamander No answer: the client times out.

A mismatch looks exactly like a blocked port. If the client times out and the firewall is open, compare obfs_password on both sides first.

The checks at startup fail closed on both ends, so a typo never turns obfuscation off silently. Each message starts with inbound hy2-in: on the server or outbound hy2-out: on the client:

Setting Error
obfs_password shorter than 4 bytes, or missing obfs_password must be at least 4 bytes for salamander
obfs_password without obfs obfs_password is set but obfs is not; did you mean obfs = "salamander"?
obfs = "Salamander" or any other value unknown obfs "Salamander" (expected "salamander")
An upstream-style [outbound.settings.obfs] table invalid settings: invalid type: map, expected a string

The server works with the upstream Hysteria client and with other clients that follow the Hysteria 2 protocol specification. This upstream client config matches the server example, as user alice:

config.yaml (upstream hysteria client)
server: proxy.example.com:443
auth: alice:replace-with-a-long-random-password
obfs:
type: salamander
salamander:
password: replace-with-a-long-random-obfs-key
tls:
sni: proxy.example.com
socks5:
listen: 127.0.0.1:1080
http:
listen: 127.0.0.1:8080

Clients that import a share link take the same values in the hysteria2:// URI form, with the credential as the user-info part:

hysteria2://alice:replace-with-a-long-random-password@proxy.example.com:443/?obfs=salamander&obfs-password=replace-with-a-long-random-obfs-key&sni=proxy.example.com

Percent-encode any character in the password or key that has a meaning in a URL, such as @, /, ?, #, & or +.

When you configure an upstream client against this server:

  • Leave out bandwidth. etemenanki-app has no bandwidth settings. In its authentication reply, the server announces its receive rate as auto. The upstream client then ignores its own bandwidth values and uses its configured congestion control. The fixed-rate Brutal mode is never active.
  • Use a single port. etemenanki-app binds exactly one UDP port, the inbound’s port, and has no port-hopping option. Give the client that port, not a range such as proxy.example.com:20000-30000.
  • Use salamander as the obfuscation type. It is the only one the server implements.
  • Use auth: user:pass for a server with users, and the bare password for a server with password, as in the table under How clients authenticate.
  • For a self-signed certificate, set tls.ca to a copy of the server’s certificate, or tls.insecure: true for a test.

The upstream client connects at startup unless it runs with lazy: true. With a wrong credential, that first connection fails and the client does not open its SOCKS port.

Clients verify the server’s certificate against the system trust store. For a certificate you create yourself, create it without the CA flag and with a subjectAltName, then give each client a copy of it:

Terminal window
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout privkey.pem -out fullchain.pem -days 365 -subj /CN=proxy.example.com \
-addext subjectAltName=DNS:proxy.example.com \
-addext basicConstraints=critical,CA:FALSE

On the etemenanki-app client, add the copy as a trusted CA:

[outbound.settings]
password = "alice:replace-with-a-long-random-password"
ca_file = "/etc/etemenanki/proxy-cert.pem"
obfs = "salamander"
obfs_password = "replace-with-a-long-random-obfs-key"

ca_file adds to the system trust store rather than replacing it, so the client still needs the system CA bundle, for example the ca-certificates package on Debian and Ubuntu. allow_insecure = true skips verification entirely; it cannot be combined with ca_file and is meant for testing only.

etemenanki-app reads the certificate only when it builds a generation. Replacing the files on disk does not reload them, so after each renewal, change the config file to trigger a reload, as described in Changing a certificate or geodata file.

A reload rebuilds the whole server and closes every QUIC connection. The etemenanki-app client logs hysteria2: connection closed, reconnecting and reconnects with the next flow; upstream clients reconnect on their own. If the new certificate is broken, the reload is refused and the server keeps running with the old one.

At startup or with --test. The message follows configuration invalid: under --test, and failed to start: when the program starts:

Error Cause and fix
inbound hy2-in: hysteria2 needs both cert_file and key_file Add both keys.
No such file or directory (os error 2) A cert_file, key_file or client ca_file path is wrong. The message does not name the file. Relative paths are resolved against the working directory, not the config file’s directory.
hysteria2: certificate and key do not match: … key_file belongs to another certificate.
inbound hy2-in: hysteria2 needs password or users Add a users table or a password.
invalid settings: unknown field … A misspelled key, or a key etemenanki-app does not have, such as up, down or hop_interval. The message lists the accepted keys.
invalid type: string "20000-30000", expected u16 A port range in port. Use a single port.
failed to start: inbound hy2-in bind udp 0.0.0.0:443 failed: Address already in use (os error 98) Another process, or another inbound in the same file, holds UDP 443.
failed to start: … Permission denied (os error 13) Binding port 443 needs root or CAP_NET_BIND_SERVICE.

On the client, at the first request. Each line starts with hysteria2: connect failed: hysteria2: no address answered ( followed by the server address and the reason. When the name resolves to several addresses, the client tries each one and lists every reason, separated by ;:

Reason Cause and fix
timed out No answer within 10 seconds for that address: UDP 443 is blocked, the server is down, or the obfs settings differ between the two ends.
hysteria2 authentication rejected with status 404 Not Found Wrong credential. Use user:pass against a server with users.
the cryptographic handshake failed: error 48: invalid peer certificate: UnknownIssuer The certificate is not from a trusted CA. Use a public CA certificate, or set ca_file.
the cryptographic handshake failed: error 42: invalid peer certificate: certificate not valid for name "…" The name in server_name, or in server when server_name is unset, is not in the certificate’s subjectAltName.
the cryptographic handshake failed: error 46: invalid peer certificate: Other(OtherError(CaUsedAsEndEntity)) The server’s certificate is marked as a CA. Recreate it with basicConstraints=critical,CA:FALSE, as in Using a self-signed certificate.
hysteria2: no system root certificates could be loaded The client machine has no system CA bundle. Install one, for example the ca-certificates package. ca_file does not replace it.

After a failed attempt, the client waits before it tries again, starting at 2 seconds and doubling up to 30. Flows that arrive in the meantime fail at once with the error hysteria2: connection is down, waiting before the next attempt instead of starting a new attempt. The hysteria2: connect failed warning appears only once per attempt, not once per flow.

If TCP works but UDP does not, the server probably has udp = false. The client logs this once:

WARN etemenanki_protocols::hysteria::connector: hysteria2: hysteria2: the server does not relay UDP; datagrams routed to this outbound are dropped