Pritunl: запуск VPN в AWS на EC2 з Terraform

Автор |  31/05/2024
 

Колись вже трохи писав про Pritunl – Pritunl: запуск VPN в Kubernetes.

Повернемось до цієї теми ще раз, але цього разу на EC2 в AWS, без Kubernetes.

Отже, що треба – це запустити якийсь VPN-сервіс для проекту, або мати доступ до всяких Kubernetes API/Kubernetes WorkerNodes/AWS RDS у приватних мережах.

Вибір тут, в принципі, є – і AWS VPN, і ванільний OpenVPN, і багато іншого.

Але я Pritunl користувався вже в декількох проектах, він має приємний інтерфейс, основні можливості VPN доступні у Free версії – тож вай нот?

Що таке Pritunl

Фактично, Pritunl – це обгортка над звичайним OpenVPN сервером. Повністю сумісний, використовує однакові конфіги, і так далі.

Вміє в інтеграцію з AWS VPC – https://pritunl.com/vpc, але мені не дуже хочеться, щоб хтось автоматом міняв таблиці маршрутизації.

Сетап мережі в AWS у нас доволі простий, і поки що все можна менеджити самому – більше контролю, більше розуміння, що може піти не так.

Ну і плюс ця інтеграція наче доступна тільки в Enerprize – Pritunl Pricing.

У Pritunl дві основні концепції – Organization  та Server:

  • Server описує конфіг для OpenVPN – порти, роути, DNS
  • Organization описує юзерів
  • Organization підключається до Server

Далі, юзер завантажує файл .ovpn, і підключається будь-яким VPN-клієнтом. Наскільки пам’ятаю, навіть дефолтний клієнт на macOS працював з ним без проблем.

Pritunl та Terraform

На попередньому проекті ми мали Pritunl в Kubernetes, але мені ця ідея якось не дуже подобається, бо, імхо, VPN має бути окремим сервісом.

Якщо говорити про Terraform, то є цікавий Pritunl Provider – але йому потрібен API ключ, який в Pritunl доступний тільки в Enerprize.

Ще є готовий код з Terraform тут – Pritunl VPN, але мені якось простіше підняти власну EC2 у власній VPC.

І ще нагуглив такий готовий модуль – AWS VPN (Pritunl) Terraform Module, виглядає наче робочим рішенням.

Проте будемо робити більш дідовським методом:

  • є звичайна AWS VPC з кількома приватними сабнетами
  • у публічному сабнеті з Terraform запустимо звичайний EC2
  • через AWS EC2 user_data встановимо і запустимо Pritunl
  • і вручну налаштуємо йому юзерів, сервери, роути

Роутинг має бути таким: всі пакети, які йдуть у VPC – відправляються через VPN, а решта – через звичайне підключення.

Terraform: створення EC2

Отже, спершу нам потрібно запустити EC2, на якій буде працювати Pritunl.

Для цього EC2 нам потрібні AWS AMI, SSK Key, Security Group, VPC ID, і відразу створимо AWS Route53 запис.

Отримання VPC ID

VPC ID отримуємо через terraform_remote_state, детальніше писав у Terraform: terraform_remote_state – отримання outputs інших state-файлів:

data "terraform_remote_state" "vpc" {
  backend = "s3"
  config = {
    bucket         = "tf-state-backend-atlas-vpc"
    key            = "${var.environment}/atlas-vpc-${var.environment}.tfstate"
    region         = var.aws_region
    dynamodb_table = "tf-state-lock-atlas-vpc"
  }
}

У цьому outputs маємо VPC ID та ID публічних сабнетів:

$ terraform output
...
vpc_id = "vpc-0fbaffe234c0d81ea"
...
vpc_public_subnets_cidrs = tolist([
  "10.0.0.0/20",
  "10.0.16.0/20",
])
vpc_public_subnets_ids = [
  "subnet-01de26778bea10395",
  "subnet-0efd3937cadf669d4",
]

І далі цей data resource використовуємо у locals:

locals {
  # get VPC info
  vpc_out = data.terraform_remote_state.vpc.outputs
}

Хоча в принципі теж можна зробити просто з data "aws_vpc".

EC2 SSH Key

Використовуємо key_pair.

Створюємо сам ключ:

$ ssh-keygen
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/setevoy/.ssh/id_ed25519): /home/setevoy/.ssh/atlas-vpn
...

Публічну частину можемо зберігати в репозиторії – створюємо каталог, і копіюємо її:

$ mkdir ssh
$ cp /home/setevoy/.ssh/atlas-vpn.pub ssh/

Описуємо ресурс aws_key_pair:

resource "aws_key_pair" "vpn_key" {
  key_name   = "atlas-vpn-key"
  public_key = file("${path.module}/ssh/atlas-vpn.pub")
}

AWS Secuirty Group

Знаходимо домашній/робочий IP:

$ curl ifconfig.me
178.***.***.52

Описуємо SecurityGroup – дозволяємо SSH тільки з цього IP, у vpc_id використовуємо local.vpc_out.vpc_id.

Додаємо порти – 80 для Let’s Encrypt, який використовується Pritunl, 443 – для доступу до його адмінки, тут знов тільки з мого IP, 10052 UPD – для клієнтів VPN:

resource "aws_security_group" "allow_ssh" {
  name        = "allow_ssh"
  description = "Allow SSH inbound traffic"
  vpc_id      = local.vpc_out.vpc_id

  ingress {
    description = "SSH Arseny home"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["178.***.***.52/32"]
  }

  ingress {
    description = "Pritunl Admin Arseny home"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["178.***.***.52/32"]
  }

  ingress {
    description = "Pritunl Lets Encrypt"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }    

  ingress {
    description = "Pritunl VPN port"
    from_port   = 10052
    to_port     = 10052
    protocol    = "udp"
    cidr_blocks = ["0.0.0.0/0"]
  } 

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-${var.environment}-allow_ssh"
  }
}

AWS AMI

Використовуючи data "aws_ami", знайдемо AWS AMI з Ubuntu.

Я спочатку пробував Pritunl запустити на Amazon Linux, але той yum і окремі репозиторії – це якась біда, на Ubuntu завелось без проблем:

data "aws_ami" "ubuntu" {
  most_recent = true

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-*-22.04-amd64-server-*"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }

  owners = ["099720109477"]  # Canonical's official AWS account ID for Ubuntu AMIs
}

Але використовуючи data "aws_ami" майте на увазі, що коли вийде якась обнова, то AWS створить новий AMI, і при наступному запуску вашого Terraform-коду він підтягне новий ID, і запропонує перестворити відповідну EC2.

Тому, можливо, краще просто знайти AMI ID вручну і занести в variables. Див. Find an AMI та Amazon EC2 AMI Locator.

AWS Elastic IP

Аби мати одну і ту ж саму адресу, зробимо її окремим ресурсом:

resource "aws_eip" "vpn_eip" {
  domain = "vpc"
}

AWS Route53 VPN record

Відразу створимо запис в DNS.

В variables.tf задаємо ID зони в Route53 та її ім’я:

variable "route53_ops_zone" {
  type = object({
    name = string
    id = string
  })
  default = {
    name = "ops.example.co"
    id = "Z02***OYY"
  }
}

І в main.tf описуємо сам запис:

resource "aws_route53_record" "vpn_dns" {
  zone_id  = var.route53_ops_zone.id
  name     = "vpn.${var.route53_ops_zone.name}"
  type     = "A"
  ttl      = 300
  records = [aws_eip.vpn_eip.public_ip]
}

Тепер у нас буде запис виду “vpn.ops.example.co IN A <EC2_EIP>.

AWS EC2 та установка Pritunl

І, врешті-решт, описуємо сам EC2, використовуючи ресурси, які створили вище:

  • ami – беремо з data.aws_ami.amazon_linu
  • key_name – беремо з aws_key_pair.vpn_key.key_name
  • vpc_security_group_ids – з SG, яку створюємо тут же
  • subnet_id, де створювати EС2 – беремо з local.vpc_out.vpc_public_subnets_ids

Тут же відразу додаємо встановлення Pritunl – див. документацію [Other Providers] Ubuntu 22.04, але вона місцями крива, тому, можливо, краще зробити установку руками після створення інстансу.

Ну, або таки додати в user_data – принаймні на момент написання з кодом нижче це працювало.

У випадку проблем з EC2 user_data – перевіряйте лог /var/log/cloud-init.log, і спробуйте запустити скрипт вручну – він має бути у файлі типу /var/lib/cloud/instance/scripts/part-001.

Майте на увазі, що user_data викликається тільки при створенні інстансу:

resource "aws_instance" "vpn" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.vpn_ec2_instance_type
  key_name      = aws_key_pair.vpn_key.key_name

  vpc_security_group_ids = [aws_security_group.allow_ssh.id]
  subnet_id     = local.vpc_out.vpc_public_subnets_ids[0]

  user_data = <<-EOF
              #!/bin/bash
              echo 'deb http://repo.pritunl.com/stable/apt jammy main' > /etc/apt/sources.list.d/pritunl.list
              echo 'deb https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/6.0 multiverse' > /etc/apt/sources.list.d/mongodb-org-6.0.list
              apt-key adv --keyserver hkp://keyserver.ubuntu.com --recv 7568D9BB55FF9E5287D586017AE645C0CF8E292A
              wget -qO - https://www.mongodb.org/static/pgp/server-6.0.asc | sudo apt-key add -
              apt update
              apt --assume-yes upgrade
              apt -y install wireguard wireguard-tools
              ufw disable
              apt -y install pritunl mongodb-org
              systemctl enable mongod pritunl
              systemctl start mongod pritunl
              EOF

  tags = {
    Name = "Pritunl VPN"
  }
}

Додаємо підключення Elastic IP до цього інстансу:

resource "aws_eip_association" "vpn_eip_assoc" {
  instance_id   = aws_instance.vpn.id
  allocation_id = aws_eip.vpn_eip.id
}

Terraform Outputs

Додамо outputs, аби потім простіше було шукати всякі ID:

output "vpn_ec2_id" {
  value = aws_instance.vpn.id
}

output "vpn_eip" {
  value = aws_eip.vpn_eip.public_ip
}

output "aws_ami_id" {
  value = data.aws_ami.ubuntu.id
}

output "vpn_dns" {
  value = aws_route53_record.vpn_dns.name
}

Робимо terraform init, terraform plan, terraform apply:

...
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:

ec2_public_ip = "3.83.69.105"
vpn_ec2_id = "i-0ea1407cb7ff8690f"

Перевіряємо інстанс:

Перевіряємо SSH до нього:

$ ssh -i ~/.ssh/atlas-vpn [email protected]
...
[ec2-user@ip-10-0-3-26 ~]$ sudo -s
[root@ip-10-0-3-26 ec2-user]#

Перевіряємо сам Pritunl на сервері:

root@ip-10-0-1-25:/home/ubuntu# systemctl status pritunl
● pritunl.service - Pritunl Daemon
     Loaded: loaded (/etc/systemd/system/pritunl.service; enabled; vendor preset: enabled)
     Active: active (running) since Fri 2024-05-31 13:04:08 UTC; 55s ago
   Main PID: 3812 (pritunl)
      Tasks: 19 (limit: 2328)
     Memory: 99.7M
        CPU: 1.318s
     CGroup: /system.slice/pritunl.service
             ├─3812 /usr/lib/pritunl/usr/bin/python3 /usr/lib/pritunl/usr/bin/pritunl start
             └─4174 pritunl-web

May 31 13:04:08 ip-10-0-1-25 systemd[1]: Started Pritunl Daemon.

Тепер можна переходити до його налаштування.

Pritunl inital setup

Документація – Configuration.

Підключаємось на EC2, виконуємо pritunl setup-key:

root@ip-10-0-1-25:/home/ubuntu# pritunl setup-key
074d9be70f1944d7a77374cca09ff8dc

Відкриваємо vpn.ops.example.co:443, на помилку ERR_CERT_AUTHORITY_INVALID поки не звертаємо уваги – Let’s Encrypt згенерить сертифікат після налаштувань Pritunl.

Передаємо setup-key, адресу MongoDB можна лишити по дефолту:

Чекаємо апдейту MongoDB:

Коли відкриється вікно логіна – на сервері виконуємо pritunl default-password:

root@ip-10-0-1-25:/home/ubuntu# pritunl default-password
[local][2024-05-31 13:12:41,687][INFO] Getting default administrator password
Administrator default password:
  username: "pritunl"
  password: "1rueBHeV9LIj"

І логінимось:

Генеруємо новий пароль, яким будемо користуватись вже постійно:

$ pwgen 12 1
iBai1Aisheat

І задаємо основні параметри Pritunl – тут тільки логін/пароль та адреси:

Якщо забули новий пароль – можна скинути з pritunl reset-password.

Error getting LetsEncrypt certificate check the logs for more information.

При проблемах з Let’s Ecnrypt – перевіряємо лог /var/log/pritunl.log, там все буде:

root@ip-10-0-1-25:/home/ubuntu# tail -f /var/log/pritunl.log
  File "/usr/lib/pritunl/usr/lib/python3.9/site-packages/pritunl/handlers/settings.py", line 1112, in settings_put
    acme.update_acme_cert()
  File "/usr/lib/pritunl/usr/lib/python3.9/site-packages/pritunl/acme.py", line 73, in update_acme_cert
    cert = get_acme_cert(settings.app.acme_key, csr, cmdline)
  File "/usr/lib/pritunl/usr/lib/python3.9/site-packages/pritunl/acme.py", line 45, in get_acme_cert
    certificate = acmetiny.get_crt(
  File "/usr/lib/pritunl/usr/lib/python3.9/site-packages/pritunl/acmetiny.py", line 138, in get_crt
    raise ValueError("Challenge did not pass for {0}: {1}".format(domain, authorization))
ValueError: Challenge did not pass for vpn.ops.example.co: {'identifier': {'type': 'dns', 'value': 'vpn.ops.example.co'}, 'status': 'invalid', 'expires': '2024-06-07T13:32:30Z', 'challenges': [{'type': 'http-01', 'status': 'invalid', 'error': {'type': 'urn:ietf:params:acme:error:dns', 'detail': 'DNS problem: NXDOMAIN looking up A for vpn.ops.example.co - check that a DNS record exists for this domain; DNS problem: NXDOMAIN looking up AAAA for vpn.ops.example.co - check that a DNS record exists for this domain', 'status': 400}, 'url': 'https://acme-v02.api.letsencrypt.org/acme/chall-v3/357864308812/RHhMwA', 'token': 'KZLx4dUxDmow5uMvfJdwbgz5bY4HG0tTQOW2m4UvFBg', 'validated': '2024-05-31T13:32:30Z'}]}
  acme_domain = "vpn.ops.example.co"

Домен новий – Let’s Encrypt про нього ще не знає.

Чекаємо кілька хвилин, і пробуємо ще раз.

Успішна реєстрація сертифікату в логах має виглядати так:

[INFO] Found domains: vpn.ops.example.co
[INFO] Getting directory...
[INFO] Directory found!
[INFO] Registering account...
[INFO] Registered!
[INFO] Creating new order...
[INFO] Order created!
[INFO] Verifying vpn.ops.example.co...
[INFO] vpn.ops.example.co verified!
[INFO] Signing certificate...
[INFO] Certificate signed!
[INFO] Settings changed, restarting server...

Створення Pritunl Organization та юзерів

Додаємо організацію – через неї будемо групувати юзерів, бо Groups в безплатний версії недоступні:

Додаємо юзера:

Email, Pin – опціональні, зараз не потрібні:

Створення Pritunl Server та роути

Див. Server configuration.

Переходимо до Servers, додаємо новий:

В DNS Server задаємо адресу DNS нашої VPC.

В Port задаємо порт, який відкривали на AWS EC2 SecurityGroup.

Virtual Network – пул, з якого будуть виділятись адреси клієнтам. Я тут використовую 172.*, бо простіше відрізнити від решти – дома 192.*, VPC 10.*.

Підключаємо створену раніше Організацію:

Стартуємо сервер:

Налаштуємо роути – щоб через VPN йшли запити тільки в VPC:

І видаляємо дефолтний роут в 0.0.0.0/0:

Linux OpenVPN – підключення до серверу

Переходимо в Users, клікаємо Download profile:

Розпаковуємо його:

$ tar xfpv test-user.tar 
org-all_test-user_org-all-serv.ovpn

І підключаємося за допомогою звичайного OpenVPN клієнта:

$ sudo openvpn --config org-all_test-user_org-all-serv.ovpn

У випадку помилки “ERROR: Cannot open TUN/TAP dev /dev/net/tun: No such device” на Linux – спробуйте перезавантажитись. В мене ядро було оновлене, і давно не ребутався.

Перевіряємо локальні роути:

$ route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
0.0.0.0         192.168.3.1     0.0.0.0         UG    600    0        0 wlan0
0.0.0.0         192.168.3.1     0.0.0.0         UG    1002   0        0 enp2s0f0
10.0.0.0        172.16.0.1      255.255.0.0     UG    0      0        0 tun0
172.16.0.0      0.0.0.0         255.255.255.0   U     0      0        0 tun0
...

Все гуд – в Інтернет, 0.0.0.0, ходимо старим маршрутом, через домашній роутер, а у VPC, 10.0.0.0 – через 172.16.0.1, наш VPN.

Спробуємо:

$ traceroute 1.1.1.1
traceroute to 1.1.1.1 (1.1.1.1), 30 hops max, 60 byte packets
 1  _gateway (192.168.3.1)  1.617 ms  1.550 ms  1.531 ms
 ...
 9  one.one.one.one (1.1.1.1)  17.265 ms  17.246 ms  18.600 ms

Окей, через домашній роутер.

І до якогось серверу в AWS VPC:

$ traceroute 10.0.42.95
traceroute to 10.0.42.95 (10.0.42.95), 30 hops max, 60 byte packets
 1  172.16.0.1 (172.16.0.1)  124.407 ms  124.410 ms  124.417 ms
 ...

Через VPN.

І навіть SSH до інстансів в приватній мережі працює:

$ ssh -i test-to-del.pem [email protected]
...
ubuntu@ip-10-0-45-127:~$

Чудово.

Linux Systemd та Pritunl/OpenVPN autostart

Давайте зробимо, аби конект був постійно.

Створюємо каталог:

$ sudo mkdir /etc/pritunl-client

Переносимо конфіг:

$ sudo mv org-all_test-user_org-all-serv.ovpn /etc/pritunl-client/work.ovpn

Пишемо самий простий файл /etc/systemd/system/pritunl-org.service:

[Unit]
Description=Pritunl Work

[Service]
Restart=always
WorkingDirectory=/etc/pritunl-client/

ExecStart=/usr/bin/openvpn --config work.ovpn

ExecStop=killall openvpn

[Install]
WantedBy=multi-user.target

І перевіряємо:

$ systemctl start pritunl-org.service
==== AUTHENTICATING FOR org.freedesktop.systemd1.manage-units ====
Authentication is required to start 'pritunl-org.service'.

Ще раз роути:

$ route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
0.0.0.0         192.168.3.1     0.0.0.0         UG    100    0        0 enp2s0f0
0.0.0.0         192.168.3.1     0.0.0.0         UG    600    0        0 wlan0
0.0.0.0         192.168.3.1     0.0.0.0         UG    1002   0        0 enp2s0f0
10.0.0.0        172.16.0.1      255.255.0.0     UG    0      0        0 tun0

Все є.

Додаємо в автостарт:

$ systemctl enable pritunl-org.service
==== AUTHENTICATING FOR org.freedesktop.systemd1.manage-unit-files ====
Authentication is required to manage system service or unit files.
Authenticating as: root
Password: 
==== AUTHENTICATION COMPLETE ====
Created symlink /etc/systemd/system/multi-user.target.wants/pritunl-org.service -> /etc/systemd/system/pritunl-org.service.

Готово.