SOAP в .NET без WCF: WS-Security, MTOM и ?wsdl как шаг маршрута

redb.Route

SOAP никуда не делся. Продаёте авиабилеты, значит ходите в Amadeus, Sabre и Travelport по SOAP. Интегрируетесь с банком, госпорталом, страховым бэкендом, биллингом оператора или почти любым корпоративным продуктом, купленным до 2015-го: там контракт это WSDL, а на проводе <soap:Envelope>. Подписи WS-Security, вложения MTOM, SOAP 1.1 рядом с 1.2, soap:Fault при ошибке: этот мир жив, он приносит деньги и не переезжает на REST по вашей вежливой просьбе.

В .NET вызывать его стало неудобно. WCF как полноценный фреймворк ушёл. CoreWCF есть, но во всех опубликованных версиях висит незакрытая крипто-уязвимость, так что затащить его значит поменять одну проблему на другую. dotnet-svcutil генерит клиент из WSDL на этапе сборки, но это кодогенерация, приклеенная сбоку, а не шаг интеграции. А хостинг SOAP-эндпоинта, WS-Security руками или проводка MTOM обычно заканчиваются кучей конфигурации System.ServiceModel, которую никто не хочет держать.

redb.Route.Soap идёт другим путём. SOAP становится обычным шагом пайплайна в вашем же .NET-процессе. Вызвал сервис, получил типизированный ответ. Поднял эндпоинт, отдал тело маршруту, вернул ответ. Всё in-box: HttpClient, общий Kestrel-хост и System.Security.Cryptography.Xml для WS-Security. Ни WCF, ни рантайма System.ServiceModel, ни уязвимой зависимости, ни отдельного шлюза. Разберём, как это используется и почему нативный коннектор внутри ESB бьёт codegen-клиента или отдельную коробку.

SOAP за минуту

services.AddRedbRoute(route =>
{
    route.Services.AddRedbRouteSoap();
    route.AddRouteBuilder<MyRoutes>();
});
// Вызов сервиса
From("direct://get-fares")
    .To(Soap.Call("https://gds/air.svc").ConnectionFactory("amadeus").Operation("GetFares"));

// Хостинг SOAP-эндпоинта
From(Soap.Listen("/svc/orders").Host("0.0.0.0").Port(4090))
    .Process(HandleOrder);

AddRedbRouteSoap() регистрирует схемы soap и soaps и делит один Kestrel-сервер приёма со всеми остальными HTTP-коннекторами процесса. В режиме по умолчанию Payload тело сообщения это XML <soap:Body>: отправил фрагмент, получил фрагмент, без кодогенерации, против любого сервиса.

Эндпоинт это строка (или флюентный билдер)

Каждый эндпоинт читается двумя равнозначными способами. Кому-то милее типобезопасный билдер, кому-то простой URI, который кладётся в конфиг. Компилируются они в одно и то же.

// Флюентно
.To(Soap.Call("https://gds/air.svc").ConnectionFactory("amadeus").Operation("GetFares"))

// Строкой (идентично)
.To("soaps://gds/air.svc?connectionFactory=amadeus&operation=GetFares")

Со стороны консюмера то же самое:

From(Soap.Listen("/svc/orders").Host("0.0.0.0").Port(4090).ConnectionFactory("orders"))
// или
From("soap:/svc/orders?host=0.0.0.0&port=4090&connectionFactory=orders")

soap это HTTP, soaps это HTTPS. Адрес продюсера soap[s]://host/path, консюмер soap:/path?host=&port=, путь сохраняется целиком. Раз URI это строка, эндпоинт берётся из appsettings.json и меняется по окружениям без правки кода.

Биндинг сервиса это один объект, а не россыпь параметров

Сертификаты, учётки, версия SOAP и формат данных не живут в URI. Они на SoapConnectionFactory, зарегистрированном один раз по имени. Маршруты ссылаются через .ConnectionFactory("name").

context.AddToRegistry("amadeus", new SoapConnectionFactory
{
    EndpointUrl = "https://gds/air.svc",
    SoapVersion = SoapVersion.Soap11,
    DefaultAction = "urn:GetFares",

    // Материал WS-Security, всё опционально
    SigningCert = ourPfx,      // наш серт + ПРИВАТНЫЙ ключ: подписывает исходящее, расшифровывает входящее
    EncryptCert = partnerCer,  // публичный серт партнёра: шифрует исходящее, аутентифицирует его подпись
    Username = "svc", Password = "secret",   // UsernameToken

    Mtom = true,               // вложения MTOM/XOP
    Wsdl = "contracts/air.wsdl",              // отдаётся на GET ?wsdl (консюмер)
});

Поле пароля помечено как чувствительное, поэтому оно вырезается из логов и из дашборда рантайма. Переезд из стейджа в прод это подмена зарегистрированного объекта, а не правка маршрутов.

Три способа, которыми маршрут видит сообщение

У camel-cxf есть форматы данных, и у этого коннектора тоже, на SoapConnectionFactory.DataFormat.

Payload (по умолчанию). Тело это внутренний XML <soap:Body>. Работает против любого сервиса, без типов и кодогенерации.

Message. Прозрачный прокси: тело это весь конверт, туда и обратно. Логировать SOAP-трафик, пробрасывать нетронутым, инспектировать.

Pojo. Типизированные объекты запроса и ответа через XmlSerializer, .NET-аналог JAXB document/literal. DTO это обычные XML-сериализуемые типы, их можно сгенерить из WSDL через dotnet-svcutil.

context.AddToRegistry("air", new SoapConnectionFactory {
    EndpointUrl = "https://gds/air.svc",
    DataFormat  = SoapDataFormat.Pojo,
    ResponseType = typeof(GetFaresResponse),
});

From("direct://q")
    .Process(e => e.In.Body = new GetFaresRequest { Route = "JFK-LHR" })
    .To(Soap.Call("https://gds/air.svc").ConnectionFactory("air"));
// e.Out.Body теперь GetFaresResponse

Уровень типизации выбираете вы. Универсальный сквозной пайплайн остаётся в Payload; сервис с фиксированным контрактом идёт в Pojo и работает с настоящими объектами.

WS-Security, который аутентифицирует, in-box

WS-Security это место, где SOAP-интеграции обычно застревают. Здесь это три поля на фабрике, и работает на System.Security.Cryptography.Xml, без всякого CoreWCF рядом.

  • UsernameToken: задайте UsernamePassword); продюсер добавляет заголовок <wsse:Security>, консюмер отдаёт учётку маршруту.
  • XML-Signature: с SigningCert продюсер подписывает <soap:Body> (Exclusive C14N, SHA-256, встроенный X.509); консюмер проверяет.
  • XML-Encryption: с EncryptCert продюсер шифрует Body на партнёра; консюмер расшифровывает своим приватным ключом.

Ключевое тут проверка. Когда задан серт партнёра, верификация подписи аутентифицирующая: подписант обязан быть именно этим сертификатом, а подпись обязана покрывать Body, так что подделка своим self-signed-сертом или подпись над декой-элементом отвергаются. Это и есть разница между «XML не подменили» и «сообщение реально от партнёра», а ошибиться тут легко.

Шифрование уходит в том виде, который делают и ждут настоящие WS-Security-стеки: EncryptedKey в заголовке <wsse:Security>, связанный ReferenceList с EncryptedData в Body, AES-256 под RSA-OAEP-обёрткой ключа. Это не приватный диалект. Независимый крипто-стек расшифровывает его сквозь (об этом ниже).

Бинарные вложения: MTOM

Крупным бинарям не место base64-раздутыми внутри XML. Поставьте Mtom = true, и вложения едут как multipart/related с XOP, на отдельной плоскости, чтобы контракт тела оставался чистым, ровно как Camel держит их на AttachmentMessage.

var msg = new Message(
    "<Upload xmlns=\"urn:svc\"><file>" +
    "<xop:Include xmlns:xop=\"http://www.w3.org/2004/08/xop/include\" href=\"cid:doc-1\"/></file></Upload>");
msg.Headers[SoapHeaders.Attachments] =
    new List<SoapAttachment> { new("doc-1", "application/pdf", pdfBytes) };
await producer.Process(new Exchange(msg));

На приёме маршрут читает входящие вложения из redbSoap.attachments и отвечает своим списком. Чужие входящие вложения коннектор молча обратно не пришлёт.

Публикуйте свой WSDL на ?wsdl

В .NET Core нет рантайм-импорта WSDL, by design, поэтому коннектор и не притворяется. Что он умеет: публиковать контракт. Укажите SoapConnectionFactory.Wsdl на файл или inline-XML у консюмера, и он отдаётся на GET {path}?wsdl с переписанным <soap:address> на реальный адрес, по которому до вас дозвонились. Сложите с режимом Pojo для типизированных запроса и ответа: получите контракт-first SOAP-сервис.

SOAP как контроллер

Вот часть, которой нет у codegen-клиента. SOAP это полноценный controller-транспорт в redb.Route, наравне с HTTP, gRPC и SignalR. Пишете контроллер, диспатчите по операции.

[Route("air")]
public class AirController : RedbController
{
    // Имя метода = SOAP-операция; XML-тело биндится внутрь, типизированный ответ сериализуется наружу.
    public Task<GetFaresResponse> GetFares([FromBody] GetFares req)
        => Task.FromResult(new GetFaresResponse { Price = Quote(req.Route) });

    [SoapOperation("HealthCheck")]           // явное имя, когда оно отличается от имени метода
    public string Health() => "ok";
}

// За SOAP-эндпоинтом:
From(Soap.Listen("/svc/air").Host("0.0.0.0").Port(4090))
    .RedbSoapController<AirController>();

Операция, которую SOAP-консюмер вытащил из запроса, мапится на метод, XML-тело биндится в параметр, возвращаемое значение уходит ответом. Без HTTP-атрибутов, без ручной возни с конвертом, soap:Fault при ошибке. Тот же класс контроллера работает за любым транспортом, так что сервис говорит SOAP и REST сразу, одной реализацией.

Где применяется и почему нативное бьёт шлюз

Сценарии скучные и несущие. Тревел-платформа тянет тарифы из GDS по SOAP и перепубликует их как JSON. Банковская интеграция принимает подписанный SOAP-запрос, валидирует и пишет в реестр. Госпортал или медицинский портал со строгим WSDL-контрактом, который надо отдавать в точности. Легаси-ERP, который умеет только SOAP 1.1 с UsernameToken.

С codegen-клиентом или отдельным шлюзом SOAP живёт рядом с вашей интеграцией. Генерите прокси или гоняете отдельный процесс, а потом всё равно перекладываете сообщение в настоящий пайплайн, транслируете fault, сшиваете трейс через границу. С redb.Route.Soap это один шаг маршрута:

From(Soap.Listen("/svc/orders").Host("0.0.0.0").Port(8443).ConnectionFactory("self"))
    .Validate(Body().Matches(orderSchema))
    .Unmarshal("xml").Marshal("json")
    .To("kafka://orders")
    .Process(e => e.Out.Body = "<Ack xmlns=\"urn:svc\">accepted</Ack>");

Один процесс, один деплой, один трейс. SOAP-запрос прилетел, провалидировался, трансформировался, лёг в Kafka, а партнёру ушёл конверт обратно, и всё это в одном OpenTelemetry-дереве спанов, что и любой другой шаг. Client-спан на вызове, Server-спан на эндпоинте, статистика эндпоинта, вырезание секретов: то же сквозное поведение, что у любого коннектора семейства.

Интероп доказан, а не заявлен

SOAP-коннектор, который говорит только сам с собой, это не SOAP-коннектор. Этот проверен против независимого стека, библиотеки Node.js soap, у которой с ним нет ни строчки общего кода, в обе стороны: наш продюсер против их сервера, их клиент против нашего консюмера, обычный SOAP и MTOM. А WS-Security-шифрование расшифровывается сквозь независимым крипто-стеком (OpenSSL в Node) прямо из нашего конверта: RSA-OAEP-разворот ключа, AES-256-CBC, паддинг XML-Encryption снимается как требует спека. Провод стандартный, и это показано, а не заявлено.

Честные границы

Рантайм-импорт WSDL (клиент интроспектит удалённый WSDL на лету) отсутствует в .NET Core by design; коннектор отдаёт статический WSDL-контракт и складывает его с Pojo-типами. WSDL не генерируется из CLR-типов рефлексией; модель контракт-first. Пара редких WS-* вариантов (внешний CipherReference, не-OAEP key transport, UsernameToken PasswordDigest) пока не покрыта. Ничто из этого не мешает магистрали: SOAP 1.1 и 1.2, faults, две плоскости заголовков, WS-Security с аутентифицирующими подписями и стандартным layout шифрования, MTOM, публикация WSDL и контроллеры.

FAQ

Нужен CoreWCF или System.ServiceModel? Нет. База это HttpClient, общий Kestrel-хост и System.Security.Cryptography.Xml. CoreWCF намеренно обойдён: во всех версиях висит незакрытая крипто-уязвимость.

SOAP 1.1 или 1.2? Оба. SoapVersion управляет Content-Type и размещением SOAPAction; faults разбираются для обоих, независимо от префикса namespace, который использует WCF- или CXF-собеседник.

Может один сервис говорить и SOAP, и REST? Да, через controller-транспорт: один и тот же RedbController стоит и за Soap.Listen(...), и за Http.Listen(...).

Куда кладутся сертификаты? На SoapConnectionFactory, как X509Certificate2. Пароль вырезается из логов и дашборда.

Установка

dotnet add package redb.Route.Soap

Пакет: redb.Route.Soap на NuGet; исходники и полный справочник по DSL в README коннектора. SOAP это ещё один транспорт в семействе redb.Route, рядом с Kafka, RabbitMQ, AS2, IBM MQ и прочими: тот же From → … → To, те же EIP, та же наблюдаемость. Разница лишь в том, что на проводе <soap:Envelope>, которого ждёт двадцатилетняя корпоративная система.

Если было полезно, ⭐ на GitHub поможет другим это найти.


Другие мои статьи — redb.ru/articles, ещё — на Хабре.