move gotd fork into repo. (#111)
- update to latest telegram layer - remove some references to fields in tg.Entities that don't exist in the schema - originally added here: https://github.com/beeper/td/commit/820929062a2ba0104397bc01235ab58a9cff780e - referenced here - https://github.com/mautrix/telegramgo/commit/124f0967ed195b5a380c9bd02e170ada9710dde3 - https://github.com/mautrix/telegramgo/commit/4205047aab2e0639217148b5d125bfaab668bd8e
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
package dcs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"math/big"
|
||||
"net"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/go-faster/errors"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/bin"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/crypto"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||
)
|
||||
|
||||
var dnsKey struct {
|
||||
rsa.PublicKey
|
||||
once sync.Once
|
||||
eBig *big.Int
|
||||
}
|
||||
|
||||
//nolint:gochecknoinits
|
||||
func init() {
|
||||
dnsKey.once.Do(func() {
|
||||
k, err := crypto.ParseRSAPublicKeys([]byte(`-----BEGIN RSA PUBLIC KEY-----
|
||||
MIIBCgKCAQEAyr+18Rex2ohtVy8sroGPBwXD3DOoKCSpjDqYoXgCqB7ioln4eDCF
|
||||
fOBUlfXUEvM/fnKCpF46VkAftlb4VuPDeQSS/ZxZYEGqHaywlroVnXHIjgqoxiAd
|
||||
192xRGreuXIaUKmkwlM9JID9WS2jUsTpzQ91L8MEPLJ/4zrBwZua8W5fECwCCh2c
|
||||
9G5IzzBm+otMS/YKwmR1olzRCyEkyAEjXWqBI9Ftv5eG8m0VkBzOG655WIYdyV0H
|
||||
fDK/NWcvGqa0w/nriMD6mDjKOryamw0OP9QuYgMN0C9xMW9y8SmP4h92OAWodTYg
|
||||
Y1hZCxdv6cs5UnW9+PWvS+WIbkh+GaWYxwIDAQAB
|
||||
-----END RSA PUBLIC KEY-----`))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
dnsKey.PublicKey = *k[0]
|
||||
dnsKey.eBig = big.NewInt(int64(dnsKey.E))
|
||||
})
|
||||
}
|
||||
|
||||
// parseDNSList decodes raw encrypted simple config.
|
||||
//
|
||||
// Notice that parseDNSList does not decode base64, user should do it manually.
|
||||
func parseDNSList(input [256]byte) (tg.HelpConfigSimple, error) {
|
||||
// See https://github.com/tdlib/td/blob/master/td/telegram/ConfigManager.cpp#L148.
|
||||
x := new(big.Int).SetBytes(input[:])
|
||||
y := new(big.Int).Exp(x, dnsKey.eBig, dnsKey.N)
|
||||
|
||||
dataRSA := make([]byte, 256)
|
||||
if !crypto.FillBytes(y, dataRSA) {
|
||||
return tg.HelpConfigSimple{}, errors.New("dataRSA has invalid size")
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(dataRSA[:32])
|
||||
if err != nil {
|
||||
return tg.HelpConfigSimple{}, err
|
||||
}
|
||||
d := cipher.NewCBCDecrypter(block, dataRSA[16:32])
|
||||
dataCBC := dataRSA[32:]
|
||||
d.CryptBlocks(dataCBC, dataCBC)
|
||||
|
||||
decrypted := dataCBC[:len(dataCBC)-16]
|
||||
decryptedHash := sha256.Sum256(decrypted)
|
||||
hash := dataCBC[len(dataCBC)-16:]
|
||||
|
||||
if !bytes.Equal(decryptedHash[:16], hash) {
|
||||
return tg.HelpConfigSimple{}, errors.New("hash mismatch")
|
||||
}
|
||||
|
||||
var cfg tg.HelpConfigSimple
|
||||
if err := cfg.Decode(&bin.Buffer{Buf: decrypted[4:]}); err != nil {
|
||||
return tg.HelpConfigSimple{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
type sortByLen []string
|
||||
|
||||
func (s sortByLen) Len() int {
|
||||
return len(s)
|
||||
}
|
||||
|
||||
func (s sortByLen) Less(i, j int) bool {
|
||||
return len(s[i]) > len(s[j])
|
||||
}
|
||||
|
||||
func (s sortByLen) Swap(i, j int) {
|
||||
s[i], s[j] = s[j], s[i]
|
||||
}
|
||||
|
||||
// DNSConfig is DC connection config obtained from DNS.
|
||||
type DNSConfig struct {
|
||||
// Date field of HelpConfigSimple.
|
||||
Date int
|
||||
// Expires field of HelpConfigSimple.
|
||||
Expires int
|
||||
// Rules field of HelpConfigSimple.
|
||||
Rules []tg.AccessPointRule
|
||||
}
|
||||
|
||||
// Options returns DC options from this config.
|
||||
func (d DNSConfig) Options() (r []tg.DCOption) {
|
||||
convertIP := func(ip int) string {
|
||||
return net.IPv4(
|
||||
byte(ip),
|
||||
byte(ip>>8),
|
||||
byte(ip>>16),
|
||||
byte(ip>>24),
|
||||
).String()
|
||||
}
|
||||
for _, rule := range d.Rules {
|
||||
for _, ip := range rule.IPs {
|
||||
switch ip := ip.(type) {
|
||||
case *tg.IPPort:
|
||||
r = append(r, tg.DCOption{
|
||||
ID: rule.DCID,
|
||||
IPAddress: convertIP(ip.Ipv4),
|
||||
Port: ip.Port,
|
||||
})
|
||||
case *tg.IPPortSecret:
|
||||
r = append(r, tg.DCOption{
|
||||
TCPObfuscatedOnly: true,
|
||||
ID: rule.DCID,
|
||||
IPAddress: convertIP(ip.Ipv4),
|
||||
Port: ip.Port,
|
||||
Secret: ip.Secret,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ParseDNSConfig parses tg.HelpConfigSimple from TXT response.
|
||||
func ParseDNSConfig(txt []string) (DNSConfig, error) {
|
||||
encoding := base64.StdEncoding
|
||||
const (
|
||||
decodedLen = 256
|
||||
encodedLen = 344
|
||||
)
|
||||
sort.Sort(sortByLen(txt))
|
||||
|
||||
var totalLength int
|
||||
for i := range txt {
|
||||
totalLength += len(txt[i])
|
||||
}
|
||||
if totalLength != encodedLen {
|
||||
return DNSConfig{}, errors.Errorf("invalid input length %d", totalLength)
|
||||
}
|
||||
|
||||
var (
|
||||
encoded [encodedLen]byte
|
||||
decoded [decodedLen]byte
|
||||
)
|
||||
n := 0
|
||||
for i := range txt {
|
||||
n += copy(encoded[n:], txt[i])
|
||||
}
|
||||
|
||||
if _, err := encoding.Decode(decoded[:], encoded[:]); err != nil {
|
||||
return DNSConfig{}, errors.Wrap(err, "decode")
|
||||
}
|
||||
|
||||
cfg, err := parseDNSList(decoded)
|
||||
if err != nil {
|
||||
return DNSConfig{}, errors.Wrap(err, "decrypt config")
|
||||
}
|
||||
|
||||
return DNSConfig{
|
||||
Date: cfg.Date,
|
||||
Expires: cfg.Expires,
|
||||
Rules: cfg.Rules,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package dcs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func testTXTResponse() []string {
|
||||
return []string{
|
||||
"LcmEoukF2bVjKwz3E+J9BsDdL+rv9lGqLQWIGXrWACT2ESk5xuOpA6Cz6klKRbhbwSiHOd2zC5PiR57j/OJHPpj4i+tw==",
|
||||
"umjjLFLpOKtPeW9zHLq2ypbMzg/zkqvPhvhr0bxrLZlgPQ04l2GpO/4qZgAx3tk3BDHbY6/gmG1e8eaFBq3YSqR5SZ5hQ1Cm5f4/" +
|
||||
"o67GYcPJClaf1TiHq3wVfsQ5OLnyJRw9A2ZfUfzIXxoSklPJrVdF/4hM1ZdUE0eWDAbmYf7JCeao8ecVVwKndd4CZHZS9wyf1T7DIUh95VpQ" +
|
||||
"sn2klLPA6gA/2YNXOh9gITvjZrKuXLwwh9hBHhPvxv",
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ParseDNSConfig(t *testing.T) {
|
||||
t.Run("Good", func(t *testing.T) {
|
||||
a := require.New(t)
|
||||
|
||||
cfg, err := ParseDNSConfig(testTXTResponse())
|
||||
a.NoError(err)
|
||||
a.Equal(1565541126, cfg.Expires)
|
||||
a.Equal(1562949126, cfg.Date)
|
||||
a.Len(cfg.Rules, 1)
|
||||
|
||||
rule := cfg.Rules[0]
|
||||
a.Equal(2, rule.DCID)
|
||||
})
|
||||
|
||||
t.Run("Bad", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []string
|
||||
}{
|
||||
{"Empty", nil},
|
||||
{"InvalidHash", func() []string {
|
||||
r := testTXTResponse()
|
||||
first := r[0]
|
||||
r[0] = string(first[0]+1) + first[1:]
|
||||
return r
|
||||
}()},
|
||||
{"InvalidBase64", func() []string {
|
||||
r := testTXTResponse()
|
||||
first := r[0]
|
||||
r[0] = string('#') + first[1:]
|
||||
return r
|
||||
}()},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ParseDNSConfig(tt.input)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkDNSConfig(b *testing.B) {
|
||||
message := testTXTResponse()
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
var (
|
||||
err error
|
||||
cfgSink DNSConfig
|
||||
)
|
||||
for i := 0; i < b.N; i++ {
|
||||
cfgSink, err = ParseDNSConfig(message)
|
||||
if cfgSink.Date == 0 || err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSConfig_Options(t *testing.T) {
|
||||
a := require.New(t)
|
||||
|
||||
cfg, err := ParseDNSConfig(testTXTResponse())
|
||||
a.NoError(err)
|
||||
|
||||
options := cfg.Options()
|
||||
a.Len(options, 1)
|
||||
option := options[0]
|
||||
a.Equal(2, option.ID)
|
||||
a.Equal(14544, option.Port)
|
||||
a.Equal("98.210.59.139", option.IPAddress)
|
||||
a.True(option.TCPObfuscatedOnly)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package dcs contains Telegram DCs list and some helpers.
|
||||
package dcs
|
||||
@@ -0,0 +1,52 @@
|
||||
package dcs_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/dcs"
|
||||
)
|
||||
|
||||
func ExampleDialFunc() {
|
||||
// Dial using proxy from environment.
|
||||
|
||||
// Creating connection.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
client := telegram.NewClient(1, "appHash", telegram.Options{
|
||||
Resolver: dcs.Plain(dcs.PlainOptions{Dial: proxy.Dial}),
|
||||
})
|
||||
|
||||
_ = client.Run(ctx, func(ctx context.Context) error {
|
||||
fmt.Println("Started")
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func ExampleDialFunc_dialer() {
|
||||
// Dial using SOCKS5 proxy.
|
||||
|
||||
sock5, _ := proxy.SOCKS5("tcp", "IP:PORT", &proxy.Auth{
|
||||
User: "YOURUSERNAME",
|
||||
Password: "YOURPASSWORD",
|
||||
}, proxy.Direct)
|
||||
dc := sock5.(proxy.ContextDialer)
|
||||
|
||||
// Creating connection.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
client := telegram.NewClient(1, "appHash", telegram.Options{
|
||||
Resolver: dcs.Plain(dcs.PlainOptions{
|
||||
Dial: dc.DialContext,
|
||||
}),
|
||||
})
|
||||
|
||||
_ = client.Run(ctx, func(ctx context.Context) error {
|
||||
fmt.Println("Started")
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dcs
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||
)
|
||||
|
||||
// FindDCs searches DCs candidates from given config.
|
||||
func FindDCs(opts []tg.DCOption, dcID int, preferIPv6 bool) []tg.DCOption {
|
||||
// Preallocate slice.
|
||||
candidates := make([]tg.DCOption, 0, 32)
|
||||
|
||||
for _, candidateDC := range opts {
|
||||
if candidateDC.ID != dcID {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, candidateDC)
|
||||
}
|
||||
|
||||
if len(candidates) < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
l, r := candidates[i], candidates[j]
|
||||
|
||||
// If we prefer IPv6 and left is IPv6 and right is not, so then
|
||||
// left is smaller (would be before right).
|
||||
if preferIPv6 {
|
||||
if l.Ipv6 && !r.Ipv6 {
|
||||
return true
|
||||
}
|
||||
if !l.Ipv6 && r.Ipv6 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Also we prefer static addresses.
|
||||
return l.Static && !r.Static
|
||||
})
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
// FindPrimaryDCs searches new primary DC from given config.
|
||||
// Unlike FindDC, it filters CDNs and MediaOnly servers, returns error
|
||||
// if not found.
|
||||
func FindPrimaryDCs(opts []tg.DCOption, dcID int, preferIPv6 bool) []tg.DCOption {
|
||||
candidates := FindDCs(opts, dcID, preferIPv6)
|
||||
// Filter (in place) from SliceTricks.
|
||||
n := 0
|
||||
for _, opt := range candidates {
|
||||
if !opt.MediaOnly && !opt.CDN {
|
||||
candidates[n] = opt
|
||||
n++
|
||||
}
|
||||
}
|
||||
return candidates[:n]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package dcs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||
)
|
||||
|
||||
func TestFindDCs(t *testing.T) {
|
||||
dcOptions := []tg.DCOption{
|
||||
{ID: 1, Ipv6: false},
|
||||
{ID: 1, Ipv6: true},
|
||||
{ID: 1, Ipv6: false, Static: true},
|
||||
|
||||
{ID: 2, Ipv6: true, Static: true},
|
||||
{ID: 2, Ipv6: true},
|
||||
{ID: 2, Ipv6: false},
|
||||
}
|
||||
for i := range dcOptions {
|
||||
dcOptions[i].IPAddress = fmt.Sprintf("DC: %d, Index: %d", dcOptions[i].ID, i)
|
||||
}
|
||||
|
||||
a := require.New(t)
|
||||
dc := FindDCs(dcOptions, -2, false)
|
||||
a.Empty(dc)
|
||||
dc = FindDCs(dcOptions, -2, true)
|
||||
a.Empty(dc)
|
||||
|
||||
// Prefer IPv6.
|
||||
dc = FindDCs(dcOptions, 1, true)
|
||||
a.True(dc[0].Ipv6)
|
||||
|
||||
// Prefer static.
|
||||
dc = FindDCs(dcOptions, 1, false)
|
||||
a.True(dc[0].Static)
|
||||
|
||||
// Prefer static and IPv6.
|
||||
dc = FindDCs(dcOptions, 2, true)
|
||||
a.True(dc[0].Static)
|
||||
a.True(dc[0].Ipv6)
|
||||
}
|
||||
|
||||
func TestFindPrimaryDCs(t *testing.T) {
|
||||
dcOptions := []tg.DCOption{
|
||||
{ID: 1, Ipv6: false},
|
||||
{ID: 1, Ipv6: true},
|
||||
{ID: 1, Ipv6: false, Static: true},
|
||||
|
||||
{ID: 2, Ipv6: true, Static: true, MediaOnly: true},
|
||||
{ID: 2, Ipv6: true, CDN: true},
|
||||
{ID: 2, Ipv6: false, CDN: true},
|
||||
}
|
||||
for i := range dcOptions {
|
||||
dcOptions[i].IPAddress = fmt.Sprintf("DC: %d, Index: %d", dcOptions[i].ID, i)
|
||||
}
|
||||
a := require.New(t)
|
||||
dc := FindPrimaryDCs(dcOptions, -2, false)
|
||||
a.Empty(dc)
|
||||
dc = FindPrimaryDCs(dcOptions, -2, true)
|
||||
a.Empty(dc)
|
||||
|
||||
// Prefer IPv6.
|
||||
dc = FindPrimaryDCs(dcOptions, 1, true)
|
||||
a.True(dc[0].Ipv6)
|
||||
|
||||
// Prefer static.
|
||||
dc = FindPrimaryDCs(dcOptions, 1, false)
|
||||
a.True(dc[0].Static)
|
||||
|
||||
// Filter CDN/MediaOnly/TCPo.
|
||||
dc = FindPrimaryDCs(dcOptions, 2, false)
|
||||
a.Empty(dc)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package dcs
|
||||
|
||||
import "go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||
|
||||
// List is a list of Telegram DC addresses and domains.
|
||||
type List struct {
|
||||
Options []tg.DCOption
|
||||
Domains map[int]string
|
||||
Test bool
|
||||
}
|
||||
|
||||
// Zero returns true if this List is zero value.
|
||||
func (d List) Zero() bool {
|
||||
return d.Options == nil && d.Domains == nil && !d.Test
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package dcs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
|
||||
"github.com/go-faster/errors"
|
||||
"go.uber.org/multierr"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/crypto"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/mtproxy"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/mtproxy/obfuscator"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/proto/codec"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/transport"
|
||||
)
|
||||
|
||||
var _ Resolver = mtProxy{}
|
||||
|
||||
type mtProxy struct {
|
||||
dial DialFunc
|
||||
protocol protocol
|
||||
addr, network string
|
||||
|
||||
secret mtproxy.Secret
|
||||
tag [4]byte
|
||||
rand io.Reader
|
||||
}
|
||||
|
||||
func (m mtProxy) Primary(ctx context.Context, dc int, _ List) (transport.Conn, error) {
|
||||
return m.resolve(ctx, dc)
|
||||
}
|
||||
|
||||
func (m mtProxy) MediaOnly(ctx context.Context, dc int, _ List) (transport.Conn, error) {
|
||||
if dc > 0 {
|
||||
dc *= -1
|
||||
}
|
||||
return m.resolve(ctx, dc)
|
||||
}
|
||||
|
||||
func (m mtProxy) CDN(ctx context.Context, dc int, _ List) (transport.Conn, error) {
|
||||
return m.resolve(ctx, dc)
|
||||
}
|
||||
|
||||
func (m mtProxy) resolve(ctx context.Context, dc int) (transport.Conn, error) {
|
||||
c, err := m.dial(ctx, m.network, m.addr)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "connect to the MTProxy %q", m.addr)
|
||||
}
|
||||
|
||||
conn, err := m.handshakeConn(c, dc)
|
||||
if err != nil {
|
||||
err = errors.Wrap(err, "handshake")
|
||||
return nil, multierr.Combine(err, c.Close())
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// handshakeConn inits given net.Conn as MTProto connection.
|
||||
func (m mtProxy) handshakeConn(c net.Conn, dc int) (transport.Conn, error) {
|
||||
var obsConn *obfuscator.Conn
|
||||
switch m.secret.Type {
|
||||
case mtproxy.Simple, mtproxy.Secured:
|
||||
obsConn = obfuscator.Obfuscated2(m.rand, c)
|
||||
case mtproxy.TLS:
|
||||
obsConn = obfuscator.FakeTLS(m.rand, c)
|
||||
default:
|
||||
return nil, errors.Errorf("unknown MTProxy secret type: %d", m.secret.Type)
|
||||
}
|
||||
|
||||
secret := m.secret
|
||||
if err := obsConn.Handshake(m.tag, dc, secret); err != nil {
|
||||
return nil, errors.Wrap(err, "MTProxy handshake")
|
||||
}
|
||||
|
||||
transportConn, err := m.protocol.Handshake(obsConn)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "transport handshake")
|
||||
}
|
||||
|
||||
return transportConn, nil
|
||||
}
|
||||
|
||||
// MTProxyOptions is MTProxy resolver creation options.
|
||||
type MTProxyOptions struct {
|
||||
// Dial specifies the dial function for creating unencrypted TCP connections.
|
||||
// If Dial is nil, then the resolver dials using package net.
|
||||
Dial DialFunc
|
||||
// Network to use. Defaults to "tcp"
|
||||
Network string
|
||||
// Random source for MTProxy obfuscator.
|
||||
Rand io.Reader
|
||||
}
|
||||
|
||||
func (m *MTProxyOptions) setDefaults() {
|
||||
if m.Dial == nil {
|
||||
var d net.Dialer
|
||||
m.Dial = d.DialContext
|
||||
}
|
||||
if m.Network == "" {
|
||||
m.Network = "tcp"
|
||||
}
|
||||
if m.Rand == nil {
|
||||
m.Rand = crypto.DefaultRand()
|
||||
}
|
||||
}
|
||||
|
||||
// MTProxy creates MTProxy obfuscated DC resolver.
|
||||
//
|
||||
// See https://core.telegram.org/mtproto/mtproto-transports#transport-obfuscation.
|
||||
func MTProxy(addr string, secret []byte, opts MTProxyOptions) (Resolver, error) {
|
||||
s, err := mtproxy.ParseSecret(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cdc codec.Codec = codec.PaddedIntermediate{}
|
||||
tag := codec.PaddedIntermediateClientStart
|
||||
|
||||
// FIXME(tdakkota): some proxies forces to use Padded (Secure) Intermediate
|
||||
// even if secret denotes to use another transport type.
|
||||
if s.Type != mtproxy.TLS {
|
||||
if c, ok := s.ExpectedCodec(); ok {
|
||||
cdc = c
|
||||
tag = [4]byte{s.Tag, s.Tag, s.Tag, s.Tag}
|
||||
}
|
||||
}
|
||||
|
||||
opts.setDefaults()
|
||||
return mtProxy{
|
||||
dial: opts.Dial,
|
||||
addr: addr,
|
||||
network: opts.Network,
|
||||
protocol: transport.NewProtocol(func() transport.Codec { return codec.NoHeader{Codec: cdc} }),
|
||||
secret: s,
|
||||
tag: tag,
|
||||
rand: opts.Rand,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package dcs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-faster/errors"
|
||||
"go.uber.org/multierr"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/crypto"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/mtproxy"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/mtproxy/obfuscator"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/proto/codec"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/transport"
|
||||
)
|
||||
|
||||
var _ Resolver = plain{}
|
||||
|
||||
type plain struct {
|
||||
dial DialFunc
|
||||
protocol Protocol
|
||||
rand io.Reader
|
||||
network string
|
||||
noObfuscated bool
|
||||
preferIPv6 bool
|
||||
}
|
||||
|
||||
func (p plain) Primary(ctx context.Context, dc int, list List) (transport.Conn, error) {
|
||||
candidates := FindPrimaryDCs(list.Options, dc, p.preferIPv6)
|
||||
if p.noObfuscated {
|
||||
n := 0
|
||||
for _, x := range candidates {
|
||||
if !x.TCPObfuscatedOnly {
|
||||
candidates[n] = x
|
||||
n++
|
||||
}
|
||||
}
|
||||
candidates = candidates[:n]
|
||||
}
|
||||
return p.connect(ctx, dc, list.Test, candidates)
|
||||
}
|
||||
|
||||
func (p plain) MediaOnly(ctx context.Context, dc int, list List) (transport.Conn, error) {
|
||||
candidates := FindDCs(list.Options, dc, p.preferIPv6)
|
||||
// Filter (in place) from SliceTricks.
|
||||
n := 0
|
||||
for _, x := range candidates {
|
||||
if x.MediaOnly {
|
||||
candidates[n] = x
|
||||
n++
|
||||
}
|
||||
}
|
||||
return p.connect(ctx, dc, list.Test, candidates[:n])
|
||||
}
|
||||
|
||||
func (p plain) CDN(ctx context.Context, dc int, list List) (transport.Conn, error) {
|
||||
return nil, errors.Errorf("can't resolve %d: CDN is unsupported", dc)
|
||||
}
|
||||
|
||||
func (p plain) dialTransport(ctx context.Context, test bool, dc tg.DCOption) (_ transport.Conn, rerr error) {
|
||||
addr := net.JoinHostPort(dc.IPAddress, strconv.Itoa(dc.Port))
|
||||
|
||||
conn, err := p.dial(ctx, p.network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if rerr != nil {
|
||||
multierr.AppendInto(&rerr, conn.Close())
|
||||
}
|
||||
}()
|
||||
|
||||
proto := p.protocol
|
||||
if dc.TCPObfuscatedOnly {
|
||||
dcID := dc.ID
|
||||
if test {
|
||||
if dcID < 0 {
|
||||
dcID -= 10000
|
||||
} else {
|
||||
dcID += 10000
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
cdc codec.Codec = codec.Intermediate{}
|
||||
tag = codec.IntermediateClientStart
|
||||
obfs = obfuscator.Obfuscated2
|
||||
)
|
||||
|
||||
secret, err := mtproxy.ParseSecret(dc.Secret)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "check DC secret")
|
||||
}
|
||||
|
||||
if secret.Type == mtproxy.TLS {
|
||||
obfs = obfuscator.FakeTLS
|
||||
} else if c, ok := secret.ExpectedCodec(); ok {
|
||||
tag = [4]byte{secret.Tag, secret.Tag, secret.Tag, secret.Tag}
|
||||
cdc = c
|
||||
}
|
||||
|
||||
obfsConn := obfs(p.rand, conn)
|
||||
if err := obfsConn.Handshake(tag, dcID, secret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn = obfsConn
|
||||
|
||||
proto = transport.NewProtocol(func() transport.Codec {
|
||||
return codec.NoHeader{Codec: cdc}
|
||||
})
|
||||
}
|
||||
|
||||
transportConn, err := proto.Handshake(conn)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "transport handshake")
|
||||
}
|
||||
|
||||
return transportConn, nil
|
||||
}
|
||||
|
||||
func (p plain) connect(ctx context.Context, dc int, test bool, dcOptions []tg.DCOption) (transport.Conn, error) {
|
||||
switch len(dcOptions) {
|
||||
case 0:
|
||||
return nil, errors.Errorf("no addresses for DC %d", dc)
|
||||
case 1:
|
||||
return p.dialTransport(ctx, test, dcOptions[0])
|
||||
}
|
||||
|
||||
type dialResult struct {
|
||||
conn transport.Conn
|
||||
err error
|
||||
}
|
||||
|
||||
// We use unbuffered channel to ensure that only one connection will be returned
|
||||
// and all other will be closed.
|
||||
results := make(chan dialResult)
|
||||
tryDial := func(ctx context.Context, option tg.DCOption) {
|
||||
conn, err := p.dialTransport(ctx, test, option)
|
||||
select {
|
||||
case results <- dialResult{
|
||||
conn: conn,
|
||||
err: err,
|
||||
}:
|
||||
case <-ctx.Done():
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialCtx, dialCancel := context.WithCancel(ctx)
|
||||
defer dialCancel()
|
||||
|
||||
for _, dcOption := range dcOptions {
|
||||
go tryDial(dialCtx, dcOption)
|
||||
}
|
||||
|
||||
remain := len(dcOptions)
|
||||
var rErr error
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case result := <-results:
|
||||
remain--
|
||||
if result.err != nil {
|
||||
rErr = multierr.Append(rErr, result.err)
|
||||
if remain == 0 {
|
||||
return nil, rErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
return result.conn, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PlainOptions is plain resolver creation options.
|
||||
type PlainOptions struct {
|
||||
// Protocol is the transport protocol to use. Defaults to intermediate.
|
||||
Protocol Protocol
|
||||
// Dial specifies the dial function for creating unencrypted TCP connections.
|
||||
// If Dial is nil, then the resolver dials using package net.
|
||||
Dial DialFunc
|
||||
// Random source for TCPObfuscated DCs.
|
||||
Rand io.Reader
|
||||
// Network to use. Defaults to "tcp".
|
||||
Network string
|
||||
// NoObfuscated denotes to filter out TCP Obfuscated Only DCs.
|
||||
NoObfuscated bool
|
||||
// PreferIPv6 gives IPv6 DCs higher precedence.
|
||||
// Default is to prefer IPv4 DCs over IPv6.
|
||||
PreferIPv6 bool
|
||||
}
|
||||
|
||||
func (m *PlainOptions) setDefaults() {
|
||||
if m.Protocol == nil {
|
||||
m.Protocol = transport.Intermediate
|
||||
}
|
||||
if m.Dial == nil {
|
||||
var d net.Dialer
|
||||
m.Dial = d.DialContext
|
||||
}
|
||||
if m.Rand == nil {
|
||||
m.Rand = crypto.DefaultRand()
|
||||
}
|
||||
if m.Network == "" {
|
||||
m.Network = "tcp"
|
||||
}
|
||||
}
|
||||
|
||||
// Plain creates plain DC resolver.
|
||||
func Plain(opts PlainOptions) Resolver {
|
||||
opts.setDefaults()
|
||||
return plain{
|
||||
dial: opts.Dial,
|
||||
protocol: opts.Protocol,
|
||||
rand: opts.Rand,
|
||||
network: opts.Network,
|
||||
noObfuscated: opts.NoObfuscated,
|
||||
preferIPv6: opts.PreferIPv6,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package dcs
|
||||
|
||||
import (
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||
)
|
||||
|
||||
// Prod returns production DC list.
|
||||
func Prod() List {
|
||||
// https://github.com/telegramdesktop/tdesktop/blob/dev/Telegram/SourceFiles/mtproto/mtproto_dc_options.cpp
|
||||
// Also available with client.API().HelpGetConfig(ctx) [tg.DCOption].
|
||||
// TODO(ernado): automate update from HelpGetConfig.
|
||||
return List{
|
||||
Options: []tg.DCOption{
|
||||
{
|
||||
ID: 1,
|
||||
IPAddress: "149.154.175.52",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
Static: true,
|
||||
ID: 1,
|
||||
IPAddress: "149.154.175.53",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
Ipv6: true,
|
||||
ID: 1,
|
||||
IPAddress: "2001:0b28:f23d:f001:0000:0000:0000:000a",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
IPAddress: "149.154.167.41",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
Static: true,
|
||||
ID: 2,
|
||||
IPAddress: "149.154.167.41",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
MediaOnly: true,
|
||||
ID: 2,
|
||||
IPAddress: "149.154.167.222",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
Ipv6: true,
|
||||
ID: 2,
|
||||
IPAddress: "2001:067c:04e8:f002:0000:0000:0000:000a",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
Ipv6: true,
|
||||
MediaOnly: true,
|
||||
ID: 2,
|
||||
IPAddress: "2001:067c:04e8:f002:0000:0000:0000:000b",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
ID: 3,
|
||||
IPAddress: "149.154.175.100",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
Static: true,
|
||||
ID: 3,
|
||||
IPAddress: "149.154.175.100",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
Ipv6: true,
|
||||
ID: 3,
|
||||
IPAddress: "2001:0b28:f23d:f003:0000:0000:0000:000a",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
ID: 4,
|
||||
IPAddress: "149.154.167.91",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
Static: true,
|
||||
ID: 4,
|
||||
IPAddress: "149.154.167.91",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
Ipv6: true,
|
||||
ID: 4,
|
||||
IPAddress: "2001:067c:04e8:f004:0000:0000:0000:000a",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
MediaOnly: true,
|
||||
ID: 4,
|
||||
IPAddress: "149.154.166.120",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
Ipv6: true,
|
||||
MediaOnly: true,
|
||||
ID: 4,
|
||||
IPAddress: "2001:067c:04e8:f004:0000:0000:0000:000b",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
Ipv6: true,
|
||||
ID: 5,
|
||||
IPAddress: "2001:0b28:f23f:f005:0000:0000:0000:000a",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
ID: 5,
|
||||
IPAddress: "91.108.56.191",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
Static: true,
|
||||
ID: 5,
|
||||
IPAddress: "91.108.56.191",
|
||||
Port: 443,
|
||||
},
|
||||
},
|
||||
Domains: map[int]string{
|
||||
1: "wss://pluto.web.telegram.org/apiws",
|
||||
2: "wss://venus.web.telegram.org/apiws",
|
||||
3: "wss://aurora.web.telegram.org/apiws",
|
||||
4: "wss://vesta.web.telegram.org/apiws",
|
||||
5: "wss://flora.web.telegram.org/apiws",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package dcs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProd(t *testing.T) {
|
||||
require.NotEmpty(t, Prod())
|
||||
|
||||
// Check copying.
|
||||
a := Prod().Options
|
||||
a[0].IPAddress = "10"
|
||||
b := Prod().Options
|
||||
require.NotEqual(t, "10", b[0].IPAddress)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package dcs
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/transport"
|
||||
)
|
||||
|
||||
type protocol interface {
|
||||
Handshake(conn net.Conn) (transport.Conn, error)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package dcs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/transport"
|
||||
)
|
||||
|
||||
var _ Resolver = DefaultResolver()
|
||||
|
||||
// Resolver resolves DC and creates transport MTProto connection.
|
||||
type Resolver interface {
|
||||
Primary(ctx context.Context, dc int, list List) (transport.Conn, error)
|
||||
MediaOnly(ctx context.Context, dc int, list List) (transport.Conn, error)
|
||||
CDN(ctx context.Context, dc int, list List) (transport.Conn, error)
|
||||
}
|
||||
|
||||
// DialFunc connects to the address on the named network.
|
||||
type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
// Protocol is MTProto transport protocol.
|
||||
//
|
||||
// See https://core.telegram.org/mtproto/mtproto-transports
|
||||
type Protocol interface {
|
||||
Codec() transport.Codec
|
||||
Handshake(conn net.Conn) (transport.Conn, error)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build js && wasm
|
||||
// +build js,wasm
|
||||
|
||||
package dcs
|
||||
|
||||
// DefaultResolver returns default DC resolver for current platform.
|
||||
func DefaultResolver() Resolver {
|
||||
return Websocket(WebsocketOptions{})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !js || !wasm
|
||||
// +build !js !wasm
|
||||
|
||||
package dcs
|
||||
|
||||
// DefaultResolver returns default DC resolver for current platform.
|
||||
func DefaultResolver() Resolver {
|
||||
return Plain(PlainOptions{})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package dcs
|
||||
|
||||
import "go.mau.fi/mautrix-telegram/pkg/gotd/tg"
|
||||
|
||||
// Staging returns staging DC list.
|
||||
//
|
||||
// Deprecated: Use Test().
|
||||
func Staging() List {
|
||||
return Test()
|
||||
}
|
||||
|
||||
// Test returns test DC list.
|
||||
func Test() List {
|
||||
return List{
|
||||
Options: []tg.DCOption{
|
||||
{
|
||||
ID: 1,
|
||||
IPAddress: "149.154.175.10",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
ID: 1,
|
||||
Ipv6: true,
|
||||
IPAddress: "2001:0b28:f23d:f001:0000:0000:0000:000e",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
IPAddress: "149.154.167.40",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Ipv6: true,
|
||||
IPAddress: "2001:067c:04e8:f002:0000:0000:0000:000e",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
ID: 3,
|
||||
IPAddress: "149.154.175.117",
|
||||
Port: 443,
|
||||
},
|
||||
{
|
||||
ID: 3,
|
||||
Ipv6: true,
|
||||
IPAddress: "2001:0b28:f23d:f003:0000:0000:0000:000e",
|
||||
Port: 443,
|
||||
},
|
||||
},
|
||||
Domains: map[int]string{
|
||||
1: "wss://pluto.web.telegram.org/apiws_test",
|
||||
2: "wss://venus.web.telegram.org/apiws_test",
|
||||
3: "wss://aurora.web.telegram.org/apiws_test",
|
||||
4: "wss://vesta.web.telegram.org/apiws_test",
|
||||
5: "wss://flora.web.telegram.org/apiws_test",
|
||||
},
|
||||
Test: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package dcs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTestDCs(t *testing.T) {
|
||||
require.NotEmpty(t, Prod())
|
||||
|
||||
// Check copying.
|
||||
a := Test().Options
|
||||
a[0].IPAddress = "10"
|
||||
b := Test().Options
|
||||
require.NotEqual(t, "10", b[0].IPAddress)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package dcs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/go-faster/errors"
|
||||
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/crypto"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/mtproxy"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/mtproxy/obfuscator"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/proto/codec"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/transport"
|
||||
"go.mau.fi/mautrix-telegram/pkg/gotd/wsutil"
|
||||
)
|
||||
|
||||
var _ Resolver = ws{}
|
||||
|
||||
type ws struct {
|
||||
dialOptions *websocket.DialOptions
|
||||
protocol protocol
|
||||
|
||||
tag [4]byte
|
||||
rand io.Reader
|
||||
}
|
||||
|
||||
func (w ws) connect(ctx context.Context, dc int, domains map[int]string) (transport.Conn, error) {
|
||||
addr, ok := domains[dc]
|
||||
if !ok {
|
||||
return nil, errors.Errorf("domain for %d not found", dc)
|
||||
}
|
||||
|
||||
conn, resp, err := websocket.Dial(ctx, addr, w.dialOptions)
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "dial ws")
|
||||
}
|
||||
obsConn := obfuscator.Obfuscated2(w.rand, wsutil.NetConn(conn))
|
||||
|
||||
if err := obsConn.Handshake(w.tag, dc, mtproxy.Secret{
|
||||
Secret: nil,
|
||||
Type: mtproxy.Simple,
|
||||
}); err != nil {
|
||||
return nil, errors.Wrap(err, "handshake")
|
||||
}
|
||||
|
||||
transportConn, err := w.protocol.Handshake(obsConn)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "transport handshake")
|
||||
}
|
||||
|
||||
return transportConn, nil
|
||||
}
|
||||
|
||||
func (w ws) Primary(ctx context.Context, dc int, list List) (transport.Conn, error) {
|
||||
return w.connect(ctx, dc, list.Domains)
|
||||
}
|
||||
|
||||
func (w ws) MediaOnly(ctx context.Context, dc int, list List) (transport.Conn, error) {
|
||||
return nil, errors.Errorf("can't resolve %d: MediaOnly is unsupported", dc)
|
||||
}
|
||||
|
||||
func (w ws) CDN(ctx context.Context, dc int, list List) (transport.Conn, error) {
|
||||
return nil, errors.Errorf("can't resolve %d: CDN is unsupported", dc)
|
||||
}
|
||||
|
||||
// WebsocketOptions is Websocket resolver creation options.
|
||||
type WebsocketOptions struct {
|
||||
// Dialer specifies the websocket dialer.
|
||||
// If Dialer is nil, then the resolver dials using websocket.DefaultDialer.
|
||||
DialOptions *websocket.DialOptions
|
||||
// Random source for MTProxy obfuscator.
|
||||
Rand io.Reader
|
||||
}
|
||||
|
||||
func (m *WebsocketOptions) setDefaults() {
|
||||
if m.DialOptions == nil {
|
||||
m.DialOptions = &websocket.DialOptions{Subprotocols: []string{
|
||||
"binary",
|
||||
}}
|
||||
}
|
||||
if m.Rand == nil {
|
||||
m.Rand = crypto.DefaultRand()
|
||||
}
|
||||
}
|
||||
|
||||
// Websocket creates Websocket DC resolver.
|
||||
//
|
||||
// See https://core.telegram.org/mtproto/transports#websocket.
|
||||
func Websocket(opts WebsocketOptions) Resolver {
|
||||
cdc := codec.Intermediate{}
|
||||
opts.setDefaults()
|
||||
|
||||
return ws{
|
||||
dialOptions: opts.DialOptions,
|
||||
protocol: transport.NewProtocol(func() transport.Codec { return codec.NoHeader{Codec: cdc} }),
|
||||
tag: cdc.ObfuscatedTag(),
|
||||
rand: opts.Rand,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user