Мы продолжаем рассказывать про миграцию мобильного сервиса в 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.