loading...

More Windows Service in C#

jeikabu profile image jeikabu Originally published at rendered-obsolete.github.io on ・1 min read

Previously I discussed a Windows service we call “layer0”.

Our application has the additional wrinkle that this service needs to interact with the user and their desktop. Interactive Services provides guidance how to accomplish this. Basically, spawn a desktop application as the user and use IPC to communicate between the two. We refer to this portion of our client as “layer1”.

Session Events

In order for layer0 service to spawn layer1 process as the desktop user, we need to track user login/logout activity. We’re going to modify the class derived from ServiceBase.

First, enable ServiceBase.CanHandleSessionChangeEvent:

public Layer0Service()
{
    CanHandleSessionChangeEvent = true;
}

Then override OnSessionChange:

protected override void OnSessionChange(SessionChangeDescription changeDescription)
{
    Log("OnSessionChange: " + changeDescription.Reason);
    base.OnSessionChange(changeDescription);
    switch (changeDescription.Reason)
    {
        case SessionChangeReason.SessionLogon:
        case SessionChangeReason.SessionUnlock: // Switch between logged in users.
            DoLogon();
            break;
        case SessionChangeReason.SessionLogoff:
        //case SessionChangeReason.SessionLock:
            DoLogoff();
            break;
    }
}

The Logon and Logoff events are self-explanatory. Unlock (and the corresponding Lock) less so. Unlock events occur when fast user switching is used, for example.

Start Process as Desktop User

Code how layer0 service creates a process as the current desktop user:

#if DEBUG
// Show console window
const Pinvoke.CreationFlags Layer1CreationFlags = Pinvoke.CreationFlags.CreateNewConsole;
#else
const Pinvoke.CreationFlags Layer1CreationFlags = Pinvoke.CreationFlags.CreateNoWindow;
#endif

Pinvoke.PROCESS_INFORMATION? serviceStartLayer1AsUser(string exePath, string command)
{
    IntPtr server = IntPtr.Zero;
    IntPtr ppSessionInfo = IntPtr.Zero;
    IntPtr userToken = IntPtr.Zero; // WARNING: the user token is supposed to be a secret, don't print it anywhere
    try
    {
        // Query all sessions on local machine
        server = Pinvoke.WTSOpenServer("localhost");
        Int32 count = 0;
        Int32 retval = Pinvoke.WTSEnumerateSessions(server, ref ppSessionInfo, ref count);
        if (retval == 0)
        {
            throw new Win32Exception(Marshal.GetLastWin32Error(), "WTSEnumerateSessions");
        }

        // Find session for the logged in user and get their token
        Int32 dataSize = Marshal.SizeOf(typeof(Pinvoke.WTS_SESSION_INFO));
        Int64 current = (Int64)ppSessionInfo;
        for (int i = 0; i < count; i++)
        {
            var si = (Pinvoke.WTS_SESSION_INFO)Marshal.PtrToStructure((IntPtr)current, typeof(Pinvoke.WTS_SESSION_INFO));
            current += dataSize;

            if (si.State != Pinvoke.WTS_CONNECTSTATE_CLASS.WTSActive && si.State != Pinvoke.WTS_CONNECTSTATE_CLASS.WTSConnected)
                continue;

            var sessionId = (uint)si.SessionID;
            // WARNING: the user token is supposed to be a secret, don't print it anywhere
            if (OS.Pinvoke.WTSQueryUserToken(sessionId, out userToken))
            {
                Log(LogLevel.Info, "WTSQueryUserToken succeeded for session: " + sessionId);
                break;
            }
        }

        if (userToken == IntPtr.Zero)
        {
            Log(LogLevel.Error, "Unable to obtain user token, unable to start layer1");
            return null;
        }

        // Launch layer1 as the logged-in user
        var nullSecurityAttributes = new Pinvoke.SECURITY_ATTRIBUTES { lpSecurityDescriptor = IntPtr.Zero };
        Pinvoke.STARTUPINFO startupInfo = Pinvoke.StartupInfoAlloc();
        Pinvoke.PROCESS_INFORMATION processInfo;
        // WARNING: the user token is supposed to be a secret, don't print it anywhere
        Pinvoke.CreateProcessAsUser(userToken, exePath, command,
            ref nullSecurityAttributes, ref nullSecurityAttributes,
            true, Layer1CreationFlags, IntPtr.Zero, null, ref startupInfo, out processInfo);

        return processInfo;
    }
    finally
    {
        if (server != IntPtr.Zero)
            Pinvoke.WTSCloseServer(server);
        if (ppSessionInfo != IntPtr.Zero)
            Pinvoke.WTSFreeMemory(ppSessionInfo);
        if (userToken != IntPtr.Zero)
            Pinvoke.CloseHandle(userToken);
    }
}

Highlights:

  1. Use WTSEnumerateSessions to iterate over all sessions looking for the active desktop session.
  2. WTSQueryUserToken to obtain primary access token for that session’s user.
  3. CreateProcessAsUser to launch a process (i.e. layer1) as that user.

Now both the layer0 service and layer1 process are running.

Comments:

Console Executable

Quick aside.

I mentioned we want our developers to be able to start layer0 as a console application. When layer0 is a desktop application instead of a service, starting layer1 is more straightforward:

Pinvoke.PROCESS_INFORMATION? startLayer1(string exePath, string commandline)
{
    var nullSecurityAttributes = new Pinvoke.SECURITY_ATTRIBUTES { lpSecurityDescriptor = IntPtr.Zero };
    Pinvoke.STARTUPINFO startupInfo = Pinvoke.StartupInfoAlloc();
    Pinvoke.PROCESS_INFORMATION procInfo;
    Pinvoke.CreateProcess(exePath, commandline,
        ref nullSecurityAttributes, ref nullSecurityAttributes,
        true, Layer1CreationFlags, IntPtr.Zero, null,
        ref startupInfo, out procInfo);
    return procInfo;
}

The canonical solution to start another exe is System.Diagnostics.Process. However, by using CreateProcess (which also returns PROCESS_INFORMATION), it means managing layer1 will be the same whether starting it from a service or console application.

Process Wrangling

Once the layer1 process is running we’ve got a lot of behaviour and handling that is specific to our application. The most interesting bit is the monitoring that layer0 does of layer1:

var objState = (ObjectState)Pinvoke.WaitForSingleObject(layer1ProcInfo.hProcess, 0);
if (user_logout)
{
    // There's no longer a user
    if (objState == Pinvoke.ObjectState.WaitObject0)
    {
        // Already not running. Wait for a user to login.
    }
    else if (objState == Pinvoke.ObjectState.WaitTimeout)
    {
        // Running. Tell it to stop.
    }
}
else if (user_login_or_fast_user_switching)
{
    // There's a user (may or may not have been one before)
    if(objState == Pinvoke.ObjectState.WaitObject0)
    {
        // Not running, so start it.
    }
    else if(objState == Pinvoke.ObjectState.WaitTimeout)
    {
        // Running as different user. Stop it, then restart it as new user
    }
}
else
{
    // Nothing special has happened. This is the state we're normally in.
    if (objState == Pinvoke.ObjectState.WaitObject0)
    {
        // Process not running. Check the exit code to see if it was intentional.
        if (Pinvoke.GetExitCodeProcess(procInfo.hProcess, out uint lpExitCode) && lpExitCode == (uint)L1ExitCode.UserLoggingOut)
        {
            // Process exited but it was told to because user is logging out. We're probably going to receive a "user logout" event.
        }
        else
        {
            // Process stopped/crashed. Restart it.
        }
    }
    else if (objState == Pinvoke.ObjectState.WaitTimeout)
    {
        // Still running. Everything ok- do nothing.
    }
}

Behaviour we wanted:

  • Wait for user login to start layer1
  • If layer1 crashes restart it
  • If switch users, need to stop layer1 and restart it as new user
  • During user logout stop layer1

Here we use WaitForSingleObject() to monitor the layer1 process. The first argument is the process handle, the second argument is 0 so the function returns immediately with the current state of the process:

  • WaitTimeout means it’s running
  • WaitObject0 means it’s not

GetExitCodeProcess() is a fairly recent addition. Looking at the log output we noticed that after the user initiates logout, layer1 process exits and then layer0 tries unsuccessfully to restart it; it either fails to start or immediately exits. Layer0 keeps trying to restart layer1 until OnSessionChange(SessionLogoff) is called (a few seconds after layer1 first exited). We use the exit code to inform layer0 that the process intended to stop and shouldn’t be restarted. Part of this will be covered when I discuss details of layer1.

Detours

Interactive Services mentions the alternative of passing SERVICE_INTERACTIVE_PROCESS to CreateService(). We didn’t pursue this approach for two reasons:

  1. NoInteractiveServices registry key is set by default starting with Windows 8; SERVICE_INTERACTIVE_PROCESS is on its way to being deprecated.
  2. Our application needs to launch 3rd-party applications, and processes launched from a service have an unusual execution environment. Compatibility issues are a concern.

Owing to our use of Apache Thrift we have a complete messaging solution for IPC between layer1 and layer0 (the service). Layer0 begins listening before starting layer1, and layer1 connects as soon as it starts.

Posted on Aug 28 '18 by:

Discussion

markdown guide