Repository navigation
v13.0.3: Hiddify app (/auto/ sing-box JSON) fails through Cloudflare CDN — ws/httpupgrade wrong ALPN + missing Host, DNS-only ECH, xhttp downloadSettings, echConfigList array #5591
Description
Environment
- HiddifyManager 13.0.3 (panel 13.0.3), Ubuntu 24.04, non-docker. Upgraded from 12.x.
- Clients: Hiddify app (hiddify-core v4.0.4, hiddify-sing-box 1.13.1) and v2rayNG.
- Domain
cdn.examplein CDN mode behind Cloudflare (proxied), paneltls_ech_enable=1, and the domain's ECH flag on.
Symptom
With the Hiddify app, which imports the /auto/ link (sing-box JSON), every config on the CDN domain fails.
They are all red, or the app stays on "connecting". The same proxies work when the app imports the /sub/ URI links,
which the app converts itself with ray2sing. On networks where only CDN + ECH gets through (e.g. Iran's MCI / Hamrah-e Aval),
the user gets no connection at all.
How it was isolated (reproducible on the server)
We ran each outbound with the app's own core, hiddify-core srun -c <cfg>, where <cfg> = socks inbound + that single outbound,
and requested https://www.gstatic.com/generate_204 through it. We compared:
- the outbound from
/auto/(panel JSON), and - the outbound produced by
hiddify-core parsefrom the/sub/links (what the app does with links).
| Outbound (CDN domain) | from /sub/ links |
from /auto/ JSON |
|---|---|---|
| ws trojan | 204 | EOF |
| xhttp vless TLS H2 | closed pipe |
closed pipe |
Then we changed one field at a time on the /auto/ outbound until it returned 204, and repeated the test with server set
to the origin IP (bypassing Cloudflare) to tell CDN-path problems from template problems.
Bugs
1. ws / httpupgrade (sing-box JSON): wrong ALPN + missing Host → EOF through the CDN
hiddify-core/client/tls/tls_http emits "alpn": ["h2","http/1.1"] for ws/httpupgrade, and
hiddify-core/client/streams/ws / httpupgrade send only default_headers with no Host.
Results of the variant tests:
| change | result |
|---|---|
alpn http/1.1 only |
EOF |
+ Host only |
EOF |
drop malformed sec-ch-ua-platform |
EOF |
alpn http/1.1 + Host |
204 |
2. ECH in sing-box JSON is DNS-only
tls_http emits:
"ech": {"enabled": true, "config": [], "config_path": "", "query_server_name": "<sni>"}so the client must fetch the ECH config via a DNS HTTPS lookup at connect time. Where that lookup is blocked (MCI), every ECH
outbound fails. The URI links carry the static key (ech=<base64>, which ray2sing wraps as PEM), and those work.
Emitting the same key in JSON fixed it.
3. xhttp through the CDN: split downloadSettings → download handshake: read payload: io: read/write on closed pipe
Fails in every mode (auto, packet-up, stream-up) whenever downloadSettings is present. The same outbound without
downloadSettings works through Cloudflare (mode: auto), and direct to origin works with it.
(Related to #5570 but a different cause from hiddify/hiddifypanel#45.)
4. echConfigList emitted as an array (Xray)
xray/client/security/tls: "echConfigList": ["{{ domain.echinfo }}"]. In Xray echConfigList is a string
(infra/conf/transport_security.go: ECHConfigList string json:"echConfigList"). This breaks xray JSON / xhttp
extra.downloadSettings for Xray-based clients. Xray also accepts the dynamic form cloudflare-ech.com+udp://1.1.1.1
(transport/internet/tls/ech.go).
5. Smaller issues found on the way
- ECH formats differ per client. ray2sing (
common.go) wraps anyech=value longer than 5 chars in-----BEGIN ECH CONFIGS-----,
so a dynamicdomain+dnsvalue in the URI breaks the Hiddify app, while Xray needs base64 ordomain+dns. The URI template should
pick the form per client (we usedctx.platform). - ECH changes (global flag + per-domain flag) only reach subscriptions after apply configs, not after a panel restart.
- The
TLS ECH Valuesetting (cloudflare-ech.com+https://…) is shown in the UI but is not referenced anywhere in 13.0.3. custom_proxy.sort_orderdid not change the order of links in/sub/, even after apply./sub/?domain=is ignored; only the clash routes passfilter_domain.
Workaround we deployed (template overrides, builtin_override=1)
### hiddify-core/client/tls/tls_http
- "alpn": {{ tls_layer.alpns() | tojson }},
+ "alpn": {% if ctx.proxy.transport in ["ws", "httpupgrade"] %}["http/1.1"]{% else %}{{ tls_layer.alpns() | tojson }}{% endif %},
"ech": {
"enabled": true,
+ {% if domain.echinfo %}
+ "config": ["-----BEGIN ECH CONFIGS-----\n{{ domain.echinfo }}\n-----END ECH CONFIGS-----"]
+ {% else %}
"config": [],
"config_path": "",
"query_server_name": "{{ domain.sni }}"
+ {% endif %}
},
### hiddify-core/client/streams/ws and hiddify-core/client/streams/httpupgrade
"headers": {
+ "Host": "{{ proxy.domain.host }}",
{% include 'hiddify-core/client/default_headers' %}
### hiddify-core/client/streams/xhttp
- {% if proxy.is_download_upload_different() %}
+ {% if proxy.is_download_upload_different() and domain.mode != 'cdn' %}
### sublink/uri/security/tls_http
- {% set qp.ech = domain.echinfo %}
+ {% if ctx.platform.is_hiddify or 'sing' in (ctx.platform.useragent or '')|lower %}{% set qp.ech = domain.echinfo %}{% else %}{% set qp.ech = 'cloudflare-ech.com+udp://1.1.1.1' %}{% endif %}
### xray/client/security/tls
- "echConfigList": ["{{ domain.echinfo }}"],
+ "echConfigList": "cloudflare-ech.com+udp://1.1.1.1",Result
/auto/CDN outbounds tested withhiddify-core srun: ws, httpupgrade, and xhttp all return 204 through Cloudflare (60 of 76;
the rest are gRPC, below).- Hiddify app on MCI connects in Auto mode with the full subscription (direct + CDN + worker). Verified in xray logs.
- gRPC through Cloudflare returned 403 only in our server-side test, because its UA is
grpc-go/*and Cloudflare blocks that.
The real app's gRPC connected fine. Probably not a Hiddify bug.
Also relevant (reported upstream elsewhere)
rpxy-l4 0.2.4, which Hiddify ships on :443, has a hard-coded 100 ms TCP probe timeout (junkurihara/rust-rpxy-l4#203).
Slow/mobile clients get dropped (Timeout (100ms) while probing, ~1,900/hour on our server; some MCI IPs lost 100%).
A build with 3000 ms brought that to 0. It may be worth shipping a patched build or making it configurable.
Thanks for Hiddify! Happy to test patches.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
Survivor-viper commentedon Oct 17, 2026
A likely root cause for #3 (xhttp
downloadSettings→download handshake: read payload: io: read/write on closed pipe), found while reading 14.0.0b15 (hiddifypanel0f8b15f). We saw the sameclosed pipeon 13.0.3 for xhttp profiles whose download leg goes to a separate domain.The download leg loses TLS in the hiddify-core / sing-box template when
download_tls_layeris emptyproxy_v3/proxy_templates/hiddify-core/client/tls/tls_http.j2, line 2:It is included from
hiddify-core/client/streams/xhttp.j2:20:download_tls_layer = Nonemeans "same as upload" (context_vars/proxy.py,models/custom_proxy.py), and the editor leaves it empty for h1. But{% with tls_layer=None %}makes the variable defined. Sois definedis true,None or "http"gives"http", and the download side gets notlsblock. The client then opens a plain-HTTP download stream to port 443, and the stream breaks immediately.The xray templates handle the same case with a truthiness test and work correctly:
Rendering the two variants with jinja2 (stub context) confirms it:
Fix:
A sturdier option is to pass
tls_layer=proxy.effective_download_tls_layer()in all three xhttp stream templates.Related: the sublink
downloadSettingsuses the upload Host and ignores the download domain's portproxy_templates/sublink/xhttp_download_settings.j2:"port": {{ proxy.download_port }}, "host": "{{ proxy.domain.host }}",The xray and hiddify-core stream templates use
domain.download.hostanddomain.download.port or proxy.download_port. So avless://link sends the download leg to the download domain's address and SNI, but with the upload domain'sHost. Its port also ignores the download domain's own TLS port, becauseProxyVar.download_portalways returns 443/80 for L7.Conversely, in non-L7 modes
domain.download.portis not filled and defaults to 443, sodomain.download.port or …in the xray/core templates always gives 443.For the port, a
ClientProxyDomainVar.download_portproperty that returnsdomain.download.portfordomains_l7_gatewayand the proxy's own port otherwise, used by all three templates, would make them consistent.hiddify-com commentedon Oct 26, 2026
This should be fixed in v14. If it still happens on the latest v14, please reopen this issue.