9 Commits

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

+ 6
- 4
contrib/sni-router/Caddyfile View File

10
 	# to Caddy's access log.  The `tls` wrapper must follow so that TLS
10
 	# to Caddy's access log.  The `tls` wrapper must follow so that TLS
11
 	# is terminated on the unwrapped connection.
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
 	servers :8443 {
18
 	servers :8443 {
17
 		listener_wrappers {
19
 		listener_wrappers {
18
 			proxy_protocol {
20
 			proxy_protocol {
19
 				timeout 5s
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
 			tls
24
 			tls
23
 		}
25
 		}

+ 23
- 0
contrib/sni-router/README.md View File

63
 If you disable one, disable all four, otherwise the backend will fail
63
 If you disable one, disable all four, otherwise the backend will fail
64
 to parse the connection.
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
 ## Fronting loop (why `[domain-fronting]` is set explicitly)
89
 ## Fronting loop (why `[domain-fronting]` is set explicitly)
67
 
90
 
68
 When mtg sees TLS that isn't valid Telegram (a probe or a browser
91
 When mtg sees TLS that isn't valid Telegram (a probe or a browser

+ 13
- 10
contrib/sni-router/docker-compose.yml View File

27
 services:
27
 services:
28
   haproxy:
28
   haproxy:
29
     image: haproxy:lts-alpine
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
     volumes:
34
     volumes:
34
       - ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro,Z
35
       - ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro,Z
35
     environment:
36
     environment:
38
       - mtg
39
       - mtg
39
       - web
40
       - web
40
     restart: unless-stopped
41
     restart: unless-stopped
41
-    sysctls:
42
-      - net.ipv4.ip_unprivileged_port_start=80
43
 
42
 
44
   mtg:
43
   mtg:
45
     # FIXME: :master until #480 lands in a tagged release; switch back to :2/:3 after release
44
     # FIXME: :master until #480 lands in a tagged release; switch back to :2/:3 after release
46
     image: nineseconds/mtg:master
45
     image: nineseconds/mtg:master
47
     volumes:
46
     volumes:
48
       - ./mtg-config.toml:/config/config.toml:ro,Z
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
     restart: unless-stopped
52
     restart: unless-stopped
52
     extra_hosts:
53
     extra_hosts:
53
       - "host.containers.internal:host-gateway"
54
       - "host.containers.internal:host-gateway"
58
       - ./Caddyfile:/etc/caddy/Caddyfile:ro,Z
59
       - ./Caddyfile:/etc/caddy/Caddyfile:ro,Z
59
       - caddy_data:/data
60
       - caddy_data:/data
60
       - ./www:/srv:ro,Z
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
     environment:
67
     environment:
65
       <<: *domain-env
68
       <<: *domain-env
66
     restart: unless-stopped
69
     restart: unless-stopped

+ 12
- 5
contrib/sni-router/haproxy.cfg View File

23
 # --- HTTP :80 — ACME challenges + redirect -----------------------------------
23
 # --- HTTP :80 — ACME challenges + redirect -----------------------------------
24
 
24
 
25
 frontend http
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
     mode http
29
     mode http
28
 
30
 
29
     # Let Caddy answer ACME HTTP-01 challenges for Let's Encrypt.
31
     # Let Caddy answer ACME HTTP-01 challenges for Let's Encrypt.
35
 # --- TLS :443 — SNI-based routing -------------------------------------------
37
 # --- TLS :443 — SNI-based routing -------------------------------------------
36
 
38
 
37
 frontend tls
39
 frontend tls
38
-    bind *:443
40
+    bind :443,[::]:443
39
     tcp-request inspect-delay 5s
41
     tcp-request inspect-delay 5s
40
     tcp-request content accept if { req_ssl_hello_type 1 }
42
     tcp-request content accept if { req_ssl_hello_type 1 }
41
 
43
 
46
 
48
 
47
     default_backend web
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
 backend mtg
56
 backend mtg
50
     # send-proxy-v2 prepends a PROXY protocol v2 header so mtg sees the
57
     # send-proxy-v2 prepends a PROXY protocol v2 header so mtg sees the
51
     # real client IP instead of HAProxy's.  mtg must have
58
     # real client IP instead of HAProxy's.  mtg must have
52
     # `proxy-protocol-listener = true` in its config.
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
 backend web
62
 backend web
56
     # send-proxy-v2 prepends a PROXY protocol v2 header so Caddy logs the
63
     # send-proxy-v2 prepends a PROXY protocol v2 header so Caddy logs the
57
     # real client IP instead of HAProxy's.  Caddy must enable the
64
     # real client IP instead of HAProxy's.  Caddy must enable the
58
     # proxy_protocol listener wrapper on :8443 (see Caddyfile).
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
 backend web_acme
68
 backend web_acme
62
     mode http
69
     mode http
63
-    server web web:80
70
+    server web 127.0.0.1:8080

+ 52
- 42
internal/cli/doctor.go View File

18
 	"github.com/9seconds/mtg/v2/internal/config"
18
 	"github.com/9seconds/mtg/v2/internal/config"
19
 	"github.com/9seconds/mtg/v2/internal/utils"
19
 	"github.com/9seconds/mtg/v2/internal/utils"
20
 	"github.com/9seconds/mtg/v2/mtglib"
20
 	"github.com/9seconds/mtg/v2/mtglib"
21
+	"github.com/9seconds/mtg/v2/mtglib/dcprobe"
21
 	"github.com/9seconds/mtg/v2/network/v2"
22
 	"github.com/9seconds/mtg/v2/network/v2"
22
 	"github.com/beevik/ntp"
23
 	"github.com/beevik/ntp"
23
 )
24
 )
46
 	)
47
 	)
47
 
48
 
48
 	tplODCConnect = template.Must(
49
 	tplODCConnect = template.Must(
49
-		template.New("").Parse("  ✅ DC {{ .dc }}\n"),
50
+		template.New("").Parse("  ✅ DC {{ .dc }} (rpc {{ .rtt }})\n"),
50
 	)
51
 	)
51
 	tplEDCConnect = template.Must(
52
 	tplEDCConnect = template.Must(
52
 		template.New("").Parse("  ❌ DC {{ .dc }}: {{ .error }}\n"),
53
 		template.New("").Parse("  ❌ DC {{ .dc }}: {{ .error }}\n"),
238
 	dcs := slices.Collect(maps.Keys(essentials.TelegramCoreAddresses))
239
 	dcs := slices.Collect(maps.Keys(essentials.TelegramCoreAddresses))
239
 	slices.Sort(dcs)
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
 	var wg sync.WaitGroup
248
 	var wg sync.WaitGroup
244
 	for i, dc := range dcs {
249
 	for i, dc := range dcs {
245
 		wg.Go(func() {
250
 		wg.Go(func() {
246
 			defer func() {
251
 			defer func() {
247
 				if r := recover(); r != nil {
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
 	wg.Wait()
259
 	wg.Wait()
256
 	ok := true
261
 	ok := true
257
 
262
 
258
 	for i, dc := range dcs {
263
 	for i, dc := range dcs {
259
-		if errs[i] == nil {
264
+		if results[i].err == nil {
260
 			tplODCConnect.Execute(os.Stdout, map[string]any{ //nolint: errcheck
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
 		} else {
269
 		} else {
264
 			tplEDCConnect.Execute(os.Stdout, map[string]any{ //nolint: errcheck
270
 			tplEDCConnect.Execute(os.Stdout, map[string]any{ //nolint: errcheck
265
 				"dc":    dc,
271
 				"dc":    dc,
266
-				"error": errs[i],
272
+				"error": results[i].err,
267
 			})
273
 			})
268
 			ok = false
274
 			ok = false
269
 		}
275
 		}
272
 	return ok
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
 	checkAddresses := []string{}
282
 	checkAddresses := []string{}
277
 
283
 
278
 	switch d.conf.PreferIP.Get("prefer-ip4") {
284
 	switch d.conf.PreferIP.Get("prefer-ip4") {
303
 	}
309
 	}
304
 
310
 
305
 	if len(checkAddresses) == 0 {
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
 	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
315
 	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
310
 	defer cancel()
316
 	defer cancel()
311
 
317
 
312
-	var (
313
-		conn net.Conn
314
-		err  error
315
-	)
318
+	var lastErr error
316
 
319
 
317
 	for _, addr := range checkAddresses {
320
 	for _, addr := range checkAddresses {
318
-		conn, err = ntw.DialContext(ctx, "tcp", addr)
321
+		conn, err := ntw.DialContext(ctx, "tcp", addr)
319
 		if err != nil {
322
 		if err != nil {
323
+			lastErr = fmt.Errorf("tcp connect to %s: %w", addr, err)
320
 			continue
324
 			continue
321
 		}
325
 		}
322
 
326
 
327
+		rtt, err := dcprobe.Probe(ctx, conn, dc)
323
 		conn.Close() //nolint: errcheck
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
 func (d *Doctor) checkFrontingDomain(ntw mtglib.Network) bool {
341
 func (d *Doctor) checkFrontingDomain(ntw mtglib.Network) bool {
361
 }
371
 }
362
 
372
 
363
 func (d *Doctor) checkSecretHost(resolver *net.Resolver, ntw mtglib.Network) bool {
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
 		tplError.Execute(os.Stdout, map[string]any{ //nolint: errcheck
377
 		tplError.Execute(os.Stdout, map[string]any{ //nolint: errcheck
367
 			"description": fmt.Sprintf("cannot resolve DNS name of %s", d.conf.Secret.Host),
378
 			"description": fmt.Sprintf("cannot resolve DNS name of %s", d.conf.Secret.Host),
368
-			"error":       err,
379
+			"error":       res.ResolveErr,
369
 		})
380
 		})
370
 		return false
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
 		tplError.Execute(os.Stdout, map[string]any{ //nolint: errcheck
385
 		tplError.Execute(os.Stdout, map[string]any{ //nolint: errcheck
385
 			"description": "cannot detect public IP address",
386
 			"description": "cannot detect public IP address",
386
 			"error":       errors.New("cannot detect automatically and public-ipv4/public-ipv6 are not set in config"),
387
 			"error":       errors.New("cannot detect automatically and public-ipv4/public-ipv6 are not set in config"),
388
 		return false
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
 	tplEDNSSNIMatch.Execute(os.Stdout, map[string]any{ //nolint: errcheck
415
 	tplEDNSSNIMatch.Execute(os.Stdout, map[string]any{ //nolint: errcheck
406
 		"hostname": d.conf.Secret.Host,
416
 		"hostname": d.conf.Secret.Host,
407
 		"resolved": strings.Join(strAddresses, ", "),
417
 		"resolved": strings.Join(strAddresses, ", "),
408
-		"ip4":      ourIP4,
409
-		"ip6":      ourIP6,
418
+		"ip4":      res.OurIPv4,
419
+		"ip6":      res.OurIPv6,
410
 	})
420
 	})
411
 
421
 
412
 	return false
422
 	return false

+ 16
- 35
internal/cli/run_proxy.go View File

215
 		return
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
 		log.BindStr("hostname", host).
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
 		return
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
 		log.Warning("SNI-DNS check: cannot detect public IP address; set public-ipv4/public-ipv6 in config or run 'mtg doctor'")
227
 		log.Warning("SNI-DNS check: cannot detect public IP address; set public-ipv4/public-ipv6 in config or run 'mtg doctor'")
237
 		return
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
 	if v4Match && v6Match {
234
 	if v4Match && v6Match {
254
 		return
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
 	our := ""
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
 		if our != "" {
249
 		if our != "" {
269
 			our += "/"
250
 			our += "/"
270
 		}
251
 		}
271
 
252
 
272
-		our += ourIP6.String()
253
+		our += res.OurIPv6.String()
273
 	}
254
 	}
274
 
255
 
275
 	entry := log.BindStr("hostname", host).
256
 	entry := log.BindStr("hostname", host).
276
 		BindStr("resolved", strings.Join(resolved, ", ")).
257
 		BindStr("resolved", strings.Join(resolved, ", ")).
277
 		BindStr("public_ip", our)
258
 		BindStr("public_ip", our)
278
 
259
 
279
-	if ourIP4 != nil {
260
+	if res.OurIPv4 != nil {
280
 		entry = entry.BindStr("ipv4_match", fmt.Sprintf("%t", v4Match))
261
 		entry = entry.BindStr("ipv4_match", fmt.Sprintf("%t", v4Match))
281
 	}
262
 	}
282
 
263
 
283
-	if ourIP6 != nil {
264
+	if res.OurIPv6 != nil {
284
 		entry = entry.BindStr("ipv6_match", fmt.Sprintf("%t", v6Match))
265
 		entry = entry.BindStr("ipv6_match", fmt.Sprintf("%t", v6Match))
285
 	}
266
 	}
286
 
267
 

+ 78
- 0
internal/cli/sni_check.go View File

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 View File

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 View File

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
+}

Loading…
Cancel
Save