The network path
Why a connection out of a run was refused, and which of the eighteen network refusals you are holding.
18 classes. 17 explained, 1 named only.
These are the refusals a run meets when it tries to reach something on the network. They come from the part of the product that judges a destination against a policy you wrote, and they are the best-documented family in this section.
Read the class from the record, not from the client
The client's error string is the same string for almost every case. That is deliberate: a proxy that explains itself is an oracle, and a client that could tell "denied by a rule" from "never listed" could map your policy with a handful of probes.
⇒ First act, every time: read the refusal class in the record.
⚠ Only a run started with the --egress-policy flag is judged by any of this. A run started without it is handed no policy document, is bounded by no ceiling, and reaches a host no policy names without anything refusing it. That is not a misconfiguration and there is no setting that changes it — enforce at your own network boundary if you need it enforced there.
Refusals of the destination
| you observe | class | what to do |
|---|---|---|
| a connection to a host nobody permitted fails | egress-destination-not-allowed | Decide whether the host belongs in the policy. ⚠ An explicit deny rule produces this same class — before adding an allow, check whether a deny already covers the host. |
| one host works on 443 and is refused on another port | egress-port-not-allowed | The rule that governs that host is an allow and its port list lacks the port. Two fixes, and they are not equivalent. Adding the port to that rule opens it for every host the rule covers — under a wildcard that is far wider than the one host you were asked about. Writing an exact allow for that host on the port it needs grants exactly that, leaves the wider rule untouched, and loads cleanly. |
| a permitted name that resolves fine is refused, right after the lookup | egress-address-denied | The name resolved to an address on the hard floor — most often a cloud metadata endpoint or a private range. Not configurable and not a bug. If the run legitimately must reach a private-range host, the answer is a different network path, never a policy rule. |
| a destination that names the proxy's own address | egress-address-denied (the same class) | In the intended per-run socket deployment this cannot fire. If it does, someone has bound the proxy to a TCP port — find out who wired it. |
| refused, and the lookup itself failed | egress-resolve-failed | Ordinary DNS diagnosis on the proxy's host. ⚠ There is no retry — one lookup is all the design allows, so a flaky resolver appears as a refusal rather than as a slow success. Do not read it as a policy decision. |
| a permitted name works, and the same host reached by its IP address is refused | egress-literal-not-recorded | Read The name is permitted; the client asked by IP address below before changing the policy. |
| TLS to a permitted name is torn down immediately, before any application data | egress-sni-mismatch | The hostname announced in the TLS handshake did not match the one the policy judged — or the handshake announced no hostname at all, or an unreadable one; all three are this one class deliberately. Look for a client that connects by name A and announces name B: a proxy-unaware SDK, a hardcoded host header, a pinned CDN alias. |
| the host string never became a host | egress-host-unparseable | The record carries a reason as a second token. See the reason table below — one of them is wider than its name. |
| everything was permitted and the connection still failed | egress-upstream-unreachable | The only class that reports the far side rather than a judgement of ours. Diagnose the network from the proxy's host. If you are holding this class, the policy said yes. |
The two cases where your instinct is right and the answer is still "refused"
These are the 3 a.m. calls. In both, nothing is misconfigured and nothing is broken.
The name is permitted; the client asked by IP address
Symptom. A host is permitted by name and works. The same host, reached by its IP address, is refused. Class: egress-literal-not-recorded.
Why. The SOCKS5 protocol lets a client ask for an IP address and never put a name on the wire at all. When that happens, an allowlist of names has nothing to match, and resolving the name once — which is what closes the window between the address that was checked and the address that is dialled — has nothing to protect. Resolving once is necessary and not sufficient. So an address destination is refused unless the policy names that address.
What to do — three options, and the first is nearly always right.
- Fix the client to ask by name. The policy is a list of names; a client that dials by IP has already thrown away the thing the policy reasons about. Most often this is a proxy setting, a resolver being bypassed, or a hardcoded address in a config template.
- Add the address to the policy, knowing exactly what that buys. An address rule is always an
allowand it is not hostname-checked — so on a shared-IP CDN you have just admitted every TLS site behind that address. - Neither. If the client insists on an IP for a shared address, the correct answer may be that this destination does not get reached.
⛔ What is not the answer. This is not a DNS fault, and there is no resolver setting that helps: there was no lookup to protect.
A host under a deny loses its port diagnostic
Symptom. A host that sits under a deny rule, asked for on a port that is also wrong, reports egress-destination-not-allowed — with no hint that the port was wrong. Looking at a policy that allows the parent domain on 443, you reasonably expect egress-port-not-allowed and go hunting for a port problem the judgement never reached.
The two cases side by side:
*.example.comallowed on 443 → a request forapi.example.com:8080→ the governing rule is theallow⇒egress-port-not-allowed.- The same, plus
*.internal.example.comdenied → a request fordb.internal.example.com:8080→ the governing rule is the narrowerdeny⇒egress-destination-not-allowed, and the port never entered the judgement.
The trade, named openly. The refusal class deliberately does not distinguish a host you denied on purpose from a host nobody ever listed. If it did, one probe on any unlisted port would map your policy rule by rule — the port-specific class would confirm "this host is known to the policy", and an attacker inside a run would enumerate your allowlist for free. The port-specific class therefore fires only when the rule that governs a host is an allow. This is a security property paid for with an operator's diagnostic. Put it in your own runbook: at 3 a.m. it reads like a bug.
What to do. ⛔ Do not chase the port. Read the policy and find the narrowest rule whose destination covers the host. If that rule is a deny, the port was never judged, and no change to any port list will alter the outcome. Either narrow the deny, or add a narrower allow beneath it — an exact allow inside a denied subtree is legal and is not read as redundant.
Refusals of the connection's ceilings
For a ceiling, the record carries the ceiling that was crossed, never how far the client got — telling a client how far it got is telling it how much further to try.
| you observe | class | what to do |
|---|---|---|
| a client refused during the handshake after sending a lot up front | egress-limit-preface-bytes | The client front-loaded tunnel data before the hostname check could be answered. It counts what your client sent through the tunnel and not the protocol request that opened it, so a long hostname cannot land here and neither can the way your client split its writes. The key that raises it is preface_bytes in the policy document's required limits block, and raising it does change what a run started with --egress-policy may send. |
| a line-length refusal | egress-limit-line-length | Two ceilings share this name, and the number printed with the refusal says which: the request ceiling — the whole protocol request cost too much — or the over-long-line ceiling for one line of an HTTP-style CONNECT handshake. Both are the wire format's own cost. ⚠ The two keys that raise them do not behave the same: raising request_bytes does change what a run started with --egress-policy may send; raising line_bytes changes nothing on any run, because the door that reads it is built but not reachable by any client today. |
| a header-count refusal | egress-limit-header-count | The client sent too many header lines in an HTTP-style CONNECT handshake. This binds that door only — a SOCKS5 request has no header block to count. ⛔ Raising header_count changes nothing, on any run: no path today carries a client to the door that counts headers, so no run can cross this ceiling. |
| a slow client, refused before any data flowed | egress-limit-handshake-deadline | One deadline covers the whole handshake including the hostname check, so you cannot tell which part spent it — by design. ⚠ Two other causes wear this class: a connection handed no budget at all, and a handshake write that timed out. |
| an established connection torn down after a silence | egress-limit-idle-deadline | Measured on both directions, and a stalled write counts as idleness — deliberately, because with both directions blocked on a write there is nothing else left to observe. ⚠ A protocol where the server speaks first always lands here — SMTP, some IMAP dialects, MySQL — because the hostname check waits for the client to speak. That is a named limit of the design, not something to tune. |
| a long-lived, legitimate, high-volume connection torn down mid-transfer | egress-limit-total-transfer | This is the only bound on the volume of a busy connection. There is no wall-clock limit, so a big clone or an image pull hits this one and nothing else. Size it deliberately. |
⚠ A connection that stopped because the caller withdrew consent mints no class at all. Cancellation is not a ceiling. Do not go looking for a limit; look for the caller.
Refusals of the wire protocol
| you observe | class | what to do |
|---|---|---|
| a client asking for an inbound socket or a UDP association | egress-method-unsupported | Only outbound connections exist. FTP active mode and some peer-to-peer libraries land here; the client needs a mode that asks for no inbound socket. Not a configuration gap. |
| a client that tries to authenticate to the proxy | egress-auth-unsupported | Turn the client's proxy credentials off. There is no proxy password and none will be added: the socket is the identity. ⚠ This is the class most often mistaken for a wrong password. |
| garbage, a truncated header, a wrong protocol version — also a port outside 1 to 65535, and a client that hung up before saying anything judgeable | egress-protocol-error | Check that the client is speaking SOCKS5 to this socket at all. A client configured with an HTTP proxy setting rather than a SOCKS5 one lands here and will keep landing here. |
The reasons carried by egress-host-unparseable
Each reason is a way one string could have meant two different hosts to two different readers.
| reason | what was in the host string | what to do |
|---|---|---|
embedded-nul | a zero byte | Almost always a client bug or an injection attempt: a zero byte truncates a hostname in some resolvers and not in others, which is exactly how one string becomes two hosts. |
whitespace | a space, tab or line break, including the Unicode ones such as NO-BREAK SPACE | Fix the string. ⛔ Do not ask for it to be trimmed: trimming is a decision about which of two hosts the client meant. |
userinfo-present | an @ | The client is passing a URL where a hostname goes. [email protected] is one host to a URL parser and a different host to a resolver, and the policy would be asked about the wrong half. |
over-length | ⚠ wider than its name. A hostname or label longer than DNS permits — and also the short end: an empty hostname, a bare ., and example..com | If you go looking for a long hostname you may not find one. Check for an empty hostname or an empty label first: a config template that interpolated nothing produces exactly this. |
idna-refused | an internationalised name the standard rejects — a disallowed character, a mixed-direction violation, invalid punycode | The precise reason is deliberately not passed on: that would be a diagnosis of the client's input. Re-run the name through an IDNA tool of your own. |
Named, not yet explained
| class | what its name indicates |
|---|---|
egress-record-unavailable | The record of a network judgement could not be reached. This section does not yet carry the resolution. ⚠ It is worth knowing why this one matters: everything above tells you to read the class from the record, and this is the refusal that says the record is what is missing. |