diff --git a/.github/workflows/PullRequest.yml b/.github/workflows/PullRequest.yml
index bc2f18fc..7bda43a3 100644
--- a/.github/workflows/PullRequest.yml
+++ b/.github/workflows/PullRequest.yml
@@ -42,6 +42,7 @@ jobs:
shell: pwsh
env:
EVENTLOG_CONTAINER: "1"
+ EVENTLOG_ALLOW_CHANNEL_MUTATION: "1"
run: |
$projects = @(Get-ChildItem tests/Integration -Filter *.csproj -Recurse)
if ($projects.Count -eq 0) { throw 'No integration test projects found under tests/Integration.' }
diff --git a/compose.yml b/compose.yml
index 05882ef7..5be0580c 100644
--- a/compose.yml
+++ b/compose.yml
@@ -13,6 +13,7 @@ x-base: &base
environment:
NUGET_PACKAGES: C:/nuget-packages
EVENTLOG_CONTAINER: "1"
+ EVENTLOG_ALLOW_CHANNEL_MUTATION: "1"
volumes:
- type: bind
source: .
diff --git a/src/EventLogExpert.Eventing/Interop/EvtVariant.cs b/src/EventLogExpert.Eventing/Interop/EvtVariant.cs
index 5402f747..a4d4d9f0 100644
--- a/src/EventLogExpert.Eventing/Interop/EvtVariant.cs
+++ b/src/EventLogExpert.Eventing/Interop/EvtVariant.cs
@@ -50,4 +50,13 @@ internal readonly record struct EvtVariant
[FieldOffset(0)] internal readonly nint XmlValArr;
[FieldOffset(8)] internal readonly uint Count;
[FieldOffset(12)] internal readonly uint Type;
+
+ // Zero the whole 16-byte union (including the padding between BooleanVal and Count) before setting the
+ // Boolean, so no indeterminate bytes reach the native EvtSetChannelConfigProperty call.
+ internal EvtVariant(bool booleanValue)
+ {
+ this = default;
+ BooleanVal = booleanValue ? 1 : 0;
+ Type = (uint)EvtVariantType.Boolean;
+ }
}
diff --git a/src/EventLogExpert.Eventing/Interop/NativeMethods.Evt.cs b/src/EventLogExpert.Eventing/Interop/NativeMethods.Evt.cs
index 3681fddc..71ffc307 100644
--- a/src/EventLogExpert.Eventing/Interop/NativeMethods.Evt.cs
+++ b/src/EventLogExpert.Eventing/Interop/NativeMethods.Evt.cs
@@ -409,6 +409,20 @@ internal static partial bool EvtRender(
out int bufferUsed,
out int propertyCount);
+ /// Persists the channel configuration changes made with EvtSetChannelConfigProperty (requires elevation)
+ [LibraryImport(EventLogApi, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool EvtSaveChannelConfig(EvtHandle channelConfig, int flags);
+
+ /// Sets the specified channel configuration property on the in-memory config handle
+ [LibraryImport(EventLogApi, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool EvtSetChannelConfigProperty(
+ EvtHandle channelConfig,
+ EvtChannelConfigPropertyId propertyId,
+ int flags,
+ in EvtVariant propertyValue);
+
///
/// Creates a subscription that will receive current and future events from a channel or log file that match the
/// specified query criteria
diff --git a/src/EventLogExpert.Eventing/Writers/ChannelConfigWriter.cs b/src/EventLogExpert.Eventing/Writers/ChannelConfigWriter.cs
new file mode 100644
index 00000000..26342b6f
--- /dev/null
+++ b/src/EventLogExpert.Eventing/Writers/ChannelConfigWriter.cs
@@ -0,0 +1,158 @@
+// // Copyright (c) Microsoft Corporation.
+// // Licensed under the MIT License.
+
+using EventLogExpert.Eventing.Interop;
+using EventLogExpert.Eventing.Readers;
+using EventLogExpert.Logging.Abstractions;
+using System.Runtime.InteropServices;
+
+namespace EventLogExpert.Eventing.Writers;
+
+public sealed class ChannelConfigWriter(ITraceLogger? logger = null) : IChannelConfigWriter
+{
+ private readonly ITraceLogger? _logger = logger;
+
+ public ChannelEnableResult EnableChannel(string channelName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(channelName);
+
+ using var channelConfig = NativeMethods.EvtOpenChannelConfig(
+ EventLogSession.GlobalSession.Handle,
+ channelName,
+ 0);
+
+ if (channelConfig.IsInvalid)
+ {
+ return FromNativeError(Marshal.GetLastWin32Error(), channelName, nameof(NativeMethods.EvtOpenChannelConfig));
+ }
+
+ // Read-first, fail-closed: a failed or non-Boolean read must never be treated as "disabled" (that would drive a
+ // machine write on an unreadable state). Only a successful Boolean read that is already true short-circuits.
+ if (!TryReadEnabled(channelConfig, channelName, out bool alreadyEnabled, out ChannelEnableResult readFailure))
+ {
+ return readFailure;
+ }
+
+ if (alreadyEnabled)
+ {
+ return new ChannelEnableResult(ChannelEnableOutcome.AlreadyEnabled, Win32ErrorCodes.ERROR_SUCCESS);
+ }
+
+ var enabledValue = new EvtVariant(true);
+
+ if (!NativeMethods.EvtSetChannelConfigProperty(
+ channelConfig,
+ EvtChannelConfigPropertyId.EvtChannelConfigEnabled,
+ 0,
+ in enabledValue))
+ {
+ return FromNativeError(
+ Marshal.GetLastWin32Error(),
+ channelName,
+ nameof(NativeMethods.EvtSetChannelConfigProperty));
+ }
+
+ return !NativeMethods.EvtSaveChannelConfig(channelConfig, 0) ?
+ FromNativeError(Marshal.GetLastWin32Error(),
+ channelName,
+ nameof(NativeMethods.EvtSaveChannelConfig)) :
+ // EvtSaveChannelConfig returning true is the persistence guarantee, so the change is committed here.
+ new ChannelEnableResult(ChannelEnableOutcome.Enabled, Win32ErrorCodes.ERROR_SUCCESS);
+ }
+
+ private ChannelEnableResult FromNativeError(int win32Error, string channelName, string stage)
+ {
+ _logger?.Debug($"{nameof(EnableChannel)}: {stage} failed for {channelName}. Error: {win32Error}");
+
+ var outcome = win32Error switch
+ {
+ Win32ErrorCodes.ERROR_ACCESS_DENIED => ChannelEnableOutcome.AccessDenied,
+ Win32ErrorCodes.ERROR_EVT_CHANNEL_NOT_FOUND => ChannelEnableOutcome.NotFound,
+ _ => ChannelEnableOutcome.Failed
+ };
+
+ return new ChannelEnableResult(outcome, win32Error);
+ }
+
+ private unsafe bool TryReadEnabled(
+ EvtHandle channelConfig,
+ string channelName,
+ out bool enabled,
+ out ChannelEnableResult failure)
+ {
+ enabled = false;
+ failure = null!;
+ IntPtr buffer = IntPtr.Zero;
+
+ try
+ {
+ bool success = NativeMethods.EvtGetChannelConfigProperty(
+ channelConfig,
+ EvtChannelConfigPropertyId.EvtChannelConfigEnabled,
+ 0,
+ 0,
+ IntPtr.Zero,
+ out int bufferSize);
+
+ int sizeError = Marshal.GetLastWin32Error();
+
+ if (!success && sizeError != Win32ErrorCodes.ERROR_INSUFFICIENT_BUFFER)
+ {
+ failure = FromNativeError(sizeError, channelName, nameof(NativeMethods.EvtGetChannelConfigProperty));
+
+ return false;
+ }
+
+ buffer = Marshal.AllocHGlobal(bufferSize);
+
+ if (!NativeMethods.EvtGetChannelConfigProperty(
+ channelConfig,
+ EvtChannelConfigPropertyId.EvtChannelConfigEnabled,
+ 0,
+ bufferSize,
+ buffer,
+ out _))
+ {
+ failure = FromNativeError(
+ Marshal.GetLastWin32Error(),
+ channelName,
+ nameof(NativeMethods.EvtGetChannelConfigProperty));
+
+ return false;
+ }
+
+ var variant = *(EvtVariant*)buffer;
+
+ if (variant.Type != (uint)EvtVariantType.Boolean)
+ {
+ // The read succeeded but the value is not Boolean, so the last Win32 error is not meaningful; surface a
+ // non-zero sentinel (ERROR_INVALID_DATA) rather than 0 so downstream diagnostics stay actionable.
+ _logger?.Debug(
+ $"{nameof(EnableChannel)}: {channelName} enabled property was not Boolean (type {variant.Type}).");
+
+ failure = new ChannelEnableResult(ChannelEnableOutcome.Failed, Win32ErrorCodes.ERROR_INVALID_DATA);
+
+ return false;
+ }
+
+ enabled = variant.BooleanVal != 0;
+ return true;
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException
+ and not StackOverflowException
+ and not AccessViolationException)
+ {
+ _logger?.Debug($"{nameof(EnableChannel)}: unexpected failure reading enabled state for {channelName}.\n{ex}");
+ failure = new ChannelEnableResult(ChannelEnableOutcome.Failed, Win32ErrorCodes.ERROR_INVALID_DATA);
+
+ return false;
+ }
+ finally
+ {
+ if (buffer != IntPtr.Zero)
+ {
+ Marshal.FreeHGlobal(buffer);
+ }
+ }
+ }
+}
diff --git a/src/EventLogExpert.Eventing/Writers/ChannelEnableOutcome.cs b/src/EventLogExpert.Eventing/Writers/ChannelEnableOutcome.cs
new file mode 100644
index 00000000..17fad918
--- /dev/null
+++ b/src/EventLogExpert.Eventing/Writers/ChannelEnableOutcome.cs
@@ -0,0 +1,25 @@
+// // Copyright (c) Microsoft Corporation.
+// // Licensed under the MIT License.
+
+namespace EventLogExpert.Eventing.Writers;
+
+public enum ChannelEnableOutcome
+{
+ /// The channel was disabled and has now been enabled (the save committed).
+ Enabled,
+
+ /// The channel was already enabled, so no write was performed.
+ AlreadyEnabled,
+
+ /// The caller is not elevated; the enable was not attempted (set by the runtime service).
+ NotElevated,
+
+ /// The channel configuration could not be opened or saved because access was denied.
+ AccessDenied,
+
+ /// No channel with the requested name is registered on the computer.
+ NotFound,
+
+ /// The enable failed for any other reason; see the Win32 error code.
+ Failed
+}
diff --git a/src/EventLogExpert.Eventing/Writers/ChannelEnableResult.cs b/src/EventLogExpert.Eventing/Writers/ChannelEnableResult.cs
new file mode 100644
index 00000000..e11dba9f
--- /dev/null
+++ b/src/EventLogExpert.Eventing/Writers/ChannelEnableResult.cs
@@ -0,0 +1,6 @@
+// // Copyright (c) Microsoft Corporation.
+// // Licensed under the MIT License.
+
+namespace EventLogExpert.Eventing.Writers;
+
+public sealed record ChannelEnableResult(ChannelEnableOutcome Outcome, int Win32Error);
diff --git a/src/EventLogExpert.Eventing/Writers/IChannelConfigWriter.cs b/src/EventLogExpert.Eventing/Writers/IChannelConfigWriter.cs
new file mode 100644
index 00000000..52210853
--- /dev/null
+++ b/src/EventLogExpert.Eventing/Writers/IChannelConfigWriter.cs
@@ -0,0 +1,13 @@
+// // Copyright (c) Microsoft Corporation.
+// // Licensed under the MIT License.
+
+namespace EventLogExpert.Eventing.Writers;
+
+public interface IChannelConfigWriter
+{
+ ///
+ /// Enables a disabled event log channel by persisting EvtChannelConfigEnabled = true. Requires an elevated
+ /// process; without elevation a native call fails and the result is .
+ ///
+ ChannelEnableResult EnableChannel(string channelName);
+}
diff --git a/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs b/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs
index f56aae53..c9cc52ff 100644
--- a/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs
+++ b/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs
@@ -3,6 +3,7 @@
using EventLogExpert.DatabaseTools.DependencyInjection;
using EventLogExpert.Eventing.Readers;
+using EventLogExpert.Eventing.Writers;
using EventLogExpert.Logging.Abstractions;
using EventLogExpert.Logging.Configuration;
using EventLogExpert.Logging.Routing;
@@ -276,9 +277,12 @@ public IServiceCollection AddEventLogRuntime()
services.AddSingleton();
services.AddSingleton(static sp =>
new EventLogChannelConfigReader(CategoryLogger(sp, LogCategories.EventLog)));
+ services.AddSingleton(static sp =>
+ new ChannelConfigWriter(CategoryLogger(sp, LogCategories.EventLog)));
services.AddSingleton();
services.AddSingleton(static sp => sp.GetRequiredService());
services.AddSingleton(static sp => sp.GetRequiredService());
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/src/EventLogExpert.Runtime/Scenarios/ChannelEnableService.cs b/src/EventLogExpert.Runtime/Scenarios/ChannelEnableService.cs
new file mode 100644
index 00000000..3e439293
--- /dev/null
+++ b/src/EventLogExpert.Runtime/Scenarios/ChannelEnableService.cs
@@ -0,0 +1,44 @@
+// // Copyright (c) Microsoft Corporation.
+// // Licensed under the MIT License.
+
+using EventLogExpert.Eventing.Writers;
+using EventLogExpert.Runtime.Common.Identity;
+
+namespace EventLogExpert.Runtime.Scenarios;
+
+internal sealed class ChannelEnableService(
+ IChannelConfigWriter channelConfigWriter,
+ IChannelReadinessService readinessService,
+ IWindowsIdentityProvider identityProvider) : IChannelEnableService
+{
+ private readonly Lazy _canEnable = new(identityProvider.IsUserInAdministratorRole);
+
+ public bool CanEnable => _canEnable.Value;
+
+ public async Task EnableAsync(string channel, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(channel);
+
+ // Do not attempt a write we know will be denied; a non-elevated caller never reaches the native layer.
+ if (!CanEnable)
+ {
+ return new ChannelEnableResult(ChannelEnableOutcome.NotElevated, 0);
+ }
+
+ // Cancellation is honored only up to the moment the native write begins: the token guards both the
+ // synchronous entry and the Task.Run scheduling boundary, so a cancel that arrives before the writer runs
+ // prevents the write. Once EnableChannel starts it runs to completion (Task.Run stops consulting the token
+ // once the delegate is executing), so a committed machine change is never reported as cancelled.
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var result = await Task.Run(() => channelConfigWriter.EnableChannel(channel), cancellationToken)
+ .ConfigureAwait(false);
+
+ if (result.Outcome is ChannelEnableOutcome.Enabled or ChannelEnableOutcome.AlreadyEnabled)
+ {
+ readinessService.Invalidate();
+ }
+
+ return result;
+ }
+}
diff --git a/src/EventLogExpert.Runtime/Scenarios/IChannelEnableService.cs b/src/EventLogExpert.Runtime/Scenarios/IChannelEnableService.cs
new file mode 100644
index 00000000..4f3ed28b
--- /dev/null
+++ b/src/EventLogExpert.Runtime/Scenarios/IChannelEnableService.cs
@@ -0,0 +1,17 @@
+// // Copyright (c) Microsoft Corporation.
+// // Licensed under the MIT License.
+
+using EventLogExpert.Eventing.Writers;
+
+namespace EventLogExpert.Runtime.Scenarios;
+
+public interface IChannelEnableService
+{
+ ///
+ /// Whether an enable can be ATTEMPTED (the process is elevated). A true value is not a guarantee that the write
+ /// will succeed - a channel-specific ACL can still deny an elevated caller.
+ ///
+ bool CanEnable { get; }
+
+ Task EnableAsync(string channel, CancellationToken cancellationToken = default);
+}
diff --git a/src/EventLogExpert.UI/Common/EnableChannelConfirmation.cs b/src/EventLogExpert.UI/Common/EnableChannelConfirmation.cs
new file mode 100644
index 00000000..05d0c390
--- /dev/null
+++ b/src/EventLogExpert.UI/Common/EnableChannelConfirmation.cs
@@ -0,0 +1,26 @@
+// // Copyright (c) Microsoft Corporation.
+// // Licensed under the MIT License.
+
+using EventLogExpert.Runtime.Alerts;
+
+namespace EventLogExpert.UI.Common;
+
+internal static class EnableChannelConfirmation
+{
+ public static Task ConfirmAsync(IAlertDialogService dialog, string channelName, bool isAnalyticOrDebug)
+ {
+ var message =
+ $"Enable the \"{channelName}\" log?\n\n" +
+ "This changes Windows event logging for the whole computer and stays in effect until it is disabled again. " +
+ "Only events generated after enabling are collected - events that occurred while the log was disabled cannot be recovered.";
+
+ if (isAnalyticOrDebug)
+ {
+ message +=
+ "\n\nThis is an analytic or debug log: enabling it can clear the records it already holds, and Windows may " +
+ "keep collecting to it while refusing to open it for live viewing until it is disabled again.";
+ }
+
+ return dialog.ShowAlert("Enable log", message, "Enable", "Cancel");
+ }
+}
diff --git a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor
index b06597a3..85a9f6b4 100644
--- a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor
+++ b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor
@@ -27,52 +27,52 @@