Skip to content

Commit 8dbcb26

Browse files
committed
fix(reality): reject donors with oversized certs (#6402) + name the cause in self-test
REALITY silently fails to complete its handshake when the donor's TLS Certificate record exceeds ~8192 bytes (XTLS/Xray-core #6402) — big-CDN donors like www.microsoft.com trip it, and the panel would accept the dest, bring REALITY up, and leave every client unable to connect with nothing in the logs. - Extend the existing live donor probe (validateRealityDestLive) to measure the cert record and reject an oversized donor with a clear message + suggestions. - Make the connection self-test report a REALITY handshake failure as "проверьте донор (dest)" instead of the generic firewall hint, since a bare EOF/timeout on REALITY almost always means the dest.
1 parent 5c1359f commit 8dbcb26

4 files changed

Lines changed: 95 additions & 3 deletions

File tree

internal/core/manager_connections.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package core
33
import (
44
"context"
55
"crypto/tls"
6+
"crypto/x509"
67
"fmt"
78
"log/slog"
89
"net"
@@ -264,12 +265,38 @@ func validateRealityDestLive(host string) error {
264265
return invalid("донор %q недоступен по TLS 1.3 на :443 (%v)", host, err)
265266
}
266267
defer conn.Close()
267-
if conn.(*tls.Conn).ConnectionState().NegotiatedProtocol != "h2" {
268+
state := conn.(*tls.Conn).ConnectionState()
269+
if state.NegotiatedProtocol != "h2" {
268270
return invalid("донор %q не поддерживает HTTP/2 — выбери другой сайт", host)
269271
}
272+
if size := certRecordSize(state.PeerCertificates); size > realityCertLimit {
273+
return invalid("сертификат донора %q слишком большой (%d Б) — REALITY-хендшейк не "+
274+
"завершается на этой версии Xray, если запись сертификата больше %d Б (issue #6402). "+
275+
"Выбери сайт с меньшим сертификатом, например www.cloudflare.com, www.apple.com или dl.google.com",
276+
host, size, realityCertLimit)
277+
}
270278
return nil
271279
}
272280

281+
// realityCertLimit is the TLS Certificate-record size above which REALITY's
282+
// handshake silently fails to complete on current Xray (XTLS/Xray-core #6402).
283+
// Big-CDN donors like www.microsoft.com (~8.3 KB chain) trip it; the panel would
284+
// otherwise accept the dest, bring REALITY up, and leave every client unable to
285+
// connect with nothing in the logs to explain why.
286+
const realityCertLimit = 8192
287+
288+
// certRecordSize estimates the bytes of the donor's TLS Certificate handshake
289+
// message — the quantity #6402 caps. It's the sum of each cert's DER length plus
290+
// the per-entry framing (3-byte length prefix), close enough to the real record to
291+
// judge the limit without parsing raw handshake bytes.
292+
func certRecordSize(chain []*x509.Certificate) int {
293+
total := 4 // certificate_request_context (1) + certificate_list length (3)
294+
for _, c := range chain {
295+
total += 3 + len(c.Raw) + 2 // cert length prefix + DER + empty extensions
296+
}
297+
return total
298+
}
299+
273300
// ApplyConnections validates and persists the whole connection surface, then does
274301
// a SINGLE reconcile (and one nft hop refresh) — so a multi-field save restarts
275302
// Xray at most once instead of once per field. The reconcile is a no-op for the
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package core
2+
3+
import (
4+
"crypto/x509"
5+
"testing"
6+
)
7+
8+
// certRecordSize gates the REALITY donor against the #6402 cert-size limit, so its
9+
// arithmetic has to track the real TLS Certificate record closely enough that a
10+
// just-oversized donor (www.microsoft.com, ~8.3 KB) is rejected while a normal one
11+
// passes. Uses synthetic DER lengths — the framing math is what's under test.
12+
func TestCertRecordSize(t *testing.T) {
13+
cert := func(derLen int) *x509.Certificate {
14+
return &x509.Certificate{Raw: make([]byte, derLen)}
15+
}
16+
17+
// A single ~4 KB leaf + ~1.5 KB intermediate stays comfortably under the limit.
18+
small := []*x509.Certificate{cert(4000), cert(1500)}
19+
if got := certRecordSize(small); got >= realityCertLimit {
20+
t.Errorf("small chain size %d should be under the %d limit", got, realityCertLimit)
21+
}
22+
23+
// A microsoft-sized chain (~8.2 KB of DER across two certs) must exceed it.
24+
big := []*x509.Certificate{cert(5200), cert(3000)}
25+
if got := certRecordSize(big); got <= realityCertLimit {
26+
t.Errorf("oversized chain size %d should exceed the %d limit", got, realityCertLimit)
27+
}
28+
}

internal/selftest/client_config_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package selftest
22

33
import (
4+
"context"
45
"encoding/json"
56
"strings"
67
"testing"
@@ -197,6 +198,23 @@ func TestRingBufferKeepsTail(t *testing.T) {
197198
}
198199
}
199200

201+
// A REALITY probe that dies with a bare EOF (donor handshake never completed) must
202+
// get the REALITY-specific hint about the dest, not the generic firewall line —
203+
// that's the difference between an operator fixing the donor and staring at "порт
204+
// закрыт".
205+
func TestExplainFailureRealityHint(t *testing.T) {
206+
err := context.DeadlineExceeded
207+
msg := explainFailure(model.ProtoReality, err, "some log with EOF here")
208+
if !strings.Contains(msg, "REALITY") || !strings.Contains(msg, "донор") {
209+
t.Errorf("REALITY EOF failure should name the donor/dest, got %q", msg)
210+
}
211+
// The same bare timeout on VLESS is a firewall/port problem, not a donor one.
212+
vmsg := explainFailure(model.ProtoVLESS, err, "")
213+
if strings.Contains(vmsg, "REALITY") {
214+
t.Errorf("VLESS failure must not mention REALITY, got %q", vmsg)
215+
}
216+
}
217+
200218
func TestFirstXrayErrorPicksFailureLine(t *testing.T) {
201219
log := "2026/07/12 10:00:00 [Info] starting\n" +
202220
"2026/07/12 10:00:01 [Warning] failed to process outbound traffic: auth failed\n"

internal/selftest/selftest.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ func runOne(ctx context.Context, binPath string, spec protoSpec, set *model.Sett
118118

119119
ip, err := probeThrough(pctx, socksPort)
120120
if err != nil {
121-
res.Detail = explainFailure(err, stderr.text())
121+
res.Detail = explainFailure(spec.key, err, stderr.text())
122122
return res
123123
}
124124

@@ -230,17 +230,36 @@ func waitForPort(ctx context.Context, port int) error {
230230

231231
// explainFailure turns a raw probe error into an operator-facing sentence, folding
232232
// in Xray's own stderr when it points at the real cause (bad auth, TLS mismatch).
233-
func explainFailure(probeErr error, xrayLog string) string {
233+
func explainFailure(proto string, probeErr error, xrayLog string) string {
234234
if x := firstXrayError(xrayLog); x != "" {
235235
return "трафик не проходит: " + x
236236
}
237+
// REALITY fails in a way that leaves no keyword-matched error line: the client
238+
// just gets EOF because the donor handshake never completes (wrong/unreachable
239+
// dest, or a donor cert too big for this Xray — issue #6402). A bare timeout on
240+
// REALITY almost always means the dest, so name it instead of the generic hint.
241+
if proto == model.ProtoReality && isHandshakeFailure(probeErr, xrayLog) {
242+
return "трафик не проходит: REALITY-хендшейк не завершился — проверьте, что донор (dest) " +
243+
"доступен, отдаёт TLS 1.3 и его сертификат не слишком большой (issue #6402)"
244+
}
237245
if strings.Contains(probeErr.Error(), context.DeadlineExceeded.Error()) ||
238246
strings.Contains(probeErr.Error(), "timeout") {
239247
return "трафик не проходит: истекло время ожидания ответа (порт закрыт снаружи, брандмауэр или неверные параметры)"
240248
}
241249
return "трафик не проходит: " + probeErr.Error()
242250
}
243251

252+
// isHandshakeFailure reports whether the failure looks like a stalled TLS/REALITY
253+
// handshake — an EOF or a plain timeout with nothing else to go on — rather than a
254+
// clean error Xray already named.
255+
func isHandshakeFailure(probeErr error, xrayLog string) bool {
256+
e := strings.ToLower(probeErr.Error())
257+
if strings.Contains(e, "eof") || strings.Contains(strings.ToLower(xrayLog), "eof") {
258+
return true
259+
}
260+
return strings.Contains(e, context.DeadlineExceeded.Error()) || strings.Contains(e, "timeout")
261+
}
262+
244263
// firstXrayError extracts the first line of Xray output that reads like a failure,
245264
// so the UI can show "auth failed" instead of a generic timeout. Empty when the log
246265
// carries nothing actionable.

0 commit comments

Comments
 (0)