À propos du VPN, IPsec et AWS

Je parlerai ici en priorité de mon expérience pour monter des VPN IPsec, soit un protocole spécifique, car c’était le besoin que j’ai été amené à couvrir.
AWS ne supporte pas encore, à l’heure où j’écris cet article, d’autre protocole pour une connexion VPN managée.

Et si j’utilise pas Terraform ?

Je vous donne ici une configuration Terraform d’exemple, mais vous pouvez très bien lire l’article (et le schéma réseau) et vous en inspirer pour créer les mêmes ressources à la main, que ce soit via la console, la ligne de commande ou le SDK.

Un petit schéma réseau

Avant toute chose, et pour s’assurer de bien comprendre comment les flux réseaux vont voyager, un petit schéma peut aider à partir sur de bonnes bases.

Schéma réseau Schéma de l’architecture cible : notre réseau privé à gauche, le réseau du client à connecter à droite.

Quelques variables Terraform utiles

Avant de commencer, j’ai l’habitude de définir quelques variables communes à tous mes runs Terraform (que je peux importer via un fichier commun, chargé par grâce à un lien symbolique) :

variable "project_name" {
  description = "Nom du projet"
  type        = string
  default     = "corentinhatte"

  validation {
    condition     = length(var.project_name) <= 12 && length(regexall("[^a-zA-Z0-9-]", var.project_name)) == 0
    error_message = "The project tag must be no more than 12 characters, and only contain letters, numbers, and hyphens."
  }
}

variable "environment" {
  description = "Nom de l'environnement actuel"
  type        = string
  default     = "test"

  validation {
    condition     = length(var.environment) <= 12 && length(regexall("[^a-zA-Z0-9-]", var.environment)) == 0
    error_message = "The environment tag must be no more than 12 characters, and only contain letters, numbers, and hyphens."
  }
}

Ces variables me permettent de définir une local pour appliquer un préfixe à tous mes noms de ressource. Pratique pour éviter les conflits de noms entre les environnements.

locals {
  prefix = "${var.project_name}-${var.environment}"
}

On peut également définir une variable pour le nom du VPN à monter (par exemple en y mettant le nom du client concerné), si on veut faire un module réutilisable :

variable "vpn_name" {
  description = "Nom du VPN"
  type        = string
  default     = ""

  validation {
    condition     = length(var.vpn_name) >= 3 && length(var.vpn_name) <= 20 && length(regexall("[^a-zA-Z0-9-]", var.vpn_name)) == 0
    error_message = "The vpn_name tag must be 3 to 20 characters long, and only contain letters, numbers, and hyphens."
  }
}

Les passerelles

Comme vous pouvez le voir sur le schéma, avant de lancer la connexion il faut donc définir les passerelles de chaque côté : c’est elles qui vont faire passer les flux réseaux.

La passerelle privée est gérée par AWS et peut être partagée entre plusieurs tunnels, là où la passerelle client est une ressource virtuelle qui permet de définir l’IP de connexion distante spécifique à chaque client.

Récupérer le VPC

Pour se simplifier la vie, on peut récupérer notre VPC via une data source aws_vpc.

data "aws_vpc" "default" {
  default = true
}
Note

On filtre ici très simplement pour récupérer le VPC par défaut du compte, mais vous pouvez tout à faire changer de sélecteur pour filtrer sur des étiquettes (tags) par exemple.

Notre passerelle privée

resource "aws_vpn_gateway" "main" {
  vpc_id = data.aws_vpc.default.id

  tags = {
    Name = "${local.prefix}-main"
  }
}

Comme vous le voyez rien d’extraordinaire : on spécifie uniquement le VPC auquel on veut attacher notre passerelle privée. Bien que ce soit facultatif (la ressource est créée dans le VPC par défaut si vous ne spécifiez pas son identifiant), ça évite de la monter dans le mauvais VPC par erreur.

La passerelle client

resource "aws_customer_gateway" "main" {
  bgp_asn     = 65000
  device_name = "${local.prefix}-${var.vpn_name}"
  ip_address  = var.gateway_ip_address
  type        = "ipsec.1"

  tags = {
    Name = "${local.prefix}-${var.vpn_name}"
  }
}

Le principe est simple : on définit l’IP de la passerelle distante à laquelle on souhaite se connecter, on précise le type de VPN (ici ipsec.1)… et c’est tout.

Note

Si vous avez besoin d’attacher un certificat, pour pouvez utiliser le paramètre certificate_arn.

Monter la connexion côté AWS

resource "aws_vpn_connection" "main" {
  vpn_gateway_id      = aws_vpn_gateway.main.id
  customer_gateway_id = aws_customer_gateway.main.id
  type                = "ipsec.1"
  static_routes_only  = true

  tags = {
    Name = "${local.prefix}-${var.vpn_name}"
  }
}
Note

Il existe beaucoup plus de paramètres pour configurer par exemple les clés pré-partagées, les algorithmes autorisés, le lancement automatique de la connexion… je vous laisse parcourir la documentation Terraform aws_vpn_connection pour trouver ceux qui vous conviennent.

À noter qu’une connexion VPN AWS crée deux tunnels par défaut, avec chacun leur IP et leurs paramètres dédiés, pour des raisons de redondance. Vous n’êtes pas obligé d’utiliser les deux tunnels si vous n’avez pas besoin de haute disponibilité (ou que votre client ne veut pas monter/gérer deux tunnels).

Et voilà !

Félicitations, vous avez créé une connexion VPN !
Vous pouvez vérifier qu’elle est opérationnelle dans la console AWS : le statut devrait être UP (s’il est DOWN c’est que la connexion n’est pas opérationnelle, il faudra peut-être adapter la configuration des passerelles).

Router le trafic

Pour diriger le trafic depuis votre sous-réseau jusqu’au réseau de votre client il faut gérer le routage à deux niveaux :

  1. Dans votre réseau interne, via une table de routage, pour diriger vers votre passerelle privée
  2. Au niveau de la passerelle privée, pour diriger vers la bonne connexion VPN

Router en interne vers la passerelle privée

Cette ressource peut être redondante à créer si vous ciblez plusieurs IP ou blocs d’IP distants. Pour me faciliter la tâche j’ai défini une variable egress_routes dans mon module :

variable "egress_routes" {
  description = "Routes/Adresses à diriger vers le VPN"
  type        = list(string)
  default     = ["8.8.8.8"]
}

Je peux alors boucler sur cette liste pour créer les routes utilisées par mes serveurs/instances privées :

resource "aws_route" "routes_az_a" {
  count = length(var.egress_routes)

  route_table_id         = data.aws_route_table.private.id
  destination_cidr_block = var.egress_routes[count.index]
  gateway_id             = aws_vpn_gateway.main.id
}
Importer la table de routage

N’oubliez pas d’importer votre table de routage via une data source si ce n’est pas déjà fait :

data "aws_route_table" "private" {
 filter {
   name   = "tag:Name"
   values = ["${local.prefix}-private"]
 }
}

Envoyer le trafic vers le bon client

Une fois que le trafic est arrivé dans la passerelle privée, il faut qu’elle sache vers quelle connexion l’envoyer (histoire que vous n’envoyiez pas des données au mauvais client). Pour cela on réutilise notre variable egress_routes pour boucler :

resource "aws_vpn_connection_route" "main" {
  count = length(var.egress_routes)

  destination_cidr_block = var.egress_routes[count.index]
  vpn_connection_id      = aws_vpn_connection.main.id
}
C’est bon !

Vous pouvez tester que votre connexion fonctionne dans un sens (le routage dans l’autre sens étant à définir sur le réseau du client) :

# Remplacez `8.8.8.8` par l'IP du serveur client à contacter
ping 8.8.8.8

Quelques notes supplémentaires

Si vous utilisez un équilibreur de charge

Vous pouvez voir dans le schéma en début d’article que j’ai ajouté un équilibreur de charge dans le réseau privé, mais que je n’ai pas défini de ressource pour ça. Je vous laisse libre d’en définir une si besoin, car chaque besoin est unique.

Quelques notes/suggestions néanmoins si vous en utilisez un :

  • Si le trafic doit rester privé, pensez à rendre l’équilibreur interne pour lui attribuer uniquement une IP privée joignable uniquement via le VPN et/ou votre réseau interne
  • Votre client aura besoin de définir une route de sortie vers votre load balancer, pas vers vos instances
  • Le trafic reçu par votre client viendra en revanche de vos instances (qui devront être autorisées en entrée, donc), pas de l'ALB

Pour stocker les clés pré-partagées

Si vous utilisez des clés pré-partagées et que vous ne laissez pas AWS les définir pour vous (par exemple si votre client les fournit), vous pouvez les stocker dans un Parameter Store ou dans Secrets Manager, puis en importer la valeur dans votre module (la clé est de toute façon stockée en clair dans le state Terraform) par exemple via une data source.