diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2e63964..662f7c8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -93,7 +93,6 @@ jobs: set -x sudo -E bash -x ./tests/wireguard-client.sh sudo env "PATH=$PATH" ./tests/ipsec-client.sh - sudo ./tests/ssh-tunnel.sh local-deploy: runs-on: ubuntu-16.04 diff --git a/config.cfg b/config.cfg index 4a73c03..7d3027e 100644 --- a/config.cfg +++ b/config.cfg @@ -26,6 +26,13 @@ ipsec_enabled: true wireguard_enabled: true wireguard_port: 51820 +# This feature allows you to configure the Algo server to send outbound traffic +# through a different external IP address than the one you are establishing the VPN connection with. +# More info https://trailofbits.github.io/algo/cloud-alternative-ingress-ip.html +# Available for the following cloud providers: +# - DigitalOcean +alternative_ingress_ip: false + # Reduce the MTU of the VPN tunnel # Some cloud and internet providers use a smaller MTU (Maximum Transmission # Unit) than the normal value of 1500 and if you don't reduce the MTU of your diff --git a/docs/cloud-alternative-ingress-ip.md b/docs/cloud-alternative-ingress-ip.md new file mode 100644 index 0000000..5c123e9 --- /dev/null +++ b/docs/cloud-alternative-ingress-ip.md @@ -0,0 +1,22 @@ +# Alternative Ingress IP + +This feature allows you to configure the Algo server to send outbound traffic through a different external IP address than the one you are establishing the VPN connection with. + +![cloud-alternative-ingress-ip](/docs/images/cloud-alternative-ingress-ip.png) + +Additional info might be found in [this issue](https://github.com/trailofbits/algo/issues/1047) + + + + +#### Caveats + +##### Extra charges + +- DigitalOcean: Floating IPs are free when assigned to a Droplet, but after manually deleting a Droplet you need to also delete the Floating IP or you'll get charged for it. + +##### IPv6 + +Some cloud providers provision a VM with an `/128` address block size. This is the only IPv6 address provided and for outbound and incoming traffic. + +If the provided address block size is bigger, e.g., `/64`, Algo takes a separate address than the one is assigned to the server to send outbound IPv6 traffic. diff --git a/docs/images/cloud-alternative-ingress-ip.png b/docs/images/cloud-alternative-ingress-ip.png new file mode 100644 index 0000000..82de4fb Binary files /dev/null and b/docs/images/cloud-alternative-ingress-ip.png differ diff --git a/library/digital_ocean_floating_ip.py b/library/digital_ocean_floating_ip.py new file mode 100644 index 0000000..963403c --- /dev/null +++ b/library/digital_ocean_floating_ip.py @@ -0,0 +1,288 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# (c) 2015, Patrick F. Marques +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = ''' +--- +module: digital_ocean_floating_ip +short_description: Manage DigitalOcean Floating IPs +description: + - Create/delete/assign a floating IP. +version_added: "2.4" +author: "Patrick Marques (@pmarques)" +options: + state: + description: + - Indicate desired state of the target. + default: present + choices: ['present', 'absent'] + ip: + description: + - Public IP address of the Floating IP. Used to remove an IP + region: + description: + - The region that the Floating IP is reserved to. + droplet_id: + description: + - The Droplet that the Floating IP has been assigned to. + oauth_token: + description: + - DigitalOcean OAuth token. + required: true +notes: + - Version 2 of DigitalOcean API is used. +requirements: + - "python >= 2.6" +''' + + +EXAMPLES = ''' +- name: "Create a Floating IP in region lon1" + digital_ocean_floating_ip: + state: present + region: lon1 + +- name: "Create a Floating IP assigned to Droplet ID 123456" + digital_ocean_floating_ip: + state: present + droplet_id: 123456 + +- name: "Delete a Floating IP with ip 1.2.3.4" + digital_ocean_floating_ip: + state: absent + ip: "1.2.3.4" + +''' + + +RETURN = ''' +# Digital Ocean API info https://developers.digitalocean.com/documentation/v2/#floating-ips +data: + description: a DigitalOcean Floating IP resource + returned: success and no resource constraint + type: dict + sample: { + "action": { + "id": 68212728, + "status": "in-progress", + "type": "assign_ip", + "started_at": "2015-10-15T17:45:44Z", + "completed_at": null, + "resource_id": 758603823, + "resource_type": "floating_ip", + "region": { + "name": "New York 3", + "slug": "nyc3", + "sizes": [ + "512mb", + "1gb", + "2gb", + "4gb", + "8gb", + "16gb", + "32gb", + "48gb", + "64gb" + ], + "features": [ + "private_networking", + "backups", + "ipv6", + "metadata" + ], + "available": true + }, + "region_slug": "nyc3" + } + } +''' + +import json +import time + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import env_fallback +from ansible.module_utils.urls import fetch_url +from ansible.module_utils.digital_ocean import DigitalOceanHelper + +class Response(object): + + def __init__(self, resp, info): + self.body = None + if resp: + self.body = resp.read() + self.info = info + + @property + def json(self): + if not self.body: + if "body" in self.info: + return json.loads(self.info["body"]) + return None + try: + return json.loads(self.body) + except ValueError: + return None + + @property + def status_code(self): + return self.info["status"] + +def wait_action(module, rest, ip, action_id, timeout=10): + end_time = time.time() + 10 + while time.time() < end_time: + response = rest.get('floating_ips/{0}/actions/{1}'.format(ip, action_id)) + status_code = response.status_code + status = response.json['action']['status'] + # TODO: check status_code == 200? + if status == 'completed': + return True + elif status == 'errored': + module.fail_json(msg='Floating ip action error [ip: {0}: action: {1}]'.format( + ip, action_id), data=json) + + module.fail_json(msg='Floating ip action timeout [ip: {0}: action: {1}]'.format( + ip, action_id), data=json) + + +def core(module): + api_token = module.params['oauth_token'] + state = module.params['state'] + ip = module.params['ip'] + droplet_id = module.params['droplet_id'] + + rest = DigitalOceanHelper(module) + + if state in ('present'): + if droplet_id is not None and module.params['ip'] is not None: + # Lets try to associate the ip to the specified droplet + associate_floating_ips(module, rest) + else: + create_floating_ips(module, rest) + + elif state in ('absent'): + response = rest.delete("floating_ips/{0}".format(ip)) + status_code = response.status_code + json_data = response.json + if status_code == 204: + module.exit_json(changed=True) + elif status_code == 404: + module.exit_json(changed=False) + else: + module.exit_json(changed=False, data=json_data) + + +def get_floating_ip_details(module, rest): + ip = module.params['ip'] + + response = rest.get("floating_ips/{0}".format(ip)) + status_code = response.status_code + json_data = response.json + if status_code == 200: + return json_data['floating_ip'] + else: + module.fail_json(msg="Error assigning floating ip [{0}: {1}]".format( + status_code, json_data["message"]), region=module.params['region']) + + +def assign_floating_id_to_droplet(module, rest): + ip = module.params['ip'] + + payload = { + "type": "assign", + "droplet_id": module.params['droplet_id'], + } + + response = rest.post("floating_ips/{0}/actions".format(ip), data=payload) + status_code = response.status_code + json_data = response.json + if status_code == 201: + wait_action(module, rest, ip, json_data['action']['id']) + + module.exit_json(changed=True, data=json_data) + else: + module.fail_json(msg="Error creating floating ip [{0}: {1}]".format( + status_code, json_data["message"]), region=module.params['region']) + + +def associate_floating_ips(module, rest): + floating_ip = get_floating_ip_details(module, rest) + droplet = floating_ip['droplet'] + + # TODO: If already assigned to a droplet verify if is one of the specified as valid + if droplet is not None and str(droplet['id']) in [module.params['droplet_id']]: + module.exit_json(changed=False) + else: + assign_floating_id_to_droplet(module, rest) + + +def create_floating_ips(module, rest): + payload = { + } + floating_ip_data = None + + if module.params['region'] is not None: + payload["region"] = module.params['region'] + + if module.params['droplet_id'] is not None: + payload["droplet_id"] = module.params['droplet_id'] + + floating_ips = rest.get_paginated_data(base_url='floating_ips?', data_key_name='floating_ips') + + for floating_ip in floating_ips: + if floating_ip['droplet'] and floating_ip['droplet']['id'] == module.params['droplet_id']: + floating_ip_data = {'floating_ip': floating_ip} + + if floating_ip_data: + module.exit_json(changed=False, data=floating_ip_data) + else: + response = rest.post("floating_ips", data=payload) + status_code = response.status_code + json_data = response.json + + if status_code == 202: + module.exit_json(changed=True, data=json_data) + else: + module.fail_json(msg="Error creating floating ip [{0}: {1}]".format( + status_code, json_data["message"]), region=module.params['region']) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + state=dict(choices=['present', 'absent'], default='present'), + ip=dict(aliases=['id'], required=False), + region=dict(required=False), + droplet_id=dict(required=False, type='int'), + oauth_token=dict( + no_log=True, + # Support environment variable for DigitalOcean OAuth Token + fallback=(env_fallback, ['DO_API_TOKEN', 'DO_API_KEY', 'DO_OAUTH_TOKEN']), + required=True, + ), + validate_certs=dict(type='bool', default=True), + timeout=dict(type='int', default=30), + ), + required_if=[ + ('state', 'delete', ['ip']) + ], + mutually_exclusive=[ + ['region', 'droplet_id'] + ], + ) + + core(module) + + +if __name__ == '__main__': + main() diff --git a/playbooks/cloud-post.yml b/playbooks/cloud-post.yml index 1495473..3ae2387 100644 --- a/playbooks/cloud-post.yml +++ b/playbooks/cloud-post.yml @@ -20,6 +20,7 @@ algo_ssh_tunneling: "{{ algo_ssh_tunneling }}" algo_store_pki: "{{ algo_store_pki }}" IP_subject_alt_name: "{{ IP_subject_alt_name }}" + alternative_ingress_ip: "{{ alternative_ingress_ip | default(omit) }}" cloudinit: "{{ cloudinit|default(false) }}" - name: Additional variables for the server diff --git a/roles/cloud-digitalocean/tasks/main.yml b/roles/cloud-digitalocean/tasks/main.yml index b41becd..2013a22 100644 --- a/roles/cloud-digitalocean/tasks/main.yml +++ b/roles/cloud-digitalocean/tasks/main.yml @@ -26,6 +26,19 @@ - Environment:Algo register: digital_ocean_droplet +- block: + - name: "Create a Floating IP" + digital_ocean_floating_ip: + state: present + oauth_token: "{{ algo_do_token }}" + droplet_id: "{{ digital_ocean_droplet.data.droplet.id }}" + register: digital_ocean_floating_ip + + - name: Set the static ip as a fact + set_fact: + cloud_alternative_ingress_ip: "{{ digital_ocean_floating_ip.data.floating_ip.ip }}" + when: alternative_ingress_ip + - set_fact: cloud_instance_ip: "{{ digital_ocean_droplet.data.ip_address }}" ansible_ssh_user: algo diff --git a/roles/common/defaults/main.yml b/roles/common/defaults/main.yml index f358d3e..4a2c6de 100644 --- a/roles/common/defaults/main.yml +++ b/roles/common/defaults/main.yml @@ -1,2 +1,9 @@ --- install_headers: true +aip_supported_providers: + - digitalocean +snat_aipv4: false +ipv6_default: "{{ ansible_default_ipv6.address + '/' + ansible_default_ipv6.prefix }}" +ipv6_subnet_size: "{{ ipv6_default | ipaddr('size') }}" +ipv6_egress_ip: >- + {{ (ipv6_default | next_nth_usable(15 | random(seed=algo_server_name + ansible_fqdn))) + '/124' if ipv6_subnet_size|int > 1 else ipv6_default }} diff --git a/roles/common/handlers/main.yml b/roles/common/handlers/main.yml index ebbe91a..6bcae5c 100644 --- a/roles/common/handlers/main.yml +++ b/roles/common/handlers/main.yml @@ -22,3 +22,6 @@ - name: restart iptables service: name=netfilter-persistent state=restarted + +- name: netplan apply + command: netplan apply diff --git a/roles/common/tasks/aip/digitalocean.yml b/roles/common/tasks/aip/digitalocean.yml new file mode 100644 index 0000000..cd5032f --- /dev/null +++ b/roles/common/tasks/aip/digitalocean.yml @@ -0,0 +1,13 @@ +--- +- name: Get the anchor IP + uri: + url: http://169.254.169.254/metadata/v1/interfaces/public/0/anchor_ipv4/address + return_content: true + register: anchor_ipv4 + until: anchor_ipv4 is succeeded + retries: 30 + delay: 10 + +- name: Set SNAT IP as a fact + set_fact: + snat_aipv4: "{{ anchor_ipv4.content }}" diff --git a/roles/common/tasks/aip/main.yml b/roles/common/tasks/aip/main.yml new file mode 100644 index 0000000..6055fd3 --- /dev/null +++ b/roles/common/tasks/aip/main.yml @@ -0,0 +1,10 @@ +--- +- name: Include alternative ingress ip configuration + include_tasks: + file: "{{ algo_provider if algo_provider in aip_supported_providers else 'placeholder' }}.yml" + when: algo_provider in aip_supported_providers + +- name: Verify SNAT IPv4 found + assert: + that: snat_aipv4 | ipv4 + msg: The SNAT IPv4 address not found. Cannot proceed with the alternative ingress ip. diff --git a/roles/common/tasks/aip/placeholder.yml b/roles/common/tasks/aip/placeholder.yml new file mode 100644 index 0000000..e69de29 diff --git a/roles/common/tasks/ubuntu.yml b/roles/common/tasks/ubuntu.yml index 97c8616..6355bbf 100644 --- a/roles/common/tasks/ubuntu.yml +++ b/roles/common/tasks/ubuntu.yml @@ -78,6 +78,16 @@ - name: Gather additional facts import_tasks: facts.yml +- name: IPv6 egress alias configured + template: + src: 99-algo-ipv6-egress.yaml.j2 + dest: /etc/netplan/99-algo-ipv6-egress.yaml + when: + - ipv6_support + - ipv6_subnet_size|int > 1 + notify: + - netplan apply + - name: Set OS specific facts set_fact: tools: @@ -112,5 +122,9 @@ state: present when: install_headers +- name: Configure the alternative ingress ip + include_tasks: aip/main.yml + when: alternative_ingress_ip + - include_tasks: iptables.yml tags: iptables diff --git a/roles/common/templates/99-algo-ipv6-egress.yaml.j2 b/roles/common/templates/99-algo-ipv6-egress.yaml.j2 new file mode 100644 index 0000000..c0aa9a2 --- /dev/null +++ b/roles/common/templates/99-algo-ipv6-egress.yaml.j2 @@ -0,0 +1,6 @@ +network: + version: 2 + ethernets: + {{ ansible_default_ipv6.interface }}: + addresses: + - {{ ipv6_egress_ip }} diff --git a/roles/common/templates/rules.v4.j2 b/roles/common/templates/rules.v4.j2 index 0f5bfba..764008a 100644 --- a/roles/common/templates/rules.v4.j2 +++ b/roles/common/templates/rules.v4.j2 @@ -35,7 +35,7 @@ COMMIT -A PREROUTING --in-interface {{ ansible_default_ipv4['interface'] }} -p udp --dport {{ wireguard_port_avoid }} -j REDIRECT --to-port {{ wireguard_port_actual }} {% endif %} # Allow traffic from the VPN network to the outside world, and replies --A POSTROUTING -s {{ subnets|join(',') }} -m policy --pol none --dir out -j MASQUERADE +-A POSTROUTING -s {{ subnets|join(',') }} -m policy --pol none --dir out {{ '-j SNAT --to ' + snat_aipv4 if snat_aipv4 else '-j MASQUERADE' }} COMMIT diff --git a/roles/common/templates/rules.v6.j2 b/roles/common/templates/rules.v6.j2 index 47226b7..96642a7 100644 --- a/roles/common/templates/rules.v6.j2 +++ b/roles/common/templates/rules.v6.j2 @@ -34,7 +34,7 @@ COMMIT -A PREROUTING --in-interface {{ ansible_default_ipv6['interface'] }} -p udp --dport {{ wireguard_port_avoid }} -j REDIRECT --to-port {{ wireguard_port_actual }} {% endif %} # Allow traffic from the VPN network to the outside world, and replies --A POSTROUTING -s {{ subnets|join(',') }} -m policy --pol none --dir out -j MASQUERADE +-A POSTROUTING -s {{ subnets|join(',') }} -m policy --pol none --dir out -j SNAT --to {{ ipv6_egress_ip | ipaddr('address') }} COMMIT diff --git a/server.yml b/server.yml index 782d713..fb472f0 100644 --- a/server.yml +++ b/server.yml @@ -35,6 +35,7 @@ IdentityFile {{ SSH_keys.private }} KeepAlive yes ServerAliveInterval 30 + when: inventory_hostname != 'localhost' become: false delegate_to: localhost