Некоторое время назад выполнял я заказ для одной конторы. Суть проекта сейчас не важна (это был некий довесок к их корпоративной системе, который они ставили на компы своим клиентам), одним из требований было что бы приложение отправляло отчет об своем использовании. А попросту говоря, ребята хотели знать насколько их программа востребована среди клиентов. И вот на этой волне, возник у меня вопрос — а действительно, написал ты программу, отдал |продал в добрые руки или просто выложил ее в интернет. И что дальше? Сколько реальных пользователей ее увидели?

Если программа продается, то количество покупателей можно легко посчитать по проданным лицензиям или ключам — это у кого как. А вот если она бесплатная, то тут возникнут проблемы. Считать количество загрузок с офсайта (если такой вообще есть) бессмысленно, т.к. если что-то попало в интернет начнет плодиться и расползаться по варезным ресурсам да торрент-трекерам.

Прилагаемое тут решение дает не такой подробный отчет, и собирает не так много данных (мне это было просто не нужно) как например, Software Statistics Service (но она, если мне не изменяет память, и платная). Но есть и плюс: систему всегда можно допилить до своей необходимости.

Не претендую на оригинальность, наверняка что-то подобное уже есть, я особо не искал. Старался писать детально, стало даже похоже на курсовую работу студента. В общем, возможно кому-то пригодится и будет интересно.

Индивидуальность


Итак, наша программа расползлась по всему миру, и теперь мы хотим знать насколько это глобально. Сначала надо определиться, как будем отличать одну копию от другой. Первое, что приходит на ум это GUID. Например, генерировать его при установке или при первом запуске приложения, и сохранять в файл или в реестр. Плюсом такова варианта является простота, а минусом, то что GUID при каждом запуске установщика будет новый. Т.е. стоит бедолаге переустановить систему (или, того проще, нашу программу) мы получим завышенный результат. Такое решение больше подходит для подсчета количества установок программы, а не реального количества пользователей, но его вполне допустимо использовать для приблизительной оценки. GUID можно получить, например, так:

string guid = Guid.NewGuid().ToString();
Console.WriteLine("guid: {0}", guid);

Более точный результат даст привязка копии программы к аппаратному обеспечению компьютера. Но, тут надо иметь ввиду, что при апгрейде (например, замене HDD) поменяется и HardwareID. Поэтому, лучше за источник данных брать то что дольше всего «живет». Это может быть материнская плата или процессор, т.к. они как правило, меняются при полной замене компьютера. Ниже приведен код, который извлекает CPU ID и MotherBoard ID, и вычисляет из них md5-хеш.

Полученный результат можно уже использовать для идентификации.

GetHID
private static string GetHID()
        {
            string CPUid = string.Empty;
            string MtbId = string.Empty;
            string DiskId = string.Empty;
            string HID = string.Empty;

            ManagementObjectSearcher mos = new ManagementObjectSearcher();
            // Процессор
            mos.Query = new ObjectQuery("Select * From Win32_processor");
            foreach (ManagementObject mo in mos.Get())
            {
                try
                {
                    CPUid = mo["ProcessorID"].ToString();
                }
                catch { }
            }
            // Материнская плата            
            mos.Query = new ObjectQuery("SELECT * FROM Win32_BaseBoard");
            foreach (ManagementObject mo in mos.Get())
            {
                try
                {
                    MtbId = mo["SerialNumber"].ToString();
                }
                catch { }
            }
            // Жесткий диск
            ManagementObject dsk = new ManagementObject(@"win32_logicaldisk.deviceid=""C:""");
            try
            {
                DiskId = dsk["VolumeSerialNumber"].ToString();
            }
            catch { }

            Byte[] Bytes = Encoding.ASCII.GetBytes(CPUid + MtbId + DiskId);
            if (Bytes.Length == 0)
                return "";
            MD5 md5 = MD5.Create();
            Byte[] HidBytes = md5.ComputeHash(Bytes);
            foreach (Byte b in HidBytes)
                HID += b.ToString("X2");
            return HID;

        }


В методе добавлен еще Volume Serial жесткого диска, т.к. в виртуальных средах CpuID и серийный номер материнской платы могут не определятся (у меня, по крайней мере, прилетали нули либо исключения).

Для получения информацию о железе используется WMI. Не забываем подключить соответствующие пространство имен:

using System.Management;

И в References добавить одноименную сборку WMI содержит огромное количество классов, позволяющих вытащить практически любую информацию о системе, поэтому при желании можно расширить список.

Как это работает


Теперь настало время задать алгоритм работы, и определить какие данные будут принимать участие в жизненном цикле нашей системы. Дабы не усложнять решение для обмена данными будем использовать протокол HTTP. Ведь WEB — это самый распространенный сервис в сети Интернет, у многих сейчас есть арендованный или свой хостинг, так почему бы не расширить его функционал? Поэтому, не будем изобретать велосипед, а просто сядем на него и поедем.
Я не поленился, и нарисовал схему (да простят меня знатоки UML). Как говорилось выше, клиент и сервер общаются по протоколу HTTP, клиент собирает необходимые данные, и в POST запросе направляет их серверу.

image

Сервер это в конечном итоге PHP скрипт, задача которого принять данные от клиента, проверить их на валидатность, и если все нормально, то сохранить эти данные в базе данных, в завершении, сообщить клиенту о результате выполненной операции. Ответы сервера определим следующие:

  • OK– данные прошли проверку, и удачно сохранены в БД. В этом случае клиенту больше нет необходимости предпринимать попытки регистрации программы.
  • COPY_EXIST – программа с таким AppName, AppVersion и MachineID уже зарегистрирована. Это говорит о том, что в предыдущем сеансе, что-то пошло не так. Программа была зарегистрирована, но клиент не отработал ответ сервера. В этом случае, клиенту также больше нет необходимости ломиться на сервер.
  • APP_NOT_EXIST – сервер не настроен для регистрации этой программы. Смысл этого ответа станет ясен позже.

Все остальные ответы сервера будем воспринимать как ошибку. В нашем случае клиентом выступает программный модуль, находящийся внутри приложения, статистику о котором мы будем собирать. Вот данные, которые отсылает клиент:

  • AppName, AppVer – имя и версия программы.
  • MachineID — это тот самый уникальный идентификатор.
  • OsVer – версия операционной системы.

Для меня этого было достаточно, но все легко расширяемо.
Помимо переданных клиентом данных сервер еще регистрирует ip адрес, с которого пришли данные, и дату.

Клиент


Весь код клиента я завернул в класс, и обозвал его AppCopy. Работать с ним предельно просто, рассмотрим диаграмму последовательности:

image

Здесь APP – это наша программа, статистику о которой собираем. В первую очередь создается представитель класса AppCopy, и передаются все необходимые параметры.
Затем нужно вызвать метод Registration, и класс начнет делать свое дело. По завершении, он возбудит событие OnRegistrationComplite, куда и передастся результат работы. Далее клиент принимает решение, если регистрация прошла удачно, то больше потребности в нем нет, если неудачно, то очевидно нужно повторить попытку, например, при следующем запуске программы. Тут все зависит от реализации и результата, который мы хотим добиться.

AppCopy
class AppCopy
    {
     // Делегат на событие. Тут Sender - ссылка на объект который сгенерировал событие, 
     // ResultStatus - результат выполения операции
     public delegate void OnRegistrationRef (AppCopy Sender, RegResult ResultStatus);    
     // Событие. Генерируется при завершении регистрации
     public event OnRegistrationRef OnRegistrationComplete;                              
     // Идентификатор
     public string MachineId;
     // Имя программы              
     public string AppName;
     // Версия программы
     public string AppVersion;
     // Версия ОС
     public string OsVersion;    
     // URL скрипта регистрации
     public string RegUrl;
     // Задает количество попыток регистрации
     public int NumbersAttempts;       
     // Задает интервал между попытками (мс)
     public int AttemtsInterval;           
     // Результат выполнения 
     public enum RegResult {             
            // Ошибок нет
            Ok,                 
            // Ошибка соединения, либо переданные параметры неверны
            NetworkError,  
            // Копия с таким id уже зарегистрирована
            AlreadyExist,
            // Не было попыток регистрации.
            NoAttempts 
        };                         
     // Экземпляр класса Thread. Служит для поддержания отдельного потока выполнения   
     private Thread RegistrationThread;     
     // Результат выполения  
     public RegResult ResultStatus            
     {
          get;
          private set;
     }
     // Исходный ответ сервера. Нужно только для отладки.
     public string HttpResponsetData         
     {
          get;
          private set;
     }
        
     // Коструктор
     public AppCopy(string RegUrl, string MachineId, string AppName, string AppVersion, string OsVersion)
      {
          this.MachineId = MachineId;
          this.OsVersion = OsVersion;
          this.AppName = AppName;
          this.AppVersion = AppVersion;
          this.RegUrl = RegUrl;

        //===Значения по умолчанию ===
        NumbersAttempts = 1;    
        AttemtsInterval = 60000;
        ResultStatus = RegResult.NoAttempts;      
        // ThreadMotion - функция потока.
        RegistrationThread = new Thread(ThreadMotion);  
        }      

     // Запуск регистрации
     public void Registration()  
     {
          RegistrationThread.Start();
     }
     // Функция потока. Тут начинается основное действие
     private void ThreadMotion() 
     {
          // Делаем NumbersAttempts количество попыток
          for (int cntAttemps = 0; cntAttemps < NumbersAttempts; cntAttemps++)   
          {
              SendRegistrationData();
              // В случае успеха обрываем цикл
              if (ResultStatus == RegResult.Ok || ResultStatus == RegResult.AlreadyExist) 
                  break;
              // Иначе, приостанавливаем поток на AttemtsInterval мс.                
              Thread.Sleep(AttemtsInterval);              
          }
          // По завершении генерируется событие OnRegistrationComplete. В пареметрах передаем код результата выполнения

          OnRegistrationComplete(this, ResultStatus);     
     }

     // Метод выполняет обмен данными с web сервером. Возвращает код результата выполнения
     private RegResult SendRegistrationData()    
     {
          // Данные ключ=значение
          string postString = "MachineID=" + this.MachineId + "&AppName=" + this.AppName + "&AppVersion=" + this.AppVersion + "&OsVersion=" + this.OsVersion;
          // Преобразуем в массив байт
          byte[] postBytes = Encoding.UTF8.GetBytes(postString);
          // Готовим все необходимые инструменты             
          Stream dataStream = null;
          WebResponse response = null;
          StreamReader reader = null;
          try
            {
                // Создаем объект для отправки запросов
                HttpWebRequest request = (HttpWebRequest)WebRequest.Create(this.RegUrl);    
                // Указываем, что будет использоваться метод POST
                request.Method = "POST";       
                // Тип данных
                request.ContentType = "application/x-www-form-urlencoded";
                // Количество байт в запросе
                request.ContentLength = postBytes.Length;                         
                // Получаем поток для отправки запроса
                dataStream = request.GetRequestStream();                                   
                // "Скидываем" данные в поток
                dataStream.Write(postBytes, 0, postBytes.Length);                   
                // Объект, работающий с ответами сервера       
                response = request.GetResponse();                                          
                // Поток куда будут падать данные ответа
                dataStream = response.GetResponseStream();                               
                // Чтение данных с потока
                reader = new StreamReader(dataStream);                                      
                this.HttpResponsetData = reader.ReadToEnd();     
                /* Данные приходят в виде строки в верхнем регистре. Важными являются только "ОК" и "EXIST_ID", в зависимости от этого устанавливаем переменную ResultStatus в нужное состояние. Все остальные варианты ответа воспринимаются как ошибка. "Сырой" ответ сервера сохраняем в HttpResponsetData */
                switch (HttpResponsetData)                  
                {                           
                    // Завершено без ошибок
                    case "OK":
                        ResultStatus = RegResult.Ok;            
                        break;
                    // Копия уже зарегистрирована
                    case "COPY_EXIST":
                        ResultStatus = RegResult.AlreadyExist;   
                        break;
                    // не проходит валидатность переданные данных, APP_NOT_EXISTS, либо любой другой ответ сервера (40*, 50*)
                    default:
                        ResultStatus = RegResult.NetworkError;  
                        break;
                }
                
            }
            catch
            {
                // Любая сетевая ошибка.
                ResultStatus = RegResult.NetworkError;          
            }
            finally
            {
                //=== Освобождаем ресурсы===
                if (dataStream != null)
                    dataStream.Close();
                if (reader != null)
                    reader.Close();
                if (response != null)
                    response.Close();                
            }
            return ResultStatus;
        }        
    }


Немного поясню. Работа с сетью считается «долгоиграющей», при плохой связи (или вообще при отсутствии интернета) приложение может “подвиснуть”, и пользователь нажмет на красный крест, а то и вовсе на три кнопки. Поэтому код работающий с web сервером я вынес в отдельный поток. Теперь взаимодействие с сервером будет происходит не заметно, и не влиять на работу основной программы. Код хорошо комментирован, поэтому не буду на нем задерживаться, покажу только пример как с ним работать.

Пример
 static void Main(string[] args)
        {
            // Создаем наш регистратор   
            AppCopy appCopy = new AppCopy("http://test.info/reg_url.php", GetHID(), "Program_name", "Program_ver", GetOsVersion());
            // Подписываемся на событие
            appCopy.OnRegistrationComplete += RegistrationFinish;
            appCopy.AttemtsInterval = 10;
            // Регистрация
            appCopy.Registration();
            Console.Read();
        }

        // Обработка события
 private static void RegistrationFinish(AppCopy Sender, AppCopy.RegResult ResultStatus)
      {
            Console.WriteLine("Registration result: {0} \nInformation: {1}", ResultStatus, Sender.HttpResponsetData);
       }

 private static string GetOsVersion()
      {

           ManagementObjectSearcher mos = new ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem");
            string name = "";
            foreach (ManagementObject mobj in mos.Get())
            {
                try
                {
                    name = mobj.GetPropertyValue("Caption").ToString();
                }
                catch { };
            }
            return name;
        }


Функция GetOsVersion() работает аналогично GetHID(), возвращает не версию а скорее имя ОС в виде «Windows 10 Корпоративная…». Можно пойти более простым путем, и вытащить именно версию ОС через .NET:

Environment.OSVersion

Но, мне показался такой вариант менее информативен.

Сервер


Итак, наша программа собрала всю необходимую информацию и отправила ее серверу. Теперь, перед нами задача принять эти данные, проверить, и в случае успеха сохранить в базе данных в надлежащем виде. Этим и займется сервер. Помимо этого, сервер должен уметь отдавать собранную информацию, и формировать отчеты в удобном виде.

Но сначала, разберемся в базе данных. Наша база состоит из трех таблиц: copies, apps и sum.

image

Таблица copies включает 6 полей, смысл которых понятен по названию. Отмечу только что date и ip клиентом не передается, их получает скрипт сервера, appid – идентификатор программы, является внешним (FOREIGN KEY) ключом и образует связь с таблицей apps. Последняя имеет три поля: уже известный appid, здесь это первичный ключ, appname и appver – соответственно имя и версия программы. Наша систему будет вести подсчет только той программы имя и версия которой занесенный в эту таблицу. И наконец, sum – это сводная таблица тут будет хранится результирующая статистика. Таблица имеет два поля appid и sum. Именно в поле sum хранится общее количество регистраций.

К таблице copies привязан триггер (tg_new_copy), который срабатывает после добавления (AFTER INSERT) новых данных (при регистрация очередной копии). Он увеличивает значение поля sum в таблице sum на единицу. Естественно, изменение sum происходит только то, чей appid соответствует appname и appver регистрируемой программы.

В таблице apps есть триггер (tg_new_app), который, также срабатывает после добавлений данных. Задача этого триггера инициализация таблицы sum.

Логика работы получается следующая. Пускай, у нас есть приложение «Program1», версия «1.0.0.0». Чтобы система знала о этой программы, и начала ее регистрировать первое что нужно сделать это добавить appname и appver в таблицу apps. Вот еще одна схема:

image

При добавлении новой записи ей автоматически присваивается appid (в схеме выше — 1), далее срабатывает триггер tg_new_app, который в свою очередь добавляет новую запись sum = 0 с appid, полученным ранее, в таблицу sum.

Теперь все готово для сбора данных о нашей программе. После добавлении новой строки в таблицу copies триггер tg_new_copy увеличивает поле sum в одноименной таблице на единицу

image

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

config.php
<?php
// Параметры подключения к БД
// Имя базы данных
$db_name = 'db_regapps';    
// Логин                 
$db_login = 'db_regapps';    
// Пароль 
$db_pass = '12345678';      
// Хост
$db_host = 'localhost';                     

// Таблици
$apps_table_name = 'tb_apps';
$stat_table_name = 'tb_copies';
$sum_table_name = 'tb_sum';


Теперь скрипт настройки БД:

setup.php
<?php
require 'config.php';
// Подключаемся к СУБД
$db_handle = new mysqli($db_host, $db_login, $db_pass, $db_name);
echo "Подключение к базе данных: <b> ";
if ($db_handle->connect_errno)
    die("Ошибка </b> ($db_handle->connect_error)");
else
    echo "ОК </b>";

// ===Создаем необходимые таблицы===
echo "<br>Создание таблицы $apps_table_name: <b>";
// Таблица  с описание программ (app)
$sql_query = "CREATE TABLE $apps_table_name (appid INT AUTO_INCREMENT NOT NULL PRIMARY KEY, appname VARCHAR(20) NOT NULL, appver VARCHAR(20) NOT NULL) CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE=InnoDB";
$result = $db_handle->query($sql_query);
show_result();

echo "<br>Создание таблицы $stat_table_name: <b>";
// Основная таблица регистраций

$sql_query = "CREATE TABLE $stat_table_name (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, machineid VARCHAR (32) NOT NULL, osver VARCHAR (128), appid INT NOT NULL, date DATETIME, ip VARCHAR(15), FOREIGN KEY fk_stat(appid) REFERENCES $apps_table_name(appid) ON UPDATE CASCADE ON DELETE CASCADE) CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE=InnoDB";
$db_handle->query($sql_query);
show_result();

echo "<br>Создание таблицы $sum_table_name: <b>";
// Сводная таблица
$sql_query = "CREATE TABLE $sum_table_name(appid INT NOT NULL , sum INT NOT NULL DEFAULT 0, FOREIGN KEY fk_sum(appid) REFERENCES $apps_table_name(appid) ON UPDATE CASCADE ON DELETE CASCADE) CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE=InnoDB";
$db_handle->query($sql_query);
show_result();

// ===Создаем триггеры===
echo "<br>Создание триггера tg_new_app: <b>";
$sql_query = "CREATE TRIGGER tg_new_app AFTER INSERT ON $apps_table_name FOR EACH ROW BEGIN INSERT INTO $sum_table_name SET appid=NEW.appid; END";
$db_handle->query($sql_query);
show_result();

echo "<br>Создание триггера tg_new_copy: <b>";
$sql_query = "CREATE TRIGGER tg_new_copy AFTER INSERT ON $stat_table_name FOR EACH ROW BEGIN UPDATE $sum_table_name SET sum= $sum_table_name.sum + 1 WHERE appid=NEW.appid; END";
$db_handle->query($sql_query);
show_result();
$db_handle->close();

function show_result()
{
    global $db_handle;
    if($db_handle->errno)
        die("Ошибка </b> ($db_handle->error)");
    else
        echo "ОК </b>";
}



Основной код я свернул в класс appstat:

appstat.php
class appstat
{
    private $db_handle;         // Ссылка на объект связанный с СУБД
    private $last_error;        // Информация об ошибках
    private $apps_table_name;    // Имя таблицы apps
    private $stat_table_name;   // Имя таблицы copies
    private $sum_table_name;    // Имя таблицы sum

    // Конструктор
    function __construct($db_name, $db_host, $db_login, $db_pass, $apps_table_name, $stat_table_name, $sum_table_name)
    {
        $this->apps_table_name = $apps_table_name;
        $this->stat_table_name = $stat_table_name;
        $this->sum_table_name = $sum_table_name;
        // Пытаемся подключиться к СУБД
        $this->db_handle = mysqli_connect($db_host, $db_login, $db_pass, $db_name);
        // Сохраняем результат
        $this->last_error = mysqli_connect_error();
    }

    // Метод добавляет новую программу. Если такая программа уже есть, то возвращает ее appid, иначе null
   public function add_app($app_name, $app_ver)
   {
       $app_id = $this->app_exist($app_name, $app_ver);
       if ($app_id != 0)
           return $app_id;
       // Если программы с таким именем и версией нет, то добавляем
       $sql_query = "INSERT INTO $this->apps_table_name(appname, appver) VALUES('$app_name', '$app_ver')";
       mysqli_query($this->db_handle, $sql_query);
       $this->last_error = mysqli_error($this->db_handle);
   }
    // Метод удаляет программу. В качестве параметров имя и версия программы
   public function delete_app($app_name, $app_ver)
   {
       // Проверяем, что такая программа есть, и получаем ее id
       $app_id = $this->app_exist($app_name, $app_ver);
       if ($app_id != 0) {
           $sql_query = "DELETE FROM $this->apps_table_name WHERE appid=$app_id";
           mysqli_query($this->db_handle, $sql_query);
       }
       $this->last_error = mysqli_error($this->db_handle);
   }

   // Проверка на существование программы в базе, возвращает 0 если приложения нет.
   // Иначе, возвращает appid программы в базе
   private function app_exist($app_name, $app_ver)
   {
       $sql_query = "SELECT appid FROM $this->apps_table_name WHERE appname='$app_name' AND appver='$app_ver'";
       $result = mysqli_query($this->db_handle, $sql_query);
       $this->last_error = mysqli_error($this->db_handle);
       if($result->num_rows === 0) {
           return 0;
       }
       else{
           return $result->fetch_assoc()['appid'];
       }
   }

    // Проверка на наличие копии программы в базе. app_id - идентификатор программы в таблице
// Возвращает 0, если такой копии нет, иначе возвращает id копии в таблице
    private function copy_exist ($machine_id, $app_id)
    {
        $sql_query = "SELECT id FROM $this->stat_table_name WHERE appid='$app_id' AND machineid='$machine_id'";
        $result = mysqli_query($this->db_handle, $sql_query);
        if ($result->num_rows != 0){
            return $result->fetch_assoc()['id'];
        }
        return 0;
    }

// Регистрирует новую копию программы. Возвращает ОК, если все прошло удачно, COPY_EXIST - если копия уже зарегистрирована,
// и APP_NOT_EXIXST - если о такой программе ничего не известно.
    public function add_copy($machine_id, $os_ver, $app_name, $app_ver, $ip)
    {
        // Проверяем, что такая программа зарегистрирована в базе
        $app_id = $this->app_exist($app_name, $app_ver);
        if ($app_id != 0){ // Зарегистрирована
            // Проверяем, что такой копии программы нет в базе
            if ($this->copy_exist($machine_id, $app_id) === 0){
                $sql_query = "INSERT INTO $this->stat_table_name(machineid, osver, appid, date, ip) VALUES('$machine_id', '$os_ver', $app_id, NOW(), '$ip')";
                mysqli_query($this->db_handle, $sql_query);
                $this->last_error = $this->db_handle->error;
                return "OK";
            }
            else{ // Копия уже существует
                return "COPY_EXIST";
            }
        }
        else // Приложение не зарегистрировано
            return "APP_NOT_EXIST";
    }

    // Закрывает соединение
    public function db_close()
    {
        mysqli_close($this->db_handle);
    }

    // Возвращает сводную таблицу о зарегистрированных программах в виде arr['appid', 'appname', 'appver', 'sum']
    public function get_sum_apps_list()
    {
        $arr_result = array();
        $sql_query = "SELECT $this->apps_table_name.appid, appname, appver, sum FROM $this->sum_table_name, $this->apps_table_name WHERE $this->sum_table_name.appid=$this->apps_table_name.appid";
        $result = mysqli_query($this->db_handle, $sql_query);
        // Упаковываем в массив
        while ($row = $result->fetch_array(MYSQLI_ASSOC))
        {
            $arr_result[] = $row;
        }
        $this->last_error = mysqli_error($this->db_handle);
        return $arr_result;
    }
    // Возвращает информацию о зарегистрированных копиях программы в виде arr['machineid', 'osver', 'date', 'ip']
// Принимает в качестве входных данных имя и версию программы
    public function get_copys_list($app_name, $app_ver)
    {
        $appid = $this->app_exist($app_name, $app_ver);
        if ($appid != 0)
        {
            $sql_query = "SELECT machineid, osver, date, ip FROM $this->stat_table_name WHERE appid=$appid";
            $result = mysqli_query($this->db_handle, $sql_query);
            $arr_result = array();
            while ($row = $result->fetch_array(MYSQLI_ASSOC))
            {
                $arr_result[] = $row;
            }
            return $arr_result;
        }

    }
    // Возвращает информацию о программах, которые зарегистрировал клиент в виде arr['appname', 'appver', 'date', 'ip']
// machine_id - hardware ID клиента
    public function get_client_apps($machine_id)
    {
        $sql_query = "SELECT appname, appver, date, ip FROM $this->apps_table_name JOIN $this->stat_table_name ON $this->stat_table_name.appid=$this->apps_table_name.appid WHERE machineid='$machine_id'";
        $result = mysqli_query($this->db_handle, $sql_query);
        $arr_result = array();
        while ($row = $result->fetch_array(MYSQLI_ASSOC))
        {
            $arr_result[] =$row;
        }
        return $arr_result;

    }
    // Возвращает информацию об ошибках.
    public function get_error()
    {
        return $this->last_error;
    }

}


Тут код также хорошо комментирован, поэтому останавливаться на нем не буду.

Как с этим работать


Покажу как со всем этим добром работать. Для начала, необходимо зарегистрировать программу, для этого можно воспользоваться формой, показанной ниже:

appform.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <titl>Добавление новой программы</titl>
</head>
<body>
<form action="http://test.info/regapp.php?Action=AddApp" method="post">

    <label>AppName</label> <br>
    <input type="text" name="AppName" ><br>
    <br>
    <label>AppVersion</label><br>
    <input type="text" name="AppVersion" ><br>
    <br>
    <button type="submit" >Send</button>

</form>
</body>
</html>

Данные улетают скрипту regapp.php в методе POST, и туда же передается параметр Action, который указывает скрипту, что мы от него хотим. Параметр передается методом GET.

regapp.php
<?php
require 'config.php';
require 'appstat.php';

// Подключаемся к СУБД
$app_stat = new appstat($db_name, $db_host, $db_login, $db_pass, $apps_table_name, $stat_table_name, $sum_table_name );
if ($app_stat->get_error())
    die("Ошибка подключения к СУБД ($app_stat->get_error())");

// Скрипт может делать две опрерации
switch ($_GET['Action'])
{
    // Добавление новой программы в базу
    case 'AddApp':
        // Проверка ожидаемых параметров
        if ((strlen($_POST['AppName']) == 0 || strlen($_POST['AppVersion']) == 0 )) {
            $app_stat->db_close();
            die('Не заданы все параметры');
        }
        else {

            $app_name = $_POST['AppName'];
            $app_ver = $_POST['AppVersion'];

            if ($app_stat->add_app($app_name, $app_ver) == false)
                echo "Программа $app_name ($app_ver) успешно добавлена";
            else
                echo 'Такая программа уже есть в базе';
        }
        break;
        // Регистрация программы
    case 'AddCopy':
        // Проверка ожидаемых параметров
        if (strlen($_POST['MachineID']) == 0 || strlen($_POST['AppName']) == 0 || strlen($_POST['AppVersion']) == 0 || strlen($_POST['OsVersion']) == 0) {
            $app_stat->db_close();
            die('Не заданы все параметры');
        }
        else {
            $app_name = $_POST['AppName'];          // Имя программы
            $machine_id = $_POST['MachineID'];      // HardwarID
            $app_ver = $_POST['AppVersion'];        // Версия программы
            $client_ip = $_SERVER['REMOTE_ADDR'];   // Ip адрес клиента
            $os_ver = $_POST['OsVersion'];          // Версия ОС
            // Регистрируем программу
            echo $app_stat->add_copy($machine_id, $os_ver, $app_name, $app_ver, $client_ip);
        }

        break;

}
$app_stat->db_close();


Как видно, скрипт может делать две операции — это добавлять новую программу в базу, тогда Action=AddApp, и вторая операция – это собственно регистрация копии, в этом случае Action должен быть равен AddCopy.

Особо любопытные могут воспользоваться формой, и зарегистрировать копию вручную:

copyform.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Регистрация новой копии</title>
</head>
<body>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title></title>
</head>
<body>
<form action="http://test.info/regapp.php?Action=AddCopy" method="post">

    <label>AppName</label> <br>
    <input type="text" name="AppName" ><br>
    <br>
    <label>AppVersion</label><br>
    <input type="text" name="AppVersion" ><br>
    <br>
    <label>MachineID</label><br>
    <input type="text" name="MachineID" ><br>
    <br>
    <label>OsVersion</label><br>
    <input type="text" name="OsVersion" ><br>
    <br>
    <button type="submit" >Send</button>

</form>
</body>
</html>
</body>
</html>

image

Если вы сейчас попытаетесь это сделать, то получите ожидаемый ответ APP_NOT_EXIST. Поэтому, сначала зарегистрируем программу:

image

Теперь получаем ответ – OK

Дальше больше. В классе appstat есть три метода которые позволяют получить отчет в виде таблиц. Покажу как это сделать:

showstat.php
<?php
require "config.php";
require "appstat.php";

$app_stat = new appstat($db_name, $db_host, $db_login, $db_pass, $apps_table_name, $stat_table_name, $sum_table_name);
// Сводная таблица
$result = $app_stat->get_sum_apps_list();
echo "Сводная таблица<br>";
echo "<table border='1'><tr><th>Имя</th><th>Версия</th><th>Число регистраций</th></tr>";
for ($i = 0; $i < count($result); $i++)
{
    $app = $result[$i];
    echo "<tr><td>$app[appname] </td><td>$app[appver]</td><td>$app[sum]</td></tr>";
}
echo "</table>";
echo "<br>";
// Детальная статистика по выбранной программе
echo "Детальная статистика по программе<br>";
$result = $app_stat->get_copys_list('Program#1', '1.0.0.0');
echo "<table border='1'><tr><th>Machine ID</th><th>Версия ОС</th><th>Дата регистрации</th><th>IP</th></tr>";
for ($i = 0; $i < count($result); $i++)
{
    $copy = $result[$i];
    echo "<tr><td>$copy[machineid]</td><td>$copy[osver]</td><td>$copy[date]</td><td>$copy[ip]</td></tr>";
}
echo "</table>";
echo "<br>";
// Статистика по клиенту
echo "Статистика по клиенту<br>";
$result = $app_stat->get_client_apps('666');
echo "<table border='1'><tr><th>Имя</th><th>Версия</th><th>Дата</th><th>IP</th></tr>";
for ($i = 0; $i < count($result); $i++)
{
    $app = $result[$i];
    echo "<tr><td>$app[appname]</td><td>$app[appver]</td><td>$app[date]</td><td>$app[ip]</td></tr>";
}
echo "</table>";

Получаем такой результат:

image

Я тут не заморачивался с дизайном, css, и т.п. Пускай этим занимается тот, кто в этом хорошо разбирается. Я ставил задачу только показать, как получить данные, и продемонстрировать работу.

Ну и наконец, давайте протестируем все с живым клиентом. Вернемся в начало статьи и изменим параметры инициализации объекта appCopy на следующие:

AppCopy appCopy = new AppCopy("http://test.info/regapp.php?Action=AddCopy", GetHID(), "Program#1", "1.0.0.0", GetOsVersion());

Все! Можно запускать программу. В ответ получаем результат:

image

А теперь посмотрим, что выдаст showstat.php:

image

Как видно в таблице, приложение удачно зарегистрировалась. Если еще раз запустить клиента, то программа выдаст сообщение, что такая копия уже есть:

image

Ну вот собственно и все. Если немного доработать, то можно, например, собирать статистику о том, как долго пользователь зависает в вашей программе (особенно актуально для игр), или переписав клиент под Java, внедрить код в Android приложение.

Ссылки


Комментарии (30)


  1. pewpew
    07.10.2017 14:47
    +4

    А ещё кейлогер туда можно засунуть и пароли его банковские подглядеть.
    Мда…


    1. Zoldan Автор
      07.10.2017 15:38
      -2

      Ну зачем уж в крайности впадать :)


  1. Wild__Recluse
    07.10.2017 15:36

    Всё-таки привязка к «железу» — довольно перспективное направление, вы довольно хорошо показали его применение помимо лицензирования.


  1. hdfan2
    07.10.2017 16:26

    И как получение уникального ID работает в скопированной виртуалке? Или защита такой надёжности не требуется?


    1. Zoldan Автор
      08.10.2017 02:02

      На счет виртуальных машин. Проверял в VirtualBox — система отдавала почему-то ноль, либо выбрасывала исключение, сейчас уже не помню какое. А вот в vmWare все работало нормально.


  1. totuin
    07.10.2017 16:33

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


    1. ashumkin
      07.10.2017 16:44

      До какой именно?
      Я в одном проекте делал: ОС, MAC сетевых адаптеров, модель диска. Не достучался только до серийного номера диска (через ioctl) и BIOS


      1. GreyCat
        08.10.2017 01:37
        +2

        Не достучался только до серийного номера диска (через ioctl)

        ioctl не нужен, вполне отдается через sysfs обычному пользователю: см., например, /sys/bus/scsi/devices/0:0:0:0/wwid


        и BIOS

        Если речь про DMI, то да, там обычно нужен либо доступ к /dev/mem, либо к чему-нибудь в районе /sys/firmware/dmi — и там тоже все только для root.


        1. ashumkin
          08.10.2017 23:14

          вот спасибо про

          /sys/bus/scsi/devices/0:0:0:0/wwid

          я через sysfs как раз получаю модель диска, но вот серийный номер… тогда не нашёл такого параметра


        1. totuin
          09.10.2017 13:47

          Спасибо, буду пробовать


  1. totuin
    07.10.2017 16:51

    Хотел получить серийник матери и диска. Под виндой использовал WMI а в линукс версии так и не добрался


    1. bormental
      09.10.2017 13:41
      +1

      В Linux посмотрите ветку /sys/class/dmi/id/
      Но наиболее надежный product_uuid обычно
      -r--------. 1 root root 4096 Oct 9 13:31 /sys/class/dmi/id/product_uuid
      Но имейте в виду, что хоть обычный пользователь и не будет этим заниматься, но подделать все эти ID не сильно сложно: изменив оригинальный образ BIOS и прошить его.


      Серийник диска получить тоже реально. Для обычных SATA легче всего, но вот со всякими SAS, NVMe, хитрыми RAID контроллерами уже "геморройнее"...


      1. totuin
        09.10.2017 13:49

        Я делаю портативную программу, запускаемую даже с флешки. И естественно без рута. Но спасибо, попробую.


  1. izzholtik
    07.10.2017 16:57
    +1

    Как бороться со злонамеренной отправкой миллиона рандомных записей?


    1. zapimir
      07.10.2017 17:35

      HMAC подпись?


    1. Zoldan Автор
      08.10.2017 03:01

      Да, есть такая дыра, на практике я использовал шифрование отправляемых данных (aes). На стороне сервера расшифровывал, проверял, если все ОК, то только тогда закидывал в БД.


      1. D3fl4t3
        08.10.2017 08:12
        +2

        В чём проблема вытащить ключ из программы и самим шифровать всё что вздумается и отправлять на сервер?


        1. Zoldan Автор
          08.10.2017 09:22
          -2

          Для этого ее нужно будет дизассемблировать, а потом рыться в IL коде. От нефик делать желающих немного найдется. Хотя… По крайней мере, я ничего другого не придумал…


          1. Tiberiumk
            08.10.2017 16:32

            Уже давно есть декомпиляторы и деобфускаторы, с которыми можно смотреть С# (конечно код далеко не всегда идентичен оригиналу). Посмотрите на ILSpy, dnSpy (мне больше всего он нравится), dotPeek.
            Из деобфускаторов наверное de4dot


    1. D3fl4t3
      08.10.2017 03:03

      Подозреваю, тут надо бояться не этого, а того, что на сервере пользовательский ввод никак не фильтруется и при должной сноровке любой желающий может получить неограниченный доступ к БД.


  1. siniy
    07.10.2017 17:42
    -2

    Пользуемся CSharpAnalytics. Позволяет отправлять данные в google analytics и потом смотреть красивые отчеты — количество онлайн, среднее время работы, ошибки, счетчики, переходы между окнами.
    Статистика как на обычном сайте. Отличие в том что на обычном сайте — вставил код трекинга в футер и все. А программе под винду нужно на каждое событие добавить код трекинга.


    1. TheTony
      07.10.2017 21:12

      Спасибо за идею, посмотрю!
      Идея претендует на большую полноту, так как предусматривает готовый сервер сбора статистики.


    1. Akuma
      07.10.2017 21:29

      Почему-то сразу подумал про гугло-аналитику. Они позволяют отслеживать что угодно, можно хоть входящих в магазин людей считать при желании.

      По сути, какая-то специальная библиотека не требуется. Достаточно отправлять collect запросы со своим UA. Не знаю правда как обезопасить себя от подделки запросов, но наверное что-то есть в доках аналитики. Ну на крайняк можно подписывать, заворачивать на свой сервер и оттуда уже в аналитику. Скрипт на PHP на пять строчек по сути.


  1. InfernalAlex
    08.10.2017 03:04

    ProcessorID не явзяется уникальным идентификатором экземпляра устройства, и даже у разных моделей может совпадать. Готовый UUID рекомендуют брать у Win32_ComputerSystemProduct (говорят интернеты).


  1. SinsI
    08.10.2017 03:04
    +1

    Я бы сделал автообновление (проверка на наличие их при работе) — и считал бы загрузки таких обновлений.


  1. 10E137
    08.10.2017 06:35

    Можете назвать меня привередой, но если уж пишете реальный код в примерах, то соблюдайте хотябы минимальную гигиену- у вас в C# коде нету ни одного вызова Dispose или использования using, причем вы открываете кучу unmanaged дескрипторов. Я уже не говорю про пустые catch и ваш весьма сомнительный подход к управлению потоками.


    1. Zoldan Автор
      08.10.2017 06:47

      Согласен с замечанием, вот так будет правильнее:

      using (ManagementObjectSearcher mos = new ManagementObjectSearcher())
                  {
                      // Процессор
                      mos.Query = new ObjectQuery("Select * From Win32_processor");
                      foreach (ManagementObject mo in mos.Get())
                      {
                          try
                          {
                              CPUid = mo["ProcessorID"].ToString();
                          }
                          catch { }
                      }
                      // Материнская плата            
                      mos.Query = new ObjectQuery("SELECT * FROM Win32_BaseBoard");
                      foreach (ManagementObject mo in mos.Get())
                      {
                          try
                          {
                              MtbId = mo["SerialNumber"].ToString();
                          }
                          catch { }
                      }
                      // Жесткий диск
                      ManagementObject dsk = new ManagementObject(@"win32_logicaldisk.deviceid=""C:""");
                      try
                      {
                          DiskId = dsk["VolumeSerialNumber"].ToString();
                      }
                      catch { }
                      finally
                      {
                          dsk.Dispose();
                      }
                  }
      

      На счет пустых catch — они нужны только для того что бы не вылетало сообщение о необработанном исключении, в случае если не удается определить id «железа». А на счет потока не совсем понял, что там не так.


      1. 10E137
        08.10.2017 09:50

        Во первых пустые кетчи это не красиво. Во вторых, в общих чертах вы пытаетесь сгенерировать хеш оборудования, и то что вы просто пропускаете ошибки не обрабатывая увеличивет количество коллизий. По управлению потоком — вы стартуете поток в Registration(). А где возможность его остановить? А где обработка ThreadAbortException?
        И кстати по самой теме статьи — NetworkInterface.GetAllNetworkInterfaces() .Where(_ => _.OperationalStatus == OperationalStatus.Up).Select( _ => _.GetPhysicalAddress().ToString())


        1. Zoldan Автор
          08.10.2017 12:24

          Согласен, код неидеален, но он и не является закрытым. Любой может взять его за основу, доработать и убрать ошибки.
          Спасибо за критику.


  1. Alex_v99
    09.10.2017 14:50

    Всё это здорово, но только в одном случае — если программу выпустят в сеть. А в общем случае никто условный калькулятор или блокнот туда не пустит в принципе. Более того, если такое приложение вдруг полезет в сеть без очевидной для пользователя нужды, это плохо скажется на его репутации.