
Если вы поставляете товар в крупную розницу, возите грузы для 3PL-оператора, шлёте платёжные извещения банку или обмениваетесь медицинскими транзакциями X12 — вы почти наверняка обмениваетесь этими документами по AS2. Заказ (EDI 850), счёт (810), уведомление об отгрузке (856), платёжное авизо (820) уходят партнёру не почтой и не через REST, а как подписанный и зашифрованный S/MIME-конверт поверх HTTP, с подписанной квиткой-распиской в ответ. Так работает регламентированный B2B-документооборот в рознице, логистике, финансах, производстве и здравоохранении уже двадцать лет: Walmart, Amazon и их сети поставщиков, банки с host-to-host каналом, автопром, дистрибьюторы — все требуют AS2.
В .NET до сих пор было два пути. Либо коммерческий AS2-шлюз — Cleo, Seeburger, BizTalk — отдельная коробка, отдельная лицензия, отдельная команда сопровождения. Либо Java-сервер с открытым кодом — OpenAS2, Mendelson Community — отдельный JVM-процесс рядом с вашим .NET-бэкендом, со своим inbox-каталогом, откуда документы надо ещё забирать джобой. В обоих случаях AS2 живёт сбоку от вашей интеграции, а не внутри неё.
redb.Route.AS2закрывает этот разрыв: AS2 становится обычным шагом маршрута в вашем .NET-процессе. Приняли конверт от партнёра, расшифровали, проверили подпись, отдали документ в pipeline — провалидировали, трансформировали, положили в Kafka или SQL — и вернули партнёру подписанную расписку. Один процесс, один деплой, одна панель наблюдаемости. Разберём, как это выглядит в коде, где применяется и почему нативный коннектор в ESB выигрывает у отдельного шлюза.
Что такое AS2 за одну минуту
AS2 (Applicability Statement 2, RFC 4130) — это протокол гарантированной доставки бизнес-документов между двумя сторонами через интернет. Технически это конверт:
Полезная нагрузка (обычно EDI: X12 или EDIFACT, но может быть XML, JSON, что угодно) — сжимается (опционально), подписывается вашим приватным ключом и шифруется публичным сертификатом партнёра.
Готовый S/MIME-конверт уходит партнёру обычным HTTP POST.
Партнёр расшифровывает своим ключом, проверяет вашу подпись вашим сертификатом и отвечает MDN (Message Disposition Notification) — распиской. Подписанный MDN несёт
Received-Content-MIC: криптографический хеш того, что партнёр реально получил.
Смысл MDN — юридически значимая неотрекаемость. Вы отправили заказ, партнёр прислал подписанную расписку с MIC, который совпадает с тем, что вы отправили: у вас есть доказательство доставки ровно того документа, а не другого. Именно поэтому AS2 стал стандартом де-факто там, где за документом стоят деньги и обязательства.
MDN бывает синхронный (расписка приходит в том же HTTP-ответе) и асинхронный (партнёр подтверждает приём и присылает MDN отдельным POST-ом позже, на ваш адрес). Оба режима — часть протокола, и оба нужны в жизни: крупные партнёры часто требуют именно асинхронный.
«А почему не просто HTTPS»
Резонный вопрос: если канал и так защищён TLS, зачем ещё подписывать и шифровать документ? Потому что TLS защищает канал, а AS2 — документ. TLS живёт ровно от вашего сокета до сокета партнёра и исчезает, как только байты легли на диск: в логе прокси, в inbox-каталоге, на балансировщике документ уже открыт. S/MIME-конверт AS2 остаётся подписанным и зашифрованным на всём пути и в покое — расшифровать его может только владелец приватного ключа, а подпись доказывает автора. И главное — TLS не даёт расписки: у HTTPS нет MDN с MIC, а именно он делает доставку неотрекаемой. AS2 обычно и ходит поверх HTTPS (as2s) — одно другому не мешает: канал шифрует TLS, документ и неотрекаемость обеспечивает S/MIME.
Читаемый маршрут: endpoint как строка
redb.Route — это Apache Camel под .NET: маршрут описывается как From → … → To, а endpoint — это URI-строка. Коннектор AS2 добавляет две схемы, as2 (HTTP) и as2s (HTTPS), и они читаются как предложение:
as2s://partner.example.com/as2?connectionFactory=walmart # кому отправляем as2:/inbound/orders?host=0.0.0.0&port=4080&connectionFactory=walmart # где принимаем as2:/as2/mdn?host=0.0.0.0&port=4081&mode=mdn&connectionFactory=walmart # куда партнёр шлёт асинхронный MDN
По URI сразу видно намерение: куда идём, на каком порту слушаем, какой партнёр. Сертификаты и алгоритмы в строке не живут — им там не место. Для этого есть отдельная сущность.
Партнёр — один объект, а не россыпь параметров
Обмен по AS2 — это всегда договорённость двух сторон: чьи сертификаты, какие AS2-идентификаторы, что подписываем и чем шифруем, какой режим MDN. Всё это — As2ConnectionFactory, зарегистрированный в реестре один раз по имени:
context.AddToRegistry("walmart", new As2ConnectionFactory { OurCertificate = ourPfx, // наш сертификат с ПРИВАТНЫМ ключом — подписывает исходящее, расшифровывает входящее PartnerCertificate = theirCer, // публичный сертификат партнёра — шифрует исходящее, проверяет их подпись As2From = "OUR-AS2-ID", As2To = "WALMART-AS2-ID", PartnerUrl = "https://partner.example.com/as2", // Профиль обмена — то, о чём договорились Sign = true, Encrypt = true, Compress = false, SignAlg = "sha-256", EncryptAlg = "aes-128-cbc", SignedMdn = true, MdnMode = As2MdnMode.Sync, });
Маршруты ссылаются на партнёра по имени — ?connectionFactory=walmart в URI или .ConnectionFactory("walmart") в fluent-DSL. Добавили второго партнёра — зарегистрировали ещё один объект, URI маршрута не меняется. Сертификаты версионируются вместе с вашим приложением, а не лежат в keystore отдельного шлюза, о существовании которого знает один человек в команде.
Один маршрут — три окружения
URI endpoint'а — строка, а в строке работают плейсхолдеры {{ключ}}, которые redb.Route подставляет из IConfiguration на этапе сборки маршрута. Партнёрский адрес в dev, staging и prod разный — но маршрут один:
From("direct://outbound") .To(As2.Send("{{walmart.as2.url}}").ConnectionFactory("walmart"));
# appsettings.Development.json → "walmart.as2.url": "https://sandbox.partner/as2" # appsettings.Production.json → "walmart.as2.url": "https://edi.partner.com/as2"
Хост, порт приёмника, имя партнёра выносятся в конфигурацию, а не хардкодятся в маршрут. Секреты — пароль от PFX — приходят из того же слоя (переменные окружения, user-secrets) и не оседают в исходниках. Один и тот же собранный образ едет из dev в prod, меняется только конфиг.
Отправка: подписали, зашифровали, получили расписку
From("direct://outbound") .To(As2.Send("https://partner.example.com/as2").ConnectionFactory("walmart"));
За этой одной строкой — сжатие (если включено), подпись вашим ключом, шифрование сертификатом партнёра, POST и разбор ответного MDN. Результат обмена коннектор кладёт на exchange.Out, чтобы маршрут мог принять решение:
Заголовок на | Что значит |
|---|---|
| вердикт партнёра, напр. |
|
|
|
|
И тут же, в том же маршруте, разветвляемся по результату:
From("direct://outbound") .To(As2.Send("https://partner/as2").ConnectionFactory("walmart")) .Choice() .When(e => e.Out!.GetHeader<bool>(As2Headers.MdnMicMatch)) .Log("доставлено и подтверждено") .Otherwise() .To("direct://delivery-alert"); // MIC не сошёлся или отрицательный MDN — эскалация
Коннектор не бросает исключение на отрицательный MDN или расхождение MIC — он выкладывает факт и оставляет решение маршруту. Для одного партнёра расхождение MIC — повод немедленно поднять тревогу, для другого — записать в журнал и продолжить. Это политика вашего процесса, а не жёстко зашитое поведение шлюза.
Приём: AS2-сервер как источник маршрута
From(As2.Receive("/inbound/orders").Host("0.0.0.0").Port(4080).ConnectionFactory("walmart")) .Unmarshal(...) // разбор EDI — ваш формат .To("direct://process-order");
From(As2.Receive(...)) поднимает AS2-сервер. Пришедший конверт коннектор расшифровывает вашим приватным ключом, проверяет подпись сертификатом партнёра, распаковывает — и в маршрут попадает чистый бизнес-документ. Его реальный тип лежит на Message.ContentType (например, application/edi-x12), а метаданные обмена — под redbAs2.*:
Заголовок | Что значит |
|---|---|
| вычисленный Message Integrity Check и его алгоритм |
| входящая подпись проверена сертификатом партнёра |
| IP отправителя |
| имя сопоставленного |
Синхронный MDN коннектор формирует и возвращает сам. AS2-заголовки самого протокола (AS2-From, AS2-To, Message-ID, Subject) кладутся на сообщение как есть. Обёрточный Content-Type S/MIME-конверта в заголовки не просачивается — маршрут видит тип реального документа, а не транспортную обёртку.
Асинхронный MDN
Крупные партнёры часто требуют асинхронную расписку: приняли — ответили 200, а подписанный MDN прислали отдельным POST-ом позже. Отправитель регистрирует исходящий Message-ID, а входящий MDN коррелируется по Original-Message-ID:
// В профиле партнёра MdnMode = As2MdnMode.Async, AsyncMdnUrl = "https://our-host:4081/as2/mdn", // Маршруты From(As2.Receive("/inbound").Host("0.0.0.0").Port(4080).ConnectionFactory("walmart")) .To("direct://process"); From(As2.ReceiveMdn("/as2/mdn").Host("0.0.0.0").Port(4081).ConnectionFactory("walmart")) .Process(e => { var original = e.In.GetHeader<string>(As2Headers.MessageId); // какой наш документ подтверждён var ok = e.In.GetHeader<bool>(As2Headers.MdnMicMatch); // и подтверждён ли целым });
Отдельный endpoint под входящие MDN — это тоже просто From в маршруте, и приходящая расписка так же становится сообщением, с которым можно работать: обновить статус заказа, снять с ретрая, закрыть сагу.
Матрица алгоритмов
Партнёры договариваются о конкретных алгоритмах, и коннектор поддерживает стандартный набор:
Параметр | Значения |
|---|---|
|
|
|
|
|
|
Неподдерживаемый алгоритм отваливается на этапе сборки маршрута, а не в рантайме на первом же сообщении партнёру. Ошибка конфигурации видна при старте, а не в три часа ночи по логам отбойника.
Криптография под капотом — MimeKit (на Bouncy Castle). Это тот же криптографический фундамент, на котором работает весь мир AS2: и Apache camel-as2, и OpenAS2, и Mendelson стоят на Bouncy Castle. Мы садимся на общую основу, на которой стороны и интеропятся, а не изобретаем свой S/MIME.
Чем это отличается от шлюза
AS2 в .NET-проекте закрывали тремя способами. Разница не в том, «умеет или нет» — умеют все, протокол один. Разница в том, где живёт AS2 относительно вашей логики.
Коммерческий шлюз | Java-сервер (OpenAS2/Mendelson) | ||
|---|---|---|---|
Процесс | отдельная коробка | отдельный JVM рядом | ваш .NET-процесс |
Принятый документ | файл в inbox-каталоге | файл в inbox-каталоге | сообщение в маршруте |
Как забрать | джоба-подборщик | джоба-подборщик | сразу в pipeline |
Деплой | своя инсталляция | своя инсталляция | вместе с приложением |
Наблюдаемость | своя панель | логи JVM | общие трейсы и статистика |
Дальше по обработке | вне шлюза, руками | вне сервера, руками | те же EIP в том же маршруте |
Лицензия | платная | открытая | открытая, без ключа |
Отдельный шлюз оправдан, когда AS2-контур сознательно вынесен в отдельную зону — например, в DMZ под управлением другой команды. Когда же документ всё равно уходит в ваш .NET-бэкенд на обработку, промежуточная коробка — это лишний прыжок, лишний каталог и лишний компонент в схеме аудита.
Где это применяется и почему именно нативный коннектор
AS2 нужен там, где документ несёт обязательство, а партнёр диктует канал. Несколько типичных ситуаций.
Поставщик в крупную розницу. Чтобы отгружать в Walmart, Target, Amazon Vendor, поставщик обязан принимать заказы (850) и слать счета (810) и ASN (856) по AS2 с подписанным MDN. Раньше под это ставили отдельный шлюз; теперь From(As2.Receive(...)) принимает заказ прямо в тот маршрут, который его валидирует и кладёт в вашу ERP.
3PL и логистика. Склад и перевозчик обмениваются статусами отгрузки, подтверждениями приёмки, инвентаризацией. Объём — потоковый, и держать под него отдельную Java-коробку с inbox-каталогом, из которого документы вычёрпывает cron-джоба, — лишнее звено. Документ должен течь, а не лежать в папке.
Здравоохранение. X12-транзакции HIPAA (claims, remittance) между плательщиками и провайдерами идут по AS2 с жёсткими требованиями к подписи и шифрованию. Тот же профиль — подпись, шифрование, подписанный MDN.
Финансы и платежи. Платёжные и расчётные документы EDI — платёжное поручение и авизо о переводе (X12 820/824) — ходят между корпоративными клиентами и их банками по AS2 там, где банк предлагает host-to-host канал. AS2 здесь не заменяет SWIFT или EBICS на межбанке, а закрывает EDI-слой: документооборот корпорации с банком и платёжные извещения в цепочке поставок, где оплата — такой же EDI-документ, как заказ и счёт. Для финтеха важна ровно неотрекаемость AS2: подписанный MDN с MIC — это доказательство, что банк получил именно это платёжное поручение, а не другое.
Производство и снабжение. Автопром и промышленные supply-chain сети гоняют по AS2 заказы, план-графики поставок, отгрузочные уведомления (EDIFACT/X12) между OEM и поставщиками разных ярусов. Обмен обязательный: без электронного документооборота поставщика к сети просто не подключат.
Страхование. Enrollment, claims, remittance (X12) между плательщиками, брокерами и провайдерами — тот же профиль подписи и шифрования, те же требования к расписке.
Консолидация шлюза. У вас уже есть коммерческий AS2-шлюз, но это отдельная коробка со своей лицензией, своим циклом патчей и своим мониторингом, живущая параллельно .NET-бэкенду, который и делает всю бизнес-логику. AS2-коннектор сворачивает этот контур внутрь приложения.
Почему нативный коннектор в ESB, а не отдельный шлюз рядом:
Документ не лежит, а течёт. У отдельного шлюза принятый файл попадает в inbox-каталог, откуда его надо забрать. У коннектора принятый документ — это сообщение в маршруте: сразу валидация, трансформация, маршрутизация. Нет промежуточной папки и джобы-подборщика.
Один процесс, один деплой. Нет второй коробки, которую надо ставить, патчить, мониторить и объяснять аудиту. AS2-эндпоинт поднимается на общем Kestrel-хосте вместе с остальными HTTP-маршрутами процесса.
Одна плоскость наблюдаемости. AS2-эндпоинты дают статистику и health (видны в дашборде redb.Tsak) и распределённые трейсы наравне с остальными коннекторами: producer открывает
Client-спан, consumer —Consumer-спан, связанный по W3Ctraceparent. Обмен с партнёром виден в той же Jaeger-трассе, что и путь документа дальше по маршруту.Композиция с EIP. Это и есть главное. AS2 — не остров, а шаг. Принятый документ можно провести через весь каталог паттернов redb.Route: провалидировать по схеме, трансформировать XSLT, разложить Splitter'ом, обогатить, продублировать WireTap'ом в архив, положить в Kafka и в SQL одновременно.
AS2 как один шаг сквозного маршрута
Ценность коннектора видна, когда AS2 стоит не сам по себе, а в цепочке. Приняли заказ от партнёра, трансформировали, разложили и разослали дальше — одним маршрутом:
From(As2.Receive("/inbound/orders").Host("0.0.0.0").Port(4080).ConnectionFactory("walmart")) .Validate(...) // документ соответствует схеме .Xslt("styles/x12-to-canonical.xsl") // X12 → ваша каноническая модель .WireTap("sftp://archive/edi?...") // копия в архив с retention .To("kafka://orders?brokers=...") // в шину для обработки .To("sql:INSERT INTO inbound_orders ..."); // и в БД аудита
Тот же документ, что пришёл в зашифрованном AS2-конверте, за один проход оказывается провалидированным, трансформированным, заархивированным, в Kafka и в SQL — и всё это видно в одной трассе. Отдельный шлюз дал бы вам файл в папке; здесь вы получаете полноценный конвейер, у которого AS2 — просто вход.
Исходящее направление симметрично: собрали документ из вашей системы, отправили партнёру, разобрали MDN, обновили статус — тоже один маршрут.
Полный контур: приняли заказ — отправили счёт
Онбординг поставщика к рознице — это два направления AS2, которые в redb.Route живут двумя маршрутами в одном процессе.
Входящее — заказ (EDI 850) от ритейлера:
From(As2.Receive("/inbound").Host("0.0.0.0").Port(4080).ConnectionFactory("walmart")) .Validate(typeof(X12OrderValidator)) // структура 850 корректна .Unmarshal(typeof(X12Format), typeof(Order)) // X12 → доменная модель .Process(async (e, ct) => await _orders.Accept((Order)e.In.Body!, ct)) .To("kafka://orders-inbound?brokers={{kafka.brokers}}");
Заказ принят, провалидирован, разобран, сохранён и опубликован в шину — за один проход, и синхронный MDN ушёл ритейлеру автоматически. Дальше ваша система обрабатывает заказ своим темпом.
Исходящее — счёт (EDI 810) обратно партнёру, когда отгрузка готова:
From("kafka://invoices-ready?brokers={{kafka.brokers}}") .Marshal(typeof(X12Format)) // доменная модель → X12 810 .To(As2.Send("{{walmart.as2.url}}").ConnectionFactory("walmart")) .Choice() .When(e => e.Out!.GetHeader<bool>(As2Headers.MdnMicMatch)) .Process(e => _invoices.MarkDelivered(e)) .Otherwise() .To("direct://edi-ops-alert");
Оба направления используют один connectionFactory("walmart") — одни сертификаты, один профиль. Весь EDI-контур с партнёром — два маршрута и один объект партнёра, внутри вашего приложения, под вашей наблюдаемостью.
Обмен виден в общей трассе
AS2 у отдельного шлюза — чёрный ящик: партнёр говорит «не пришло», и вы идёте в чужие логи на чужой коробке. Здесь AS2-эндпоинты — часть общей наблюдаемости redb.Route. Producer открывает Client-спан, consumer — Consumer-спан, связанный с входящим документом по W3C traceparent. В одной Jaeger-трассе видно всё: пришёл конверт от партнёра → расшифрован и проверена подпись → провалидирован → трансформирован → ушёл в Kafka. Статистика эндпоинта — сколько принято, сколько отправлено, ошибки — в дашборде redb.Tsak, рядом с остальными коннекторами. Когда партнёр спорит о доставке, у вас на руках трасса конкретного Message-ID, а не «где-то в логах шлюза».
Что важно знать про AS2 на практике
Несколько вещей, на которые наступаешь ровно один раз.
MIC считается по каноническому виду, байт в байт. Message Integrity Check — это хеш подписанной части в CRLF-канонической форме, вычисленный ровно так, как его считала отправляющая сторона. Отсюда весь интероп-боль AS2: если каноникализация или алгоритм разошлись хоть на байт, MIC в MDN не совпадёт, и партнёр решит, что получил не то. Коннектор считает MIC по RFC 4130 и сверяет его на приёме и в разборе MDN — и именно это проверено против чужого сервера, а не на самом себе.
Sync или async — решает партнёр, не вы. Синхронный MDN проще: расписка в том же ответе, маршрут сразу знает исход. Но при большом объёме синхронный MDN держит HTTP-соединение до конца обработки, и крупные партнёры требуют асинхронный: приняли — ответили 200, MDN прислали отдельным POST-ом. Коннектор поддерживает оба; выбор — строка в профиле партнёра, а не переписывание маршрута.
Подписанный MDN — это про неотрекаемость, а не про галочку. SignedMdn = true значит, что расписка подписана ключом принимающей стороны и несёт MIC. Для документа, за которым стоит обязательство, это и есть доказательство: у вас на руках подписанное подтверждение, что партнёр получил именно этот документ целым. Неподписанный MDN — просто «дошло», чему в споре грош цена.
Content-Transfer-Encoding: binary. EDI-документы бинарны по духу, и партнёры обычно обмениваются в binary, а не base64 — меньше накладных расходов на большом объёме. Деталь профиля, но именно на ней часто спотыкаются самодельные реализации.
Сертификаты и секреты
AS2 стоит на паре ключей: ваш приватный (подпись исходящего, расшифровка входящего) и публичный сертификат партнёра (шифрование исходящего, проверка их подписи). В коннекторе это X509Certificate2 в As2ConnectionFactory — грузите из PKCS#12/PFX как удобно: из файла, из хранилища сертификатов Windows, из секрет-менеджера.
Сертификаты — часть конфигурации партнёра в реестре, а не строки в URI. Пароль от PFX, если он приходит через параметр endpoint'а, помечен [Sensitive] и вырезается из логов и из дашборда — секрет не утекает в трассу. Ротация партнёрского сертификата — это замена объекта в реестре; маршрут не трогается.
Интероп доказан, а не заявлен
Про AS2 легко написать «поддерживаем» и приложить юнит-тесты, которые гоняют коннектор против самого себя. Это доказывает внутреннюю непротиворечивость, но не то, что ваш конверт примет чужой софт. Настоящая проверка — обмен с независимой реализацией.
Коннектор проверен против живого OpenAS2 v4.9.0 (зрелый сервер на Bouncy Castle) в Docker, в обоих направлениях, профилем «подпись SHA-256 + шифрование AES-128-CBC + подписанный MDN»:
redb → OpenAS2. Наш producer отправил документ, OpenAS2 своими логами подтвердил: расшифровал нашим конвертом, проверил нашу подпись, сохранил документ, вернул положительный подписанный MDN. Наш разбор MDN проверил его подпись и подтвердил совпадение
Received-Content-MIC.OpenAS2 → redb. OpenAS2 собрал подписанный и зашифрованный документ и отправил нашему consumer'у. Мы расшифровали приватным ключом, проверили подпись OpenAS2, отдали EDI в маршрут и вернули подписанный MDN, который OpenAS2 принял и скоррелировал со своим ожиданием.
Это закрывает «сложную часть» AS2 в обе стороны: вычисление MIC по RFC 4130, структуру S/MIME и обработку MDN — на приём и на отправку — принимает настоящий, чужой партнёр.
Тесты открыты. Как именно проверяли — три слоя (крипто-раунд-трипы и MIC, e2e-петля продюсер→консьюмер через живой Kestrel, интероп против OpenAS2) — расписано в файле TESTING.md коннектора на GitHub. Стенд интеропа (docker-compose с OpenAS2, конфиг, сертификаты) воспроизводится одной командой; интероп-тест gated — обычный прогон зелёный и без контейнера.
Частые вопросы
Несколько партнёров на одном порту? Да. Приёмные endpoint'ы разных партнёров живут на общем Kestrel-хосте, разведённые по пути (/inbound/walmart, /inbound/target), каждый со своим connectionFactory. Один сервер, несколько партнёрств.
Крупные файлы? Тело сообщения — байты, и поток идёт через обычный HTTP-конвейер, а не собирается в строку. Для действительно тяжёлых выгрузок партнёры обычно переходят на SFTP — который в redb.Route тоже коннектор и стоит в том же маршруте.
mTLS / клиентские сертификаты на транспорте? AS2 сам аутентифицирует документ подписью, но некоторые партнёры дополнительно требуют TLS client-auth. Схема as2s идёт поверх того же Kestrel-хоста, что и HTTP-коннектор, с его настройками TLS.
Что при рестарте? Асинхронный MDN коррелируется по Original-Message-ID; исходящие, ждущие расписку, — это состояние вашего маршрута, и хранится оно там, где вы держите состояние саги или идемпотентности, а не в памяти коннектора.
Установка
services.AddRedbRoute(route => { route.Services.AddRedbRouteAs2(); route.AddRouteBuilder<MyRoutes>(); });
AddRedbRouteAs2() регистрирует схемы as2 / as2s и переиспользует общий Kestrel-хост процесса — AS2-маршрут и обычный HTTP-маршрут в одном воркере делят один сервер и не спорят за порт.
Пакет — redb.Route.As2 на NuGet, исходники и полное описание DSL — в README коннектора. AS2 — очередной транспорт в семействе redb.Route, наравне с Kafka, RabbitMQ, IBM MQ, SFTP и остальными: тот же From → … → To, те же EIP, та же наблюдаемость. Разница только в том, что на входе и выходе — подписанный и зашифрованный конверт, которого ждёт ваш торговый партнёр.
Другие мои статьи — redb.ru/articles, ещё — на Хабре.
If this was useful — a ⭐ on GitHub helps others find it.

