Highly-opinionated (ex-bullshit-free) MTPROTO proxy for Telegram. If you use v1.0 or upgrade broke you proxy, please read the chapter Version 2
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

doctor.go 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. package cli
  2. import (
  3. "context"
  4. "crypto/tls"
  5. "crypto/x509"
  6. "errors"
  7. "fmt"
  8. "maps"
  9. "net"
  10. "os"
  11. "slices"
  12. "strconv"
  13. "strings"
  14. "sync"
  15. "text/template"
  16. "time"
  17. "github.com/9seconds/mtg/v2/essentials"
  18. "github.com/9seconds/mtg/v2/internal/config"
  19. "github.com/9seconds/mtg/v2/internal/utils"
  20. "github.com/9seconds/mtg/v2/mtglib"
  21. "github.com/9seconds/mtg/v2/mtglib/dcprobe"
  22. "github.com/9seconds/mtg/v2/network/v2"
  23. "github.com/beevik/ntp"
  24. )
  25. var (
  26. funcs = template.FuncMap{
  27. "join": strings.Join,
  28. }
  29. tplError = template.Must(
  30. template.New("").
  31. Funcs(funcs).
  32. Parse(" ‼️ {{ .description }}: {{ .error }}\n"),
  33. )
  34. tplWDeprecatedConfig = template.Must(
  35. template.New("").
  36. Funcs(funcs).
  37. Parse(` ⚠️ Option {{ .old | printf "%q" }}{{ if .old_section }} from section [{{ .old_section }}]{{ end }} is deprecated and will be removed in v{{ .when }}. Please use {{ .new | printf "%q" }}{{ if .new_section }} in [{{ .new_section }}] section{{ end }} instead.` + "\n"),
  38. )
  39. tplOTimeSkewness = template.Must(
  40. template.New("").
  41. Funcs(funcs).
  42. Parse(" ✅ Time drift is {{ .drift }}, but tolerate-time-skewness is {{ .value }}\n"),
  43. )
  44. tplWTimeSkewness = template.Must(
  45. template.New("").
  46. Funcs(funcs).
  47. Parse(" ⚠️ Time drift is {{ .drift }}, but tolerate-time-skewness is {{ .value }}. Please check ntp.\n"),
  48. )
  49. tplETimeSkewness = template.Must(
  50. template.New("").
  51. Funcs(funcs).
  52. Parse(" ❌ Time drift is {{ .drift }}, but tolerate-time-skewness is {{ .value }}. You will get many rejected connections!\n"),
  53. )
  54. tplODCConnect = template.Must(
  55. template.New("").
  56. Funcs(funcs).
  57. Parse(" ✅ DC {{ .dc }} (rpc {{ .rtt }})\n"),
  58. )
  59. tplEDCConnect = template.Must(
  60. template.New("").
  61. Funcs(funcs).
  62. Parse(" ❌ DC {{ .dc }}: {{ .error }}\n"),
  63. )
  64. tplODNSSNIMatch = template.Must(
  65. template.New("").
  66. Funcs(funcs).
  67. Parse(" ✅ IP address {{ .ip }} matches secret hostname {{ .hostname }}\n"),
  68. )
  69. tplEDNSSNIMatch = template.Must(
  70. template.New("").
  71. Funcs(funcs).
  72. Parse(` ❌ Hostname {{ .hostname }} resolves to {{ join ", " .resolved }} but public IP is {{ .ip }}` + "\n"),
  73. )
  74. tplOFrontingDomain = template.Must(
  75. template.New("").
  76. Funcs(funcs).
  77. Parse(" ✅ {{ .address }} is reachable\n"),
  78. )
  79. tplEFrontingDomain = template.Must(
  80. template.New("").
  81. Funcs(funcs).
  82. Parse(" ❌ {{ .address }}: {{ .error }}\n"),
  83. )
  84. tplOFrontingTLS = template.Must(
  85. template.New("").Parse(" ✅ TLS certificate for {{ .host }} is valid\n"),
  86. )
  87. tplEFrontingTLS = template.Must(
  88. template.New("").Parse(" ❌ TLS certificate for {{ .host }} is invalid: {{ .error }}\n"),
  89. )
  90. tplSFrontingTLS = template.Must(
  91. template.New("").Parse(" ⏭ TLS certificate check skipped: proxy-protocol is enabled (the listener expects a PROXY header that mtg doctor does not send yet)\n"),
  92. )
  93. )
  94. type Doctor struct {
  95. conf *config.Config
  96. ConfigPath string `kong:"arg,required,type='existingfile',help='Path to the configuration file.',name='config-path'"` //nolint: lll
  97. SkipNativeCheck bool `kong:"help='Skip the native network connectivity check (useful when proxy chaining is configured and direct egress is not expected to work).',name='skip-native-check'"` //nolint: lll
  98. }
  99. func (d *Doctor) Run(cli *CLI, version string) error {
  100. conf, err := utils.ReadConfig(d.ConfigPath)
  101. if err != nil {
  102. return fmt.Errorf("cannot init config: %w", err)
  103. }
  104. d.conf = conf
  105. fmt.Println("Deprecated options")
  106. everythingOK := d.checkDeprecatedConfig()
  107. fmt.Println("Time skewness")
  108. everythingOK = d.checkTimeSkewness() && everythingOK
  109. resolver, err := network.GetDNS(conf.GetDNS())
  110. if err != nil {
  111. return fmt.Errorf("cannot create DNS resolver: %w", err)
  112. }
  113. base := network.New(
  114. resolver,
  115. "",
  116. conf.Network.Timeout.TCP.Get(10*time.Second),
  117. conf.Network.Timeout.HTTP.Get(0),
  118. conf.Network.Timeout.Idle.Get(0),
  119. net.KeepAliveConfig{
  120. Enable: !conf.Network.KeepAlive.Disabled.Get(false),
  121. Idle: conf.Network.KeepAlive.Idle.Get(0),
  122. Interval: conf.Network.KeepAlive.Interval.Get(0),
  123. Count: int(conf.Network.KeepAlive.Count.Get(0)),
  124. },
  125. int(conf.Network.TCPNotSentLowat.Get(network.DefaultTCPNotSentLowat)),
  126. )
  127. fmt.Println("Validate native network connectivity")
  128. if d.SkipNativeCheck {
  129. fmt.Println(" ⏭ Skipped (--skip-native-check)")
  130. } else {
  131. everythingOK = d.checkNetwork(base) && everythingOK
  132. }
  133. for _, url := range conf.Network.Proxies {
  134. value, err := network.NewProxyNetwork(base, url.Get(nil))
  135. if err != nil {
  136. return err
  137. }
  138. fmt.Printf("Validate network connectivity with proxy %s\n", url.Get(nil))
  139. everythingOK = d.checkNetwork(value) && everythingOK
  140. }
  141. fmt.Println("Validate fronting domain connectivity")
  142. everythingOK = d.checkFrontingDomain(base) && everythingOK
  143. fmt.Println("Validate SNI-DNS match")
  144. everythingOK = d.checkSecretHost(resolver, base) && everythingOK
  145. if !everythingOK {
  146. os.Exit(1)
  147. }
  148. return nil
  149. }
  150. func (d *Doctor) checkDeprecatedConfig() bool {
  151. ok := true
  152. if d.conf.DomainFrontingIP.Value != nil {
  153. ok = false
  154. tplWDeprecatedConfig.Execute(os.Stdout, map[string]string{ //nolint: errcheck
  155. "when": "2.3.0",
  156. "old": "domain-fronting-ip",
  157. "old_section": "",
  158. "new": "host",
  159. "new_section": "domain-fronting",
  160. })
  161. }
  162. if d.conf.DomainFronting.IP.Value != nil {
  163. ok = false
  164. tplWDeprecatedConfig.Execute(os.Stdout, map[string]string{ //nolint: errcheck
  165. "when": "2.4.0",
  166. "old": "ip",
  167. "old_section": "domain-fronting",
  168. "new": "host",
  169. "new_section": "domain-fronting",
  170. })
  171. }
  172. if d.conf.DomainFrontingPort.Value != 0 {
  173. ok = false
  174. tplWDeprecatedConfig.Execute(os.Stdout, map[string]string{ //nolint: errcheck
  175. "when": "2.3.0",
  176. "old": "domain-fronting-port",
  177. "old_section": "",
  178. "new": "port",
  179. "new_section": "domain-fronting",
  180. })
  181. }
  182. if d.conf.DomainFrontingProxyProtocol.Value {
  183. ok = false
  184. tplWDeprecatedConfig.Execute(os.Stdout, map[string]string{ //nolint: errcheck
  185. "when": "2.3.0",
  186. "old": "domain-fronting-proxy-protocol",
  187. "old_section": "",
  188. "new": "proxy-protocol",
  189. "new_section": "domain-fronting",
  190. })
  191. }
  192. if d.conf.Network.DOHIP.Value != nil {
  193. ok = false
  194. tplWDeprecatedConfig.Execute(os.Stdout, map[string]string{ //nolint: errcheck
  195. "when": "2.3.0",
  196. "old": "doh-ip",
  197. "old_section": "network",
  198. "new": "dns",
  199. "new_section": "network",
  200. })
  201. }
  202. if ok {
  203. fmt.Println(" ✅ All good")
  204. }
  205. return ok
  206. }
  207. func (d *Doctor) checkTimeSkewness() bool {
  208. response, err := ntp.Query("0.pool.ntp.org")
  209. if err != nil {
  210. tplError.Execute(os.Stdout, map[string]any{ //nolint: errcheck
  211. "description": "cannot access ntp pool",
  212. "error": err,
  213. })
  214. return false
  215. }
  216. skewness := response.ClockOffset.Abs()
  217. confValue := d.conf.TolerateTimeSkewness.Get(mtglib.DefaultTolerateTimeSkewness)
  218. diff := float64(skewness) / float64(confValue)
  219. tplData := map[string]any{
  220. "drift": response.ClockOffset,
  221. "value": confValue,
  222. }
  223. switch {
  224. case diff < 0.3:
  225. tplOTimeSkewness.Execute(os.Stdout, tplData) //nolint: errcheck
  226. return true
  227. case diff < 0.7:
  228. tplWTimeSkewness.Execute(os.Stdout, tplData) //nolint: errcheck
  229. default:
  230. tplETimeSkewness.Execute(os.Stdout, tplData) //nolint: errcheck
  231. }
  232. return false
  233. }
  234. func (d *Doctor) checkNetwork(ntw mtglib.Network) bool {
  235. dcs := slices.Collect(maps.Keys(essentials.TelegramCoreAddresses))
  236. slices.Sort(dcs)
  237. type dcResult struct {
  238. rtt time.Duration
  239. err error
  240. }
  241. results := make([]dcResult, len(dcs))
  242. var wg sync.WaitGroup
  243. for i, dc := range dcs {
  244. wg.Go(func() {
  245. defer func() {
  246. if r := recover(); r != nil {
  247. results[i].err = fmt.Errorf("panic: %v", r)
  248. }
  249. }()
  250. results[i].rtt, results[i].err = d.checkNetworkAddresses(ntw, dc, essentials.TelegramCoreAddresses[dc])
  251. })
  252. }
  253. wg.Wait()
  254. ok := true
  255. for i, dc := range dcs {
  256. if results[i].err == nil {
  257. tplODCConnect.Execute(os.Stdout, map[string]any{ //nolint: errcheck
  258. "dc": dc,
  259. "rtt": results[i].rtt.Round(time.Microsecond),
  260. })
  261. } else {
  262. tplEDCConnect.Execute(os.Stdout, map[string]any{ //nolint: errcheck
  263. "dc": dc,
  264. "error": results[i].err,
  265. })
  266. ok = false
  267. }
  268. }
  269. return ok
  270. }
  271. func (d *Doctor) checkNetworkAddresses(ntw mtglib.Network, dc int, addresses []string) (time.Duration, error) {
  272. checkAddresses := []string{}
  273. switch d.conf.PreferIP.Get("prefer-ip4") {
  274. case "only-ipv4":
  275. for _, addr := range addresses {
  276. host, _, err := net.SplitHostPort(addr)
  277. if err != nil {
  278. panic(err)
  279. }
  280. if ip := net.ParseIP(host); ip != nil && ip.To4() != nil {
  281. checkAddresses = append(checkAddresses, addr)
  282. }
  283. }
  284. case "only-ipv6":
  285. for _, addr := range addresses {
  286. host, _, err := net.SplitHostPort(addr)
  287. if err != nil {
  288. panic(err)
  289. }
  290. if ip := net.ParseIP(host); ip != nil && ip.To4() == nil {
  291. checkAddresses = append(checkAddresses, addr)
  292. }
  293. }
  294. default:
  295. checkAddresses = addresses
  296. }
  297. if len(checkAddresses) == 0 {
  298. return 0, fmt.Errorf("no suitable addresses after IP version filtering")
  299. }
  300. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  301. defer cancel()
  302. var lastErr error
  303. for _, addr := range checkAddresses {
  304. conn, err := ntw.DialContext(ctx, "tcp", addr)
  305. if err != nil {
  306. lastErr = fmt.Errorf("tcp connect to %s: %w", addr, err)
  307. continue
  308. }
  309. rtt, err := dcprobe.Probe(ctx, conn, dc)
  310. conn.Close() //nolint: errcheck
  311. if err != nil {
  312. lastErr = fmt.Errorf("rpc handshake to %s: %w", addr, err)
  313. continue
  314. }
  315. return rtt, nil
  316. }
  317. return 0, lastErr
  318. }
  319. func (d *Doctor) checkFrontingDomain(ntw mtglib.Network) bool {
  320. // SNI must always be the secret host: that is what domain fronting puts on
  321. // the wire and what the certificate is issued for. The TCP target may be a
  322. // different address when domain-fronting.host overrides it (in the
  323. // sni-router setup it is an internal name like "web").
  324. sniHost := d.conf.Secret.Host
  325. dialHost := sniHost
  326. if override := d.conf.GetDomainFrontingHost(); override != "" {
  327. dialHost = override
  328. }
  329. port := d.conf.GetDomainFrontingPort(mtglib.DefaultDomainFrontingPort)
  330. address := net.JoinHostPort(dialHost, strconv.Itoa(int(port)))
  331. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  332. defer cancel()
  333. dialer := ntw.NativeDialer()
  334. conn, err := dialer.DialContext(ctx, "tcp", address)
  335. if err != nil {
  336. tplEFrontingDomain.Execute(os.Stdout, map[string]any{ //nolint: errcheck
  337. "address": address,
  338. "error": err,
  339. })
  340. return false
  341. }
  342. conn.Close() //nolint: errcheck
  343. tplOFrontingDomain.Execute(os.Stdout, map[string]any{ //nolint: errcheck
  344. "address": address,
  345. })
  346. // With proxy-protocol enabled the fronting listener expects a PROXY header
  347. // before the TLS ClientHello, so a bare TLS handshake would hang or be
  348. // rejected and report a misleading failure. mtg doctor does not emit that
  349. // header yet, so skip the certificate probe rather than print a false
  350. // negative. See issue #518.
  351. if d.conf.GetDomainFrontingProxyProtocol(false) {
  352. tplSFrontingTLS.Execute(os.Stdout, nil) //nolint: errcheck
  353. return true
  354. }
  355. // A default crypto/tls client handshake against the fronting endpoint with
  356. // ServerName = secret host validates the whole certificate in one shot:
  357. // chain against the system roots, leaf SAN against the secret host, and
  358. // validity period. An expired / untrusted / wrong-host certificate all
  359. // surface as descriptive x509 errors.
  360. if err := probeFrontingTLS(ctx, dialer, address, sniHost, nil); err != nil {
  361. tplEFrontingTLS.Execute(os.Stdout, map[string]any{ //nolint: errcheck
  362. "host": sniHost,
  363. "error": err,
  364. })
  365. return false
  366. }
  367. tplOFrontingTLS.Execute(os.Stdout, map[string]any{ //nolint: errcheck
  368. "host": sniHost,
  369. })
  370. return true
  371. }
  372. // probeFrontingTLS dials dialAddress over TCP and performs a TLS handshake
  373. // presenting sniHost as the SNI / ServerName. Verification is left at the
  374. // crypto/tls default (InsecureSkipVerify=false), so the handshake fails with a
  375. // descriptive x509 error if the certificate chain is untrusted, the leaf SAN
  376. // does not cover sniHost, or the certificate is expired/not-yet-valid.
  377. //
  378. // rootCAs overrides the trust anchors; it is nil in production (system roots)
  379. // and is only set by tests that need a self-signed anchor.
  380. func probeFrontingTLS(
  381. ctx context.Context,
  382. dialer *net.Dialer,
  383. dialAddress string,
  384. sniHost string,
  385. rootCAs *x509.CertPool,
  386. ) error {
  387. conn, err := dialer.DialContext(ctx, "tcp", dialAddress)
  388. if err != nil {
  389. return fmt.Errorf("cannot dial %s: %w", dialAddress, err)
  390. }
  391. defer conn.Close() //nolint: errcheck
  392. if deadline, ok := ctx.Deadline(); ok {
  393. conn.SetDeadline(deadline) //nolint: errcheck
  394. }
  395. tlsConn := tls.Client(conn, &tls.Config{
  396. ServerName: sniHost,
  397. RootCAs: rootCAs,
  398. MinVersion: tls.VersionTLS12,
  399. })
  400. defer tlsConn.Close() //nolint: errcheck
  401. return tlsConn.HandshakeContext(ctx)
  402. }
  403. func (d *Doctor) checkSecretHost(resolver *net.Resolver, ntw mtglib.Network) bool {
  404. res, err := runSNICheck(context.Background(), d.conf, resolver, ntw)
  405. if err != nil {
  406. tplError.Execute(os.Stdout, map[string]any{ //nolint: errcheck
  407. "description": fmt.Sprintf("cannot resolve DNS name of %s", d.conf.Secret.Host),
  408. "error": err,
  409. })
  410. return false
  411. }
  412. if res.OurIP4 == "" && res.OurIP6 == "" {
  413. tplError.Execute(os.Stdout, map[string]any{ //nolint: errcheck
  414. "description": "cannot detect public IP address",
  415. "error": errors.New("cannot detect automatically and public-ipv4/public-ipv6 are not set in config"),
  416. })
  417. return false
  418. }
  419. ok := true
  420. if len(res.ResolvedIP4) > 0 {
  421. if slices.Contains(res.ResolvedIP4, res.OurIP4) {
  422. tplODNSSNIMatch.Execute(os.Stdout, map[string]any{ //nolint: errcheck
  423. "ip": res.OurIP4,
  424. "hostname": d.conf.Secret.Host,
  425. })
  426. } else {
  427. tplEDNSSNIMatch.Execute(os.Stdout, map[string]any{ //nolint: errcheck
  428. "ip": res.OurIP4,
  429. "resolved": res.ResolvedIP4,
  430. "hostname": d.conf.Secret.Host,
  431. })
  432. ok = false
  433. }
  434. }
  435. if len(res.ResolvedIP6) > 0 {
  436. if slices.Contains(res.ResolvedIP6, res.OurIP6) {
  437. tplODNSSNIMatch.Execute(os.Stdout, map[string]any{ //nolint: errcheck
  438. "ip": res.OurIP6,
  439. "hostname": d.conf.Secret.Host,
  440. })
  441. } else {
  442. tplEDNSSNIMatch.Execute(os.Stdout, map[string]any{ //nolint: errcheck
  443. "ip": res.OurIP6,
  444. "resolved": res.ResolvedIP6,
  445. "hostname": d.conf.Secret.Host,
  446. })
  447. ok = false
  448. }
  449. }
  450. return ok
  451. }