9 Revīzijas

Autors SHA1 Ziņojums Datums
  Sergei Arkhipov 6a939eef6a
Merge pull request #528 from 9seconds/refactor/consolidate-sni-check 1 mēnesi atpakaļ
  Sergei Arkhipov 2d7c71657c
Merge pull request #522 from 9seconds/sni-router-host-mode-real-ips 1 mēnesi atpakaļ
  Sergei Arkhipov dca19dcf57
Merge pull request #496 from dolonet/doctor/rpc-probe 1 mēnesi atpakaļ
  Alexey Dolotov 9593becc2a internal/cli: consolidate duplicated SNI-DNS check 1 mēnesi atpakaļ
  Alexey Dolotov eaff7007fd doctor: deepen DC verification with MTProto handshake probe 1 mēnesi atpakaļ
  Alexey Dolotov ed4d6a0ee1 mtglib/dcprobe: unauthenticated DC verification probe 1 mēnesi atpakaļ
  Alexey Dolotov a7febc2bf2 sni-router: collapse haproxy bind to comma-separated form 1 mēnesi atpakaļ
  Alexey Dolotov b083d75731 sni-router: review fixups (concise comments, accurate v6only note, narrow Caddy allow) 1 mēnesi atpakaļ
  Alexey Dolotov 4a4e001980 sni-router: switch HAProxy to host networking for real client IPs 1 mēnesi atpakaļ

+ 6
- 4
contrib/sni-router/Caddyfile Parādīt failu

@@ -10,14 +10,16 @@
10 10
 	# to Caddy's access log.  The `tls` wrapper must follow so that TLS
11 11
 	# is terminated on the unwrapped connection.
12 12
 	#
13
-	# `allow` lists the networks permitted to send PROXY headers.  These
14
-	# ranges cover docker compose's default bridge networks; tighten
15
-	# them if you pin a specific subnet in docker-compose.yml.
13
+	# `allow` lists the networks permitted to send PROXY headers.
14
+	# 127.0.0.1/32 covers HAProxy reaching Caddy over host loopback (HAProxy
15
+	# runs in network_mode: host and connects to the published 127.0.0.1
16
+	# port).  The RFC1918 ranges cover mtg → Caddy on the compose bridge
17
+	# (fronting path; see "Fronting loop" in README.md).
16 18
 	servers :8443 {
17 19
 		listener_wrappers {
18 20
 			proxy_protocol {
19 21
 				timeout 5s
20
-				allow 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
22
+				allow 127.0.0.1/32 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
21 23
 			}
22 24
 			tls
23 25
 		}

+ 23
- 0
contrib/sni-router/README.md Parādīt failu

@@ -63,6 +63,29 @@ must stay in sync:
63 63
 If you disable one, disable all four, otherwise the backend will fail
64 64
 to parse the connection.
65 65
 
66
+### Why HAProxy uses `network_mode: host`
67
+
68
+A published port on a bridge network rewrites the source IP of inbound
69
+connections to the bridge gateway before HAProxy sees it (Docker's
70
+`docker-proxy`, Podman's `slirp4netns`/`pasta`), so the PROXY v2 header
71
+HAProxy forwards downstream carries that gateway address, not the real
72
+client.  Host-mode HAProxy binds in the host netns directly, no NAT in
73
+the path, and the rewrite never happens.  mtg and Caddy stay on the
74
+compose bridge and are published on `127.0.0.1` only — HAProxy reaches
75
+them over host loopback.  `mtg-config.toml` does not need to change;
76
+fronting still uses `host = "web"` over compose-network DNS.
77
+
78
+**Trade-offs.**
79
+- HAProxy owns the host's `:443` and `:80` — don't run anything else
80
+  on those ports.
81
+- Linux host only.  On Docker Desktop (macOS/Windows), "host" means
82
+  the Linux VM, not the user's machine, so external clients can't
83
+  reach the proxy.
84
+- If you run Docker with `userns-remap`, the in-container "root"
85
+  loses the privilege to bind `<1024` on the host; either disable
86
+  `userns-remap` for this stack or lower `net.ipv4.ip_unprivileged_port_start`
87
+  on the host.
88
+
66 89
 ## Fronting loop (why `[domain-fronting]` is set explicitly)
67 90
 
68 91
 When mtg sees TLS that isn't valid Telegram (a probe or a browser

+ 13
- 10
contrib/sni-router/docker-compose.yml Parādīt failu

@@ -27,9 +27,10 @@ x-domain-env: &domain-env
27 27
 services:
28 28
   haproxy:
29 29
     image: haproxy:lts-alpine
30
-    ports:
31
-      - "443:443"
32
-      - "80:80"
30
+    # Host netns so HAProxy sees real client IPs (v4/v6) instead of the
31
+    # bridge gateway address.  Linux host only; see README → "Why HAProxy
32
+    # uses network_mode: host" for the rationale and trade-off.
33
+    network_mode: host
33 34
     volumes:
34 35
       - ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro,Z
35 36
     environment:
@@ -38,16 +39,16 @@ services:
38 39
       - mtg
39 40
       - web
40 41
     restart: unless-stopped
41
-    sysctls:
42
-      - net.ipv4.ip_unprivileged_port_start=80
43 42
 
44 43
   mtg:
45 44
     # FIXME: :master until #480 lands in a tagged release; switch back to :2/:3 after release
46 45
     image: nineseconds/mtg:master
47 46
     volumes:
48 47
       - ./mtg-config.toml:/config/config.toml:ro,Z
49
-    expose:
50
-      - "3128"
48
+    # Published on host loopback only — HAProxy (host netns) reaches it via
49
+    # 127.0.0.1.
50
+    ports:
51
+      - "127.0.0.1:3128:3128"
51 52
     restart: unless-stopped
52 53
     extra_hosts:
53 54
       - "host.containers.internal:host-gateway"
@@ -58,9 +59,11 @@ services:
58 59
       - ./Caddyfile:/etc/caddy/Caddyfile:ro,Z
59 60
       - caddy_data:/data
60 61
       - ./www:/srv:ro,Z
61
-    expose:
62
-      - "80"
63
-      - "8443"
62
+    # Published on host loopback only — HAProxy reaches Caddy on 127.0.0.1.
63
+    # Port 8080 (not 80) on the host because HAProxy already owns host :80.
64
+    ports:
65
+      - "127.0.0.1:8080:80"
66
+      - "127.0.0.1:8443:8443"
64 67
     environment:
65 68
       <<: *domain-env
66 69
     restart: unless-stopped

+ 12
- 5
contrib/sni-router/haproxy.cfg Parādīt failu

@@ -23,7 +23,9 @@ defaults
23 23
 # --- HTTP :80 — ACME challenges + redirect -----------------------------------
24 24
 
25 25
 frontend http
26
-    bind *:80
26
+    # Explicit v4 + v6 binds so IPv6 clients are accepted regardless of
27
+    # the host's net.ipv6.bindv6only sysctl.
28
+    bind :80,[::]:80
27 29
     mode http
28 30
 
29 31
     # Let Caddy answer ACME HTTP-01 challenges for Let's Encrypt.
@@ -35,7 +37,7 @@ frontend http
35 37
 # --- TLS :443 — SNI-based routing -------------------------------------------
36 38
 
37 39
 frontend tls
38
-    bind *:443
40
+    bind :443,[::]:443
39 41
     tcp-request inspect-delay 5s
40 42
     tcp-request content accept if { req_ssl_hello_type 1 }
41 43
 
@@ -46,18 +48,23 @@ frontend tls
46 48
 
47 49
     default_backend web
48 50
 
51
+# Backends reach mtg and web on host loopback — they publish to 127.0.0.1
52
+# (see docker-compose.yml), and HAProxy runs in the host netns
53
+# (network_mode: host).  PROXY v2 still carries the real client address
54
+# (v4 or v6) end-to-end, independent of the loopback transport.
55
+
49 56
 backend mtg
50 57
     # send-proxy-v2 prepends a PROXY protocol v2 header so mtg sees the
51 58
     # real client IP instead of HAProxy's.  mtg must have
52 59
     # `proxy-protocol-listener = true` in its config.
53
-    server mtg mtg:3128 send-proxy-v2
60
+    server mtg 127.0.0.1:3128 send-proxy-v2
54 61
 
55 62
 backend web
56 63
     # send-proxy-v2 prepends a PROXY protocol v2 header so Caddy logs the
57 64
     # real client IP instead of HAProxy's.  Caddy must enable the
58 65
     # proxy_protocol listener wrapper on :8443 (see Caddyfile).
59
-    server web web:8443 send-proxy-v2
66
+    server web 127.0.0.1:8443 send-proxy-v2
60 67
 
61 68
 backend web_acme
62 69
     mode http
63
-    server web web:80
70
+    server web 127.0.0.1:8080

+ 52
- 42
internal/cli/doctor.go Parādīt failu

@@ -18,6 +18,7 @@ import (
18 18
 	"github.com/9seconds/mtg/v2/internal/config"
19 19
 	"github.com/9seconds/mtg/v2/internal/utils"
20 20
 	"github.com/9seconds/mtg/v2/mtglib"
21
+	"github.com/9seconds/mtg/v2/mtglib/dcprobe"
21 22
 	"github.com/9seconds/mtg/v2/network/v2"
22 23
 	"github.com/beevik/ntp"
23 24
 )
@@ -46,7 +47,7 @@ var (
46 47
 	)
47 48
 
48 49
 	tplODCConnect = template.Must(
49
-		template.New("").Parse("  ✅ DC {{ .dc }}\n"),
50
+		template.New("").Parse("  ✅ DC {{ .dc }} (rpc {{ .rtt }})\n"),
50 51
 	)
51 52
 	tplEDCConnect = template.Must(
52 53
 		template.New("").Parse("  ❌ DC {{ .dc }}: {{ .error }}\n"),
@@ -238,17 +239,21 @@ func (d *Doctor) checkNetwork(ntw mtglib.Network) bool {
238 239
 	dcs := slices.Collect(maps.Keys(essentials.TelegramCoreAddresses))
239 240
 	slices.Sort(dcs)
240 241
 
241
-	errs := make([]error, len(dcs))
242
+	type dcResult struct {
243
+		rtt time.Duration
244
+		err error
245
+	}
246
+	results := make([]dcResult, len(dcs))
242 247
 
243 248
 	var wg sync.WaitGroup
244 249
 	for i, dc := range dcs {
245 250
 		wg.Go(func() {
246 251
 			defer func() {
247 252
 				if r := recover(); r != nil {
248
-					errs[i] = fmt.Errorf("panic: %v", r)
253
+					results[i].err = fmt.Errorf("panic: %v", r)
249 254
 				}
250 255
 			}()
251
-			errs[i] = d.checkNetworkAddresses(ntw, essentials.TelegramCoreAddresses[dc])
256
+			results[i].rtt, results[i].err = d.checkNetworkAddresses(ntw, dc, essentials.TelegramCoreAddresses[dc])
252 257
 		})
253 258
 	}
254 259
 	wg.Wait()
@@ -256,14 +261,15 @@ func (d *Doctor) checkNetwork(ntw mtglib.Network) bool {
256 261
 	ok := true
257 262
 
258 263
 	for i, dc := range dcs {
259
-		if errs[i] == nil {
264
+		if results[i].err == nil {
260 265
 			tplODCConnect.Execute(os.Stdout, map[string]any{ //nolint: errcheck
261
-				"dc": dc,
266
+				"dc":  dc,
267
+				"rtt": results[i].rtt.Round(time.Microsecond),
262 268
 			})
263 269
 		} else {
264 270
 			tplEDCConnect.Execute(os.Stdout, map[string]any{ //nolint: errcheck
265 271
 				"dc":    dc,
266
-				"error": errs[i],
272
+				"error": results[i].err,
267 273
 			})
268 274
 			ok = false
269 275
 		}
@@ -272,7 +278,7 @@ func (d *Doctor) checkNetwork(ntw mtglib.Network) bool {
272 278
 	return ok
273 279
 }
274 280
 
275
-func (d *Doctor) checkNetworkAddresses(ntw mtglib.Network, addresses []string) error {
281
+func (d *Doctor) checkNetworkAddresses(ntw mtglib.Network, dc int, addresses []string) (time.Duration, error) {
276 282
 	checkAddresses := []string{}
277 283
 
278 284
 	switch d.conf.PreferIP.Get("prefer-ip4") {
@@ -303,29 +309,33 @@ func (d *Doctor) checkNetworkAddresses(ntw mtglib.Network, addresses []string) e
303 309
 	}
304 310
 
305 311
 	if len(checkAddresses) == 0 {
306
-		return fmt.Errorf("no suitable addresses after IP version filtering")
312
+		return 0, fmt.Errorf("no suitable addresses after IP version filtering")
307 313
 	}
308 314
 
309 315
 	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
310 316
 	defer cancel()
311 317
 
312
-	var (
313
-		conn net.Conn
314
-		err  error
315
-	)
318
+	var lastErr error
316 319
 
317 320
 	for _, addr := range checkAddresses {
318
-		conn, err = ntw.DialContext(ctx, "tcp", addr)
321
+		conn, err := ntw.DialContext(ctx, "tcp", addr)
319 322
 		if err != nil {
323
+			lastErr = fmt.Errorf("tcp connect to %s: %w", addr, err)
320 324
 			continue
321 325
 		}
322 326
 
327
+		rtt, err := dcprobe.Probe(ctx, conn, dc)
323 328
 		conn.Close() //nolint: errcheck
324 329
 
325
-		return nil
330
+		if err != nil {
331
+			lastErr = fmt.Errorf("rpc handshake to %s: %w", addr, err)
332
+			continue
333
+		}
334
+
335
+		return rtt, nil
326 336
 	}
327 337
 
328
-	return err
338
+	return 0, lastErr
329 339
 }
330 340
 
331 341
 func (d *Doctor) checkFrontingDomain(ntw mtglib.Network) bool {
@@ -361,26 +371,17 @@ func (d *Doctor) checkFrontingDomain(ntw mtglib.Network) bool {
361 371
 }
362 372
 
363 373
 func (d *Doctor) checkSecretHost(resolver *net.Resolver, ntw mtglib.Network) bool {
364
-	addresses, err := resolver.LookupIPAddr(context.Background(), d.conf.Secret.Host)
365
-	if err != nil {
374
+	res := runSNICheck(context.Background(), resolver, d.conf, ntw)
375
+
376
+	if res.ResolveErr != nil {
366 377
 		tplError.Execute(os.Stdout, map[string]any{ //nolint: errcheck
367 378
 			"description": fmt.Sprintf("cannot resolve DNS name of %s", d.conf.Secret.Host),
368
-			"error":       err,
379
+			"error":       res.ResolveErr,
369 380
 		})
370 381
 		return false
371 382
 	}
372 383
 
373
-	ourIP4 := d.conf.PublicIPv4.Get(nil)
374
-	if ourIP4 == nil {
375
-		ourIP4 = getIP(ntw, "tcp4")
376
-	}
377
-
378
-	ourIP6 := d.conf.PublicIPv6.Get(nil)
379
-	if ourIP6 == nil {
380
-		ourIP6 = getIP(ntw, "tcp6")
381
-	}
382
-
383
-	if ourIP4 == nil && ourIP6 == nil {
384
+	if !res.PublicIPKnown() {
384 385
 		tplError.Execute(os.Stdout, map[string]any{ //nolint: errcheck
385 386
 			"description": "cannot detect public IP address",
386 387
 			"error":       errors.New("cannot detect automatically and public-ipv4/public-ipv6 are not set in config"),
@@ -388,25 +389,34 @@ func (d *Doctor) checkSecretHost(resolver *net.Resolver, ntw mtglib.Network) boo
388 389
 		return false
389 390
 	}
390 391
 
391
-	strAddresses := []string{}
392
-	for _, value := range addresses {
393
-		if (ourIP4 != nil && value.IP.String() == ourIP4.String()) ||
394
-			(ourIP6 != nil && value.IP.String() == ourIP6.String()) {
395
-			tplODNSSNIMatch.Execute(os.Stdout, map[string]any{ //nolint: errcheck
396
-				"ip":       value.IP,
397
-				"hostname": d.conf.Secret.Host,
398
-			})
399
-			return true
392
+	if res.IPv4Match || res.IPv6Match {
393
+		var matched net.IP
394
+
395
+		for _, ip := range res.Resolved {
396
+			if (res.OurIPv4 != nil && ip.String() == res.OurIPv4.String()) ||
397
+				(res.OurIPv6 != nil && ip.String() == res.OurIPv6.String()) {
398
+				matched = ip
399
+				break
400
+			}
400 401
 		}
401 402
 
402
-		strAddresses = append(strAddresses, `"`+value.IP.String()+`"`)
403
+		tplODNSSNIMatch.Execute(os.Stdout, map[string]any{ //nolint: errcheck
404
+			"ip":       matched,
405
+			"hostname": d.conf.Secret.Host,
406
+		})
407
+		return true
408
+	}
409
+
410
+	strAddresses := make([]string, 0, len(res.Resolved))
411
+	for _, ip := range res.Resolved {
412
+		strAddresses = append(strAddresses, `"`+ip.String()+`"`)
403 413
 	}
404 414
 
405 415
 	tplEDNSSNIMatch.Execute(os.Stdout, map[string]any{ //nolint: errcheck
406 416
 		"hostname": d.conf.Secret.Host,
407 417
 		"resolved": strings.Join(strAddresses, ", "),
408
-		"ip4":      ourIP4,
409
-		"ip6":      ourIP6,
418
+		"ip4":      res.OurIPv4,
419
+		"ip6":      res.OurIPv6,
410 420
 	})
411 421
 
412 422
 	return false

+ 16
- 35
internal/cli/run_proxy.go Parādīt failu

@@ -215,72 +215,53 @@ func warnSNIMismatch(conf *config.Config, ntw mtglib.Network, log mtglib.Logger)
215 215
 		return
216 216
 	}
217 217
 
218
-	addresses, err := net.DefaultResolver.LookupIPAddr(context.Background(), host)
219
-	if err != nil {
218
+	res := runSNICheck(context.Background(), net.DefaultResolver, conf, ntw)
219
+
220
+	if res.ResolveErr != nil {
220 221
 		log.BindStr("hostname", host).
221
-			WarningError("SNI-DNS check: cannot resolve secret hostname", err)
222
+			WarningError("SNI-DNS check: cannot resolve secret hostname", res.ResolveErr)
222 223
 		return
223 224
 	}
224 225
 
225
-	ourIP4 := conf.PublicIPv4.Get(nil)
226
-	if ourIP4 == nil {
227
-		ourIP4 = getIP(ntw, "tcp4")
228
-	}
229
-
230
-	ourIP6 := conf.PublicIPv6.Get(nil)
231
-	if ourIP6 == nil {
232
-		ourIP6 = getIP(ntw, "tcp6")
233
-	}
234
-
235
-	if ourIP4 == nil && ourIP6 == nil {
226
+	if !res.PublicIPKnown() {
236 227
 		log.Warning("SNI-DNS check: cannot detect public IP address; set public-ipv4/public-ipv6 in config or run 'mtg doctor'")
237 228
 		return
238 229
 	}
239 230
 
240
-	v4Match := ourIP4 == nil
241
-	v6Match := ourIP6 == nil
242
-
243
-	for _, addr := range addresses {
244
-		if ourIP4 != nil && addr.IP.String() == ourIP4.String() {
245
-			v4Match = true
246
-		}
247
-
248
-		if ourIP6 != nil && addr.IP.String() == ourIP6.String() {
249
-			v6Match = true
250
-		}
251
-	}
231
+	v4Match := res.OurIPv4 == nil || res.IPv4Match
232
+	v6Match := res.OurIPv6 == nil || res.IPv6Match
252 233
 
253 234
 	if v4Match && v6Match {
254 235
 		return
255 236
 	}
256 237
 
257
-	resolved := make([]string, 0, len(addresses))
258
-	for _, addr := range addresses {
259
-		resolved = append(resolved, addr.IP.String())
238
+	resolved := make([]string, 0, len(res.Resolved))
239
+	for _, ip := range res.Resolved {
240
+		resolved = append(resolved, ip.String())
260 241
 	}
261 242
 
262 243
 	our := ""
263
-	if ourIP4 != nil {
264
-		our = ourIP4.String()
244
+	if res.OurIPv4 != nil {
245
+		our = res.OurIPv4.String()
265 246
 	}
266 247
 
267
-	if ourIP6 != nil {
248
+	if res.OurIPv6 != nil {
268 249
 		if our != "" {
269 250
 			our += "/"
270 251
 		}
271 252
 
272
-		our += ourIP6.String()
253
+		our += res.OurIPv6.String()
273 254
 	}
274 255
 
275 256
 	entry := log.BindStr("hostname", host).
276 257
 		BindStr("resolved", strings.Join(resolved, ", ")).
277 258
 		BindStr("public_ip", our)
278 259
 
279
-	if ourIP4 != nil {
260
+	if res.OurIPv4 != nil {
280 261
 		entry = entry.BindStr("ipv4_match", fmt.Sprintf("%t", v4Match))
281 262
 	}
282 263
 
283
-	if ourIP6 != nil {
264
+	if res.OurIPv6 != nil {
284 265
 		entry = entry.BindStr("ipv6_match", fmt.Sprintf("%t", v6Match))
285 266
 	}
286 267
 

+ 78
- 0
internal/cli/sni_check.go Parādīt failu

@@ -0,0 +1,78 @@
1
+package cli
2
+
3
+import (
4
+	"context"
5
+	"net"
6
+
7
+	"github.com/9seconds/mtg/v2/internal/config"
8
+	"github.com/9seconds/mtg/v2/mtglib"
9
+)
10
+
11
+// sniCheckResult holds the data gathered while comparing the secret
12
+// hostname's DNS records against this server's public IP addresses.
13
+//
14
+// IPv4Match / IPv6Match report whether a resolved record actually equals the
15
+// corresponding public IP. They are false when that family's public IP could
16
+// not be determined — there is nothing to compare against. Callers decide
17
+// what counts as a clean result from these fields: `mtg doctor` and the
18
+// startup warning apply different rules.
19
+type sniCheckResult struct {
20
+	Resolved   []net.IP
21
+	OurIPv4    net.IP
22
+	OurIPv6    net.IP
23
+	IPv4Match  bool
24
+	IPv6Match  bool
25
+	ResolveErr error
26
+}
27
+
28
+// PublicIPKnown reports whether at least one public IP family was detected.
29
+func (r sniCheckResult) PublicIPKnown() bool {
30
+	return r.OurIPv4 != nil || r.OurIPv6 != nil
31
+}
32
+
33
+// runSNICheck resolves conf.Secret.Host and compares the records with this
34
+// server's public IPv4 and IPv6. Public IPs come from config first and fall
35
+// back to on-the-fly detection via ntw. It gathers data only — it does not
36
+// decide success; see sniCheckResult.
37
+func runSNICheck(
38
+	ctx context.Context,
39
+	resolver *net.Resolver,
40
+	conf *config.Config,
41
+	ntw mtglib.Network,
42
+) sniCheckResult {
43
+	res := sniCheckResult{}
44
+
45
+	addrs, err := resolver.LookupIPAddr(ctx, conf.Secret.Host)
46
+	if err != nil {
47
+		res.ResolveErr = err
48
+
49
+		return res
50
+	}
51
+
52
+	res.Resolved = make([]net.IP, 0, len(addrs))
53
+	for _, a := range addrs {
54
+		res.Resolved = append(res.Resolved, a.IP)
55
+	}
56
+
57
+	res.OurIPv4 = conf.PublicIPv4.Get(nil)
58
+	if res.OurIPv4 == nil {
59
+		res.OurIPv4 = getIP(ntw, "tcp4")
60
+	}
61
+
62
+	res.OurIPv6 = conf.PublicIPv6.Get(nil)
63
+	if res.OurIPv6 == nil {
64
+		res.OurIPv6 = getIP(ntw, "tcp6")
65
+	}
66
+
67
+	for _, ip := range res.Resolved {
68
+		if res.OurIPv4 != nil && ip.String() == res.OurIPv4.String() {
69
+			res.IPv4Match = true
70
+		}
71
+
72
+		if res.OurIPv6 != nil && ip.String() == res.OurIPv6.String() {
73
+			res.IPv6Match = true
74
+		}
75
+	}
76
+
77
+	return res
78
+}

+ 181
- 0
mtglib/dcprobe/probe.go Parādīt failu

@@ -0,0 +1,181 @@
1
+// Package dcprobe verifies that a TCP endpoint is a real Telegram DC by
2
+// performing the unauthenticated first step of the MTProto handshake
3
+// (req_pq_multi -> resPQ) on top of mtg's existing obfuscated2 transport.
4
+//
5
+// No auth_key is generated; no long-lived state is introduced. Two TL
6
+// messages, one round-trip. A generic listener cannot fake the reply
7
+// because it must echo back our random nonce in resPQ.
8
+//
9
+// References:
10
+//   - https://core.telegram.org/mtproto/auth_key      (handshake step 1)
11
+//   - https://core.telegram.org/schema/mtproto        (TL schema)
12
+//   - https://core.telegram.org/mtproto/mtproto-transports#padded-intermediate
13
+package dcprobe
14
+
15
+import (
16
+	"bytes"
17
+	"context"
18
+	"crypto/rand"
19
+	"encoding/binary"
20
+	"errors"
21
+	"fmt"
22
+	"io"
23
+	"net"
24
+	"time"
25
+
26
+	"github.com/9seconds/mtg/v2/essentials"
27
+	"github.com/9seconds/mtg/v2/mtglib/obfuscation"
28
+)
29
+
30
+// MTProto wire constants (https://core.telegram.org/schema/mtproto).
31
+//
32
+//	req_pq_multi#be7e8ef1 nonce:int128 = ResPQ;
33
+//	resPQ#05162463 nonce:int128 server_nonce:int128 pq:string
34
+//	    server_public_key_fingerprints:Vector<long> = ResPQ;
35
+const (
36
+	ctorReqPQMulti uint32 = 0xbe7e8ef1
37
+	ctorResPQ      uint32 = 0x05162463
38
+
39
+	// Minimum legal resPQ frame: 20-byte unencrypted-message envelope +
40
+	// 4-byte ctor + 16-byte nonce echo. Anything below cannot be a resPQ.
41
+	minResPQFrame = 20 + 4 + 16
42
+	// Upper bound: real resPQ replies are ~84 bytes (envelope + ~64-byte
43
+	// payload). 256 is comfortable headroom; anything beyond is hostile or
44
+	// not Telegram.
45
+	maxResPQFrame = 256
46
+)
47
+
48
+// Probe sends req_pq_multi over an obfuscated2 + padded-intermediate transport
49
+// and verifies that the peer replies with a matching resPQ.
50
+//
51
+// conn must be a freshly opened reliable byte stream (typically TCP) to a
52
+// Telegram DC, but a SOCKS/proxy-wrapped net.Conn works just as well — Probe
53
+// adapts whatever it gets to the half-close interface mtg's obfuscator
54
+// requires. Probe does NOT close conn — the caller does. dc is the DC number
55
+// (1..5) that gets baked into the obfuscated2 handshake frame.
56
+//
57
+// The returned duration is the round-trip from "first byte sent after the
58
+// obfs handshake" to "resPQ frame fully read".
59
+func Probe(ctx context.Context, conn net.Conn, dc int) (time.Duration, error) {
60
+	if deadline, ok := ctx.Deadline(); ok {
61
+		_ = conn.SetDeadline(deadline)
62
+		defer func() { _ = conn.SetDeadline(time.Time{}) }()
63
+	}
64
+
65
+	// Honour ctx cancellation as well as its deadline: a parent ctx that is
66
+	// canceled (without an earlier deadline expiring) would otherwise let
67
+	// Probe block on an in-flight Read until the deadline. Forcing the
68
+	// deadline to "now" makes the next syscall return an i/o timeout error
69
+	// that Probe wraps and surfaces.
70
+	stop := context.AfterFunc(ctx, func() {
71
+		_ = conn.SetDeadline(time.Now())
72
+	})
73
+	defer stop()
74
+
75
+	// 1. obfuscated2 handshake. Empty Secret = no MTProxy secret mixing,
76
+	// which is how mtg itself talks to a DC (see mtglib/proxy.go).
77
+	obfsConn, err := obfuscation.Obfuscator{}.SendHandshake(adaptConn(conn), dc)
78
+	if err != nil {
79
+		return 0, fmt.Errorf("obfuscated2 handshake: %w", err)
80
+	}
81
+
82
+	// 2. build req_pq_multi TL payload: 4-byte LE constructor + 16-byte nonce.
83
+	var nonce [16]byte
84
+	if _, err := rand.Read(nonce[:]); err != nil {
85
+		return 0, fmt.Errorf("read nonce: %w", err)
86
+	}
87
+	tlBody := make([]byte, 4+16)
88
+	binary.LittleEndian.PutUint32(tlBody[:4], ctorReqPQMulti)
89
+	copy(tlBody[4:], nonce[:])
90
+
91
+	// 3. wrap in an MTProto unencrypted message envelope (per
92
+	// https://core.telegram.org/mtproto/description#unencrypted-message):
93
+	//   auth_key_id:long(=0) | message_id:long | message_data_length:int | message_data:bytes
94
+	// Without this envelope the DC silently drops the connection.
95
+	msg := make([]byte, 8+8+4+len(tlBody))
96
+	// auth_key_id = 0 (already zeroed by make)
97
+	binary.LittleEndian.PutUint64(msg[8:16], generateMessageID())
98
+	binary.LittleEndian.PutUint32(msg[16:20], uint32(len(tlBody)))
99
+	copy(msg[20:], tlBody)
100
+
101
+	// 4. wrap in a padded-intermediate frame: length(LE) + msg.
102
+	// Padding is allowed [0..15] but not required when len(msg) % 4 == 0.
103
+	frame := make([]byte, 4+len(msg))
104
+	binary.LittleEndian.PutUint32(frame[:4], uint32(len(msg)))
105
+	copy(frame[4:], msg)
106
+
107
+	start := time.Now()
108
+	if _, err := obfsConn.Write(frame); err != nil {
109
+		return 0, fmt.Errorf("write req_pq_multi: %w", err)
110
+	}
111
+
112
+	// 5. read padded-intermediate reply: length, then that many bytes.
113
+	// The reply is itself an MTProto unencrypted message (same envelope as
114
+	// what we sent), so we must skip 20 bytes to get to the resPQ TL.
115
+	var lenBuf [4]byte
116
+	if _, err := io.ReadFull(obfsConn, lenBuf[:]); err != nil {
117
+		return 0, fmt.Errorf("read frame length: %w", err)
118
+	}
119
+	respLen := binary.LittleEndian.Uint32(lenBuf[:])
120
+	if respLen < minResPQFrame {
121
+		return 0, fmt.Errorf("%w: resPQ frame too short (%d bytes)", ErrNotTelegram, respLen)
122
+	}
123
+	if respLen > maxResPQFrame {
124
+		return 0, fmt.Errorf("%w: resPQ frame too large (%d bytes, max %d)", ErrNotTelegram, respLen, maxResPQFrame)
125
+	}
126
+	resp := make([]byte, respLen)
127
+	if _, err := io.ReadFull(obfsConn, resp); err != nil {
128
+		return 0, fmt.Errorf("read resPQ frame: %w", err)
129
+	}
130
+	rtt := time.Since(start)
131
+
132
+	// 6. unwrap the MTProto envelope: skip auth_key_id(8) + message_id(8) +
133
+	// message_data_length(4) = 20 bytes.
134
+	tlResp := resp[20:]
135
+
136
+	// 7. verify constructor and nonce echo. We deliberately do not parse
137
+	// server_nonce, pq, or fingerprints — they are not needed to prove
138
+	// the peer can speak MTProto.
139
+	if got := binary.LittleEndian.Uint32(tlResp[:4]); got != ctorResPQ {
140
+		return rtt, fmt.Errorf("%w: got constructor 0x%08x, want resPQ 0x%08x", ErrNotTelegram, got, ctorResPQ)
141
+	}
142
+	if !bytes.Equal(tlResp[4:4+16], nonce[:]) {
143
+		return rtt, fmt.Errorf("%w: nonce echo mismatch", ErrNotTelegram)
144
+	}
145
+
146
+	return rtt, nil
147
+}
148
+
149
+// generateMessageID returns an MTProto message_id roughly synchronized with
150
+// server time, with the lower 2 bits cleared (client-to-server requests).
151
+// See https://core.telegram.org/mtproto/description#message-identifier-msg-id.
152
+func generateMessageID() uint64 {
153
+	nano := uint64(time.Now().UnixNano())
154
+	sec := nano / 1_000_000_000
155
+	nsInSec := nano % 1_000_000_000
156
+	subsec := (nsInSec << 32) / 1_000_000_000
157
+	id := (sec << 32) | subsec
158
+	return id &^ 3
159
+}
160
+
161
+// ErrNotTelegram is returned (wrapped) when the peer's reply is not a
162
+// well-formed resPQ matching our nonce. Use errors.Is to distinguish
163
+// "the TCP connection was OK but the peer is not a Telegram DC" from
164
+// transport errors.
165
+var ErrNotTelegram = errors.New("peer did not respond with a matching resPQ")
166
+
167
+// adaptConn returns conn as essentials.Conn if it already satisfies the
168
+// interface (typically *net.TCPConn), otherwise wraps it with no-op
169
+// CloseRead/CloseWrite. mtg's obfuscator only ever calls Read/Write/Close,
170
+// so the no-ops are safe.
171
+func adaptConn(conn net.Conn) essentials.Conn {
172
+	if ec, ok := conn.(essentials.Conn); ok {
173
+		return ec
174
+	}
175
+	return halfCloseShim{Conn: conn}
176
+}
177
+
178
+type halfCloseShim struct{ net.Conn }
179
+
180
+func (halfCloseShim) CloseRead() error  { return nil }
181
+func (halfCloseShim) CloseWrite() error { return nil }

+ 110
- 0
mtglib/dcprobe/probe_test.go Parādīt failu

@@ -0,0 +1,110 @@
1
+package dcprobe_test
2
+
3
+import (
4
+	"context"
5
+	"errors"
6
+	"io"
7
+	"net"
8
+	"os"
9
+	"testing"
10
+	"time"
11
+
12
+	"github.com/9seconds/mtg/v2/mtglib/dcprobe"
13
+)
14
+
15
+// TestProbeAgainstTelegramDCs makes outbound TCP connections to public
16
+// Telegram DCs. Skipped by default; opt-in with MTG_PROBE_NETWORK=1.
17
+func TestProbeAgainstTelegramDCs(t *testing.T) {
18
+	if os.Getenv("MTG_PROBE_NETWORK") != "1" {
19
+		t.Skip("skipping network probe (set MTG_PROBE_NETWORK=1 to enable)")
20
+	}
21
+
22
+	cases := []struct {
23
+		dc   int
24
+		addr string
25
+	}{
26
+		{1, "149.154.175.50:443"},
27
+		{2, "149.154.167.51:443"},
28
+		{2, "95.161.76.100:443"},
29
+		{3, "149.154.175.100:443"},
30
+		{4, "149.154.167.91:443"},
31
+		{5, "149.154.171.5:443"},
32
+		{1, "[2001:b28:f23d:f001::a]:443"},
33
+		{2, "[2001:67c:04e8:f002::a]:443"},
34
+	}
35
+
36
+	for _, tc := range cases {
37
+		t.Run(tc.addr, func(t *testing.T) {
38
+			ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
39
+			defer cancel()
40
+
41
+			conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", tc.addr)
42
+			if err != nil {
43
+				t.Fatalf("dial: %v", err)
44
+			}
45
+			t.Cleanup(func() { _ = conn.Close() })
46
+
47
+			rtt, err := dcprobe.Probe(ctx, conn, tc.dc)
48
+			if err != nil {
49
+				t.Fatalf("probe DC %d: %v", tc.dc, err)
50
+			}
51
+			t.Logf("DC %d (%s): rtt=%s", tc.dc, tc.addr, rtt)
52
+		})
53
+	}
54
+}
55
+
56
+// TestProbeRejectsMisbehavingPeer connects to an in-process listener that
57
+// accepts the obfs2 handshake, then writes back arbitrary bytes. With
58
+// overwhelming probability the decrypted reply fails one of: frame-length
59
+// bounds, resPQ constructor, or nonce echo. All three paths wrap
60
+// ErrNotTelegram, so we assert errors.Is.
61
+func TestProbeRejectsMisbehavingPeer(t *testing.T) {
62
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
63
+	if err != nil {
64
+		t.Fatal(err)
65
+	}
66
+	t.Cleanup(func() { _ = ln.Close() })
67
+
68
+	go func() {
69
+		c, err := ln.Accept()
70
+		if err != nil {
71
+			return
72
+		}
73
+		defer c.Close() //nolint: errcheck
74
+
75
+		// Discard the 64-byte obfs2 handshake the client sends.
76
+		var hs [64]byte
77
+		if _, err := io.ReadFull(c, hs[:]); err != nil {
78
+			return
79
+		}
80
+		// Write enough garbage to satisfy any plausible respLen the client
81
+		// might decode (we cap at maxResPQFrame=256 in probe.go). Whatever
82
+		// the client decrypts will fail constructor or nonce verification.
83
+		junk := make([]byte, 512)
84
+		for i := range junk {
85
+			junk[i] = byte(i)
86
+		}
87
+		_, _ = c.Write(junk)
88
+		// Keep the conn open until the client closes it (avoids racing the
89
+		// client's read against our close).
90
+		_, _ = io.Copy(io.Discard, c)
91
+	}()
92
+
93
+	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
94
+	defer cancel()
95
+
96
+	conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", ln.Addr().String())
97
+	if err != nil {
98
+		t.Fatal(err)
99
+	}
100
+	t.Cleanup(func() { _ = conn.Close() })
101
+
102
+	_, err = dcprobe.Probe(ctx, conn, 2)
103
+	if err == nil {
104
+		t.Fatal("expected ErrNotTelegram, got nil")
105
+	}
106
+	if !errors.Is(err, dcprobe.ErrNotTelegram) {
107
+		t.Fatalf("expected errors.Is(err, ErrNotTelegram) to be true, got: %v", err)
108
+	}
109
+	t.Logf("rejected: %v", err)
110
+}

Notiek ielāde…
Atcelt
Saglabāt