среда, 7 августа 2013 г.

Dev: Как я писал программу для мониторинга рабочего времени сотрудников компании


Однажды ко мне обратились за помощью с требованием написать программу, которая позволила бы собирать статистику рабочего времени сотрудников компании. Да на рынке существуют готовые решения, но они либо не работают на Win 7 x64, либо собирают не то что надо. Статистика нужна была предельно простая, когда человек пришел, когда ушел, и с какими программами работал в рабочее время с процентной оценкой, не более. Для организации подобного решения было решено как обычно использовать .NET C# для клиентской части и  ASP.NET для серверной. Если в реализации серверной части вопросов и нюансов не возникает(кроме ускорения работы Entity Framework), но с клиентской частью было больше геммороя. чем ожидалось.


Клиентская часть. Нюансы.

Итак для того чтобы собирать статистику клиентская часть должна уметь следующее:
1. Хендлить события изменения сессии (Logon, Logoff, Lock и Unlock)
2. Регистрировать foreground окно на данный момент времени с определенной периодичностью.

Итак первым делом я создал службу и ввиду своей неопытности попробовал вытянуть foreground окно из неё. Но службы не могут взаимодействовать с рабочим столом пользователей и эта информация им не доступна, после гугления и дизассемблирования готовых решения(посмотреть на список импорта функций из WinAPI) вариант был всего один - нужно из под службы запускать приложение от имени пользователя в его среде, таким образом приложение будет иметь доступ к рабочей среде пользователя и может регистрировать foreground окно.
Код запуска подобного процесса:


class CreateProcessAsUserWrapper
{
    public static int LaunchChildProcess(string childProcName)
    {
        IntPtr ppSessionInfo = IntPtr.Zero;
        UInt32 SessionCount = 0;
        int result = 0;

        if (WTSEnumerateSessions(
            (IntPtr)WTS_CURRENT_SERVER_HANDLE,
            0,
            1,
            ref ppSessionInfo,
            ref SessionCount
            ))
        {
            for (int nCount = 0; nCount < SessionCount; nCount++)
            {
                WTS_SESSION_INFO tSessionInfo = (WTS_SESSION_INFO)Marshal.PtrToStructure(
                    ppSessionInfo + nCount * Marshal.SizeOf(typeof(WTS_SESSION_INFO)),
                    typeof(WTS_SESSION_INFO)
                    );

                if (WTS_CONNECTSTATE_CLASS.WTSActive == tSessionInfo.State)
                {
                    IntPtr hToken = IntPtr.Zero;

                    if (WTSQueryUserToken(tSessionInfo.SessionID, out hToken))
                    {
                        STARTUPINFO tStartUpInfo = new STARTUPINFO();
                        tStartUpInfo.cb = Marshal.SizeOf(typeof(STARTUPINFO));
                        PROCESS_INFORMATION tProcessInfo;
                        bool ChildProcStarted = CreateProcessAsUser(
                            hToken,
                            childProcName,
                            null,
                            IntPtr.Zero,
                            IntPtr.Zero,
                            false,
                            0,
                            null,
                            null,
                            ref tStartUpInfo,
                            out tProcessInfo
                            );
                        result = tProcessInfo.dwProcessId;
                        if (ChildProcStarted)
                        {
                            CloseHandle(tProcessInfo.hThread);
                            CloseHandle(tProcessInfo.hProcess);
                        }
                        else
                        {
                            // CreateProcessAsUser failed!
                        }
                        CloseHandle(hToken);
                        break;
                    }
                    else
                    {
                        // WTSQueryUserToken failed!
                    }
                }
                else
                {
                    // This Session is not active!
                }
            }
            WTSFreeMemory(ppSessionInfo);
        }
        else
        {
            // WTSEnumerateSessions failed!
        }

        return result;
    }


    #region P/Invoke WTS APIs

    private const int WTS_CURRENT_SERVER_HANDLE = 0;
    private enum WTS_CONNECTSTATE_CLASS
    {
        WTSActive,
        WTSConnected,
        WTSConnectQuery,
        WTSShadow,
        WTSDisconnected,
        WTSIdle,
        WTSListen,
        WTSReset,
        WTSDown,
        WTSInit
    }

    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
    private struct WTS_SESSION_INFO
    {
        public UInt32 SessionID;
        public string pWinStationName;
        public WTS_CONNECTSTATE_CLASS State;
    }

    [DllImport("WTSAPI32.DLL", SetLastError = true, CharSet = CharSet.Auto)]
    static extern bool WTSEnumerateSessions(
        IntPtr hServer,
        [MarshalAs(UnmanagedType.U4)] UInt32 Reserved,
        [MarshalAs(UnmanagedType.U4)] UInt32 Version,
        ref IntPtr ppSessionInfo,
        [MarshalAs(UnmanagedType.U4)] ref UInt32 pSessionInfoCount
        );

    [DllImport("WTSAPI32.DLL", SetLastError = true, CharSet = CharSet.Auto)]
    static extern void WTSFreeMemory(IntPtr pMemory);

    [DllImport("WTSAPI32.DLL", SetLastError = true, CharSet = CharSet.Auto)]
    static extern bool WTSQueryUserToken(UInt32 sessionId, out IntPtr Token);
    #endregion


    #region P/Invoke CreateProcessAsUser

    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
    struct STARTUPINFO
    {
        public Int32 cb;
        public string lpReserved;
        public string lpDesktop;
        public string lpTitle;
        public Int32 dwX;
        public Int32 dwY;
        public Int32 dwXSize;
        public Int32 dwYSize;
        public Int32 dwXCountChars;
        public Int32 dwYCountChars;
        public Int32 dwFillAttribute;
        public Int32 dwFlags;
        public Int16 wShowWindow;
        public Int16 cbReserved2;
        public IntPtr lpReserved2;
        public IntPtr hStdInput;
        public IntPtr hStdOutput;
        public IntPtr hStdError;
    }

    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
    struct PROCESS_INFORMATION
    {
        public IntPtr hProcess;
        public IntPtr hThread;
        public int dwProcessId;
        public int dwThreadId;
    }

    [DllImport("ADVAPI32.DLL", SetLastError = true, CharSet = CharSet.Auto)]
    static extern bool CreateProcessAsUser(
        IntPtr hToken,
        string lpApplicationName,
        string lpCommandLine,
        IntPtr lpProcessAttributes,
        IntPtr lpThreadAttributes,
        bool bInheritHandles,
        uint dwCreationFlags,
        string lpEnvironment,
        string lpCurrentDirectory,
        ref STARTUPINFO lpStartupInfo,
        out PROCESS_INFORMATION lpProcessInformation
        );

    [DllImport("KERNEL32.DLL", SetLastError = true, CharSet = CharSet.Auto)]
    static extern bool CloseHandle(IntPtr hHandle);
    #endregion
}

Данные от дочерго роцесса преедаются службе через WCF, там они  аккумулируются и сериализуются на диск, а их регулярно отправляет на сервер отдельный поток в службе.

Вроде все хорошо? Не все.

1. Служба не имеет представление по какому пользователю происходят события сессии, и если  при Logon, Lock и Unlock можно получить дополнительную информацию по идентификатору сессии, то при Logoff сессии уже закрыта к моменту хендла события службой, поэтому эту задачу также выполняет дочерний процесс обрабатывая событие приложения OnSessionEnding и передавая это службе посредством WCF.

2. Служба должна запускаться за 30 секунд иначе Windows её убивает(можно отредактировать время ожидания запуска службы в реестре, но это костыль). Судя по журналу событий Windows, помимо меня это не знали разрабы драйверов nVidia чья служба тоже попадала под откос. Выход прост: вынести всю логику из onStart и производить инициализацию в отдельном потоке, чтобы служба как можно скорее сообщила Windows о запуске. Почему служба запускалась 30 секунд? Сложно сказать,  у меня при запуске выполнялось чтение сериализованных данных с прошлого сеанса работы, вероятно при запуске компа диск был так загружен, что эта операция занимала так много времени.

3. Служба может быть запущена после входа пользователем. Да порядок запуска служб регламенитрвоан только прядком групп запуска, если до того ка кнаступит очередь твоей службы, что-то долго запускается или тормозит, а пользователь уже успеет совершить вход, то твоя служба будет запущена после события Logon, а значит захендлить его мы не сможем, в этом случае необходимо совершать дополнительные проверки при запуске служб и формировать Logon отчет, если уже выполнен вход.

Ниже представлена реализация 2-х методов: первый для определения времени простоя системы,  второй для определения foreground окна используя WinAPI


public class IdleTimeFinder
    {
        [DllImport("User32.dll")]
        private static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);

        [DllImport("Kernel32.dll")]
        private static extern uint GetLastError();

        public static uint GetIdleTime()
        {
            LASTINPUTINFO lastInPut = new LASTINPUTINFO();
            lastInPut.cbSize = (uint)Marshal.SizeOf(lastInPut);
            GetLastInputInfo(ref lastInPut);

            return ((uint)Environment.TickCount - lastInPut.dwTime);
        }
        /// 
        /// Get the Last input time in ticks
        /// 
        /// 
        public static long GetLastInputTime()
        {
            LASTINPUTINFO lastInPut = new LASTINPUTINFO();
            lastInPut.cbSize = (uint)Marshal.SizeOf(lastInPut);
            if (!GetLastInputInfo(ref lastInPut))
            {
                throw new Exception(GetLastError().ToString(CultureInfo.InvariantCulture));
            }
            return lastInPut.dwTime;
        }
    }

    public class ForegroundWindowFinder
    {

        #region WinAPI

        [DllImport("psapi.dll", CallingConvention = CallingConvention.StdCall, SetLastError = true)]
        public static extern int EnumProcessModules(IntPtr hProcess, [Out] IntPtr lphModule, uint cb, out uint lpcbNeeded);

        [DllImport("user32.dll")]
        public static extern IntPtr GetWindowThreadProcessId(IntPtr hWnd, out uint ProcessId);

        [DllImport("kernel32.dll")]
        public static extern IntPtr OpenProcess(int dwDesiredAccess, bool bInheritHandle, int dwProcessId);


        [DllImport("user32.dll")]
        private static extern IntPtr GetForegroundWindow();

        [DllImport("user32.dll", EntryPoint = "GetWindowText", ExactSpelling = false, CharSet = CharSet.Auto, SetLastError = true)]
        public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpWindowText, int nMaxCount);

        [DllImport("user32.dll")]
        [return: MarshalAs(UnmanagedType.Bool)]
        public static extern bool IsWindowVisible(IntPtr hWnd);

        #endregion


        public delegate bool EnumDelegate(IntPtr hWnd, int lParam);

        public static Report GetReport()
        {
            string title;
            string name;
            string path;
            try
            {
                const int chars = 256;
                StringBuilder buff = new StringBuilder(chars);
                IntPtr handle = GetForegroundWindow();
                if (handle.ToInt32() == 0)
                {
                    return null;
                }
                GetWindowText(handle, buff, chars);
                uint pid;
                GetWindowThreadProcessId(handle, out pid);
                Process process = Process.GetProcessById((int) pid);
                title = buff.ToString();
                name = process.ProcessName;
                path = process.Modules[0].FileName;
            }
            catch
            {
                title = "Рабочий стол";
                name = "Desktop";
                path = "none";
            }
            return new Report
            {
                // ReSharper disable PossibleNullReferenceException
                User = System.Security.Principal.WindowsIdentity.GetCurrent().Name,
                // ReSharper restore PossibleNullReferenceException
                ComputerName = Environment.MachineName,
                Date = DateTime.Now,
                Name = name,
                Path = path,
                Title = title,
                CheckInterval = (long) Settings.Default.CheckInterval.TotalMilliseconds,
                IdleTime = new TimeSpan(IdleTimeFinder.GetIdleTime())
            };
        }

    }

Серверная часть. Нюансы.

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

Сама статистика располагалась на мною же написанном корпоративном портале. Система доступа базировалась на членстве в группах из AD, и если microsoft позаботились о предоставлении MembershipProvider для AD, то с RoleProvider  все печально.
Ниже приведу свою реализацию этого интерфейса( не все метода реализованы ибо не все они мне были нужны):
public class ADRoleProvider : RoleProvider
{
    #region Properties

    public override string ApplicationName { get; set; }

    #endregion

    private string groupPrefix;
    private DirectoryEntry entry;
    private string _connectionUsername;
    private string _connectionStringName;
    private string _connectionPassword;

    public override void Initialize(string name, NameValueCollection config)
    {
        base.Initialize(name, config);
        var configurationManager = ConfigurationManager.ConnectionStrings;
        _connectionStringName = ConfigurationManager.ConnectionStrings[config["connectionString"]].ConnectionString;
        _connectionUsername = config["connectionUsername"];
        _connectionPassword = config["connectionPassword"];
        groupPrefix = config["groupPrefix"];

        entry = new DirectoryEntry(_connectionStringName, _connectionUsername, _connectionPassword);

    }

    public override string[] FindUsersInRole(string roleName, string usernameToMatch)
    {
        DirectorySearcher groupSearcher = new DirectorySearcher(entry)
                                                {
                                                    Filter =
                                                        "(&(objectClass=group)(SAMAccountName=" +
                                                        roleName + "))"
                                                };

        DirectorySearcher searcher = new DirectorySearcher(entry)
                                         {
                                             Filter = String.Format("(cn={0})", roleName)
                                         };
        searcher.PropertiesToLoad.Add("member");
        SearchResult oSr = searcher.FindOne();

        if (null == oSr) { return new string[0]; }
        List sUsers = new List();
        var members = groupSearcher.FindOne().GetDirectoryEntry().Invoke("Members", null);
        foreach (var member in (IEnumerable)members)
        {
            var currentMember = new DirectoryEntry(member);
            sUsers.Add((string)currentMember.Properties["samaccountname"].Value);
        }
        return sUsers.ToArray();
    }

    public override string[] GetAllRoles()
    {
        //TODO
        return new string[0];
    }

    public override string[] GetRolesForUser(string username)
    {
        DirectorySearcher search = new DirectorySearcher(entry)
                                       {
                                           Filter = "(&(samaccountname=" + username + "))"
                                       };
        SearchResult oSR = search.FindOne();
        if (null == oSR) { return new string[0]; }

        if (oSR.Properties["memberOf"] == null) { return new string[0]; }

        int iPropertyCount = oSR.Properties["memberOf"].Count;
        List sGroups = new List();
        for (int i = 0; i < iPropertyCount; i++)
        {
            string dn = (string)oSR.Properties["memberOf"][i];
            int equalsIndex = dn.IndexOf("=", 1, StringComparison.Ordinal);
            int commaIndex = dn.IndexOf(",", 1, StringComparison.Ordinal);

            if (-1 == equalsIndex) return new string[0];

            string groupname = dn.Substring((equalsIndex + 1), (commaIndex - equalsIndex) - 1);
            if (groupname.StartsWith(groupPrefix))
                sGroups.Add(groupname);
        }
        return sGroups.ToArray();
    }

    public override string[] GetUsersInRole(string roleName)
    {
        return FindUsersInRole(roleName, "");
    }

    public override bool IsUserInRole(string username, string roleName)
    {
        string[] roles = GetRolesForUser(username);
        return roles.Contains(roleName);
    }

    public override bool RoleExists(string roleName)
    {
        return true;//TODO
    }

    #region Not implemented methods
    public override void CreateRole(string roleName)
    {
        throw new NotImplementedException();
    }

    public override bool DeleteRole(string roleName, bool throwOnPopulatedRole)
    {
        throw new NotImplementedException();
    }

    public override void AddUsersToRoles(string[] usernames, string[] roleNames)
    {
        throw new NotImplementedException();
    }

    public override void RemoveUsersFromRoles(string[] usernames, string[] roleNames)
    {
        throw new NotImplementedException();
    }
    #endregion
}

Еще был вопрос в нахождении Полных имен по юзернейму из AD, этот вопрос я решил следующим кодом (код ниже сокращен и отвязан о всего другого):
DirectoryEntry obDirEntry = new DirectoryEntry("LDAP://*.*.*.*/CN=Users,DC=whitehouse,DC=local", "Username", "Password", AuthenticationTypes.Secure);
DirectorySearcher searcher = new DirectorySearcher(obDirEntry)
     {
          Filter = "(&(samaccountname=" + username + "))"
     };
SearchResult oSr = searcher.FindOne();
if (oSr == null) return null;
string result = oSr.GetDirectoryEntry().Properties["displayName"].Value.ToString();
obDirEntry.Close();
return result;

Хочу заметить, что работа с AD не блещет скоростью, поэтому все данные вытянутые оттуда лучше кешировать хотя бы через тот же MemoryCache.

Комментариев нет:

Отправить комментарий