Telegram-iOS 源码分析:第四部分(MTProto)

2020-11-16  本文已影响0人  灰原丶逗
版权声明
本文内容均为搬运,目的只为更方便的学习Telegram编码思维。

如需查阅原作者文章,附赠原文章机票

正如在上一篇文章中所阐述的,TCP是Telegram-iOS上仅存的MTProto传输。让我们继续分析MTProto连接管理的实现细节。

网络部分的代码主要位于模块TelegramCoreMTProtoKit中。在正文开始前,我们先来看一个简单的问题:

在首次登录过程中MTProtoKit使用了多少个连接?

结果让我感到惊讶:20个TCP连接到Telegram的数据中心以及8个HTTPS拓展服务请求。常见的最佳做法是使用尽可能少的连接。

在深入研究代码以揭示谜题之前,先介绍一些重要的概念。

1.连接的基本概念

数据中心

Telegram 将其后台服务器分为5个数据中心。每个数据中心都有自己的ID和别名。别名用于撰写用于HTTP传输的URI,iOS应用程序中未使用到。Telegram后端将每一个注册帐户都关联到一个主要到数据中心。它要求客户端使用最合适的主数据中心来访问用户数据,并且可能使用其他数据中心来下载图片,文件等。

DC 1, pluto
DC 2, venus
DC 3, aurora
DC 4, vesta
DC 5, flora

每个数据中心可以通过多个IP地址连接。不直接使用域名通常有以下几个原因:

Telegram-iOS内置了几个用于冷启动的种子地址:

let seedAddressList: [Int: [String]]
seedAddressList = [
    1: ["149.154.175.50", "2001:b28:f23d:f001::a"],   //AS59930
    2: ["149.154.167.50", "2001:67c:4e8:f002::a"],    //AS62041
    3: ["149.154.175.100", "2001:b28:f23d:f003::a"],  //AS59930
    4: ["149.154.167.91", "2001:67c:4e8:f004::a"],    //AS62041
    5: ["149.154.171.5", "2001:b28:f23f:f005::a"]     //AS62014
]

Telegram拥有四个用于发布IP的AS号:AS62014AS62041AS59930AS44907(如果您感兴趣,可以通过搜索AS号找到更多静态IP)。

端点发现

Telegram-iOS可以通过内部和外部服务更新端点。这些方法作为其他方法的补充,以最大限度地提高更新的成功率。结果通过keydatacenterAddressSetById保存在Keychain中。

// https://dns.google.com/resolve?name=apv3.stel.com&type=16&random_padding=Fw8ZQonqP0qOqoa
{
  "Status": 0,
  "Question": [
    {
      "name": "apv3.stel.com.",
      "type": 16
    }
  ],
  "Answer": [
    {
      "name": "apv3.stel.com.",
      "type": 16,
      "data": "\"vEB1g6iW/a5RtZI/Rx33SEzLmRhz+vNenoY7iqAHW35plgToLfkNRVfvlaBsztOTeYSRqFko73rr2lumKmGax2biMcSQ==\""
    },
    {
      "name": "apv3.stel.com.",
      "type": 16,
      "data": "\"pEI+NHncHJCj9S0XzxhhTd3bkPteVxE5UQ8T06KCz0nP591un4Un82id0FyCEDF0BVmxMp+t673l3HAGD+fzR/qaJ1XpQ6KWxNpRLqA74m2UFTI1REP7ZczU2hmbURzSQvWQTxfp9tnGc1EnyqpUYphFb/Vi+sV83iaw6dTGOcKW1Kp/PW2xV99mmSFLBsspQRdUbKWvbrSpmXHbPbkSRZV61NvtaEiODG1We29nG58DUBqdW7m68ae11w\""
    }
  ]
}

Google service以多个DNS TXT内容作为响应,这些内容可以合并并转换为有效的base64字符串。客户端有RSA公钥解码数据并将其反序列化MTBackupDatacenterAddress的列表。

代码内部有一个小窍门。除了正常的请求"dns.google.com"外,还会将Host header设置为另一个发送"https://www.google.com/resolve""dns.google.com"的请求,看起来好像是在将域前置到Google的一个子域,这使得DNS请求被伪装成像是正在访问谷歌搜索。Google于2018年4月宣布禁用域前置

MTProto代理

除了由Telegram的工程团队操作的普通端点之外,Telegram还构建了一个代理系统,该系统允许第三方服务器代理其流量。作为交换,代理提供者需要将推广频道提供给其他用户。官方的代理代码是开源的
作为不引入其他协议更改的反向代理,从客户端的角度来看,它与官方端点基本没有区别。

加密连接

除数据中心地址的IP和端口外,还提供一个可选参数secret,以指示客户端如何加密TCP连接。请注意,它与MTProto消息加密是两个不同的概念。它旨在混淆网络流量,这有助于应对DPI(深度数据包检测)

加密有四种可能的类型:

让我们以MTProto代理的共享URL为例。字符串以开头ee表明其加密类型是MTProxySecretType2。机密数据用零填充,伪造的域为itunes.apple.com

https://t.me/proxy?server=0.0.0.0
    &port=8080
    &secret=ee000000000000000000000000000000006974756e65732e6170706c652e636f6d
# 6974...6f6d can be decoded to "itunes.apple.com"

连接选择

由于一个数据中心可以具有一组地址,因此MTContext实施选择策略以选择具有最早故障时间戳的地址。

数据中心授权

在将MTProto消息发送到数据中心之前,需要完成目标DC的(p,q)授权AuthInfo在代码中称为数据中心身份验证信息

用户授权

通过SMS代码或其他方法成功验证后,主数据中心将用户帐户与客户端的帐户相关联auth_key_id,从而授权以用户身份访问数据中心。如果客户端要使用相同的用户帐户访问其他数据中心,则需要提前转移授权

回顾一下

根据概念,可知以下内容是客户端与后端交互的要求:

2.代码结构

让我们看一下Telegram-iOS如何构建代码的。如下图所示:


part-4-network_modules.png
public class UnauthorizedAccount {
    ...
    public let network: Network
    ...
}

public class Account {
    ...
    public let network: Network
    ...
}
/* Code snippets from Network.swift */
public final class Network: NSObject, MTRequestMessageServiceDelegate {
    ...
    func background() -> Signal<Download, NoError>
    
    public func request<T>(
        _ data: (FunctionDescription, Buffer, DeserializeFunctionResponse<T>), 
        tag: NetworkRequestDependencyTag? = nil, 
        automaticFloodWait: Bool = true
        ) -> Signal<T, MTRpcError>
    ...
}

// a Signal operator to retry a RPC
public func retryRequest<T>(signal: Signal<T, MTRpcError>) -> Signal<T, NoError>

/* A code snippet to request login code from `Authorization.swift` */
// construct an MTProto API object
let sendCode = 
    Api.functions.auth.sendCode(
        flags: 0, 
        phoneNumber: phoneNumber, 
        currentNumber: nil, 
        apiId: apiId, 
        apiHash: apiHash)
// send the API via `network`
account.network.request(sendCode, automaticFloodWait: false)

核心MTProtoKit类

3.在首次登录过程中

典型的首次登录过程分为四个阶段:欢迎界面电话号码界面验证码界面主界面。让我们看下每个阶段触发的连接数。

part-4-login-flow.png

提供了几张图来说明每个阶段的简化工作流程。有关图的一些注意事项:

首次启动

part-4-onboarding.png

首次启动该应用程序时,每个数据中心都没有帐户数据,也没有身份验证信息。一个新的UnAuthorizedAccountAsk实例MTContext从DC 1、2和4获取身份验证信息,这将创建TCP连接①②③。身份验证操作完成后,连接将关闭。

// UnauthorizedAccount.swift
public class UnauthorizedAccount {
    ...
    init(networkArguments: NetworkInitializationArguments, id: AccountRecordId, rootPath: String, basePath: String, testingEnvironment: Bool, postbox: Postbox, network: Network, shouldKeepAutoConnection: Bool = true) {
        ...
        network.context.performBatchUpdates({
            var datacenterIds: [Int] = [1, 2]
            if !testingEnvironment {
                datacenterIds.append(contentsOf: [4])
            }
            for id in datacenterIds {
                if network.context.authInfoForDatacenter(withId: id) == nil {
                    network.context.authInfoForDatacenter(withIdRequired: id, isCdn: false)
                }
            }
            network.context.beginExplicitBackupAddressDiscovery()
        })
    }
}

// Network.swift
context.setDiscoverBackupAddressListSignal(
    MTBackupAddressSignals.fetchBackupIps(
        testingEnvironment, 
        currentContext: context, 
        additionalSource: wrappedAdditionalSource, 
        phoneNumber: phoneNumber))

它还会调用beginExplicitBackupAddressDiscovery并启动在Network中设置的信号。fetchBackupIps结合了不同的发现信号,并且只需要第一个响应(多个请求,只要有一个请求有响应就行)fetchConfigFromAddress。如概念部分所述,它启动4个HTTP请求:①②向Google,③到Cloudflare和④到CloudKit。

+ (MTSignal * _Nonnull)fetchBackupIps:(bool)isTestingEnvironment currentContext:(MTContext * _Nonnull)currentContext additionalSource:(MTSignal * _Nullable)additionalSource phoneNumber:(NSString * _Nullable)phoneNumber {
    ...
    NSMutableArray *signals = [[NSMutableArray alloc] init];
    [signals addObject:[self fetchBackupIpsResolveGoogle:isTestingEnvironment phoneNumber:phoneNumber currentContext:currentContext addressOverride:currentContext.apiEnvironment.accessHostOverride]];
    [signals addObject:[self fetchBackupIpsResolveCloudflare:isTestingEnvironment phoneNumber:phoneNumber currentContext:currentContext addressOverride:currentContext.apiEnvironment.accessHostOverride]];
    [signals addObject:[additionalSource mapToSignal:^MTSignal *(MTBackupDatacenterData *datacenterData) {
            ...
        }]];
    return [[[MTSignal mergeSignals:signals] take:1] mapToSignal:^MTSignal *(MTBackupDatacenterData *data) {
        NSMutableArray *signals = [[NSMutableArray alloc] init];
        NSTimeInterval delay = 0.0;
        for (MTBackupDatacenterAddress *address in data.addressList) {
            MTSignal *signal = [self fetchConfigFromAddress:address currentContext:currentContext];
            if (delay > DBL_EPSILON) {
                signal = [signal delay:delay onQueue:[[MTQueue alloc] init]];
            }
            [signals addObject:signal];
            delay += 5.0;
        }
        return [[MTSignal mergeSignals:signals] take:1];
    };
}

现在,DC 2的身份验证信息已准备就绪,可以进行连接②,因为DC 2被编码为默认的主数据中心ID,所以将使用身份验证信息重新创建新的TCP连接④:

// Account.swift
public func accountWithId(accountManager: AccountManager, networkArguments: NetworkInitializationArguments, id: AccountRecordId, encryptionParameters: ValueBoxEncryptionParameters, supplementary: Bool, rootPath: String, beginWithTestingEnvironment: Bool, backupData: AccountBackupData?, auxiliaryMethods: 
    ...
    return initializedNetwork(
        arguments: networkArguments, 
        supplementary: supplementary, 
        datacenterId: 2,  // use DC 2 for unauthrized account
        keychain: keychain, 
        basePath: path, 
        testingEnvironment: beginWithTestingEnvironment, 
        languageCode: localizationSettings?.primaryComponent.languageCode, 
        proxySettings: proxySettings, 
        networkSettings: networkSettings, phoneNumber: nil)
                        |> map { network -> AccountResult in
                            return .unauthorized(UnauthorizedAccount(networkArguments: networkArguments, id: id, rootPath: rootPath, basePath: path, testingEnvironment: beginWithTestingEnvironment, postbox: postbox, network: network, shouldKeepAutoConnection: shouldKeepAutoConnection))
                        }
}

同时,Google DoH返回196.55.216.85DC 4的新地址。对于任何新发现的端点,fetchConfigFromAddress调用requestDatacenterAddress以发送RPChelp.getConfig进行新配置。

作为一种新MTContext不复制内部数据创建,它不知道原来的情况下可能有DC 4的身份验证信息,具有创造DC 4的身份验证信息的TCP连接⑤,断开连接,然后重新连接到通过另一个连接⑥发送RPC。

从DC 4返回的配置内部的数据中心地址被提取并解码为MTDatacenterAddressListData。然后关闭连接⑥。

// Addresses in Config.config from DC 4
MTDatacenterAddressListData({
    1 =     (
        "149.154.175.51:443#(media no, cdn no, preferForProxy no, secret )",
        "149.154.175.50:443#(media no, cdn no, preferForProxy yes, secret )",
        "2001:0b28:f23d:f001:0000:0000:0000:000a:443#(media no, cdn no, preferForProxy no, secret )"
    );
    2 =     (
        "149.154.167.50:443#(media no, cdn no, preferForProxy no, secret )",
        "149.154.167.51:443#(media no, cdn no, preferForProxy yes, secret )",
        "149.154.167.151:443#(media yes, cdn no, preferForProxy no, secret )",
        "2001:067c:04e8:f002:0000:0000:0000:000a:443#(media no, cdn no, preferForProxy no, secret )",
        "2001:067c:04e8:f002:0000:0000:0000:000b:443#(media yes, cdn no, preferForProxy no, secret )"
    );
    3 =     (
        "149.154.175.100:443#(media no, cdn no, preferForProxy no, secret )",
        "149.154.175.100:443#(media no, cdn no, preferForProxy yes, secret )",
        "2001:0b28:f23d:f003:0000:0000:0000:000a:443#(media no, cdn no, preferForProxy no, secret )"
    );
    4 =     (
        "149.154.167.92:443#(media no, cdn no, preferForProxy no, secret )",
        "149.154.167.92:443#(media no, cdn no, preferForProxy yes, secret )",
        "149.154.165.96:443#(media yes, cdn no, preferForProxy no, secret )",
        "2001:067c:04e8:f004:0000:0000:0000:000b:443#(media yes, cdn no, preferForProxy no, secret )",
        "2001:067c:04e8:f004:0000:0000:0000:000a:443#(media no, cdn no, preferForProxy no, secret )"
    );
    5 =     (
        "91.108.56.143:443#(media no, cdn no, preferForProxy no, secret )",
        "91.108.56.143:443#(media no, cdn no, preferForProxy yes, secret )",
        "2001:0b28:f23f:f005:0000:0000:0000:000a:443#(media no, cdn no, preferForProxy no, secret )"
    );
})

如果新数据不同,则原始上下文将使用新配置替换其种子数据中心地址集,并将其保存到钥匙串中。更改也分派给所有监听者。原文作者认为,代码内部存在一个错误fetchConfigFromAddresscurrentAddressSet是从错误的上下文中提取的,并且始终为 nil

// MTBackupAddressSignals.m, fetchConfigFromAddress
__strong MTContext *strongCurrentContext = weakCurrentContext;
[result.addressList enumerateKeysAndObjectsUsingBlock:^(NSNumber *nDatacenterId, NSArray *list, __unused BOOL *stop) {
  MTDatacenterAddressSet *addressSet = [[MTDatacenterAddressSet alloc] initWithAddressList:list];
  // Bug here, should use `strongCurrentContext` instead of `context`
  MTDatacenterAddressSet *currentAddressSet = [context addressSetForDatacenterWithId:[nDatacenterId integerValue]];
  // It's always true as `currentAddressSet` is always nil
  if (currentAddressSet == nil || ![addressSet isEqual:currentAddressSet])
  {
      [strongCurrentContext 
          updateAddressSetForDatacenterWithId:[nDatacenterId integerValue] 
                                   addressSet:addressSet 
                           forceUpdateSchemes:true];
      ...
  }
}];

// MTContext.m
- (void)updateAddressSetForDatacenterWithId:(NSInteger)datacenterId 
   addressSet:(MTDatacenterAddressSet *)addressSet 
   forceUpdateSchemes:(bool)updateSchemes {
   ...
   // replace the address set and save it the Keychain
   _datacenterAddressSetById[@(datacenterId)] = addressSet;
   [_keychain setObject:_datacenterAddressSetById 
                 forKey:@"datacenterAddressSetById" 
                  group:@"persistent"];
   ...
   bool shouldReset = previousAddressSetWasEmpty || updateSchemes;
   ...
   // broadcast the change event. `shouldReset` is True if the callee is fetchConfigFromAddress 
   for (id<MTContextChangeListener> listener in currentListeners) {
       [listener 
           contextDatacenterTransportSchemesUpdated:self 
                                       datacenterId:datacenterId 
                                        shouldReset:shouldReset];
   }
}

// MTProto.m
- (void)contextDatacenterTransportSchemesUpdated:(MTContext *)context 
                                   datacenterId:(NSInteger)datacenterId 
                                    shouldReset:(bool)shouldReset {
   ...        
   if (resolvedShouldReset) {
       // reset the current transport
       [self resetTransport];
       [self requestTransportTransaction];
   }
   ...
}

监听者之一是MTProto,它保持与DC 2的有效连接④。它被命令重置其传输并创建与149.154.167.51DC 2的连接⑦ 。

到目前为止,还没有用户交互,让我们刷新状态:

创建了7个TCP连接和4个HTTP请求。与DC 2的连接⑦处于活动状态,而其他关闭。
客户端已经收集了DC 1、2、4的认证信息。
数据中心地址集已更新。

输入电话号码

part-4-phonenumber.png

输入电话号码并点击下一步后,auth.sendCode将通过活动连接将RPC发送到DC2。它将PHONE_MIGRATE_5作为帐户属于DC 5进行响应。

// Authorization.swift
public func sendAuthorizationCode(accountManager: AccountManager, account: UnauthorizedAccount, phoneNumber: String, apiId: Int32, apiHash: String, syncContacts: Bool) -> Signal<UnauthorizedAccount, AuthorizationCodeRequestError> {
   ...
   switch (error.errorDescription ?? "") {
       case Regex("(PHONE_|USER_|NETWORK_)MIGRATE_(\\d+)"):
           let range = error.errorDescription.range(of: "MIGRATE_")!
           // extract data center id from error description
           let updatedMasterDatacenterId = Int32(error.errorDescription[range.upperBound ..< error.errorDescription.endIndex])!
           let updatedAccount = account.changedMasterDatacenterId(accountManager: accountManager, masterDatacenterId: updatedMasterDatacenterId)
           return updatedAccount
           |> mapToSignalPromotingError { updatedAccount -> Signal<(Api.auth.SentCode, UnauthorizedAccount), MTRpcError> in
               return updatedAccount.network.request(sendCode, automaticFloodWait: false)
               |> map { sentCode in
                   return (sentCode, updatedAccount)
               }
           }
   }
   ...
}

// Account.swift, class Account
public func changedMasterDatacenterId(accountManager: AccountManager, masterDatacenterId: Int32) -> Signal<UnauthorizedAccount, NoError> {
   ...
   return accountManager.transaction { ... }
   |> mapToSignal { localizationSettings, proxySettings -> Signal<(LocalizationSettings?, ProxySettings?, NetworkSettings?), NoError> in
       ...
   }
   |> mapToSignal { (localizationSettings, proxySettings, networkSettings) -> Signal<UnauthorizedAccount, NoError> in
       return initializedNetwork(arguments: self.networkArguments, supplementary: false, datacenterId: Int(masterDatacenterId), keychain: keychain, basePath: self.basePath, testingEnvironment: self.testingEnvironment, languageCode: localizationSettings?.primaryComponent.languageCode, proxySettings: proxySettings, networkSettings: networkSettings, phoneNumber: nil)
       |> map { network in
           let updated = UnauthorizedAccount(networkArguments: self.networkArguments, id: self.id, rootPath: self.rootPath, basePath: self.basePath, testingEnvironment: self.testingEnvironment, postbox: self.postbox, network: network)
           updated.shouldBeServiceTaskMaster.set(self.shouldBeServiceTaskMaster.get())
           return updated
       }
   }
}

客户端被告知,DC 5是主数据中心,而不是DC 2.调用initializedNetwork并创建实例UnauthorizaedAccount来重发再次auth.sendAuth到DC 5的内部initializedNetwork,新MTContext创建以支持后续业务。

由于客户端还没有DC 5的身份验证信息,因此将建立连接⑧。同时,不幸的是,UnauthorizaedAccount新的实例启动了相同的端点发现逻辑,这导致了更多不必要的操作:

获得身份验证信息后,连接⑪将关闭,并且连接 ⑫将开始发送auth.sendAuthto DC 5。

此阶段的摘要:

顺便说一句,如果种子地址和从DoH来的备用地址不起作用了,Telegram-iOS会在20秒后提示你设置代理,目前没有其他解决办法。

输入授权码

part-4-code.png

输入从SMS接收到的登录代码后,auth.signIn通过活动连接由RPC发送。DC 5验证其正确并返回auth.Authorization.authorization。最终,客户端最终可以通过调用switchToAuthorizedAccount来替换其未经授权的状态。

创建新的AccountNetwork替换正在使用的,连接⑫和⑦关闭。创建连接 ⑬ 连接91.108.56.143到DC 5获取用户身份验证数据。ChatHistoryPreloadManager建立连接 ⑭ 与DC 5的连接以下载聊天记录

managedConfigurationUpdates函数中一堆RPC通过连接⑬发送,包括help.getConfig协议。DC 5的配置与DC 4的配置具有不同的地址集列表,尤其是DC 5的地址更改为91.108.56.156。连接⑬⑭和不作为受到新的配置forceUpdateSchemes假的managedConfigurationUpdates

// Addresses in Config.config from DC 5
MTDatacenterAddressListData({
    1 =     (
        "149.154.175.57:443#(media no, cdn no, preferForProxy no, secret )",
        "149.154.175.50:443#(media no, cdn no, preferForProxy yes, secret )",
        "2001:0b28:f23d:f001:0000:0000:0000:000a:443#(media no, cdn no, preferForProxy no, secret )"
    );
    2 =     (
        "149.154.167.50:443#(media no, cdn no, preferForProxy no, secret )",
        "149.154.167.51:443#(media no, cdn no, preferForProxy yes, secret )",
        "149.154.167.151:443#(media yes, cdn no, preferForProxy no, secret )",
        "2001:067c:04e8:f002:0000:0000:0000:000a:443#(media no, cdn no, preferForProxy no, secret )",
        "2001:067c:04e8:f002:0000:0000:0000:000b:443#(media yes, cdn no, preferForProxy no, secret )"
    );
    3 =     (
        "149.154.175.100:443#(media no, cdn no, preferForProxy no, secret )",
        "149.154.175.100:443#(media no, cdn no, preferForProxy yes, secret )",
        "2001:0b28:f23d:f003:0000:0000:0000:000a:443#(media no, cdn no, preferForProxy no, secret )"
    );
    4 =     (
        "149.154.167.92:443#(media no, cdn no, preferForProxy no, secret )",
        "149.154.167.92:443#(media no, cdn no, preferForProxy yes, secret )",
        "149.154.166.120:443#(media yes, cdn no, preferForProxy no, secret )",
        "2001:067c:04e8:f004:0000:0000:0000:000b:443#(media yes, cdn no, preferForProxy no, secret )",
        "2001:067c:04e8:f004:0000:0000:0000:000a:443#(media no, cdn no, preferForProxy no, secret )"
    );
    5 =     (
        "91.108.56.156:443#(media no, cdn no, preferForProxy no, secret )",
        "91.108.56.156:443#(media no, cdn no, preferForProxy yes, secret )",
        "2001:0b28:f23f:f005:0000:0000:0000:000a:443#(media no, cdn no, preferForProxy no, secret )"
    );
})

主界面已准备好与帐户一起显示。其他模块开始获取UI组件的资源,例如头像图片等。所有请求均通过multiplexedRequestManager拥有的account.network发送。在此登录会话期间,所有资源都位于DC 1上。

在客户端可以从DC 1下载文件之前,它必须将其用户授权从DC 5转移到DC1。创建连接 ⑮要求DC 5导出身份验证数据,创建连接⑯将数据导入DC 1。业务完成后,两个连接均关闭。

MultiplexedRequestManagerContext每个DC最多限制4个workers。这就是为什么有连接 ⑰ ⑱ ⑲ ⑳用来Download的原因。

4 结论

上一篇 下一篇

猜你喜欢

热点阅读