Odnawiamy certyfikat portalu SSL VPN na Fortigate za pomocą Ansible

Ostatnio zastąpiłem mojego starego, wysłużonego (i głośnego) Junipera SRX urządzeniem Fortigate 30E. Firewall jest zainstalowany na brzegu mojej sieci domowo-labowej. Zapewnia mi między innymi zdalny dostęp do laba czy przestrzeni dyskowej na NAS. Jedną z kluczowych funkcjonalności jest zatem zdalny dostęp za pomocą SSL VPN. Ponieważ portal SSL VPN jest wystawiony na świat, to chciałem by prezentował się za pomocą publicznego certyfikatu podpisanego przez jedno z globalnych CA. Niestety mój firewall nie posiada funkcji automatycznego odnawiania certyfikatów Let’s Encrypt, dlatego czynność tą musiałem samodzielnie zautomatyzować. Pokażę Ci w jaki sposób wykorzystałem do tego Ansible.

Fortigate i jego API

O API na urządzeniach Fortineta pisałem już w artykule Pierwsza styczność z API na Fortigate i jak wiesz z lektury jest sporo rzeczy, które łagodnie mówiąc mnie nie urzekły. Opisywałem w nim wersję FortiOS 7.0.0, na moim urządzeniu zaś ostatnim dostępnym wydaniem jest 6.2.10. Linia 7.x nie będzie na niego dostępna, jednak co do zasady działanie API jest podobne. Tak jak w przypadku nowszego firmware na początku wygenerowałem użytkownika REST API i powiązany z nim token, który będzie służył mi do autoryzacji.

Do realizacji mojego celu postanowiłem użyć Ansible. Wybór padł na to narzędzie nie tylko ze względu na jego popularność czy osobistą sympatię. Ważnym czynnikiem jest to, że Fortinet wydaje i utrzymuje własne moduły w ramach Ansible Community. Daje mi to minimalne poczucie, ze producent wie co robi. Sam playbook wykonuje się co tydzień z mojego NAS-a. Zadanie jest ustawione w cron.

Token dostępu do API podaję jako zmienną w inventory, gdyż w przypadku większej liczby firewalli token będzie unikalny dla każdego z urządzeń.

				
					[fortigates]
fg.lan fortios_access_token="<<Put Your API Token Here>>"

				
			

Kilka dodatkowych zmiennych umieściłem bezpośrednio w playbooku. Odnoszą się one do portu, na którym nasłuchuje działa API, czy VDOM, w ramach którego zadanie ma zostać wykonane.

Pełen kod playbooka wraz z komentarzami znajdziesz na moim repozytorium GitHub

Generowanie certyfikatu

W playbooku jest 11 zadań, przy czym ostatnie z nich w moim przypadku jest nadmiarowe,. Służy ono do ustawienia parametrów portalu SSL takich jak algorytmy szyfrowania czy zezwolenie jedynie na protokół TLS 1.3. W pierwszym zadaniu playbooka generuję nowy certyfikat ZeroSSL. To serwis podobny do Let’s Encrypt. Używam do tego skryptu acme.sh, który wywoływany jest lokalnie na komputerze, na którym uruchamiam playbook. Mechanizm acme.sh to jeden ze sposobów na wygenerowanie certyfikatów w zautomatyzowanych serwisach takich jak Let’s Encrypt czy ZeroSSL. Jest to skrypt shell-a, dlatego wywołujemy go modułem local_action, który należy do podstawowych modułów Ansible.
				
					  - name: Renew cert using acme.sh with dnf_cf
      local_action: ansible.builtin.command {{ acme_path }}/acme.sh --issue --dns dns_cf -d {{ fqdn }} 
				
			

Jeżeli to zadanie nie wykona się poprawnie, to Ansible nie wykona kolejnych zadań. Dzieje się tak dlatego, że skrypt acme.sh zwraca 0 jedynie w przypadku poprawnego zakończenia pracy. Jest to na przykład wygenerowanie nowego certyfikatu lub odnowienie już istniejącego. Jeżeli nowy certyfikat nie zostanie wygenerowany poprawnie, lub nie ma potrzeby jego odnowienia, to skrypt zwraca wartość inną niż 0. Ansible korzysta z wartości zwróconej przez skrypt, aby określić czy zadanie wykonało się poprawnie. Zatem pozostała część playbooka wykona się jedynie wtedy, gdy nastąpi wygenerowanie lub odnowienie certyfikatu. Dzięki temu nie musimy programować dodatkowej obsługi tego typu wyjątku lub bez potrzeby podmieniać certyfikat co tydzień na taki sam.

Poniżej przykład zadania, które się nie wykonało, ponieważ ważność certyfikatu jest jeszcze odpowiednio długa.

				
					peper@NAS:~/ansible/fortigate-ssl-portal-certificate-renewal$ ansible-playbook -i inventory playbook.yml 

PLAY [fortigates] ***************************************************************************************************************************************************************************************************************************************

TASK [Renew cert using acme.sh with dnf_cf] *************************************************************************************************************************************************************************************************************
fatal: [fg.lan -> localhost]: FAILED! => {"changed": true, "cmd": ["/var/services/homes/peper/.acme.sh/acme.sh", "--issue", "--dns", "dns_cf", "-d", "test.vpn.szkoladevnet.pl"], "delta": "0:00:00.199363", "end": "2022-07-27 01:49:13.415695", "msg": "non-zero return code", "rc": 2, "start": "2022-07-27 01:49:13.216332", "stderr": "", "stderr_lines": [], "stdout": "[Wed Jul 27 01:49:13 CEST 2022] Domains not changed.\n[Wed Jul 27 01:49:13 CEST 2022] Skip, Next renewal time is: 2022-08-30T17:47:47Z\n[Wed Jul 27 01:49:13 CEST 2022] Add '--force' to force to renew.", "stdout_lines": ["[Wed Jul 27 01:49:13 CEST 2022] Domains not changed.", "[Wed Jul 27 01:49:13 CEST 2022] Skip, Next renewal time is: 2022-08-30T17:47:47Z", "[Wed Jul 27 01:49:13 CEST 2022] Add '--force' to force to renew."]}

PLAY RECAP **********************************************************************************************************************************************************************************************************************************************
fg.lan                     : ok=0    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0   

				
			

Komunikaty o ważności certyfikatu pochodzą ze skryptu acme.sh i są przekazywane do Ansible, zatem w razie potrzeby możemy dowolnie je parsować.

Wygenerowany certyfikat oraz klucz muszą być odpowiednio sparsowane i zapisane w zmiennych, które będą dostępne w kolejnych zadaniach. Używamy do tego modułu set_fact oraz funkcji lookup i b64encode.
				
					  - name: Read cert file
    set_fact:
      cert_content: "{{ lookup('file', acme_path + '/' + fqdn + '/' + fqdn + '.cer') | b64encode }}"
  - name: Read key file
    set_fact:
      key_file_content: "{{ lookup('file', acme_path + '/' + fqdn + '/' + fqdn + '.key') | b64encode }}"
				
			

Obsługa certyfikatu

Programując pamiętajmy zawsze, że jesteśmy ograniczeni mechanizmami, które twórcy biblioteki nam dostarczyli. Z taką sytuacją musiałem zmierzyć się w tym skrypcie. Twórcy modułów do Ansible nie przewidzieli modułu do podmiany certyfikatu. Są za to moduły służące do wgrania certyfikatu, skasowania go, a także ustawienia wskazanego certyfikatu jako aktywny. Dlatego we własnym zakresie zaimplementowałem mechanizmy, które zapobiegają skasowaniu starego certyfikatu przed poprawnym wgraniem nowego. Nowy certyfikat przecież mógł się nie wygenerować poprawnie bądź z jakiegokolwiek innego powodu jego wgranie na urządzenie mogło nie przebiec poprawnie. W takiej sytuacji mój zdalny dostęp przestałby działać, ponieważ żaden certyfikat nie byłby przypisany do portalu SSL VPN. Chciałem też zachować pewną spójność nazewnictwa, aby certyfikat zawsze miał nazwę FQDN domeny, dla której został wystawiony. Przyjąłem więc następujący scenariusz:

  1. Kasuję tymczasowy certyfikat jeżeli istnieje (ma on postać nazyw domeny z „-1” na końcu.
  2. Wgrywam nowy certyfikat pod tymczasową nazwą
  3. Ustawiam certyfikat pod tymczasową nazwą jako certyfikat portalu SSL VPN.
  4. Kasuję stary certyfikat
  5. W grywam nowy certyfikat już pod docelowo nazwą
  6. Ustawiam docelowy certyfikat dla portalu SSL VPN.
  7. Kasuję tymczasowy certyfikat.

Dzięki takiemu rozwiązaniu, jeżeli zawiedzie wgranie nowego certyfikatu to w konfiguracji urządzenia nadal aktywny będzie ten stary. Jeżeli nie uda się podmiana certyfikatu na tymczasowy to nadal mogę przywrócić stary choćby ręcznie.


Subscribe
Powiadom o
guest

Witryna wykorzystuje Akismet, aby ograniczyć spam. Dowiedz się więcej jak przetwarzane są dane komentarzy.

0 komentarzy
Inline Feedbacks
View all comments

ZdradziĆ Ci sekretY udanego projektu automatyzacji?

(link otwiera się w nowym oknie)