Winlog

WINLOG est maintenant disponible sur GitHub : https://github.com/jbousquie/winlog


Le but est de mettre en place un système de surveillance de l'activité de connexion à Active Directory des postes Windows de salles de cours informatiques sans avoir à "éplucher" les tickets Kerberos sur le serveur Windows.
Ce système ne doit en outre pas nécessiter de déploiement ou d'installation sur le parc Windows et doit fonctionner simplement avec toutes les versions d'OS (win XP, win 7).

L'architecture en place consiste en :

  • un parc windows XP ou Seven sous AD. Chaque machine a une adresse IP réservée dans le DHCP.
  • un contrôleur de domaine Windows avec Active Directory. Le domaine Windows installé se nomme ici domaine.local. Le schéma AD contient des comptes utilisateurs étudiants ou enseignants respectivement rangés dans des OU "Etudiants" et "Enseignants" ainsi que des comptes d'ordinateurs rangés dans des OU nommées comme les salles qui les hébergent,
  • un serveur linux nommé winlog.domaine.local faisant tourner un apache/php et un mysql.
Elle fonctionne très simplement :
  • à chaque ouverture/fermeture de session windows, le PC exécute un script délivré par le contrôleur de domaine,
  • ce script émet une requête HTTP POST vers le serveur winlog avec quelques données (username, nom de la machine),
  • un serveur http filtre et accepte les requêtes, puis stocke les données reçues dans une base de données.

Ces données peuvent ensuite être consultées en temps réel, ou a posteriori, au travers d'une webapp php.



Script de connexion

Le script de connexion est écrit en VBScript, langage interprété tournant sur toutes les versions de windows. Il est déployé dans la GPO pour être exécuté à la connexion à AD par chaque utilisateur.
\\domaine.local\SysVol\domaine.local\Policies\{F292B1A0-F0DD-4704-BEAA-A5F2E7E225CD}\User\Scripts\Logon\logon.vbs

Il envoie le code de l'action de connexion "C", le username et le nom de la machine.
On envoie aussi un code arbitraire, connu du serveur, pour filtrer d'éventuelles requêtes POST anonymes sur ce dernier. Attention, le code ne doit pas contenir des caractères interférant avec l'encodage URL comme +, &, %, etc. Ce code peut être fixé en dur dans les scripts côté serveur et client une fois pour toute, généré chaque jour des deux côtés, etc.

logon.vbs :

Dim  o, n, data, secopt
Set o = CreateObject("WinHttp.WinHttpRequest.5.1")
Set n = CreateObject("wscript.network")
o.setproxy 1
o.Option(4) = 13056 'pour forcer à ignorer toutes les erreurs de certificats
o.open "POST", "https://winlog.domaine.local/", False
o.setRequestHeader "Content-Type", "application/x-www-form-urlencoded"
data = "code=valeur_du_code&action=C&username="+LCase(n.Username)+"&computer="+n.ComputerName
o.send data

Script de déconnexion

Il s'agit exactement du même script que le script de connexion, mais avec l'envoi d'un code d'action de déconnexion "D".
\\domaine.local\SysVol\domaine.local\Policies\{F292B1A0-F0DD-4704-BEAA-A5F2E7E225CD}\User\Scripts\Logoff\logout.vbs

logout.vbs :

Dim  o, n, data, secopt
Set o = CreateObject("WinHttp.WinHttpRequest.5.1")
Set n = CreateObject("wscript.network")
o.setproxy 1
o.Option(4) = 13056 'pour forcer à ignorer toutes les erreurs de certificats
o.open "POST", "https://winlog.domaine.local/", False
o.setRequestHeader "Content-Type", "application/x-www-form-urlencoded"
data = "
code=valeur_du_code&action=D&username="+LCase(n.Username)+"&computer="+n.ComputerName
o.send data

Idée : des scripts similaires, codés par exemple en python, pourraient être déployés sur les machines Linux du parc pour execution à l'ouverture/fermeture de session.


Serveur web

Il s'agit d'un simple serveur Apache + php. On chiffre les flux afin de se protéger d'un éventuel sniff du réseau.
Mise en place de SSL :
apt-get install openssl
cd /etc/apache2
mkdir certs
cd certs
openssl req -new > winlog.domaine.local.csr
openssl rsa -in privkey.pem -out winlog.domaine.local.key
openssl x509 -in winlog.domaine.local.csr -out winlog.domaine.local.cert -req -signkey winlog.domaine.local.key -days 1095
a2enmod ssl
/etc/init.d/apache2 force-reload

On créée un virtual host :
nano /etc/apache2/sites-available/default.ssl

NameVirtualHost 192.168.100.59:443
<VirtualHost 192.168.100.59:443>
       ServerAdmin webmaster@localhost
       DocumentRoot /var/www/
       SSLEngine on
       SSLCertificateFile /etc/apache2/certs/winlog.domaine.local.cert
       SSLCertificateKeyFile /etc/apache2/certs/winlog.domaine.local.key
       CustomLog /var/log/apache2/ssl-access.log combined
       ErrorLog /var/log/apache2/ssl-error.log
</VirtualHost>

On l'active et on le charge :
a2ensite default.ssl
a2dissite 000-default
/etc/init.d/apache2 reload


script du serveur :

Le script sur le serveur va vérifier que la requête est bien un POST et qu'il contient le code partagé entre le client et le serveur avant de stocker un enregistrement de connexion ou de déconnexion. A noter : des filtres supplémentaires (plage d'adresses, ACL, etc) peuvent bien sûr être déployés sur apache ou sur le firewall entre les postes clients et les PC des salles.

/var/www/index.php :
<?php
$db_server = "winlog.domaine.local";
$db_dbname ="winlog";
$db_user = "route";
$db_passwd = "maux de passe";
$server_code = "
valeur_du_code";

// ne traiter que sur des requêtes POST
if ( $_SERVER["REQUEST_METHOD"] == "POST" ) {
    $action = $_POST["action"];
    $username = $_POST["username"];
    $computer = $_POST["computer"];
    $code = $_POST["code"];
    $ip = $_SERVER["REMOTE_ADDR"];

    if (strcmp($code, $server_code)!=0) { exit; } // se protéger des POST anonymes par un code partagé entre client et serveur

    $db = mysql_pconnect($db_server, $db_user, $db_passwd);
    mysql_select_db($db_dbname, $db);
    
    // requête de purge d'une éventuelle connexion restée ouverte sur une machine (multi-session non autorisée sur les PC)
    $req_purge_C = 'UPDATE connexions SET close = 1 WHERE close = 0 AND hote = "'.$computer.'"';
    // requête de création de l'enregistrement de connexion
    $req_con_C ='INSERT INTO connexions (username, hote, ip, debut_con, close) VALUES ("'.$username.'", "'.$computer.'", "'.$ip.'", CURRENT_TIMESTAMP(),0)';
    //requête de mise à jour (fermeture) de la connexion
    $req_con_D = 'UPDATE connexions SET close = 1 WHERE close = 0 AND username = "'.$username.'" AND hote = "'.$computer.'"';
    // si action = C alors $req = $req_con_C, sinon $req_con_D
    $req = $action == "C" ? $req_con_C:$req_con_D;
    
    if ($action == "C") { mysql_query($req_purge_C, $db); } // on commence par purger avant de créer une connexion
    $res = mysql_query($req, $db);
    }
?>

LA BASE WINLOG

La base de données winlog comprend 4 tables : comptes, connexions, machines et salles.
Les tables comptes, machines et salles sont pré-remplies par le script recup_salles.php. Ce dernier interroge Active Directory et récupère les comptes windows dans l'AD, ainsi que le nom des salles et des machines (machines dans des OU).

Seule la table connexions est alimentée au fil de l'eau par les connexions/déconnexions des utilisateurs à leur session windows.

La table comptes :
SET SQL_MODE="NO_AUTO_VALUE_ON_ZERO";
--
-- Structure de la table `comptes`
--

CREATE TABLE IF NOT EXISTS `comptes` (
  `compte_id` int(11) NOT NULL auto_increment,
  `username` varchar(25) collate utf8_unicode_ci NOT NULL,
  `prenom` varchar(25) collate utf8_unicode_ci NOT NULL,
  `nom` varchar(30) collate utf8_unicode_ci NOT NULL,
  `groupe` varchar(25) collate utf8_unicode_ci default NULL,
  PRIMARY KEY  (`compte_id`)
) ENGINE=MyISAM  DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=679 ;

La table connexions :

SET SQL_MODE="NO_AUTO_VALUE_ON_ZERO";
--
-- Structure de la table `connexions`
--

CREATE TABLE IF NOT EXISTS `connexions` (
  `con_id` bigint(20) NOT NULL auto_increment,
  `username` varchar(30) collate utf8_unicode_ci NOT NULL,
  `hote` varchar(15) collate utf8_unicode_ci NOT NULL,
  `ip` varchar(30) collate utf8_unicode_ci default NULL,
  `fin_con` timestamp NULL default CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP,
  `debut_con` timestamp NULL default '0000-00-00 00:00:00',
  `close` tinyint(1) NOT NULL default '1',
  PRIMARY KEY  (`con_id`),
  KEY `user_host` (`username`,`hote`),
  KEY `close` (`close`)
) ENGINE=MyISAM  DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=93667 ;

La table machines :

SET SQL_MODE="NO_AUTO_VALUE_ON_ZERO";
--
-- Structure de la table `machines`
--

CREATE TABLE IF NOT EXISTS `machines` (
  `machine_id` varchar(15) collate utf8_unicode_ci NOT NULL,
  `salle` varchar(10) collate utf8_unicode_ci NOT NULL,
  `os` varchar(30) collate utf8_unicode_ci NOT NULL,
  `os_sp` varchar(20) collate utf8_unicode_ci NOT NULL,
  `os_version` varchar(20) collate utf8_unicode_ci NOT NULL,
  `ip_fixe` varchar(30) collate utf8_unicode_ci NOT NULL,
  PRIMARY KEY  (`machine_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

La table salles :

SET SQL_MODE="NO_AUTO_VALUE_ON_ZERO";
--
-- Structure de la table `salles`
--

CREATE TABLE IF NOT EXISTS `salles` (
  `salle_id` varchar(10) collate utf8_unicode_ci NOT NULL,
  `libre_service` tinyint(1) NOT NULL,
  PRIMARY KEY  (`salle_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

script de chargement des tables comptes, salles et machines : recup_salles.php.
Il vide les tables au préalable, avant de les remplir à nouveau avec les valeurs récupérées dans l'AD.

<?php
// Récupération des salles et des machines dans l'AD, chargement dans la base

include('winlog_admin.conf');

// Connexion à la base winlog
$db = mysql_pconnect($db_server, $db_user, $db_passwd);
mysql_select_db($db_dbname, $db);
$req_purge_machine = "TRUNCATE machines";
$req_purge_salle = "TRUNCATE salles";
$req_purge_compte = "TRUNCATE comptes";

// connexion LDAP à l'AD
$ldap_con = ldap_connect($ldap_host, $ldap_port);
$ldap_auth = ldap_bind($ldap_con, $ldap_rdn, $ldap_passwd);

// Lecture des salles dans AD
$res_salles = ldap_search($ldap_con, $base_salles, $filtre_salles, $attr_salles);
$entry_salles = ldap_get_entries($ldap_con, $res_salles);

// Insertion des machines
mysql_query($req_purge_machine,$db);
$salles = array();  // Ce tableau indexé contiendra les salles qui possèdent des machines
for ($i=0; $i<$entry_salles["count"];$i++) {
    $dn_tab = ldap_explode_dn($entry_salles[$i]["dn"],1);
    $salle = $dn_tab[1];  // le nom de la salle dans laquelle elle se trouve est le 2° élément du DN d'une machine
    $salles[$i] = $salle; // tableau des salles construit à la volée
    $machine_id = $entry_salles[$i]["cn"][0];
    $os = $entry_salles[$i]["operatingsystem"][0];
    $os_sp = $entry_salles[$i]["operatingsystemservicepack"][0];
    $os_version = $entry_salles[$i]["operatingsystemversion"][0];
    $req_machine = "INSERT INTO machines (machine_id, salle, os, os_sp, os_version) VALUES ('{$machine_id}', '{$salle}', '{$os}', '{$os_sp}', '{$os_version}')";
    mysql_query($req_machine,$db);
}

// Insertion des salles
$salles = array_unique($salles); // suppression des doublons dans le tableau des salles
mysql_query($req_purge_salle,$db);
foreach($salles as $s) {
    $req_salle = "INSERT INTO salles (salle_id) VALUES ('{$s}')";
    mysql_query($req_salle,$db);
    }

// Lecture des enseignants dans AD
$res_enseignants = ldap_search($ldap_con, $base_enseignants, $filtre_enseignants, $attr_enseignants);
$entry_enseignants = ldap_get_entries($ldap_con, $res_enseignants);

// Insertions des enseignants
mysql_query($req_purge_compte,$db);
for ($i=0; $i<$entry_enseignants["count"];$i++) {
    $dn_tab = ldap_explode_dn($entry_salles[$i]["dn"],1);
    $username = $entry_enseignants[$i]["samaccountname"][0];
    $prenom = $entry_enseignants[$i]["givenname"][0];
    $nom = $entry_enseignants[$i]["sn"][0];
    $req_enseignant = "INSERT INTO comptes (username, prenom, nom, groupe) VALUES ('{$username}', '{$prenom}', '{$nom}', 'Enseignant')";
    mysql_query($req_enseignant,$db);
    }

// Lecture des étudiants dans AD
$res_etudiants = ldap_search($ldap_con, $base_etudiants, $filtre_etudiants, $attr_etudiants);
$entry_etudiants = ldap_get_entries($ldap_con, $res_etudiants);

// Insertions des étudiants
for ($i=0; $i<$entry_etudiants["count"];$i++) {
    $dn_tab = ldap_explode_dn($entry_etudiants[$i]["dn"],1);
    $groupe = $dn_tab[1];
    $username = $entry_etudiants[$i]["cn"][0];
    $prenom = $entry_etudiants[$i]["givenname"][0];
    $nom = $entry_etudiants[$i]["sn"][0];
    $req_etudiant = "INSERT INTO comptes (username, prenom, nom, groupe) VALUES ('{$username}', '{$prenom}', '{$nom}', '{$groupe}')";
    mysql_query($req_etudiant,$db);
    }

ldap_close($ldap_con);

?>
Comments