Article publié dans Linux Magazine 96, juillet/août 2007.
Copyright © 2007 - Laurent Gautrot
Class::DBI
[1] offre une couche d'abstraction très pratique et très agréable
aux bases de données. Nous allons voir en pratique comment utiliser Perl et
CDBI
pour gérer des zones DNS à partir d'une base de données SQLite.
Perl permet l'utilisation de nombreuses sources de données (bases de données
relationnelles ou fichiers) grâce à DBI
. Sa portabilité et sa souplesse ne
sont plus à démontrer.
Pour des projets d'envergure, l'appel direct à DBI
peut devenir un
casse-tête, en obligeant à manipuler des notions de base de données et
de logique métier au sein des mêmes morceaux de code.
Les notions de persistance ne sont pas non plus au rendez-vous. Un ORM
(Object-Relational Mapper) comme Class::DBI
permet de se concentrer
sur le métier et de laisser de côté le gros du travail de connexion
aux bases de données. En particulier, il n'est pas besoin d'écrire de SQL,
au moins dans 80% des cas.
Naturellement, CDBI
(c'est son petit nom) établit des correspondances
entre un modèle objet et un modèle relationnel, en donnant des méthodes
(accesseurs) pour les colonnes des tables, ainsi que des moyens de naviguer
entre les tables en modélisant les relations.
En fait, CDBI
va même beaucoup plus loin. Certaines fonctionnalités
inexistantes au niveau du SGBD peuvent être utilisées. C'est le cas des
déclencheurs (triggers), du support de l'intégrité référentielle et des
mécanismes afférents (suppressions ou mise à « NULL
» en cascade),
des transactions, ou même de petites choses toutes simples comme les
auto-incréments pour des valeurs entières de clefs primaires.
Il est aussi possible de « mélanger » des SGBD, ce qui est très pratique lors de migrations progressives, avec certains morceaux du SI qui utilisent une technologie et d'autres morceaux qui en utilisent une autre.
Ce transfert de compétences au niveau applicatif apporte une grande souplesse et une portabilité, qui peut toutefois s'exercer au détriment de la performance.
Il existe bien sûr plusieurs autres ORM dans le monde Perl, parmi
lesquels on peut citer DBIx::Class
, Tangram, Alzabo,
Jifty::DBI
. Certains comme Tangram ou Alzabo sont assez anciens
(les premières versions de Tangram datent de 1999, celles d'Alzabo
de 2000), et donc stables et matures. Class::DBI
lui-même date de
fin 2001, mais par rapport aux précédents s'est rapidement orienté sur
une approche très modulaire, en favorisant l'utilisation de plugins.
Plus récent, DBIx::Class
est apparu il y a deux ans pour répondre
aux besoins des développeurs de Catalyst (le framework web agile en Perl,
comparable à RoR) qui ne pouvaient être satisfaits par Class:DBI
.
Quoiqu'avec une orientation différente, DBIx::Class
en reste assez
proche sur certains points dont une partie de l'API, et inclut d'ailleurs
la suite de tests de Class::DBI
. Encore plus récent, Jifty::DBI
est l'ORM utilisé par Jifty (un nouveau framework web agile).
Comme son nom l'indique (au moins, un peu, reconnaissez-le), CDBI
s'appuie
sur DBI
pour s'interfacer avec les bases de données. En fonction de ce que
vous prévoyez d'utiliser comme SGBD, il est judicieux d'installer le ou les
pilotes (DBD : DataBase Driver) correspondant à votre système :
DBD::Pg
, DBD::MySQL
, DBD::SQLite
, etc.
Dans les exemples qui suivent, le SGDB utilisé est SQLite. Le module
DBD::SQLite
fournit à DBI
un pilote pour les bases de données de ce
type. Il contient un moteur SQLite (domaine public), et il n'est donc pas
nécessaire d'installer un autre moteur.
Les exemples classiques tirés de la documentation de CDBI
(et de
DBIx::Class
, entre autres) traitent de la base de données de gestion
de discothèque. Seul Alzabo
joue la carte de la dissidence en
proposant la gestion de vidéothèque, mais je m'égare.
La correspondance entre le modèle objet et le modèle relationnel est très simple, voire simpliste, puisqu'une table de la base correspond à une classe.
La première étape consiste à choisir un espace de nommage pour les classes que nous allons décrire.
En général, on considère que c'est une bonne pratique de définir une classe
qui hérite de CDBI
, puis de créer des classes qui héritent de cette classe.
On peut alors préciser des paramétrages au niveau de cette classe « racine »
(valeurs d'AutoCommit
, etc.). Les autres classes du programme héritent
ensuite de cette classe, en bénéficiant des paramètres ou en les surchargeant en
cas de besoin, ce qui permet de ne décrire la connexion à la base de données
qu'une seule fois. Toutefois, si plusieurs bases de données sont impliquées, il
suffit de surcharger la connexion à la base de données.
Ensuite, à l'aide de méthodes bien nommées, on paramètre la table (méthode
table()
) correspondant à une classe, les colonnes (columns()
) de cette
table et les relations qui peuvent exister avec d'autres tables (has_a()
,
has_many()
, might_have()
). Bien entendu, si des relations particulières
existent et ne sont pas prévues, il est possible de les développer et
d'enrichir CDBI
. Contributeurs bienvenus.
Toutes les autres définitions (déclencheurs, auto-incréments, etc.) sont aussi définies à ce niveau.
La déclaration de la classe rend disponibles des méthodes nommées de manière prévisible pour accéder aux valeurs des colonnes d'une table ou des tables qui lui sont liées.
Si j'ai une table « zone », qui contient la liste des zones que je gère
dans mon DNS, je peux utiliser la requête SQL
suivante :
CREATE TABLE zone ( id INTEGER PRIMARY KEY, zone VARCHAR(255), internal INTEGER NOT NULL DEFAULT 0 );
Et une connexion CDBI
s'écrirait de cette manière :
package DNS::Manager::DBI; use base 'Class::DBI'; DNS::Manager::DBI->connection('dbi:SQLite:dbname=dns.db', '', ''); package DNS::Manager::zone; use base 'DNS::Manager::DBI'; DNS::Manager::zone->table('zone'); DNS::Manager::zone->columns(All => qw/id zone/);
Pour les administrateurs systèmes qui gèrent des DNS, la fabrication des fichiers de zone (les bases de données à proprement parler) « à la main » peut s'avérer fastidieuse, voire périlleuse.
Quoi de plus rageant qu'un caractère parasite introduit par une intervention humaine et indétectable pour qui n'est pas une machine ? Certes, il existe des outils pour valider la syntaxe des fichiers de zone. Néanmoins, certaines erreurs peuvent être très pénibles à détecter et corriger, si elles ne relèvent pas de la syntaxe.
Fichiers de zone
Les fichiers de zone constituent un format parfaitement défini (qui est aussi celui des requêtes DNS) et relativement lisible pour un sysadmin. Étant des fichiers texte, ils ne nécessitent qu'un simple éditeur de texte (vi, emacs, autre) pour être modifiés et peuvent facilement être gérés dans un SCM (SCCS, RCS, CVS, SVN, autre).
Utiliser une base de données offre en effet un certain confort lorsqu'on est amené à gérer plusieurs centaines d'enregistrements, mais cela nécessite aussi de disposer d'une interface graphique ou web permettant de gérer les entrées. Par ailleurs, cela impose de repenser/recréer un système de gestion des versions.
Nous envisageons donc une application avec un jeu de commandes
s'appuyant sur une base de données assez simple. Cette dernière contient
les correspondances A-Record (Nom<->IP) et CNAME
(Nom -> Nom).
Pour cela, nous allons avoir dans un premier temps un schéma constitué de cinq tables. C'est un schéma « simple », mais qui contient deux petites subtilités dans la mise en œuvre.
Ce petit projet devrait offrir :
La possibilité de modifier des informations facilement (correction de noms, d'adresses, etc.).
La possibilité d'ajouter des zones ou d'en supprimer.
L'uniformisation des commandes (avec les mêmes commutateurs).
Une génération automatisée de la configuration pour un outil de surveillance, en l'occurrence Nagios [9].
Les relations entre associations se traduisent par les références suivantes :
ip
-> machine
machine_zone
-> zone
, machine
alias
-> zone
Et le code SQL résultant serait le suivant :
CREATE TABLE machine ( id INTEGER PRIMARY KEY AUTOINCREMENT, machine VARCHAR(50) UNIQUE NOT NULL DEFAULT '', visible INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE zone ( id INTEGER PRIMARY KEY AUTOINCREMENT, zone VARCHAR(255) UNIQUE NOT NULL DEFAULT '', managed INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE alias ( alias VARCHAR(50) NOT NULL DEFAULT '', zone_alias INTEGER NOT NULL REFERENCES zone(id), destination VARCHAR(50) NOT NULL DEFAULT '', zone_destination INTEGER NOT NULL REFERENCES zone(id), recursive INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (alias,zone_alias,recursive) ); CREATE TABLE ip ( ip VARCHAR(16) UNIQUE NOT NULL DEFAULT '', machine INTEGER NOT NULL REFERENCES machine(id), PRIMARY KEY (ip) ); CREATE TABLE zone_machine ( zone INTEGER NOT NULL REFERENCES zone(id), machine INTEGER NOT NULL REFERENCES machine(id), PRIMARY KEY (zone,machine) );
Quant à la table zone
, on peut commencer à l'alimenter avec quelques
lignes.
INSERT INTO zone(zone) VALUES ('zone1'); INSERT INTO zone(zone) VALUES ('zone2'); INSERT INTO zone(zone) VALUES ('zone3'); INSERT INTO zone(zone) VALUES ('zone4');
Un scénario assez simple consiste à afficher une liste de valeurs d'une table.
Ce script utilise Class::DBI
pour lister les zones contenues dans
la table « zone ».
1 #!/usr/bin/perl 2 3 use strict; 4 use warnings; 5 6 package DNS::Manager::DBI; 7 use base 'Class::DBI'; 8 DNS::Manager::DBI->connection('dbi:SQLite:dbname=dns.db', '', ''); 9 10 package DNS::Manager::zone; 11 use base 'DNS::Manager::DBI'; 12 DNS::Manager::zone->table('zone'); 13 DNS::Manager::zone->columns(All => qw/id zone/); 14 15 my @zones = DNS::Manager::zone->retrieve_all; 16 foreach my $zone (@zones) { 17 print $zone->zone, "\n"; 18 }
Les lignes 6 à 8 permettent la connexion à la base de données.
Les lignes 10 à 13 permettent la création d'un objet DNS::Manager::zone
qui
est lié à la table zone
de la base de données. Les colonnes à utiliser
sont listées à l'aide de la méthode columns
. Il existe de nombreuses
façons de déclarer des colonnes, comme décrit dans la documentation de
CDBI.
Il est aussi possible de le réaliser automatiquement à l'aide de
Class::DBI::Loader
[2], qui sera abordé plus loin.
Enfin, dans les lignes 15 à 18, on récupère tous les enregistrements
de la table et l'on affiche pour chacun d'eux le champ zone
.
On peut améliorer la portabilité du code, si on a besoin de changer
l'espace de nom par exemple, en utilisant __PACKAGE__
lors des appels
de méthodes de classe.
Le script précédent deviendrait alors :
1 #!/usr/bin/perl 2 3 use strict; 4 use warnings; 5 6 package DNS::Manager::DBI; 7 use base 'Class::DBI'; 8 __PACKAGE__->connection('dbi:SQLite:dbname=dns.db', '', ''); 9 10 package DNS::Manager::zone; 11 use base 'DNS::Manager::DBI'; 12 __PACKAGE__->table('zone'); 13 __PACKAGE__->columns(All => qw/id zone/); 14 15 my @zones = __PACKAGE__->retrieve_all; 16 foreach my $zone (@zones) { 17 print $zone->zone, "\n"; 18 }
Pour vérifier la syntaxe du script et l'exécuter :
% perl -c dns-zone-list dns-zone syntax OK % perl dns-zone-list zone1 zone2 zone3 zone4
Si le nombre d'enregistrements dans une table est potentiellement élevé, vous auriez intérêt à utiliser des itérateurs. En effet, dans la version précédente, tous les enregistrements sont retournés en bloc, puis affichés un à un.
En utilisant un itérateur, il suffit de demander l'enregistrement suivant à
l'aide de la méthode next()
. La seule modification incontournable réside
dans le type d'objet retourné par la méthode retrieve_all()
. Un scalaire
est utilisé plutôt qu'un tableau.
Au final, le code est très proche, et la réécriture du script précédent est la suivante :
#!/usr/bin/perl use strict; use warnings; package DNS::Manager::DBI; use base 'Class::DBI'; __PACKAGE__->connection('dbi:SQLite:dbname=dns.db', '', ''); package DNS::Manager::zone; use base 'DNS::Manager::DBI'; __PACKAGE__->table('zone'); __PACKAGE__->columns(All => qw/id zone/); my $iterator = __PACKAGE__->retrieve_all; while (my $zone = $iterator->next) { print $zone->zone, "\n"; }
Cette version du script s'appuie sur quelques principes assez simples.
Tout d'abord, les déclarations du paquetage DNS::Manager::zone
et
la connexion à la
base de données sont réalisées dans un fichier séparé, inclus au début
du script. Quelques fonctions sont écrites dans un module séparé pour
diverses fonctions élémentaires de vérification.
Ensuite, une table de distribution [6] contient les différentes actions que peut accomplir le script. Pour chacune de ces actions, une référence vers une fonction anonyme est passée.
Grâce au module standard Getopt::Long
[5], les arguments de la ligne de
commande sont déroulés et une boucle foreach
sur les arguments permet
de traiter en une ligne toutes les valeurs successives.
1 #!/usr/bin/perl 2 3 # un commentaire pour Sonia 4 5 use strict; 6 use warnings; 7 use Getopt::Long; 8 9 use lib 'lib'; 10 use DNS::Manager; 11 use DNS::Manager::Utils; 12 13 my %action = ( 14 list => sub { 15 my $internal = shift; 16 foreach ( DNS::Manager::Zone->retrieve_all ) { 17 next if not $_->managed and not $internal; 18 print $_->zone; 19 print $_->managed ? '' : '*'; 20 print "\n"; 21 } 22 }, 23 24 add => sub { 25 my ($zone,$internal) = @_; 26 if ( exists_zone($zone) ) { 27 warn "[$0] « $zone » existe déjà\n"; 28 } 29 else { 30 print "[$0] ajout de « $zone »\n"; 31 DNS::Manager::Zone->insert({ 32 zone => $zone, 33 managed => ($internal) ? 0 : 1, 34 }); 35 } 36 }, 37 38 remove => sub { 39 my $zone = shift; 40 if ( exists_zone($zone) ) { 41 print "[$0] suppression de « $zone »\n"; 42 DNS::Manager::Zone->search(zone=>$zone)->delete_all; 43 } 44 else { 45 warn "[$0] « $zone » n'existe pas\n"; 46 } 47 }, 48 49 ); 50 51 my %zone; 52 my $internal; 53 GetOptions ( 54 'internal' => \$internal, 55 'add=s' => \@{ $zone{add} }, 56 'remove=s' => \@{ $zone{remove} }, 57 'list' => sub { $action{list}->() }, 58 'full-list' => sub { $action{list}->(1) }, 59 'help' => sub { usage($0) }, 60 ); 61 62 for my $action (qw/add remove/) { 63 foreach ( split /,/, join ',', @{ $zone{$action} } ) { 64 $action{$action}->($_,$internal); 65 } 66 }
La fonction exists_zone()
, du module DNS::Manager::Utils
permet
de vérifier qu'une zone existe dans la base. Cette fonction est appelée
lors des ajouts et des suppressions.
sub exists_zone { my $zone = shift; DNS::Manager::Zone->retrieve(zone=>$zone) or return 0; return 1; }
La première action décrite concerne l'affichage d'une liste de zones
lignes 15 à 23.
La méthode retrieve_all()
récupère comme son nom l'indique l'ensemble
des lignes de la table et retourne une liste d'objets. Il suffit alors
d'affecter de parcourir un tableau et d'appeler successivement les
méthodes zone()
et managed()
pour afficher leur contenu.
L'ajout de zone (lignes 25 à 37) utilise une fonction qui effectue
simplement un retrieve
.
L'avantage de cette méthode est qu'elle retourne 0
(assimilable à
« faux ») si aucun enregistrement ne correspond à un critère fourni.
Une ligne est ajoutée à la base s'il n'existe pas déjà une ligne similaire.
La suppression de zone (lignes 39 à 48) fonctionne sur le même schéma
que l'ajout, avec une vérification d'existence au préalable. Comme il est
possible d'appeler la méthode delete_all()
sur une liste d'objets, et que
la méthode search()
retourne une liste d'objets, la formulation est
très élégante (ligne 43).
La récupération des options (lignes 54 à 61) avec Getopt::Long
est un
jeu d'enfant et permet l'utilisation d'options courtes.
Si la syntaxe peut sembler déroutante, la documentation regorge d'exemples.
Les valeurs du hachage sont toutes des références vers des scalaires, des
listes, ou des fonctions (anonymes ou non).
La partie vraiment active du script est comprise dans les lignes 63 à 67.
Pour chaque zone des listes zone{add}
et zone{remove}
, la liste est
remise à jour si plusieurs zones sont fournies selon différentes syntaxes :
% perl dns-zone --add zone5,zone6 --add zone7 --remove zone4,zone58 [dns-zone] ajout de « zone5 » [dns-zone] ajout de « zone6 » [dns-zone] ajout de « zone7 » [dns-zone] suppression de « zone4 » [dns-zone] « zone58 » n'existe pas
Ce script couvre les besoins élémentaires de gestion d'un objet DNS::Manager::Zone
mais nous n'avons pas pris en compte les descriptions des relations entre
objets, et donc des relations entre tables.
Prenons la relation qui existe entre une « machine » et « ip ». Une machine peut disposer de plusieurs interfaces réseau, et donc avoir plusieurs adresses IP. Il est important de souligner qu'une adresse ne peut appartenir qu'à une machine. On a donc une relation 1-n entre « machine » et « ip ».
Certes, dans des contextes de cluster de haute disponibilité ou dans un souci
de répartition de charge, il est possible d'associer des adresses IP virtuelles
et d'avoir une adresse du réseau partagée entre plusieurs machines. Néanmoins,
le schéma est ainsi constitué, et il permet surtout d'illustrer les relations
has_a()
et has_many()
.
En supposant que les déclarations des classes DNS::Manager::Machine
et
DNS::Manager::IP
ont été effectuées, la relation entre les deux classes
s'écrit tout simplement :
DNS::Manager::Machine->has_many(ips => 'DNS::Manager::IP'); DNS::Manager::IP->has_a(machine => 'DNS::Manager::Machine');
Cette écriture nous permet alors de manipuler des données dans la base de données aisément, sans écrire une ligne de SQL.
foreach my $machine ( DNS::Manager::Machine->retrieve_all ) { print $machine->machine, "\n"; foreach my $ip ($machine->ips) { print ' -> ', $ip->ip, "\n"; } }
Le résultat de l'exécution du code ci-avant serait une liste des machines et pour chacune, une liste de ses adresses IP.
machine1 -> 1.1.1.1 -> 1.1.1.2 machine2 -> 1.1.1.3 -> 1.1.1.4
Quelle que soit la méthode utilisée par CDBI
pour effectuer cette opération
(sous-requête ou jointure), nous n'avons qu'à nous soucier de la logique
de notre script, et utiliser un appel de méthode (ici, la méthode ips
de la classe DNS::Manager::Machine
) pour obtenir une liste d'objets,
que nous pouvons parcourir à l'aide d'une boucle foreach
.
Une facilité du même ordre existe pour l'ajout. Imaginons que la machine
nommée hephaistos
se voit attribuer une nouvelle interface, avec l'adresse
IP 192.168.12.13
. À l'aide de CDBI
,
my ($machine) = DNS::Manager::Machine->search(machine => 'hephaistos'); $machine->add_to_ips({ ip => '192.168.12.13' });
La première ligne retourne une liste d'objets, ce qui justifie la présence
de parenthèses autour de $machine
. En fait, dans notre base de données,
nous supposons qu'une seule machine porte ce nom, ce qui nous permet de passer
à la ligne suivante sans trop nous poser de questions. La méthode add_to_ips()
est appelée sur l'objet $machine
. Minute ! D'où sort cette méthode ?
C'est là aussi la magie de CDBI
. En décrivant la relation avec has_many()
cette méthode a été implicitement créée. Et il faut bien reconnaître que ça
facilite la vie en évitant l'écriture de deux ou trois requêtes SQL sur
deux tables.
La mise à jour suit la même simplicité d'écriture. Enfin, la suppression
est aussi simple, et s'applique en cascade. En réalité, il est possible de
modifier le comportement de CDBI
par rapport aux suppressions (mise à
NULL
au lieu de suppression de lignes, par exemple), mais le comportement
par défaut est la suppression des lignes liées.
DNS::Manager::Machine->search(machine => 'hephaistos')->delete_all;
Cette simple ligne permet d'effectuer une recherche dans la table machine
pour les machines dont le nom est exactement hephaistos
. Compte tenu des
contraintes sur la table machine
, cette recherche ne peut retourner au plus
qu'un objet, mais dans tous les cas, la méthode delete_all()
supprimera tous
les objets concernés et donc les lignes correspondantes dans la table
machine
. Là où la magie de CDBI
nous saisit encore, c'est que grâce à
has_many()
, les lignes correspondantes dans la table ip
seront également
supprimées. On devine aisément la puissance de l'outil et la prudence qu'il
doit susciter. En plus, c'est très sexy à lire.
Ce paragraphe a décrit les relations 1-n
, mais CDBI
peut-il faire encore
plus ?
Si l'on se fie uniquement à la documentation du module, la réponse à cette question est : « On ne peut pas. Si vous le voulez, faites-le vous-même. »
Mais si l'on fouille sur le wiki de CDBI
, la réponse est beaucoup moins
frustrante. On peut voir une relation n-m comme la nécessité de créer
une table d'association, qui peut éventuellement être porteuse d'information.
CDBI
permet de décrire cela, avec beaucoup de sucre syntaxique.
DNS::Manager::Machine->has_many(zone_machines => 'DNS::Manager::ZoneMachine'); DNS::Manager::ZoneMachine->has_a(machine => 'DNS::Manager::Machine'); DNS::Manager::Zone->has_many(zones_machine => 'DNS::Manager::ZoneMachine'); DNS::Manager::ZoneMachine->has_a(zone => 'DNS::Manager::Zone'); DNS::Manager::Machine->has_many(zones => ['DNS::Manager::ZoneMachine' => 'zone']); DNS::Manager::Zone->has_many(machines => ['DNS::Manager::ZoneMachine' => 'machine']);
Dans cette relation, trois tables sont en jeu : machine
, zone
et la table d'association zone_machine
, qui contiendra les clefs étrangères
de machine
et zone
.
Les relations sont décrites assez classiquement par les quatre premières lignes
en utilisant has_a()
et has_many()
. Les deux dernières lignes décrivent
la relation n-m. Elles permettent l'ajout ou la suppression dans la table
d'association zone_machine
lors de l'utilisation de la méthode
add_to_zones()
sur un objet DNS::Manager::Machine
ou de la méthode delete()
.
my $machine = 'machine1'; my @zones = qw/ zone1 zone2 zone3 zone4 /; my @ips = qw/ 1.1.1.1 1.1.1.2 1.1.1.3 /; my $new_machine = DNS::Manager::Machine->insert({ machine => $machine }); foreach (@zones) { my ($zone_obj) = DNS::Manager::Zone->search({zone=>$_}); $new_machine->add_to_zones({ zone => $zone_obj->id }); } foreach (@ips) { $new_machine->add_to_ips({ ip => $_ }); }
Dans cet exemple, la troisième ligne ajoute une entrée dans la table machine
.
Les quatre lignes suivantes parcourent une liste de zones, et pour chacune,
obtiennent l'identifiant et ajoutent la zone. Enfin, les adresses IP sont
ajoutées.
En particulier, comment modéliser plusieurs clefs étrangères composites dans une table référençant la même clef primaire d'une autre table ?
Ce cas de figure n'est pas non plus très courant, mais si le schéma de votre base en comporte, voici une astuce tirée du wiki pour les gérer.
Ici encore, on trouve beaucoup de sucre syntaxique.
DNS::Manager::Zone->has_many( zones_alias => ['DNS::Manager::Alias' => 'zone_alias'], 'zone_destination' ); DNS::Manager::Zone->has_many( zones_destination => ['DNS::Manager::Alias' => 'zone_destination'], 'zone_alias' );
Il s'agit de faire entendre à CDBI qu'il y a une relation décrite à l'aide
de la méthode has_many()
, sauf que dans le cas de zones_alias
, il y a
autre chose, en l'occurrence zone_destination
.
Ensuite, le symétrique est décrit (seconde ligne).
L'information est recherchée pour les cas les plus courants avec la méthode
search()
. Dans les quelques lignes ci-avant, nous l'avons utilisée pour
identifier une ligne pour la suppression ou l'ajout dans des tables
référencées.
En fait, il existe une variante, search_like()
, qui utilise le prédicat
LIKE
. On peut alors spécifier des motifs comme en SQL pour les
correspondances approchées en utilisant « % » et « _ ».
Comme pour search()
, les recherches peuvent concerner plusieurs attributs.
En outre, il est possible de spécifier une clause ORDER BY
dans une
référence de hachage.
my @machines = DNS::Manager::Machine->search( machine => 'machine1', visible => 1 ); my @zones = DNS::Manager::Zone->search_like( zone => 'z_n%', managed => 1, { order_by=>'id DESC, zone' } );
Si ces fonctions sont intéressantes et pratiques, elles restent limitées parce qu'elles ne s'appliquent qu'à une classe à la fois et donc à une seule table.
Nous verrons un peu plus loin comment bâtir des recherches qui
ressemblent à des vraies requêtes ensemblistes, grâce au module
Class::DBI::AbstractSearch
[3].
Class::DBI::Loader
CDBI
est un bon moyen pour fournir une couche d'abstraction qui
étend les possibilités du SGBD sous-jacent.
L'abstraction reste appréciable si le SGBD dispose des fonctionnalités exigées (pour le meilleur et pour le pire) comme les déclencheurs, transactions, etc.
Mais l'on a vu que pour chaque table, un objet lui est associé. Dans le cas le plus simple, quatre lignes de description de l'objet sont nécessaires. Nous n'avons pas de clefs composites et nous n'avons pas abordé les relations.
Si notre schéma comporte moins d'une dizaine de tables et que l'on souhaite gérer finement le schéma, une description intégrale reste possible. Si le schéma comprend quelques dizaines (voire centaines) de tables, avec un grand nombre de colonnes, on frise la crise de nerf.
C'est pour cela que le module Class::DBI::Loader
[2] existe. Il peut générer
à partir du dictionnaire du SGBD (pour ceux qui sont supportés, à savoir
à l'heure de la rédaction de cet article,
MySQL, PostgreSQL, SQLite, Informix, DB2, Oracle, Sybase) tous les objets
CDBI
et les relations. Pour l'anecdote, il existe un
Class::DBI::Loader::GraphViz
.
use Class::DBI::Loader; my $loader = Class::DBI::Loader->new( dsn => 'dbi:SQLite:dbname=dns.db', namespace => 'DNS::Manager', );
La différence la plus flagrante (outre l'absence de description très lourde) est qu'il suffit de fournir un nom pour la classe racine et tous les objets déductibles seront déduits et créés dans l'espace de nommage.
Le code s'en trouve considérablement allégé, mais au prix des deux conditions incontournables :
Le SGBD doit être supporté par Class::DBI::Loader
.
Le schéma doit être correctement décrit dans le dictionnaire du SGBD.
Un usage rigoureux des contraintes PRIMARY
, CHECK
, et autres
REFERENCES
est indispensable.
Ce module escamote la partie fastidieuse. Nous allons voir un autre aspect très appréciable, relatif aux recherches complexes (qui dépassent le cadre des recherches sur critères simples).
Class::DBI::AbstractSearch
Autant l'avouer tout de suite, Class::DBI::AbstractSearch
[3] ne permet pas
non plus de retourner des données provenant de plusieurs tables. Pour cela,
SQL
reste indispensable.
Par contre, il est souvent nécessaire de retourner un sous-ensemble de
résultats d'une requête. Pour certains SGBD, on peut utiliser une clause
LIMIT
. Mais tous les SGBD ne sont pas égaux en fonctionnalités, et
les syntaxes ne sont pas toutes identiques. CDBI::AbstractSearch
greffe SQL::Abstract::Limit
dans CDBI
. Il est possible de ne
retourner qu'une dizaine de lignes pour une pagination de résultats dans
une application.
use Class::DBI::AbstractSearch; my @machines = DNS::Manager::Machine->search_where( machine => [ 'hephaistos', 'odin' ], visible => { '!=', 0 }, ); my @machines = DNS::Manager::Machine->search_where( { machine => [ '%o%' ], visible => { '!=', 0 }, }, { logic => 'AND', order_by => "machine DESC", limit_dialect => 'LimitOffset', limit => 10, offset => 20, }, );
Supposons que l'on souhaite identifier 3 zones qui contiennent moins de 5
enregistrements A
. Dans notre schéma, cela correspond aux zones qui ont
moins de 5 machines.
Avec des fonctions de regroupement et des restrictions, il est nécessaire d'écrire une requête SQL personnalisée, d'autant que la requête SQL concerne deux tables.
DNS::Manager::Zone->columns(TEMP => qw/nb_machines/); DNS::Manager::Zone->set_sql(small_zones => q{ SELECT zone.id, COUNT(zone_machine.machine) AS nb_machines FROM zone, zone_machine WHERE zone.id = zone_machine.zone GROUP BY zone.id HAVING nb_machines <= 5 LIMIT 3 }); foreach my $zone (DNS::Manager::Zone->search_small_zones) { printf "%s : %d machine(s)\n", $zone->zone, scalar $zone->nb_machines; }
La première ligne précise que la colonne nb_machines
est non-persistente.
Grâce à cette astuce, il sera possible d'utiliser les accesseurs classiques de
CDBI
sur cette colonne bien qu'elle n'existe pas dans la base.
Dans le cas contraire, il aurait fallu utiliser les méthodes au niveau de DBI pour manipuler les données retournées.
À l'exécution, on aurait une sortie comme :
% perl dns-zone-list-small-zones zone1 : 5 machine(s) zone2 : 1 machine(s) zone3 : 1 machine(s)
Si vous préférez utiliser les connexions DBI et/ou que vous ne souhaitez
pas déclarer des colonnes temporaires, c'est bien entendu possible. C'est un
peu moins sexy et cela oblige à modifier la requête SQL pour obtenir le même
résultat. En effet, alors qu'avec CDBI
, nous pouvions retrouver le nom
de la zone à partir de sa clef primaire, nous sommes désormais contraints de
faire apparaître le nom de la zone dans les colonnes à retourner.
DNS::Manager::DBI->set_sql(small_zones => q{ SELECT zone.zone, COUNT(zone_machine.machine) AS nb_machines FROM zone, zone_machine WHERE zone.id = zone_machine.zone GROUP BY zone.id HAVING nb_machines <= 5 LIMIT 3 }); my $sth = DNS::Manager::DBI->sql_small_zones; $sth->execute(); while (my $row = $sth->fetch) { my $zone = $row->[0]; my $nb_machines = $row->[1]; printf "%20s : %d machines\n", $zone, scalar $nb_machines; }
On peut aussi utiliser le mécanisme des placeholders (ou paramètres
fictifs), qui permet de préparer
une requête et de substituer des valeurs après une phase de précompilation.
Dans ce cas, la requête SQL décrite à l'aide de set_sql()
contient des
?
à la place des valeurs à substituer et la méthode appelée prendra en
paramètre une liste de valeurs. Seules les valeurs sont remplaçables, pas
les noms de tables ou de colonnes. Je vous renvoie à la documentation de
DBI
et CDBI
pour plus d'exemples.
Un déclencheur est un ensemble d'opérations réalisées à la survenue d'un
événement. Ces événements sont très couramment des modifications
(ajout, mise à jour, suppression) et les opérations sont réalisées au choix
avant ou après l'événement en question. La documentation de CDBI révèle des
informations précieuses sur la survenue des événements (par exemple, SELECT
).
Sachez que l'on peut écrire plusieurs déclencheurs pour le même événement, mais qu'il n'est pas possible de spécifier leur ordre d'exécution.
Par exemple, pour ajouter une machine d'office dans une zone par défaut, on peut écrire un déclencheur comme :
DNS::Manager::Machine->add_trigger(after_create => \&add_to_zone1); sub add_to_zone1 { my ($self,%args) = @_; $self->add_to_zones({ zone => 1 }); }
Ce qui peut aussi s'écrire :
DNS::Manager::Machine->add_trigger(after_create => sub { my ($self,%args) = @_; $self->add_to_zones({ zone => 1 }); });
Dans la même veine, les contraintes procèdent de la même manière. Pour une
classe donnée, une méthode add_constraint()
prend en paramètre un nom
de contrainte et une paire « nom de colonne / coderef ».
Si vous trouvez la gestion des contraintes un peu lourde, il existe un petit
raccourci pour des contraintes simples de domaine ou de valeur à l'aide
de la méthode constraint_column()
.
Les transactions ne sont pas en reste. Si vous ne souhaitez pas les implémenter
par le biais de votre SGBD ou qu'elles ne sont pas disponibles, vous pouvez
toujours en utiliser à travers CDBI
. En fait, les mécanismes employés sont
ceux de DBI
et les méthodes dbi_commit()
et dbi_rollback()
sont des
alias vers celles de DBI
.
La documentation présente un enrobage agréable à partir d'une suggestion de
Dominic Mitchell. L'astuce consiste à désactiver la validation automatique
(autocommit) localement, et de valider toute la transaction en fin de bloc.
Ceci se fait naturellement parce qu'en sortie de bloc, la valeur
d'autocommit
est restaurée et qu'un commit est exécuté. Mais dans le cas
où l'exécution du code provoque une erreur, alors la méthode dbi_rollback()
est appelée.
sub do_transaction { my $class = shift; my ( $code ) = @_; local $class->db_Main->{ AutoCommit }; eval { $code->() }; if ( $@ ) { my $commit_error = $@; eval { $class->dbi_rollback }; die $commit_error; } } DNS::Manager::DBI->do_transaction( sub { my $zone = DNS::Manager::Zone->insert(zone => 'zone58'); my $machine = DNS::Manager::Machine->insert({ machine => 'machine58' }); $machine->add_to_ips({ ip => '10.1.1.58' }); $machine->add_to_zones({ zone => $zone->id }); } );
La transaction porte sur l'ajout d'une zone, d'une machine, et pour cette machine, l'ajout d'une adresse et le rattachement à la zone nouvellement créée.
À partir des données dont nous disposons dans notre base de données, la fabrication de fichiers de zone (correspondances directes et inverses) est relativement aisée.
Il existe quelques modules sur le CPAN pour faciliter l'écriture des entêtes de fichiers de zone et leur contenu. Comme le format des données est relativement trivial, le code ci-après prend en charge directement l'écriture des enregistrements. Bien que le résultat soit fonctionnel, vous préférerez peut-être utiliser des modules existants pour remplir ces fonctions.
Pour les correspondances inverses, on met en regard une adresse réseau (IP) et un nom complet (FQDN). Pour ce type de requêtes DNS, les alias ne sont d'aucune utilité. Il nous faut obtenir la liste des machines et de leur adresse IP pour une zone donnée.
La requête SQL qui fournit ces informations est la suivante :
SELECT DISTINCT machine.machine AS src, ip.ip AS dst FROM zone, zone_machine, machine, ip WHERE zone.zone = '$zone' AND zone.id = zone_machine.zone AND zone_machine.machine = machine.id AND machine.id = ip.machine AND machine.visible = 1
Pour les correspondances directes, la requête DNS porte sur un nom de domaine
complet (FQDN) et en réponse, le DNS fournit une adresse réseau avec si
nécessaire les étapes de résolution des CNAME
.
On utilise les données de la requête SQL qui retourne la liste des machines et leur adresse à laquelle on ajoute les alias qui pointent vers la même zone et ceux qui pointent vers une autre zone. La requête qui ajoute ces informations est la suivante :
SELECT CASE WHEN recursive = 1 THEN '*.' || alias ELSE alias END AS src, destination AS dst FROM alias, zone z1, zone z2 WHERE z1.zone = '$zone' AND alias.zone_alias = z1.id AND z2.zone = '$zone' AND alias.zone_destination = z2.id UNION SELECT CASE WHEN recursive = 1 THEN '*.' || alias ELSE alias END AS src, destination || '.' || z2.zone || '.' AS dst FROM alias, zone z1, zone z2 WHERE z1.zone = '$zone' AND alias.zone_alias = z1.id AND z2.zone <> '$zone' AND alias.zone_destination = z2.id
Elle est ajoutée à la précédente par un opérateur UNION
de SQL.
En pratique, on peut supposer que les deux SELECT
pourraient être regroupés
en un seul, et que le serveur DNS optimiserait lors des transferts de zones ce
qui peut l'être, mais pour la beauté du geste, je laisse ces concaténations
sauvages. Ce code a été testé sur PostgreSQL, SQLite et Oracle.
Pour bénéficier de la souplesse de CDBI, nous allons préciser que les deux
colonnes que nous manipulons dans ces requêtes (src
et dst
)
n'ont pas d'existence réelle, et donc qu'elles ne doivent pas être prises en
compte dans les mécanismes de persistance.
Ensuite, nous ajoutons une requête SQL à l'aide de la méthode set_sql()
.
L'utilisation du résultat sera identique à ce que nous avons vu jusqu'ici.
DNS::Manager::Alias->columns(TEMP => qw/src dst/); DNS::Manager::Alias->set_sql(for_dump => $dump_query); my @aliases = DNS::Manager::Alias->search_for_dump;
Pour ne pas empiéter sur les colonnes réelles, il est nécessaire de nommer
les colonnes retournées par la requête avec des noms différents. Sans cette
précaution, aucune modification ne sera enregistrée dans la base lors des
insertions ou mises à jour pour les colonnes qui apparaissent à la fois dans
All
et TEMP
.
Comme il est possible de définir des requêtes SQL à tout moment, avec les méthodes associées, ce script peut construire une requête et la soumettre à CDBI en cours d'exécution, ce qui est assez spectaculaire en soi.
La version intégrale du script de génération des zones pourrait ressembler à ceci :
#!/usr/bin/perl use strict; use warnings; use Getopt::Long; use Regexp::Common; use Fcntl ':flock'; use Socket; use lib 'lib'; use DNS::Manager; use DNS::Manager::Utils; my ($direct, $reverse, $output_file, $zone); GetOptions ( 'direct' => \$direct, 'reverse' => \$reverse, 'output-file' => \$output_file, 'zone=s' => \$zone, 'help' => sub { usage($0); exit 1 }, ); die "[$0] quelle zone ?\n" unless $zone; die "[$0] zone non gérée\n" unless is_managed_zone($zone); die "[$0] ni direct ni inverse ???\n" if !$reverse and !$direct; my $zone_filename = ''; my $query = make_alias_dump_query($zone, $direct); DNS::Manager::Alias->set_sql(for_dump => $query); my $result = ''; foreach my $alias (DNS::Manager::Alias->search_for_dump) { if ($direct) { $result .= $alias->src . ' IN '; $result .= $alias->dst =~ /$RE{net}{IPv4}/ && inet_aton($alias->dst) ? 'A ' . $alias->dst : 'CNAME ' . $alias->dst ; $result .= "\n"; } elsif ($reverse) { my $reverse_ip = join '.', (reverse(split /\./, $alias->dst)); $result .= sprintf("%s.in-addr.arpa. IN PTR %s.%s.\n", $reverse_ip, $alias->src, $zone ); } } $zone_filename = $reverse ? 'inverse.' . $zone : $direct ? 'direct.' . $zone : '___.' . $zone; open(my $lf, ">$zone_filename.lock") or die "Impossible d'ouvrir un fichier verrou : $!\n"; flock($lf, 2) or die "Impossible de verrouiller : $!\n"; my $serial = compute_serial($zone_filename); $result = generate_header($serial, $zone) . $result ; if ($output_file) { open(my $of, "> $zone_filename.$serial") or die "Impossible d'ouvrir le fichier $zone_filename.$serial : $!\n"; print $of $result or die "Impossible d'écrire dans le fichier $zone_filename.$serial : $!\n"; close($of) or warn "Impossible de fermer dans le fichier $zone_filename.$serial : $!\n"; if (-f $zone_filename) { unlink $zone_filename or die "Impossible de supprimer le lien $zone_filename\n"; } symlink "$zone_filename.$serial", $zone_filename or die "Impossible de créer le lien $zone_filename\n"; } else { print $result ; } END { if ($zone_filename && -f "$zone_filename.lock") { unlink "$zone_filename.lock" } }
Quelques fonctions non décrites ici sont appelées pour générer un numéro de série valide, comportant la date du jour et un incrément sur deux chiffres ainsi qu'un entête de fichier de zone valide. Ces fonctions sont très largement inspirées de Perl for System Administrator [7].
La requête SQL est également générée à l'aide d'une fonction qui retranscrit à la virgule près ce qui a été décrit un peu plus tôt.
DNS::Manager::Utils
exporte les quelques fonctions utilisées dans le script
précédent, à savoir usage()
, is_managed_zone()
, make_alias_dump_query
,
compute_serial()
et generate_header
. Le code de ce module utilitaire ainsi
que les scripts sont consultables en ligne [10].
Au final, un fichier produit aurait cet aspect :
% perl dns-file --zone zone1 --direct ~/src/dnsmam ; Generated by dns-file. Arguments were: "--zone zone1 --direct" ; Don't edit manually, it will be overwritten. ; This program was run by bifo (uid=756) at Sat Aug 19 19:04:35 2006. $TTL 1d @ IN SOA ns0.zone1. nsmaster.zone1. ( 2006081900 ; serial 3h ; refresh 1h ; retry 1w ; expire 1h) ; TTL ; IN NS ns0.zone1. IN NS ns1.zone1. ; machine1 IN A 10.1.1.1 machine2 IN A 10.1.1.2 machine3 IN A 10.1.1.3 machine4 IN A 10.1.1.4 machine5 IN A 10.1.1.5 machine6 IN A 10.1.1.6 externe IN CNAME autre.domaine. *.externe-recursif IN CNAME autre.domaine. service1 IN CNAME machine1 *.service1 IN CNAME service1
Les lignes introduites par « ; » sont des commentaires et sont ignorées.
Il serait aussi possible d'utiliser les informations relatives aux projets
pour supporter les champs SRV
. En partant de ce principe, il serait possible
d'étendre aux autres types de champs comme les enregistrements LOC
et les
clefs de machines KEY
ainsi que les signatures (enregistrements SIG
),
si le DNS fonctionne en DNSSEC.
Une autre amélioration dans la factorisation serait souhaitable, mais cette
réécriture se traduirait peut-être par du code un peu moins lisible. Chaque
script est conçu sur le même modèle (à l'exclusion du générateur de fichiers
de zone), en exploitant une table de distribution, Getopt::Long
et bien
entendu CDBI
.
L'utilisation d'un module pour les écritures dans les fichiers de zone
constitue aussi une évolution souhaitable. Il en existe un certain nombre,
comme DNS::Manager::ZoneParse
, Net::DNS::Manager::Zone::Parser
,
Net::DNS::Manager::ZoneFile
,
ou encore DNS::Manager::ZoneFile
. Consultez le CPAN pour plus d'informations.
Enfin, le versionnement des fichiers de zone après écriture devrait être
pris en compte, et si possible à l'aide d'un module comme SVK
[8]
plutôt qu'une conservation intégrale de toutes les versions.
La distribution de BIND est assortie d'outils de vérification de fichiers de configuration et de fichiers de zones. Néanmoins, cette opération est fastidieuse lorsque l'on héberge de nombreuses zones.
En effet, named-checkzone
permet de vérifier une zone à la fois. Or le
fichier de configuration énumère l'ensemble de ces zones avec l'emplacement
du fichier de zone.
Voici un script qui permet de vérifier la configuration, d'une part, et toutes les zones qui y sont énumérées. Il est assez peu portable, en ce sens qu'il s'appuie sur des emplacements de configuration que l'on peut trouver sur des distributions basées sur Red Hat. Néanmoins, la logique propre à la vérification en cascade reste la même.
#!/usr/bin/perl # Suggested (brilliantly) by William HOFFMANN use strict; use warnings; use File::Slurp; our $conf_file = '/etc/named.conf'; our $sysconf_file = '/etc/sysconfig/named'; our $check_conf_command = '/usr/sbin/named-checkconf'; our $check_zone_command = '/usr/sbin/named-checkzone'; my $sysconf_text = read_file($sysconf_file) or die "Could not read '$sysconf_file': $!"; # Find the chroot's path in /etc/sysconfig # Another way to do it would be to source the file and check the var in @ENV my ($root_dir) = $sysconf_text =~ m/^ROOTDIR=['"]?([^'"]*)['"]?$/m; $root_dir = '' unless $root_dir; chomp $root_dir; print 'Found a root directory: ', $root_dir, "\n"; # Get the named.conf path and check it with named_checkconf if ( -r "$root_dir/$conf_file" ) { $conf_file = "$root_dir/$conf_file"; } else { die "$root_dir/$conf_file is not readable: $!"; } # Variants for named-checkconf # * -t root_dir # * path/to/named.conf my @check_command = qq/$check_conf_command $conf_file/; run( \@check_command, $conf_file ) or die "Something went wrong during config file checking: $!\n"; # If the conf is OK, get the zone list my $conf_text = read_file($conf_file); # get optionaly the directory in which the zonefiles are stored # e.g. directory "/var/named"; ( my $zone_basedir ) = $conf_text =~ m/^[^#]*directory\s+"([^"]+)"/; print "Found a root for zonefiles: '$zone_basedir'\n" unless $zone_basedir =~ /^$/; # For each zone, # * get the filename for its datafile # * check the zone file with named_checkzone my @zonenames = $conf_text =~ m/zone\s+"([^"]+)"/xmsg; my @zones = $conf_text =~ m/(zone\s+"([^"]+)"\s*IN\s*\{(?:[^}]*|.*file\s*"[^"]+"\s*;.*?\1)\};)/xmsg; my %file; foreach my $index ( 0..$#zones-1 ) { next if $index%2; my $zonename = $zones[ $index + 1 ]; my ($zonefile) = $zones[ $index ] =~ m/^.*file\s*"([^"]+)".*$/gmsx; $file{$zonename} = $zonefile; } foreach my $zone (sort keys %file) { print 'zone: ', $zone, ' file: ', $file{$zone}, "\n"; my $zone_filename = "$root_dir/$zone_basedir/$file{$zone}"; my @check_command = qq/$check_zone_command $zone $zone_filename/; run( \@check_command, $file{$zone} ); } sub run { my ( $commandref, $tested ) = @_; my @command = @$commandref; system(@command); if ( $? == -1 ) { print "Failed to execute: $!\n"; return 0; } elsif ( $? & 127 ) { printf "Child died with signal %d, %s coredump\n", ( $? & 127 ), ( $? & 128 ) ? 'with' : 'without'; return 0; } else { my $return = $? >> 8; if ( $return == 0 ) { print "'$conf_file' seems OK.\n"; } else { warn "'@command' failed with code: $?"; return 0; } } return 1; }
La fonction run
, pale copie de ce que l'on peut trouver dans
perldoc -f system
est appelée pour toutes les vérifications à l'aide de
named-checkconf
et named_checkzone
.
Le principe est d'extraire l'emplacement des fichiers depuis les différents
fichiers de configuration. Ensuite, le fichier de configuration est testé, et si
cette opération se déroule correctement, les noms des zones et les emplacements
des fichiers correspondants sont également extraits. Ces informations sont
utilisées lors de l'appel de named-checkzone
.
Pour un administrateur système, si Perl s'impose comme tronçonneuse suisse incontournable, l'utilisation de bases de données peut tourner au cauchemar.
Grâce à Class::DBI
, et un SGBD de son choix, les tâches récurrentes
d'interrogation ou de modification peuvent être réalisées très simplement.
Les opérations plus complexes, qui nécessitent l'écriture de requêtes SQL personnalisées, restent possibles. Les fonctionnalités absentes peuvent être ajoutées au niveau applicatif, pour permettre un support des transactions, des contraintes, etc.
Laurent Gautrot est utilisateur et prosélyte de logiciels libres depuis une dizaine d'années.
Merci aux Mongueurs de toute la Francophonie qui ont assuré la relecture de cet article.
[1] Class::DBI
-
http://search.cpan.org/dist/Class-DBI/
[2] Class::DBI::Loader
-
http://search.cpan.org/dist/Class-DBI-Loader/
[3] Class::DBI::AbstractSearch
-
http://search.cpan.org/dist/Class-DBI-AbstractSearch/
[4] Guide du débutant de Class::DBI
-
http://wiki.class-dbi.com/wiki/Beginners_guide
[5] Getopt::Long
-
http://perldoc.perl.org/Getopt/Long.html
[6] Tables de distributions - http://articles.mongueurs.net/magazines/perles/perles-06.html#h1
[7] Perl for System Administrator - David N. Blank-Edelman O'Reilly - 2000
[8] SVK - http://svk.elixus.org - http://search.cpan.org/~clkao/SVK/lib/SVK.pm
[9] Nagios - http://www.nagios.org/ - http://articles.mongueurs.net/magazines/linuxmag65-bis.html
[10] dnsmam - http://l.gautrot.free.fr/dnsmam/
Copyright © Les Mongueurs de Perl, 2001-2011
pour le site.
Les auteurs conservent le copyright de leurs articles.