Ansible.s2sVPN.Fortinet

Working with Fortinet firewalls is something new to me. So I’ve started with a virtual Fortinet in GNS3. After doing some policies(ACLs) and checking a few things, I wanted to do a VPN between 2 firewalls. Doing it using CLI and GUI was pretty easy. But nowadays I want to do anything with Ansible and Python, so the next natural step for me was doing it with Ansible.

Using the above topology in GNS3 I’ve started my project. First, it was a little bit difficult to build my var files. That’s because on Fortinet’s you need to specify and configure a phase2 security association for each pair of local/remote subnets. So I’ve used a list of dictionaries to do this, called sas (from security associations). And this list is part of a bigger dictionary called ‘vpn’.
In this dictionary I’ve put all the basic requirements for a site to site vpn: pre-shared-key, DH group, peer IP, the phase1 and phase2 proposal (i’ve used the same one for both), local/remote site name, local/remote subnets.
It would be a good idea to keep the password for the user and the pre-shared key in an ansible vault.(I’ve skipped this step this time.)

Here is one of my var files (varsA):

vpn:
  site:
    host: 10.0.0.21
    username: admin
    password: admin
    vdom: root
    peerIP: 192.168.2.1
    pskey: Ohmah4Ookee0
    dh: 5
    proposal: des-sha512
    phase2keylifetime: 3600
    subnets:
      site_local: Dublin	
      site_remote: Tokyo  
      sas: 
        - name: sa1
          local: 172.16.1.0 255.255.255.0
          remote: 172.16.2.0 255.255.255.0
        - name: sa2
          local: 172.16.11.0 255.255.255.0
          remote: 172.16.22.0 255.255.255.0
        - name: sa3
          local: 172.16.1.0 255.255.255.0
          remote: 172.16.22.0 255.255.255.0

In many cases, you only have to configure one side of the tunnel. That’s why I’ve used 2 var files: varsA for siteA and varsB for siteB. This way if you need to configure only one side you can use varsA file, and if you need to configure the other end too, you will need to run again the playbook but this time with varsB file.
So in my case, where I’ve configured both sides, it would look like this:

ansible-playbook  vpn-setup.yml -e "vars=varsA"  
ansible-playbook  vpn-setup.yml -e "vars=varsB"  

This works because I’ve defined the vars variable in the main YAML file:

---
- hosts: localhost
  gather_facts: no
  vars_files:
    - "{{vars}}"

I’ve defined some variables to make my life easier:

- set_fact: hostname="{{vpn.site.host}}"
            user="{{vpn.site.username}}"
            password="{{vpn.site.password}}"
            vdom="{{vpn.site.vdom}}"
            proposal="{{vpn.site.proposal}}"
            phase2keylifetime="{{vpn.site.phase2keylifetime}}"
            site_local_name="{{vpn.site.subnets.site_local}}"
            site_remote_name="{{vpn.site.subnets.site_remote}}"
            sas="{{vpn.site.subnets.sas}}"
  tags: route,vpn_status

Next, I used the phase1-interface module and phase2-interface module.
In the phase2-interface module, I’ve used the ‘sas’ list of dictionaries to create all phase2 security associations ( or phase2 interfaces).

 - name: VPN PHASE2
   tags: vpn1
   fortios_vpn_ipsec_phase2_interface:
     host:  "{{ hostname }}"
     username: "{{ user }}"
     password: "{{ password }}"
     vdom:  "{{ vdom }}"
     https: "False"
     state: "present"
     vpn_ipsec_phase2_interface:
       name: "P2vpn-to-{{site_remote_name}}-{{item.name}}"
       phase1name: "P1vpn-to-{{site_remote_name}}"
       proposal: "{{ proposal }}"
       replay: "enable"
       pfs: "enable"
       auto_negotiate: "enable"
       keylifeseconds: "{{phase2keylifetime }}"
       src_subnet: "{{item.local}}"
       dst_subnet: "{{item.remote}}" 
   with_items: "{{sas}}"

This phase2 config looks like this on the firewall after I’ve pushed it:

    edit "P2vpn-to-Tokyo-sa1"
        set phase1name "P1vpn-to-Tokyo"
        set proposal des-sha512
        set auto-negotiate enable
        set keylifeseconds 3600
        set src-subnet 172.16.1.0 255.255.255.0
        set dst-subnet 172.16.2.0 255.255.255.0
    next
    edit "P2vpn-to-Tokyo-sa2"
        set phase1name "P1vpn-to-Tokyo"
        set proposal des-sha512
        set auto-negotiate enable
        set keylifeseconds 3600
        set src-subnet 172.16.11.0 255.255.255.0
        set dst-subnet 172.16.22.0 255.255.255.0
    next
    edit "P2vpn-to-Tokyo-sa3"
        set phase1name "P1vpn-to-Tokyo"
        set proposal des-sha512
        set auto-negotiate enable
        set keylifeseconds 3600
        set src-subnet 172.16.1.0 255.255.255.0
        set dst-subnet 172.16.22.0 255.255.255.0
    next

Next, I have tasks for local and remote addresses and policies to allow traffic inbound and outbound for each security association.
Here you have the tasks that create the inbound policies for each security association:

- name: firewall policy inbound
  tags: firewall_in                                                                                                                                                                    
  fortios_firewall_policy:                                                                                                                                                                              
    host: "{{ hostname }}"                                                                                                                                                                              
    username: "{{ user }}"                                                                                                                                                                       
    password: "{{ password }}"                                                                                                                                                           
    vdom: "{{ vdom }}"                                                                                                                                                                      
    https: "False"                                                                                                                                                                                    
    state: "present"                                                                                                                                                                         
    firewall_policy:                                                                                                                                                                      
      policyid: "0"                                                                                                                                                                                 
      srcintf:                                                                                                                                                                                               
       - name: "P1vpn-to-{{site_remote_name}}"                                                                                                                                
      dstintf:                                                                                                                                                                                        
       - name: "port3"                                                                                                      
      dstaddr:                                                                                                                                                                                        
       - name: "{{site_local_name}}-{{item.name}}"
      srcaddr:                                                                                                                                                                                              
       - name: "{{site_remote_name}}-{{item.name}}"
      schedule: "always"                                                                                                                                                                                 
      action: "accept"                                                                                                                                                                                 
      service:                                                                                                                                                                                                
       - name: "PING"                                                                                                                                                                                           
       - name: "SSH"                                                                                                                                                                                           
  with_items: "{{sas}}"

Again I am using that ‘sas’ list of dictionaries to iterate.
And then the last step is to route traffic through the VPN:

 - name: route through vpn tunnel
   tags: route                                                                                                                                                                                
   ignore_errors: yes
   fortios_router_static:          
     host: "{{ hostname }}"                                                                                                                                                                             
     username: "{{ user }}"                                                                                                                                                     
     password: "{{ password }}"                                                                                                                                                            
     vdom: "{{ vdom }}"                                                                                                                                                                    
     https: "False"                                                                                                                                                                                    
     state: "present"                                                                                                                                                                                  
     router_static:
       dst: "{{item.remote}}"
       device: "P1vpn-to-{{site_remote_name}}"
       seq_num: "0" 
   with_items: "{{sas}}"

And that’s it, one side of the VPN is set up. If you need to set up the other end you run the playbook again with varsB file.
After creating the VPN I wanted to add also a way to see the status of the VPN. So using the ‘URI’ ansible module I’ve created some tasks that use the Fortinet Rest API and get some stats for each phase2 security association.
To do this I have a task to log in:

 - name: get auth cookie
   tags: vpn_status
   uri:
     url: "http://{{hostname}}/logincheck"
     method: POST
     validate_certs: no
     return_content: yes
     status_code: 200
     body:
       "username={{user}}&secretkey={{password}}" 
   register: output 
 - set_fact: cookie="{{output.cookies_string}}"
   tags: vpn_status

This task takes the authentication cookie and uses it (in the HTTP header) on the next task to get the phase2 statistics :

- name: get vpn status  
  tags: vpn_status
  uri:
    url: "http://{{hostname}}/api/v2/monitor/vpn/ipsec/select/"
    method: GET
    validate_certs: no
    return_content: yes
    status_code: 200
    headers:
       Cookie: "{{cookie}}" 
  register: output 

Then using the output and json_query I’ve extracted the name, status, incoming bytes, and outgoing bytes:

- set_fact:
    name: " {{ result | json_query(query1) }}"  
    status: "{{ result | json_query(query2) }}"
    in_bytes: "{{ result | json_query(query3) }}"
    out_bytes: "{{ result | json_query(query4) }}"
  vars:
    query1: "results[?name=='P1vpn-to-{{site_remote_name}}'].proxyid[*].p2name"
    query2: "results[?name=='P1vpn-to-{{site_remote_name}}'].proxyid[*].status "
    query3: "results[?name=='P1vpn-to-{{site_remote_name}}'].proxyid[*].incoming_bytes "
    query4: "results[?name=='P1vpn-to-{{site_remote_name}}'].proxyid[*].outgoing_bytes "
  tags: vpn_status   

And last, print it on the screen using debug :

 - name: print status
   vars:
     msg: |
          VPN status:
          Name: {{name}}
          Status: {{status}}
          inBytes: {{in_bytes}}
          outBytes: {{out_bytes}}
   debug:
     msg: "{{ msg.split('\n')}}"
   tags: vpn_status 

The output of the VPN status looks like this:

TASK [print status] ****************************************************************************
ok: [localhost] => {
    "msg": [
        "VPN status:",
        "Name:  [[u'P2vpn-to-Tokyo-sa1', u'P2vpn-to-Tokyo-sa2', u'P2vpn-to-Tokyo-sa3']]",
        "Status: [[u'up', u'up', u'up']]",
        "inBytes: [[420, 420, 0]]",
        "outBytes: [[780, 780, 0]]",
        ""
    ]
}

If you don’t like displaying this on the screen you could save it to a file.
After building the VPN’s, every time you want only to check the status, you can use tags and run only the status tasks, like this:

 ansible-playbook  vpn-setup.yml -e "vars=varsA" --tags "vpn_status"  

You can find all the files here:
https://github.com/czirakim/Ansible.s2sVPN.Fortinet

About the author

Mihai is a Senior Network Engineer with more than 15 years of experience