Introduction

Par le passé, j’ai travaillé avec des firewalls propriétaires (Stormshield, Arkoon, Netasq). L’expérience a été plutôt mitigée : il est compliqué de debugger l’ensemble du fonctionnement, les mises à jour sont conditionnées à des licences, les prix sont excessifs et il y a de nombreuses limitations techniques.

Autant de raisons d’écarter le propriétaire pour ce genre de design, au profit d’une solution basée sur Linux (FRR, BGP, nftables) : open source, innovation et indépendance.

L’idée : un design en hub and spoke où les firewalls jouent le rôle de hub, et personne ne se parle sans passer par eux.

Généralités

Voila un schéma de ce design simplifié :

evpn-vrf-route-propagation

Sur les différents routeurs du réseau, on instancie une VRF hub services (HUBSVC dans la configuration). Cette VRF possède uniquement les default routes originées par les différents firewalls.

Une seconde VRF est présente, il s’agit de la VRF spoke. Cette VRF servira de VRF d’accueil pour les interfaces clientes que l’on souhaite isoler. Elle va nous permettre d’exporter en EVPN les différentes destinations joignables pour le firewall. Quand des machines présentes dans cette VRF voudront parler à d’autres machines, le flux sera attiré grâce à un leak des default routes de hub services.

Dans notre schéma, l’interface entre r1 et riri est dans la VRF spoke : quand riri veut communiquer avec loulou, il suit la default route de hub services.

Le firewall monte une session eBGP unicast IPv4 et IPv6 pour spoke services (SPKSVC) et pour hub services : l’EVPN s’arrête au spine. On aurait pu faire une seule session EVPN entre le firewall et le spine ; garder des sessions unicast séparées permet de découpler la gestion du réseau de celle des firewalls, par exemple quand ce ne sont pas les mêmes équipes qui s’en occupent.

Voici un exemple de flux entre riri et loulou :

evpn-hub-spoke-firewall

Dans une optique de redondance, ce design peut comporter plusieurs firewalls : il arrive alors que le flux retour n’emprunte pas le même firewall qu’à l’aller. Ce genre de cas empêche de faire du firewall stateful : il faudrait soit synchroniser le conntrack entre tous les firewalls (avec conntrackd), soit renoncer à l’état. C’est la seconde option qui est retenue ici, détaillée dans la section Filtrage.

evpn-hub-spoke-firewall-redondance

La mécanique EVPN

Toute l’astuce de l’isolation tient dans les route-targets de la VRF spoke. Voici l’extrait de la configuration FRR sur r1 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
! VRF spoke : héberge les interfaces clientes, rattachée au VNI 110 (L3VNI)
vrf spoke
 vni 110
exit-vrf
!
! instance BGP de la VRF spoke, dans l'AS de la fabric (65010)
router bgp 65010 vrf spoke
 !
 address-family ipv4 unicast
  ! annonce les réseaux directement connectés (les subnets des clients)
  redistribute connected
  ! importe la default depuis la VRF HUBSVC, filtrée par la route-map (0.0.0.0/0 seulement)
  import vrf route-map LEAK_DEFAULT4
  import vrf HUBSVC
 exit-address-family
 !
 address-family ipv6 unicast
  redistribute connected
  ! idem en IPv6 : seule ::/0 est autorisée à fuiter depuis HUBSVC
  import vrf route-map LEAK_DEFAULT6
  import vrf HUBSVC
 exit-address-family
 !
 address-family l2vpn evpn
  rd 192.0.2.3:110
  ! RT d'import sentinelle : ne matche rien, donc r1 n'apprend aucun autre spoke
  route-target import 65010:65000
  ! RT d'export : permet de générer la vrf spoke services
  route-target export 65010:110
  ! publie les préfixes IPv4/IPv6 de la VRF dans l'EVPN (routes de type 5)
  advertise ipv4 unicast
  advertise ipv6 unicast
 exit-address-family
!
ip prefix-list DEFAULT4 seq 5 permit 0.0.0.0/0
ipv6 prefix-list DEFAULT6 seq 5 permit ::/0
!
route-map LEAK_DEFAULT4 permit 10
 match ip address prefix-list DEFAULT4
!
route-map LEAK_DEFAULT6 permit 10
 match ipv6 address prefix-list DEFAULT6

Trois choses à remarquer :

  • La VRF spoke exporte ses réseaux avec le route-target 65010:110. Le spine les importe dans une VRF spoke services et les annonce en eBGP au firewall : le firewall connaît donc toutes les destinations des spokes.
  • La VRF spoke importe 65010:65000, un route-target “sentinelle” qui ne match volontairement rien. Sans lui, FRR importerait automatiquement son propre route-target d’export, et chaque spoke apprendrait les réseaux des autres spokes. C’est cette ligne qui garantit qu’il n’existe aucune route directe entre r1 et r2.
  • La default route arrive par le import vrf HUBSVC, filtrée par une route-map qui ne laisse passer que la default : 0.0.0.0/0 en IPv4 (LEAK_DEFAULT4) et ::/0 en IPv6 (LEAK_DEFAULT6).

Résultat sur r1, la table de routage de la VRF spoke est minimaliste :

1
2
3
4
5
6
7
8
9
r1# show ip route vrf spoke
VRF spoke:
B>* 0.0.0.0/0 [20/0] via 192.0.2.1 (vrf HUBSVC) onlink, weight 1, 00:13:37
C>* 198.51.100.0/28 is directly connected, eth2, 00:13:42

r1# show ipv6 route vrf spoke
VRF spoke:
B>* ::/0 [20/0] via 2001:db8:ffff::1 (vrf HUBSVC) onlink, weight 1, 00:13:37
C>* 2001:db8:a::/64 is directly connected, eth2, 00:13:42

Les réseaux de loulou (198.51.100.16/28 et 2001:db8:b::/64) n’apparaissent nulle part : la seule porte de sortie est la default route, et elle mène au firewall.

Filtrage

Ici, le choix est de travailler uniquement en filtrage sans état. On peut ainsi ajouter autant de firewalls que souhaité, sans installer de solution de synchronisation de conntrack : comme le flux retour peut emprunter un autre firewall que l’aller, un filtrage stateful obligerait à répliquer les états entre toutes les machines.

L’inconvénient est qu’il faut écrire chaque règle dans les deux sens : inverser les sources / destinations pour les flux retour.

En chaînant les règles nftables de façon classique, on rencontre vite des soucis de performances, avec une gigue importante : chaque paquet parcourt la chaîne règle par règle, et le temps de traitement dépend de la position de la règle qui match.

À l’inverse, les sets nftables (les héritiers d’ipset) sont bien plus adaptés à ce besoin : le coût du lookup reste quasi constant, quel que soit le nombre d’entrées.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
root@fw1:~# nft list ruleset
table inet filter {
	set setipv4 {
		type ipv4_addr . ipv4_addr . inet_service
		flags interval
		elements = { 198.51.100.0/28 . 198.51.100.16/28 . 443,
			     198.51.100.0/28 . 198.51.100.16/28 . 22 }
	}

	set setipv6 {
		type ipv6_addr . ipv6_addr . inet_service
		flags interval
		elements = { 2001:db8:a::/64 . 2001:db8:b::/64 . 443,
			     2001:db8:a::/64 . 2001:db8:b::/64 . 22 }
	}

	chain forward {
		type filter hook forward priority filter; policy drop;
		icmp type { echo-request, echo-reply } accept
		icmpv6 type { echo-request, echo-reply } accept
		ip saddr . ip daddr . tcp dport @setipv4 accept
		ip daddr . ip saddr . tcp sport @setipv4 accept
		ip6 saddr . ip6 daddr . tcp dport @setipv6 accept
		ip6 daddr . ip6 saddr . tcp sport @setipv6 accept
	}
}

Les deux premières règles autorisent l’ICMP et l’ICMPv6 : pratique pour les tests de joignabilité. Le reste du filtrage passe par les sets, un par famille d’adresses.

Une connexion TCP de riri vers loulou sur le port 443 génère deux trames aux en-têtes symétriques :

1
2
3
          source        destination
aller     riri:40000  →  loulou:443
retour    loulou:443  →  riri:40000

Le set ne stocke qu’une seule orientation, celle de l’aller : riri . loulou . 443 (client, serveur, port du serveur). Le port éphémère (40000) n’y figure pas.

Pour que la trame retour retombe sur cette même entrée, la seconde règle lit ses champs à l’envers :

1
2
aller    ip saddr . ip daddr . tcp dport  =  riri . loulou . 443
retour   ip daddr . ip saddr . tcp sport  =  riri . loulou . 443

Les champs saddr et daddr sont permutés (la source et la destination s’échangent au retour), et on match sur sport au lieu de dport (le port du serveur passe de la destination à la source). Une seule entrée dans le set couvre donc les deux sens.

Deux sets, quelques règles fixes, et la chaîne forward ne grossit jamais : seuls les sets évoluent.

Autre avantage opérationnel : les sets se modifient à chaud, sans recharger le ruleset.

1
root@fw1:~# nft add element inet filter setipv4 { 198.51.100.0/28 . 198.51.100.16/28 . 5432 }

Vérification

Depuis riri, on vérifie que loulou est joignable :

1
2
3
4
5
riri:~# ping -c1 198.51.100.18
64 bytes from 198.51.100.18: icmp_seq=1 ttl=61 time=1.42 ms

riri:~# ping -c1 2001:db8:b::18
64 bytes from 2001:db8:b::18: icmp_seq=1 ttl=61 time=1.51 ms

Et sur le firewall, un tcpdump montre que le flux passe bien par lui, dans les deux sens :

1
2
3
4
5
6
7
8
9
root@fw1:~# tcpdump -ni any 'icmp or icmp6'
eth1  In  IP 198.51.100.2 > 198.51.100.18: ICMP echo request, id 42, seq 1
eth2  Out IP 198.51.100.2 > 198.51.100.18: ICMP echo request, id 42, seq 1
eth1  In  IP 198.51.100.18 > 198.51.100.2: ICMP echo reply, id 42, seq 1
eth2  Out IP 198.51.100.18 > 198.51.100.2: ICMP echo reply, id 42, seq 1
eth1  In  IP6 2001:db8:a::2 > 2001:db8:b::18: ICMP6, echo request, id 43, seq 1
eth2  Out IP6 2001:db8:a::2 > 2001:db8:b::18: ICMP6, echo request, id 43, seq 1
eth1  In  IP6 2001:db8:b::18 > 2001:db8:a::2: ICMP6, echo reply, id 43, seq 1
eth2  Out IP6 2001:db8:b::18 > 2001:db8:a::2: ICMP6, echo reply, id 43, seq 1

Le paquet entre par la VRF hub services (la default route) et ressort par la VRF spoke services. Si la règle nftables n’existe pas, le paquet meurt ici : policy drop.

Conclusion

Avec ce fonctionnement, tous les flux entre machines sont attirés par les firewalls. La redondance et la mise à l’échelle sont faciles.

Le tout avec du Linux, FRR et nftables. Zéro boîtier propriétaire, zéro licence, et un debug qui se fait avec les outils qu’on connaît déjà. Et quand quelque chose nous manque ou nous bloque, on peut lire le code et contribuer, plutôt que d’ouvrir un ticket chez un éditeur et attendre.

Le déploiement de la configuration reste simple : pas besoin de contrôleurs propriétaires ni d’interfaces web pour gérer un grand nombre d’équipements.

Un lab containerlab complet qui reproduit ce design (en dual-stack IPv4/IPv6) est disponible sur GitHub.

Pour aller plus loin