Мы продолжаем рассказывать про миграцию мобильного сервиса в ASP.NET Core и Docker. В этой статье будет идти речь про модуль WCF-клиента, упомянутый в предыдущей статье, NTLM-авторизацию и другие проблемы при его миграции. Сейчас расскажем, почему нам пришлось немного изучить анатомию и пощупать .NET Core изнутри.



Мягкий путь. Windows-контейнер


Первым делом мы настроили дебаг в docker-образ и локально запустили сервис в windows-контейнере.


При попытке отправки запроса в WCF-сервис получили весьма витиеватую ошибку:


System.ServiceModel.Security.MessageSecurityException: The HTTP request is unauthorized with client authentication scheme 'Negotiate'. The authentication header received from the server was 'Negotiate TlRMTVNTUAACAAAAEAAQADgAA...


Методом проб вышли на то, что в креденшиалах сервиса требуется указывать Domain. Смешно, что можно указать любое значение, лишь бы не null — тогда работает.  


static partial void ConfigureEndpoint(ServiceEndpoint serviceEndpoint, ClientCredentials clientCredentials)
{
    ...    
    clientCredentials.Windows.ClientCredential.Domain = "";
}

Всё, теперь запросы ходят, теперь в Windows-контейнере дела ок. Едем дальше.


Пробуем .NET Core под Linux


Переключившись на сборку в Linux-контейнер, ради интереса убрали значение Domain — и оно работает.


Первая проблема при отправке запросов в WCF связана с SSL. Ругается так:


System.Net.Http.CurlException SSL peer certificate or SSH remote key was not OK


Что означает: нет доверия к сертификату. Если бы WCF-сервис присылал не только конечный сертификат, но и все промежуточные, проблемы бы не было.


Как решили:


1. Выкачиваем промежуточные сертификаты.


Например, в Chrome открываем ссылку и идем в F12 во вкладку Security. Дальше View Certificate > Certification Path. Для каждого сертификата открываем View Certificate и на вкладке Details по кнопке Copy To File сохраняем Base-64 encoded сертификаты в директорию проекта. Расширение файлов нужно поменять на .crt.


2. Дописываем в Dockerfile новый слой.


FROM microsoft/aspnetcore:latest AS base
WORKDIR /app
EXPOSE 80  

FROM base AS cert
COPY Certificates/*.crt /usr/local/share/ca-certificates/
RUN update-ca-certificates || exit 0

Для дебага и экспериментов можно и просто временно отключить SSL-валидацию:


clientCredentials.ServiceCertificate.SslCertificateAuthentication =
    new X509ServiceCertificateAuthentication()
       CertificateValidationMode = X509CertificateValidationMode.None,
       RevocationMode = X509RevocationMode.NoCheck
    };

Самое полезное и важное, что мы узнали, получив CurlException — то, что для сетевых запросов используется libcurl.


Вкусная часть ждала нас впереди.


Linux + WCF + NTLM = любовь, но после ужина


Теперь дорогу преградил такой эксепшн


MessageSecurityException The HTTP request is unauthorized with client authentication scheme 'Negotiate'. The authentication header received from the server was 'Negotiate, NTLM'.


Меняем Security.Transport.ClientCredentialType = HttpClientCredentialType.Windows на HttpClientCredentialType.Ntlm


Ошибка несколько изменилась, но легче не стало:


MessageSecurityException The HTTP request is unauthorized with client authentication scheme 'Ntlm'. The authentication header received from the server was 'Negotiate, NTLM'.


Давайте убедимся, что мы не столкнулись с еще одной docker-related особенностью наподобие значения Domain.


Запускаем сервис в виртуалке с Ubuntu LTS.


Лирическое отступление: Docker for Windows любит Hyper-V и при установке прочих виртуальных машин может отказаться работать. Поэтому в этот раз пришлось поднимать Ubuntu под Hyper-V, в котором не работает копипаста между хостовой и гостевой машинами, что не может не радовать.

Кстати, Microsoft, как там дружба с Apple?

Рядом лежал Mac с установленным Visual Studio. Руки сами зачесались. При запуске с отключенным SslCertificateAuthentication валится ошибка The handler does not support custom handling of certificates with this combination of libcurl (7.54.0) and its SSL backend ("SecureTransport"). Если вернуть на место валидацию сертификатов, то будет та же ошибка с NTLM. И все-таки первая ошибка навевает подозрение, что отличия от Linux могут быть значительным.

Какие есть еще способы поизвращаться?  

Ubuntu on Windows — при запуске сервиса уперлись в ошибку System.DllNotFoundException: Unable to load DLL 'System.Net.Http.Native'.

И по делу: на чистом Linux ошибка ровно та же самая, что в контейнере, а значит, проблема железно кроется в реализации WCF-клиента.


Пробуем зайти с другой стороны. Из командной строки запускаем:


1. curl -v --negotiate — завершается ошибкой


* gss_init_sec_context() failed: SPNEGO cannot find mechanisms to negotiate.

2. curl -v --ntlm — все хорошо, запрос работает


Здесь самое время вспомнить список официально поддерживаемых фич в WCF. В искомой строчке написано, что Core на Linux не умеет в NTLM. Но это никак не клеется с тем, что curl-то умеет, и было бы странно не реализовать настолько популярный вариант.


Из комментария в интернете узнаем, что Negotiate Negotiate-у рознь: в определенных реализациях есть поддержка fallback Kerberos>NTLM (повсеместно на винде), а в других — нет. Curl из последних, и Negotiate становится препятствием.


Все это наводит на мысль, что HttpClient может не учитывать этот нюанс, а значит, надежда на победу есть.


Смотрим исходники


И вот здесь нельзя не порадоваться за новый Microsoft за их решение открыть код миру. В сорцах находим ключик CURLHANDLER_DEBUG_VERBOSE=true, который нам расcкажет, чем занимается libcurl в момент выполнения WCF-запросов.


В логах видим уже знакомую ошибку gss_init_sec_context() failed и для HttpClientCredentialType.Windows, и для HttpClientCredentialType.Ntlm.


Теперь понятно, что WCF-клиент не реагирует на переключение с Windows-авторизации на NTLM и пытается использовать Negotiate в обоих случаях. Скорее всего, это происходит из-за двойного хедера WWW-Authentication 'Negotiate, NTLM', который присылает WCF сервис, и поскольку Negotiate является более сильной авторизацией, то он и используется.


Из мануала libcurl вкурили, что тип авторизации задается через опцию CURLOPT_HTTPAUTH. По этому следу мы вышли на таблицу выбора авторизации:


private static readonly KeyValuePair<string,CURLAUTH>[] s_orderedAuthTypes = new KeyValuePair<string, CURLAUTH>[] { 
    new KeyValuePair<string,CURLAUTH>("Negotiate", CURLAUTH.Negotiate), 
    new KeyValuePair<string,CURLAUTH>("NTLM", CURLAUTH.NTLM), 
    new KeyValuePair<string,CURLAUTH>("Digest", CURLAUTH.Digest), 
    new KeyValuePair<string,CURLAUTH>("Basic", CURLAUTH.Basic), 
}; 

Атрибуты static readonly выглядят особенно соблазнительно, поскольку это означает, что достаточно поиграться при помощи Reflection со значениями в таблице на старте сервиса, и при HTTP-запросах не будет никакого оверхеда.


Добавили в Program.cs следующий код:


public static void Main(string[] args)
{
    …
    // redirect Negotiate to NTLM (only for libcurl on Linux)
    var curlHandlerType = typeof(HttpClient).Assembly.GetTypes()
        .FirstOrDefault(type => type.Name == "CurlHandler");
    if (curlHandlerType != null)
    {
        var authTypesField = сurlHandlerType.GetField("s_orderedAuthTypes",
            BindingFlags.Static | BindingFlags.NonPublic);
        var authTypes = authTypesField.GetValue(null);

        var authTypesGetByIndex = authTypes.GetType().GetMethod("Get");
        var ntlmKeyValuePair = authTypesGetByIndex.Invoke(authTypes, new object[] { 1 });
        var ntlmValue = ntlmKeyValuePair.GetType().GetProperty("Value");
        var CURLAUTH = ntlmValue.GetMethod.ReturnType;
        var CURLAUTH_NTLM = ntlmValue.GetValue(ntlmKeyValuePair);

        var authTypeKeyValuePairBuilder = typeof(KeyValuePairBuilder<,>)
            .MakeGenericType(new[] { typeof(string), CURLAUTH });
        var builder = Activator.CreateInstance(authTypeKeyValuePairBuilder);
        var negotiateToNtlmKeyValuePair = authTypeKeyValuePairBuilder
            .GetMethod("Build")
            .Invoke(builder, new object[] { "", CURLAUTH_NTLM });

        var authTypesSetByIndex = authTypes.GetType().GetMethod("Set");
        authTypesSetByIndex.Invoke(authTypes,
           new object[] { 0, negotiateToNtlmKeyValuePair });
   }
}

// makes it possible to call Activator.CreateInstance on KeyValuePair struct
public class KeyValuePairBuilder<K, V>
{
   public KeyValuePair<K, V> Build(K k, V v)
   {
       return new KeyValuePair<K, V>(k, v);
   }
}

Здесь мы прибиваем гвоздями соответствие между "Negotiate" и CURLAUTH.NTLM.


Вуаля, теперь запросы срабатывают успешно.


Бонус-трек


Мы не остановились на достигнутом. Если внимательно посмотреть на логи, то видно, что один WCF запрос-ответ включает в себя несколько запросов-ответов HTTP, и один из ответов стабильно возвращается с Bad Request. В чем дело?


Для ошибочного запроса используется метод HEAD. И действительно, такое же поведение легко эмулируется с curl -I. В libcurl это соответствует опции CURLOPTION_NOBODY. В corefx эта опция используется при отправке HttpMethod.Head реквестов.
Поднимаемся по стеку выше в WCF. Видим, что в методе SendPreauthenticationHeadRequestIfNeeded отправляется HEAD запрос для авторизации, а все ошибки просто игнорируются:


try 
{
    // There is a possibility that a HEAD pre-auth request might fail when the actual request 
    // will succeed. For example, when the web service refuses HEAD requests. We don't want 
    // to fail the actual request because of some subtlety which causes the HEAD request. 
    await SendPreauthenticationHeadRequestIfNeeded(); 
}
catch { /* ignored */ } 

Здесь явно напрашивается флаг, подобный HttpClientHandler.PreAuthenticate, чтобы не запускать запрос, заранее обреченный на 400.


Раз уж о нас не позаботились, значит, будем резать.


Метод SendPreauthenticationHeadRequestIfNeeded асинхронный, поэтому его патчинг может привести к красноглазию в слишком раннем возрасте. Если оглядеться по сторонам, то можно заметить простой и неприхотливый метод AuthenticationSchemeMayRequireResend. Очевидно, если он будет возвращать всегда false, то и не будет запускаться SendPreauthenticationHeadRequestIfNeeded.


Приступаем к операции.


Добавляем в решение новый проект WcfPreauthPatch. Теперь ставим Cecil, при помощи которого полезем в IL-код. Нужна бета-версия, чтобы работало под .NET Core.


Install-Package Mono.Cecil -Version 0.10.0-beta7 -ProjectName WcfPreauthPatch


Код такой:


static void Main(string[] args)
{
    var curlHandlerType = typeof(HttpClient).Assembly.GetTypes()
        .FirstOrDefault(type => type.Name == "CurlHandler");
    if (curlHandlerType == null) return; // continue only when libcurl is used

    var wcfDllPath = typeof(System.ServiceModel.ClientBase<>)
       .Assembly.ManifestModule.FullyQualifiedName;
    var wcfAssembly = AssemblyDefinition.ReadAssembly(wcfDllPath);
    var requestType = wcfAssembly.MainModule.GetAllTypes()
        .FirstOrDefault(type => type.Name.Contains("HttpClientChannelAsyncRequest"));
    var authRequiredMethod = requestType.Methods
        .FirstOrDefault(method => method.Name.Contains("AuthenticationSchemeMayRequireResend"));

    authRequiredMethod.Body.Instructions.Insert(0, Instruction.Create(OpCodes.Ldc_I4_0)); // put false on stack
    authRequiredMethod.Body.Instructions.Insert(1, Instruction.Create(OpCodes.Ret));

    wcfAssembly.Write(wcfDllPath + ".patched");

    File.Delete(wcfDllPath);
    File.Move(wcfDllPath + ".patched", wcfDllPath);
}

В Dockerfile допишем


# for build image
FROM build AS build-wcf-patch
WORKDIR /src/WcfPreauthPatch/
RUN dotnet build -c Debug -o /app

...

# for release image
FROM base AS base-wcf-preauth-fixed
COPY --from=publish /app .
RUN dotnet WcfPreauthPatch.dll

Запускаем сервис и убеждаемся, что в логах одним запросом стало меньше.


Эпилог


WCF-клиент в .NET Core доставил нам немало хлопот.


На github уже есть обсуждение поднятых в статье проблем и вопросов:


1. Negotiate/NTLM


https://github.com/dotnet/corefx/issues/9533
https://github.com/dotnet/corefx/issues/9234


2. Preauthentication-запрос


https://github.com/dotnet/wcf/issues/2433


Однако, как мы увидели, эти проблемы не решены полностью. Надеемся, что наши 5 копеек в обсуждении добавят процессу новый оборот.


На закуску несколько идей и фактов


  • При осутствии интеграции с docker патчинг можно запускать как postbuild target.


  • Существуют NTLM-прокси, например, CNTLM. Альтернативный путь с настройкой NTLM-прокси внутри контейнера также имеет перспективы и более универсален, а готовый настроенный образ будет достоен выкладки на Docker Hub.


  • Гипотетически можно попробовать править хедер авторизации WWW-Authentication, который приходит от WCF-сервиса. Нужно переопределить поведение WCF-клиента через IEndpointBehavior и метод AfterReceiveReply. Однако, это сработает только в случае, если preauthentication запрос выключен, т.к. AfterReceiveReply его не поймает.


  • Если вы используете/имеете доступ к HttpClient, то вот ссылочка на workaround для похожей проблемы с NTLM.


  • Пропатчить CurlHandler при помощи Сecil не получится: System.Net.Http.dll — это mixed mode assembly (т.е. либа с managed и native кодом), и такой вариант в Cecil пока что не поддерживается.


  • Подмена указателя на метод в рантайме, описанная в статье, не работает, не пытайтесь.


  • Под линуксом в .NET Core нет поддержки function breakpoints.

Комментарии (9)


  1. Veikedo
    27.02.2018 15:20

    Не пробовали в CoreFx закомитить переключалку способа аутентификации?


    1. eastbanctech Автор
      28.02.2018 14:27

      Переключалка не выглядит правильным способом решения проблемы. Скорее всего, HttpClient в связке с CurlHandler должен при неудачном Negotiate-запросе пробовать следующий вариант. Скоро закинем вопрос на github.


  1. zaq1xsw2cde3vfr4
    28.02.2018 06:14

    Запиливание сервисов Windows под Linux и наоборот — это как забивать шурупы молотком. При этом извращения в сфере IT считается нормой, а ситуация с шурупами почему-то всех возмущает.


    1. lioncub
      28.02.2018 08:02

      Может необходимость? Если бизнес потребует закручивать гвозди, то поверьте будут закручивать.


      1. zaq1xsw2cde3vfr4
        28.02.2018 20:12

        Ситуация скорее выглядит так — IT специалист ответственный за принятие решения по выбору ПО принимает решение на основании закидонов своих внутренних тараканов, а не потребностей бизнеса. Результатом таких обоснований выбора оказывается, что цена слишком высока и бизнес не готов столько платить. И тут начинаются извращения типа контроллер домена на самбе и т.д.


  1. lioncub
    28.02.2018 06:15

    Почему NTLM, а не сразу безопасный Kerberos? У которого кстати поддержка Linux, Windows намного лучше.


    1. eastbanctech Автор
      28.02.2018 14:28

      Как вы правильно заметили в другом комментарии, заказчик диктует условия, Kerberos не подключен.


  1. kefirr
    01.03.2018 16:56

    Только что прочитал, что libcurl выпиливают в .NET Core 2.1:
    https://blogs.msdn.microsoft.com/dotnet/2018/02/27/announcing-net-core-2-1-preview-1/


    Интересно, что как это обернётся для вас :)


    1. eastbanctech Автор
      02.03.2018 08:40

      Ваша новость одновременно и печалит, и радует ;)