Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
74e3641
Fix UseSystemd() silently failing with ProtectProc=invisible
CybCorv Mar 13, 2026
db71c71
Remove unused variable in Test
CybCorv Mar 13, 2026
87a6fd5
Merge branch 'main' into fix/systemd-protect-proc-invisible
CybCorv Mar 16, 2026
83c25dd
Merge branch 'dotnet:main' into fix/systemd-protect-proc-invisible
CybCorv Mar 17, 2026
8d3278f
Add SYSTEMD_EXEC_PID constant and refactor comments in SystemdHelpers
CybCorv Mar 17, 2026
9e3f6c7
Fall through to legacy detection on SYSTEMD_EXEC_PID mismatch
CybCorv Mar 24, 2026
a0c7769
Decouple sd_notify and systemd log formatter detection
CybCorv Mar 25, 2026
452bdc3
Address Copilot review feedback
CybCorv Mar 25, 2026
e0dc29b
Merge branch 'main' into fix/systemd-protect-proc-invisible
CybCorv Apr 2, 2026
d6d7269
Refactor tests: consolidate assertions and align with new detection l…
CybCorv Apr 2, 2026
381df75
Address tmds review feedback
CybCorv Apr 2, 2026
09da18f
Address Copilot review feedback
CybCorv Apr 2, 2026
7ab282e
Update src/libraries/Microsoft.Extensions.Hosting.Systemd/src/Systemd…
CybCorv Apr 2, 2026
b9581d4
Update src/libraries/Microsoft.Extensions.Hosting.Systemd/tests/UseSy…
CybCorv Apr 2, 2026
ee82b81
Address Copilot review feedbacks
CybCorv Apr 2, 2026
5441de8
Update src/libraries/Microsoft.Extensions.Hosting.Systemd/src/Systemd…
cincuranet Apr 21, 2026
89fef01
Merge branch 'main' into fix/systemd-protect-proc-invisible
cincuranet Apr 21, 2026
56b6551
Update src/libraries/Microsoft.Extensions.Hosting.Systemd/src/Systemd…
cincuranet Apr 21, 2026
801e28c
Update src/libraries/Microsoft.Extensions.Hosting.Systemd/src/Systemd…
cincuranet Apr 21, 2026
27374ef
Merge branch 'main' into fix/systemd-protect-proc-invisible
cincuranet Apr 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

Comment thread
CybCorv marked this conversation as resolved.
namespace Microsoft.Extensions.Hosting.Systemd
{
internal static class SystemdConstants
{
/// <summary>
/// Environment variable set by systemd (v229+) to the path of the notify socket for the service. If this variable is set, the service should send status notifications to systemd using this socket.
/// </summary>
/// <see href="https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#%24NOTIFY_SOCKET" />
internal const string NotifySocket = "NOTIFY_SOCKET";

/// <summary>
/// Environment variable set by systemd (v248+) to the PID of the main service process.
/// This is the most reliable way to detect if we're running as a systemd service, as it doesn't
/// require reading /proc and works even when ProtectProc=invisible is set.
/// </summary>
/// <see href="https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#%24SYSTEMD_EXEC_PID" />
internal const string SystemdExecPid = "SYSTEMD_EXEC_PID";

/// <summary>
/// Environment variable set by systemd for socket activation, indicating the PID
/// that should receive the listen file descriptors.
/// </summary>
/// <see href="https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#%24LISTEN_FDS" />
internal const string ListenPid = "LISTEN_PID";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,33 @@ private static bool GetIsSystemdService()
return false;
}

// To support containerized systemd services, check if we're the main process (PID 1)
// and if there are systemd environment variables defined for notifying the service
// manager, or passing listen handles.
int processId = Environment.ProcessId;

// Preferred detection method: compare SYSTEMD_EXEC_PID to the current PID.
// Works even when ProtectProc=invisible hides /proc entries.
string? systemdExecPid = Environment.GetEnvironmentVariable(SystemdConstants.SystemdExecPid);
if (!string.IsNullOrEmpty(systemdExecPid))
{
if (int.TryParse(systemdExecPid, NumberStyles.None, CultureInfo.InvariantCulture, out int execPid))
{
if (execPid == processId)
{
return true;
}
// Mismatch: fall through to PID 1 / legacy checks
}
// Malformed value: don't trust it, fall through to legacy detection.
}

// To support containerized systemd services (e.g. Podman with --sdnotify=container)
if (processId == 1)
{
return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("NOTIFY_SOCKET")) ||
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("LISTEN_PID"));
return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(SystemdConstants.NotifySocket)) ||
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(SystemdConstants.ListenPid));
}

// Legacy detection for systemd < 248 (e.g. Ubuntu 20.04, Debian 11).
// Note: silently returns false if /proc cannot be read, such as when ProtectProc=invisible is set.
try
{
// Check whether our direct parent is 'systemd'.
Expand All @@ -56,5 +72,36 @@ private static bool GetIsSystemdService()

return false;
}

private static readonly bool _isSystemdNotify = GetIsSystemdNotify();

/// <summary>
/// Checks if the current process has systemd notify enabled.
/// </summary>
private static bool IsSystemdNotify() => _isSystemdNotify;

private static bool GetIsSystemdNotify()
{
// No point in testing anything unless it's Unix
if (Environment.OSVersion.Platform != PlatformID.Unix)
{
return false;
}
// Checks whether NOTIFY_SOCKET is set, indicating the service manager expects sd_notify notifications.
string? socketPath = Environment.GetEnvironmentVariable(SystemdConstants.NotifySocket);
return !string.IsNullOrEmpty(socketPath);
}

/// <summary>
/// Checks if the systemd journal log formatter should be enabled.
/// </summary>
// TODO: #127218
internal static bool IsSystemdLogger() => IsSystemdService();

/// <summary>
/// Checks if <see cref="SystemdLifetime"/> and <see cref="SystemdNotifier"/> should be registered.
/// </summary>
/// <remarks><see cref="SystemdNotifier"/> is a noop when <c>NOTIFY_SOCKET</c> is absent.</remarks>
internal static bool IsSystemdLifetime() => IsSystemdService() || IsSystemdNotify();
Comment thread
CybCorv marked this conversation as resolved.
Comment thread
CybCorv marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,21 @@ public static class SystemdHostBuilderExtensions
/// <summary>
/// Configures the <see cref="IHost"/> lifetime to <see cref="SystemdLifetime"/>,
/// provides notification messages for application started and stopping,
/// and configures console logging to the systemd format.
/// and configures console logging to the systemd format when running as a systemd service.
/// </summary>
/// <remarks>
/// <para>
/// This is context aware and will only activate if it detects the process is running
/// as a systemd Service.
/// as a systemd Service or if <c>NOTIFY_SOCKET</c> is set.
/// </para>
/// <para>
/// The console log formatter is enabled when the process is detected as a systemd service.
/// The <see cref="SystemdLifetime"/> and <see cref="SystemdNotifier"/> are registered when
/// <c>NOTIFY_SOCKET</c> is set or the process is detected as a systemd service.
/// </para>
/// <para>
/// The systemd service file must be configured with <c>Type=notify</c> to enable
/// notifications. See https://www.freedesktop.org/software/systemd/man/systemd.service.html.
/// notifications. See <see href="https://www.freedesktop.org/software/systemd/man/systemd.service.html"/>.
/// </para>
/// </remarks>
/// <param name="hostBuilder">The <see cref="IHostBuilder"/> to configure.</param>
Expand All @@ -34,7 +39,15 @@ public static IHostBuilder UseSystemd(this IHostBuilder hostBuilder)
{
ArgumentNullException.ThrowIfNull(hostBuilder);

if (SystemdHelpers.IsSystemdService())
if (SystemdHelpers.IsSystemdLogger())
{
hostBuilder.ConfigureServices((hostContext, services) =>
{
AddSystemdLogger(services);
});
}

if (SystemdHelpers.IsSystemdLifetime())
{
hostBuilder.ConfigureServices((hostContext, services) =>
{
Expand All @@ -47,12 +60,17 @@ public static IHostBuilder UseSystemd(this IHostBuilder hostBuilder)
/// <summary>
/// Configures the lifetime of the <see cref="IHost"/> built from <paramref name="services"/> to
/// <see cref="SystemdLifetime"/>, provides notification messages for application started
/// and stopping, and configures console logging to the systemd format.
/// and stopping, and configures console logging to the systemd format when running as a systemd service.
/// </summary>
/// <remarks>
/// <para>
/// This is context aware and will only activate if it detects the process is running
/// as a systemd Service.
/// as a systemd Service or if <c>NOTIFY_SOCKET</c> is set.
/// </para>
/// <para>
/// The console log formatter is enabled when the process is detected as a systemd service.
/// The <see cref="SystemdLifetime"/> and <see cref="SystemdNotifier"/> are registered when
/// <c>NOTIFY_SOCKET</c> is set or the process is detected as a systemd service.
/// </para>
/// <para>
/// The systemd service file must be configured with <c>Type=notify</c> to enable
Expand All @@ -69,21 +87,33 @@ public static IServiceCollection AddSystemd(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);

if (SystemdHelpers.IsSystemdService())
if (SystemdHelpers.IsSystemdLogger())
{
AddSystemdLogger(services);
}

if (SystemdHelpers.IsSystemdLifetime())
{
AddSystemdLifetime(services);
}

return services;
}

private static void AddSystemdLifetime(IServiceCollection services)
private static void AddSystemdLogger(IServiceCollection services)
{
services.Configure<ConsoleLoggerOptions>(options =>
{
options.FormatterName = ConsoleFormatterNames.Systemd;
});
}

// IsSystemdService() will never return true for android/browser/iOS/tvOS
private static void AddSystemdLifetime(IServiceCollection services)
Comment thread
CybCorv marked this conversation as resolved.
{
// SystemdNotifier and SystemdLifetime are Unix-only; IsSystemdLifetime() ensures
// we only reach this code when running on Unix and when the environment indicates
// systemd-style integration (for example, when NOTIFY_SOCKET is set or the process
// is detected as a systemd service).
#pragma warning disable CA1416 // Validate platform compatibility
services.AddSingleton<ISystemdNotifier, SystemdNotifier>();
services.AddSingleton<IHostLifetime, SystemdLifetime>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ namespace Microsoft.Extensions.Hosting.Systemd
[UnsupportedOSPlatform("browser")]
public class SystemdNotifier : ISystemdNotifier
{
private const string NOTIFY_SOCKET = "NOTIFY_SOCKET";

private readonly string? _socketPath;

/// <summary>
Expand Down Expand Up @@ -60,13 +58,18 @@ public void Notify(ServiceState state)

private static string? GetNotifySocketPath()
{
string? socketPath = Environment.GetEnvironmentVariable(NOTIFY_SOCKET);
string? socketPath = Environment.GetEnvironmentVariable(SystemdConstants.NotifySocket);

if (string.IsNullOrEmpty(socketPath))
{
return null;
}

// Because this method is called on Notifier construction, the envvar is cleared when the Host is built
// (IHostLifetime depends on ISystemdNotifier). This prevents child processes from inheriting the socket
// and interfering with service manager notifications.
Environment.SetEnvironmentVariable(SystemdConstants.NotifySocket, null);
Comment thread
cincuranet marked this conversation as resolved.

Comment thread
cincuranet marked this conversation as resolved.
// Support abstract socket paths.
if (socketPath[0] == '@')
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
<PropertyGroup>
<TargetFramework>$(NetCoreAppCurrent)</TargetFramework>
<EnableDefaultItems>true</EnableDefaultItems>
<IncludeRemoteExecutor>true</IncludeRemoteExecutor>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\src\Microsoft.Extensions.Hosting.Systemd.csproj "/>
<ProjectReference Include="..\src\Microsoft.Extensions.Hosting.Systemd.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Globalization;
using Microsoft.DotNet.RemoteExecutor;
using Microsoft.Extensions.Hosting.Systemd;
using Xunit;

namespace Microsoft.Extensions.Hosting
Comment thread
CybCorv marked this conversation as resolved.
{
public class SystemdHelpersTests
{
public static bool IsRemoteExecutorSupportedOnLinux => PlatformDetection.IsLinux && RemoteExecutor.IsSupported;

[ConditionalFact(typeof(SystemdHelpersTests), nameof(IsRemoteExecutorSupportedOnLinux))]
public void IsSystemdServiceReturnsTrueWhenSystemdExecPidMatchesCurrentProcessId()
{
using var _ = RemoteExecutor.Invoke(static () =>
{
string processId = Environment.ProcessId.ToString(CultureInfo.InvariantCulture);
Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", processId);

Assert.True(SystemdHelpers.IsSystemdService());
});
}

[ConditionalFact(typeof(SystemdHelpersTests), nameof(IsRemoteExecutorSupportedOnLinux))]
public void IsSystemdServiceReturnsFalseWhenSystemdExecPidDoesNotMatchOutsideSystemdSession()
{
using var _ = RemoteExecutor.Invoke(static () =>
{
// When SYSTEMD_EXEC_PID is set but doesn't match the current PID, the code
// falls through to legacy detection. Outside a real systemd session
// (not PID 1, parent is not named "systemd"), the legacy path returns false.
// Note: the fall-through itself cannot be directly observed in a unit test
// without being PID 1 or having a parent named "systemd".
int nonMatchingPid = int.MaxValue; // No real process will ever have this PID.
Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", nonMatchingPid.ToString(CultureInfo.InvariantCulture));
Environment.SetEnvironmentVariable("NOTIFY_SOCKET", null);
Environment.SetEnvironmentVariable("LISTEN_PID", null);

Assert.False(SystemdHelpers.IsSystemdService());
});
}

[ConditionalFact(typeof(SystemdHelpersTests), nameof(IsRemoteExecutorSupportedOnLinux))]
public void IsSystemdServiceReturnsFalseWhenSystemdExecPidIsAbsent()
{
using var _ = RemoteExecutor.Invoke(static () =>
{
// When SYSTEMD_EXEC_PID is absent the code skips the v248+ path entirely
// and falls through to the legacy detection. Outside a real systemd session
// (not PID 1, parent is not systemd), the result must be false.
Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", null);
Environment.SetEnvironmentVariable("NOTIFY_SOCKET", null);
Environment.SetEnvironmentVariable("LISTEN_PID", null);

Assert.False(SystemdHelpers.IsSystemdService());
});
}

[ConditionalFact(typeof(SystemdHelpersTests), nameof(IsRemoteExecutorSupportedOnLinux))]
public void IsSystemdServiceReturnsFalseWhenSystemdExecPidIsMalformed()
{
using var _ = RemoteExecutor.Invoke(static () =>
{
// A malformed SYSTEMD_EXEC_PID must not be trusted: the logic falls through
// to the legacy /proc-based detection, which won't match outside a real systemd session.
Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", "not-a-pid");
Environment.SetEnvironmentVariable("NOTIFY_SOCKET", null);
Environment.SetEnvironmentVariable("LISTEN_PID", null);

Assert.False(SystemdHelpers.IsSystemdService());
});
}

[ConditionalFact(typeof(SystemdHelpersTests), nameof(IsRemoteExecutorSupportedOnLinux))]
public void IsSystemdServiceCachesFirstEvaluation()
{
using var _ = RemoteExecutor.Invoke(static () =>
{
string processId = Environment.ProcessId.ToString(CultureInfo.InvariantCulture);
int nonMatchingPid = int.MaxValue; // No real process will ever have this PID.

Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", processId);
bool firstEvaluation = SystemdHelpers.IsSystemdService();

Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", nonMatchingPid.ToString(CultureInfo.InvariantCulture));
bool secondEvaluation = SystemdHelpers.IsSystemdService();

Assert.True(firstEvaluation);
Assert.True(secondEvaluation);
});
}

[ConditionalFact(typeof(SystemdHelpersTests), nameof(IsRemoteExecutorSupportedOnLinux))]
public void IsSystemdServiceCachesFirstNegativeEvaluation()
{
using var _ = RemoteExecutor.Invoke(static () =>
{
Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", null);
Environment.SetEnvironmentVariable("NOTIFY_SOCKET", null);
Environment.SetEnvironmentVariable("LISTEN_PID", null);

var firstEvaluation = SystemdHelpers.IsSystemdService();

string processId = Environment.ProcessId.ToString(CultureInfo.InvariantCulture);
Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", processId);

var secondEvaluation = SystemdHelpers.IsSystemdService();
Assert.False(firstEvaluation);
Assert.False(secondEvaluation);
});
}

[ConditionalFact(typeof(SystemdHelpersTests), nameof(IsRemoteExecutorSupportedOnLinux))]
public void IsSystemdServiceReturnsFalseWhenSystemdExecPidDoesNotMatchAndNotifySocketIsSet()
{
// Child process scenario: SYSTEMD_EXEC_PID is set but doesn't match the current PID,
// while NOTIFY_SOCKET is inherited from the parent service.
// The mismatch falls through to legacy detection (not PID 1, parent not named "systemd"),
// which returns false. NOTIFY_SOCKET is only considered in the PID 1 container path.
using var _ = RemoteExecutor.Invoke(static () =>
{
int nonMatchingPid = int.MaxValue;
Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", nonMatchingPid.ToString(CultureInfo.InvariantCulture));
Environment.SetEnvironmentVariable("NOTIFY_SOCKET", "/run/systemd/notify");
Environment.SetEnvironmentVariable("LISTEN_PID", null);

Assert.False(SystemdHelpers.IsSystemdService());
});
}

[ConditionalFact(typeof(SystemdHelpersTests), nameof(IsRemoteExecutorSupportedOnLinux))]
public void IsSystemdServiceReturnsFalseWhenNotifySocketIsEmpty()
{
using var _ = RemoteExecutor.Invoke(static () =>
{
Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", null);
Environment.SetEnvironmentVariable("NOTIFY_SOCKET", "");
Environment.SetEnvironmentVariable("LISTEN_PID", null);

Assert.False(SystemdHelpers.IsSystemdService());
});
}
}
}
Loading
Loading