⚠️ Disclaimer

La méthode que je présente ici correspond à ma propre démarche d’apprentissage. Elle peut contenir des approximations ou des erreurs, car j’apprends et je progresse chaque jour un peu plus. Ne prenez donc pas ce que je fais comme une référence absolue, mais plutôt comme un retour d’expérience personnel.

Contexte et objectifs

J’ai eu l’occasion de travailler sur un projet pour une startup du secteur de l’assurance en expansion. Le défi était de mettre en place une infrastructure web en stack LAMP (Linux, Apache, MySQL, PHP) avec une architecture 3-tiers, trois machines distinctes plutôt qu’une grosse boîte monolithique. Cette séparation promise d’améliorer la sécurité en isolant les services, ce qui m’intéressait particulièrement venant du monde de l’analyse de données où ces préoccupations n’existaient pas.

Architecture mise en place

Trois machines virtuelles Debian 13, chacune dédiée à une fonction :

ServeurFonctionAdresse IPServices
ns1DNS172.20.5.10Bind9
web1Web172.20.5.20Apache 2.4, PHP 8.0
db1Base de données172.20.5.30MySQL 8.0

Le domaine était techcorp.local, accessible via www.techcorp.local.

Pourquoi trois tiers ? L’idée m’a séduit au premier abord : une compromission du serveur web n’expose pas directement la base de données. Les mises à jour de chaque composant peuvent se faire indépendamment. Chaque serveur peut être dimensionné selon ses besoins réels, plus de RAM pour MySQL, plus de CPU pour Apache. C’est théoriquement plus flexible qu’une seule grosse machine.


Configurer le DNS avec Bind9

J’ai commencé par le serveur DNS, qui doit répondre correctement pour que tout le reste fonctionne. Bind9 est le standard de facto pour un environnement interne.

Déclaration des zones dans /etc/bind/named.conf.local :

1
2
3
4
5
6
7
8
9
zone "techcorp.local" {
    type master;
    file "/etc/bind/zones/db.techcorp.local";
};

zone "5.20.172.in-addr.arpa" {
    type master;
    file "/etc/bind/zones/db.172.20.5";
};

Configuration de la zone directe :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$TTL    86400
@       IN      SOA     ns1.techcorp.local. admin.techcorp.local. (
                              2024111701
                              3600
                              1800
                              604800
                              86400 )

@       IN      NS      ns1.techcorp.local.

ns1     IN      A       172.20.5.10
www     IN      A       172.20.5.20
@       IN      A       172.20.5.20
db1     IN      A       172.20.5.30

Configuration de la zone inverse :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$TTL    86400
@       IN      SOA     ns1.techcorp.local. admin.techcorp.local. (
                              2024111701
                              3600
                              1800
                              604800
                              86400 )

@       IN      NS      ns1.techcorp.local.

10      IN      PTR     ns1.techcorp.local.
20      IN      PTR     www.techcorp.local.
30      IN      PTR     db1.techcorp.local.

Validation de la configuration :

1
2
3
4
named-checkconf
named-checkzone techcorp.local /etc/bind/zones/db.techcorp.local
named-checkzone 5.20.172.in-addr.arpa /etc/bind/zones/db.172.20.5
dig @localhost www.techcorp.local

Serveur web et Apache

Apache 2.4 avec PHP 8.0 devait résoudre www.techcorp.local via notre DNS interne. J’ai configuré le client DNS sur web1 en pointant vers ns1 dans /etc/systemd/resolved.conf.

Configuration du VirtualHost dans /etc/apache2/sites-available/techcorp.conf :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<VirtualHost *:80>
    ServerName www.techcorp.local
    ServerAlias techcorp.local
    ServerAdmin admin@techcorp.local

    DocumentRoot /var/www/techcorp

    <Directory /var/www/techcorp>
        Options -Indexes +FollowSymLinks
        AllowOverride All
        Require all granted
    </Directory>

    ErrorLog ${APACHE_LOG_DIR}/techcorp_error.log
    CustomLog ${APACHE_LOG_DIR}/techcorp_access.log combined
</VirtualHost>

Quant aux permissions, j’ai appliqué le principe du moindre privilège, directement inspiré de mes lectures en cybersécurité :

1
2
3
4
chown -R www-data:www-data /var/www/techcorp
find /var/www/techcorp -type d -exec chmod 755 {} \;
find /var/www/techcorp -type f -exec chmod 644 {} \;
chmod 640 /var/www/techcorp/config.php

Base de données MySQL

MySQL 8.0 avec mysql_secure_installation. Ensuite, il fallait que MySQL écoute uniquement sur son IP interne, pas sur le réseau entier. Modification dans /etc/mysql/mysql.conf.d/mysqld.cnf :

1
2
[mysqld]
bind-address = 172.20.5.30

Création de la base et utilisateur avec privilèges limités :

1
2
3
4
5
6
7
CREATE DATABASE app_production CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

CREATE USER 'webapp_user'@'172.20.5.20' IDENTIFIED BY 'Str0ng_P@ssw0rd_2024!';

GRANT SELECT, INSERT, UPDATE, DELETE ON app_production.* TO 'webapp_user'@'172.20.5.20';

FLUSH PRIVILEGES;

L’utilisateur est restreint à l’IP du serveur web ('webapp_user'@'172.20.5.20'), avec uniquement les privilèges SELECT, INSERT, UPDATE, DELETE. Rien d’autre, pas de CREATE, pas de DROP.

Structure de la table :

1
2
3
4
5
6
7
8
USE app_production;

CREATE TABLE welcome_messages (
    id INT AUTO_INCREMENT PRIMARY KEY,
    message TEXT NOT NULL,
    author VARCHAR(100),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Intégration PHP et MySQL

Le fichier config.php gère la connexion à la base :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<?php
define('DB_SERVER', '172.20.5.30');
define('DB_USERNAME', 'webapp_user');
define('DB_PASSWORD', 'Str0ng_P@ssw0rd_2024!');
define('DB_NAME', 'app_production');

try {
    $conn = new mysqli(DB_SERVER, DB_USERNAME, DB_PASSWORD, DB_NAME);
    
    if ($conn->connect_error) {
        throw new Exception("Échec de la connexion");
    }
    
    $conn->set_charset("utf8mb4");
    
} catch (Exception $e) {
    error_log($e->getMessage());
    die("Erreur de connexion à la base de données");
}
?>

Et index.php qui affiche les données :

1
2
3
4
5
6
7
8
9
<?php
require_once 'config.php';

$sql = "SELECT message, author, created_at FROM welcome_messages ORDER BY created_at DESC";
$result = $conn->query($sql);

// Affichage HTML avec htmlspecialchars() pour la protection XSS
// Design responsive avec CSS intégré
?>

Tests et validation

Pour la résolution DNS :

1
2
3
4
5
dig www.techcorp.local
# Résultat attendu : 172.20.5.20

dig -x 172.20.5.20
# Résultat attendu : www.techcorp.local

Connectivité MySQL depuis web1 :

1
2
3
4
5
6
# Depuis le serveur web
mysql -h 172.20.5.30 -u webapp_user -p app_production

# Test de requête
SHOW TABLES;
SELECT * FROM welcome_messages;

Le site lui-même :

1
2
3
4
5
curl -I http://www.techcorp.local
# Résultat attendu : HTTP/1.1 200 OK

curl http://www.techcorp.local
# Affichage de la page HTML avec les données de la base

Vérification des logs :

  • Apache : /var/log/apache2/techcorp_access.log, techcorp_error.log
  • MySQL : /var/log/mysql/error.log
  • Bind9 : journalctl -u bind9

Sécurité

Le moindre privilège s’applique à plusieurs niveaux. MySQL : utilisateur CRUD uniquement, IP-restreint, pas de wildcard. Fichiers : répertoires 755, PHP 644, configuration 640. Réseau : MySQL n’écoute que sur 172.20.5.30, pas d’exposition directe.

Chaque service sur sa propre machine. Si le web se fait compromettre, l’attaquant n’y gagne pas un accès direct au serveur de base de données.


Ce qui a accroché en chemin

Les points finaux des enregistrements DNS, oui, ce détail stupide. Oublier le point final dans Bind9 cause une double concaténation du domaine. ns1 devient ns1.techcorp.local.techcorp.local. Il faut systématiquement utiliser la notation FQDN complète avec le point : ns1.techcorp.local.

MySQL refusait les connexions depuis web1. L’utilisateur existait, les permissions étaient bonnes, mais rien. Le problème : le paramètre bind-address était sur localhost. J’ai dû le changer pour écouter sur 172.20.5.30.

Et puis web1 lui-même ne résolvait pas techcorp.local. J’ai dû configurer systemd-resolved pour pointer vers ns1 (172.20.5.10) plutôt que vers les DNS publics. Détails bêtes, mais bloquants.


Prochaines étapes

Matériellement parlant, la structure fonctionne. Mais pour un vrai environnement de production, il y aurait du travail : pare-feu sur chaque serveur (UFW), SSL/TLS pour MySQL, HTTPS avec Let’s Encrypt, Fail2Ban. Côté disponibilité, un DNS secondaire, un second serveur web avec load balancer, réplication MySQL Master-Slave. Et puis du monitoring, Prometheus, Grafana, centraliser les logs avec ELK.

Ressources :