Изменение содержимого Web.config в runtime при отладке в Visual Studio и IISExpress

    Технологически в этой статье ничего нового, просто еще одно полезное применение 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
    • +2

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


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


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

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

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

          • +1

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

          • 0

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

          • +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.
            • после изменения конфигурации нужно пересобрать проект
            • 0
              Дополню: я еще выношу appSettings во внешний файл (через атрибут file), а версия для продакшена в git вообще не существует (т.к. там хранятся приватные ключи для различных сервисов).
              • 0

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


                Offtop

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

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

                    А как же connectionStrings?

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

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.