Технологически в этой статье ничего нового, просто еще одно полезное применение winapi-хуков для решения специфичной проблемы.

При работе с веб-проектами в Visual Studio существует одна неприятная мелочь — при использовании в процессе разработки нескольких бранчей, каждый из которых должен использовать свою копию окружения (например базу данных, или какие то внешние сервисы), возникает проблема с конфигурационными файлами в момент отладки — IISExpress использует только основной web.config в папке проекта, где обычно всякие connection strings содержат значения по умолчанию и где нет никаких специфичных для бранча настроек, и никаких трансформаций при запуске к нему не применяется. Можно конечно принудительно либо автоматически, либо вручную, применять трансформации к web.config, но во-первых измененный файл будет постоянно висеть в pending changes, что создает риск коммита нежелательных изменений, которые потом попадут в другие бранчи, а во-вторых это создает массу неудобств при его редактировании, поскольку перед коммитом каких-либо изменений в конфигурационном файле такие трансформации придется вручную убирать.

Рассмотрим как этого избежать.

Решение довольно простое — необходимо перехватывать чтение конфигурационного файла процессом IISExpress, и вместо исходного файла подсунуть другой, временный файл в котором внесены соответствующие исправления, и который не добавлен в Source Control. Список исправлений которые необходимо применить в зависимости от того из какой папки запускается проект можно указывать, к примеру, в простом xml файле.

Для этого понадобятся:

Фоновая утилита следящая за созданием новых процессов, 32 и 64-битные dll c хуками, 32 и 64-битные exe запускаемые фоновой утилитой и загружающие соответствующую dll с хуком в процесс соответствующей разрядности.

Фоновая утилита следит за процессами через WMI используя класс ManagementEventWatcher и запрос к __InstanceOperationEvent с фильтрацией по типу объекта Win32_Process и требуемым именам процессов. Получение события ____InstanceCreationEvent означает что был создан процесс, информацию о котором можно получить из EventArrivedEventArgs.NewEvent. В данном случае необходим только ProcessId.

processWatcher.Query.QueryString = @"SELECT * FROM __InstanceOperationEvent WITHIN 1" +
    "WHERE TargetInstance ISA 'Win32_Process' AND (" + 
    string.Join(" OR ", processNames.Select(x => "TargetInstance.Name = '" + x + ".exe'")) + ")";
processWatcher.EventArrived += (sender, e) =>
{
    if (e.NewEvent.ClassPath.ClassName == "__InstanceCreationEvent")
    {
        var processId = (uint)((ManagementBaseObject)e.NewEvent["TargetInstance"])
           .Properties["ProcessId"].Value;
        // ... Do smth useful 
    }
};

Алгоритм загрузки dll в чужой процесс стандартный — в чужом процессе через VirtualAllocEx выделяется память под путь к dll и создается поток путем передачи адреса LoadLibrary в CreateRemoteThread. Если код инициализации хуков находится в DllMain, то никаких дополнительных действий не понадобится. Но фоновая утилита самостоятельно внедрять dll и в 32 и в 64-битные процессы одновременно не сможет. Теоретически конечно вызов CreateRemoteThread из 64-битного процесса может создать поток в 32-битном процессе, но в данном случае в качестве функции для потока используется LoadLibrary. А максимально простым способом через GetProcAddress можно получить ее адрес только для той же разрядности что и текущий процесс. У kernel32.dll fixed base address, поэтому для разных процессов одной разрядности адрес функции совпадает. В теории конечно можно бы было вручную разбирать PE-заголовки и не использовать дополнительные процесы для внедрения, но это сложнее.

Перехватывать надо конечно же функцию CreateFileW. Сначала я весь код написал полностью на C#, но на практике с перехватом некоторых слишком фундаментальных функций вроде этой возникают ошибки с loader lock и им подобные, когда managed код вызывается, например, из DllMain каких-либо сторонних библиотек подгружаемых процессом. Поэтому пришлось установку хуков и фильтрацию вызовов требующих обработки вынести в native dll на C, которая в свою очередь загружает managed dll и вызывает managed код оттуда только когда CreateFileW вызывается для .config файлов. Для установки хуков я использовал проверенную временем стороннюю библиотеку MinHook, про нее в интернете много информации и останавливаться на ее описании не буду. Возможно, у кого то возникнет вопрос — 'а не проще ли было все полностью сделать на C и не создавать кучу .net сборок', возможно да, но это скучно.

Логика фильтрации должна проверять что файл существует, является файлом а не директорией, содержит в имени web.config, не расположен в папке windows\microsoft.net\… (системные конфиги нас не интересуют). Если все эти условия выполняются то передаем HANDLE, полученный от вызова исходной системной функции CreateFileW перехватчиком, а также все параметры CreateFileW в managed обработчик, который по этому HANDLE прочтет содержимое. Для простоты лучше использовать оригинальный HANDLE чтобы не делать защиту от рекурсии, к которой приведет чтение того же самого файла каким-нибудь File.ReadAllText, поскольку сработает этот же хук. Далее, в полученном содержимом заменяются все необходимые строки и измененное содержимое записывается во временный файл у которого имя не соответствует вышеописанным критериям фильтрации (опять же чтобы не попасть в рекурсию). Вызываем CreateFileW для этого временного файла с теми же параметрами с которыми был открыт web.config и полученный HANDLE возвращаем из перехватчика CreateFileW. Исходный HANDLE уже не нужен и его следует закрыть.

Хук
HANDLE WINAPI _CreateFileW(
    LPCWSTR lpFileName, 
    DWORD dwDesiredAccess, 
    DWORD dwShareMode, 
    LPSECURITY_ATTRIBUTES lpSecurityAttributes, 
    DWORD dwCreationDisposition, 
    DWORD dwFlagsAndAttributes, 
    HANDLE hTemplateFile)
{
    DWORD attributes = GetFileAttributesW(lpFileName);
    HANDLE result = CreateFileWOriginal(lpFileName, 
        dwDesiredAccess, 
        dwShareMode, 
        lpSecurityAttributes, 
        dwCreationDisposition, 
        dwFlagsAndAttributes, 
        hTemplateFile);
    HANDLE newFile = NULL;

    if (attributes != INVALID_FILE_ATTRIBUTES && 
         (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 && 
         (StrStrI(lpFileName, L"web.config") != NULL || StrStrI(lpFileName, L"app.config") != NULL) &&
         StrStrI(lpFileName, L"Windows") == NULL)
    {
        fileHandler(result, &newFile, dwDesiredAccess, dwShareMode, lpSecurityAttributes, 
            dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile);
    }
    if (newFile != NULL)
    {
        CloseHandle(result);
        result = newFile;
    }
    return result;
}


Managed обработчик
public static void GetUpdatedConfigF(IntPtr handle, IntPtr newHandleAddress, 
    uint access, uint share, IntPtr securityAttributes, uint creationDisposition, 
    uint flagsAndAttributes, IntPtr templateFile)
{
    try
    {
        if (config == null)
            return;
        var path = new StringBuilder(260);
        if (GetFinalPathNameByHandle(handle, path, (uint)path.Capacity, 0) == 0)
            return;
        var matchedSection = config.FirstOrDefault(x => 
            path.ToString().IndexOf(x.Branch, StringComparison.OrdinalIgnoreCase) >= 0);
        if (matchedSection == null)
            return;
        var size = GetFileSize(handle, IntPtr.Zero);
        if (size == 0)
            return;
        var buffer = new byte[size];
        uint bytesRead;
        if (!ReadFile(handle, buffer, (uint)buffer.Length, out bytesRead, IntPtr.Zero))
            return;
        var content = Encoding.UTF8.GetString(buffer);
        foreach (var replacement in matchedSection.Replacements)
            content = content.Replace(replacement.Find, replacement.ReplaceWith);
        var tempFile = Path.GetTempFileName();
        MoveFileEx(tempFile, null, 4);
        File.WriteAllText(tempFile, content);
        var newHandle = CreateFileW(tempFile, access, share, securityAttributes, 
            creationDisposition, flagsAndAttributes, templateFile);
        Marshal.WriteIntPtr(newHandleAddress, newHandle);
    }
    catch
    {
    }
}


Native dll с хуком вызывает обработчик из managed dll через LoadLibrary и GetProcAddress. Для этого необходимо экспортировать статический метод как обычную dll функцию. Это делается немного шаманским способом через дизассемблирование ildasm-ом, добавление специальных опций к методу в il-коде и ассемблирование обратно в dll. Об этом тоже в интернете много статей, повторяться не буду, их легко найти поискав например ".vtentry". В исходном коде присутствует простейшая утилита обрабатывающая таким образом сборки.

Помимо IISExpress схожая проблема актуальна и для wcf-сервисов запускаемых через wcfsvchost. Правда в этом случае применение трансформаций работает нормально, но чтобы все было единообразно и чтобы не клонировать лишних файлов с трансформациями и не переключать ничего в Configuration Manager-е, рассмотрим и этот случай. Здесь есть некоторые отличия — wcfsvchost читает конфигурацию сразу при старте процесса, а WMI событие приходит слишком поздно, и хук устанавливается позже чем надо. Но путь к файлу конфигурации передается через командную строку, поэтому внедряться следует в parent-процесс и перехватывать CreateProcessW.

Parent-процесс в данном случае devenv.exe, т.е. Visual Studio. В этом случае, перед тем как вызвать исходную системную функцию CreateProcessW, в managed обработчик передаем строку параметров с которыми создается процесс и адрес массива куда запишется исправленная строка. В обработчике строка разбивается на параметры путем вызова CommandLineToArgvW, далее среди них определяется путь к файлу конфигурации, а затем аналогично создается временный файл с исправленным содержимым и путь в параметрах подменяется на него.

Хук
BOOL WINAPI _CreateProcessW(LPCWSTR lpApplicationName, 
    LPWSTR lpCommandLine, 
    LPSECURITY_ATTRIBUTES lpProcessAttributes, 
    LPSECURITY_ATTRIBUTES lpThreadAttributes, 
    BOOL bInheritHandles, 
    DWORD dwCreationFlags, 
    LPVOID lpEnvironment, 
    LPCWSTR lpCurrentDirectory, 
    LPSTARTUPINFOW lpStartupInfo, 
    LPPROCESS_INFORMATION lpProcessInformation)
{
    BOOL result;
    LPWSTR buffer = NULL;
    if (lpCommandLine != NULL && StrStrI(lpCommandLine, L".config") != NULL)
    {
        buffer = (LPWSTR)malloc(BUFFER_SIZE);
        memset(buffer, 0, BUFFER_SIZE);
        processHandler(lpCommandLine, buffer);
        lpCommandLine = buffer;
    }
    result = CreateProcessWOriginal(lpApplicationName, lpCommandLine, 
         lpProcessAttributes, lpThreadAttributes, bInheritHandles, dwCreationFlags, 
         lpEnvironment, lpCurrentDirectory, lpStartupInfo, lpProcessInformation);
    if (buffer != NULL)
        free(buffer);
    return result;
}


Managed обработчик
public static void GetUpdatedConfigP(IntPtr commandLine, IntPtr newCommandLine)
{
    var commandLineText = Marshal.PtrToStringUni(commandLine);
    try
    {
        int numArgs;
        var argArray = CommandLineToArgvW(commandLineText, out numArgs);
        if (argArray != IntPtr.Zero)
        {
            var pointerArray = new IntPtr[numArgs];
            Marshal.Copy(argArray, pointerArray, 0, numArgs);
            var arguments = pointerArray.Select(x => Marshal.PtrToStringUni(x)).ToArray();

            var configFile = arguments.FirstOrDefault(x => 
                x.EndsWith(".config", StringComparison.OrdinalIgnoreCase));
            var matchedSection = config.FirstOrDefault(x => configFile.ToString()
                .IndexOf(x.Branch, StringComparison.OrdinalIgnoreCase) >= 0);
          
            if (matchedSection != null && configFile != null && 
                configFile.StartsWith("/config:", StringComparison.OrdinalIgnoreCase) && 
                commandLineText.IndexOf("wcfsvchost", StringComparison.OrdinalIgnoreCase) >= 0)
            {
                configFile = configFile.Substring("/config:".Length);
                
                var content = File.ReadAllText(configFile);
                foreach (var replacement in matchedSection.Replacements)
                    content = content.Replace(replacement.Find, replacement.ReplaceWith);

                var tempFile = Path.GetTempFileName();
                MoveFileEx(tempFile, null, 4);
                File.WriteAllText(tempFile, content);

                commandLineText = commandLineText.Replace(configFile, tempFile);
            }
        }
    }
    catch
    {
    }
    Marshal.Copy(commandLineText.ToCharArray(), 0, newCommandLine, commandLineText.Length);
}


> Код к статье (перфекционистов просьба не возмущаться — это минимально рабочий вариант сделанный наспех, без обработки ошибок и со многими допущениями)
Поделиться с друзьями
-->

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


  1. chumakov-ilya
    14.02.2017 10:31
    +2

    А можно просто создать по конфигурации на бранч и включить трансформацию конфигов при каждой сборке проекта (не путать с трансформацией при деплое). Например, так:


    Web.Base.Config + Web.$(Configuration).config = Web.Config


    То, что собранный Web.Config будет попадать в pending changes — не имеет никакого значения, поскольку он все равно перезапишется при следующем билде соответственно выбранной конфигурации. Оригинал хранится в Web.Base.Config и правится редко. На билд-сервере трансформация тоже будет работать.


    1. Einherjar
      14.02.2017 10:42

      Будут пляски с бубном с nuget пакетами к примеру, которые что то пишут в конфиг в момент установки, т.к. писать они будут не в оригинал Web.Base.Config а в генерируемый Web.Config, который перезапишется при первом билде стерев эти изменения. Ну это конечно сравнительно редко необходимо, и действительно можно пренебречь и вручную все скопировать ради простоты решения, особенно если ответственный за установку и обновление пакетов знает что на это следует обращать внимание, но тем не менее.


      1. chumakov-ilya
        14.02.2017 11:54

        Будут. Но на практике, основной проблемой, с которой мы сталкивались, оказалось — приучить коллег проверять, у каждого ли проекта в солюшене выставлена правильная конфигурация в Configuration Manager. И не забывать добавлять наши кастомные конфигурации для новых проектов, что, впрочем, легко диагностировалось по падению сборки на билд-сервере.


      1. mayorovp
        14.02.2017 13:01
        +1

        Наоборот удобно — в web.config не попадает всякий мусор. А нужные вещи можно и перенести вручную...


    1. mayorovp
      14.02.2017 12:57

      Чтобы собранный web.config не попадал в pending changes — его надо добавлять в .gitignore (или анологичный список файлов для других VCS)!


  1. ZerGer
    14.02.2017 11:12
    +1

    Я решал эту задачу используя штатный Web.config Transformation:


    1) Добавляем /Web.config в .gitignore


    2) Создаем конфигурации для требуемых вариантов сборки:


    • /App_Config/Web.config
    • /App_Config/Web.Release.config
    • /App_Config/Web.Stage.config

    3) Добавляем небольшой код в файл проекта:


      <PropertyGroup>
        <SiteConfigDir>$(MSBuildProjectDirectory)\App_Config</SiteConfigDir>
        <SiteRootDir>$(MSBuildProjectDirectory)</SiteRootDir>
      </PropertyGroup>
      <Target Name="ProcessConfigFiles">
        <TransformXml Condition="Exists('$(SiteConfigDir)\Web.$(Configuration).config')" Source="$(SiteConfigDir)\Web.config" Transform="$(SiteConfigDir)\Web.$(Configuration).config" Destination="$(SiteRootDir)\Web.config" StackTrace="true" />
        <Copy Condition="!Exists('$(SiteConfigDir)\Web.$(Configuration).config')" SourceFiles="$(SiteConfigDir)\Web.config" DestinationFolder="$(SiteRootDir)" OverwriteReadOnlyFiles="true" />
      </Target>
      <Target Name="AfterBuild">
        <CallTarget Targets="ProcessConfigFiles" />
      </Target>

    Pros:


    • \Web.config не попадает в pending changes
    • все варианты настроек хранятся в git.

    Cons:


    • нужно не забывать, что правки требуется вносить в /App_Config/Web.config, а не в /Web.config.
    • после изменения конфигурации нужно пересобрать проект


    1. pokryshkin
      14.02.2017 14:59

      Дополню: я еще выношу appSettings во внешний файл (через атрибут file), а версия для продакшена в git вообще не существует (т.к. там хранятся приватные ключи для различных сервисов).


      1. ZerGer
        14.02.2017 15:54

        Ну, в общем случае настройка окружения не ограничивается только appSettings. Как минимум, еще будет system.net\mailSettings, connectionStrings, system.web\customErrors, содержимое system.serviceModel, настройки логов (nlog и/или system.diagnostic). Так что держать все это в одном файле, как мне кажется, проще.


        Offtop

        Если честно, то приведенный выше пример я давно не использую. Сейчас предпочитаю хранить в appSettings один параметр DeployMode, на основе которого производится конфигурация IoC-контейнера. Получается более гибко, плюс строгая типизация не даст что-нибудь забыть.


        1. pokryshkin
          14.02.2017 17:05

          Как правило «секретное» хранится в appSettings и mailsettings, которые и можно вынести во внешние файлы, чтобы случайно или специально не засветить их где-то. Все остальное да, трансформациями и через контроль версий.


          1. mayorovp
            14.02.2017 17:16

            А как же connectionStrings?


            1. pokryshkin
              14.02.2017 17:19

              а что в нем секретного? Если команда маленькая, то все разработчики и так имеют доступ к боевой БД, если большая, то ИМХО они даже имея connectionString с логином и паролем не должны иметь доступ к базе.