Alternative Ingress IP (#1605)

* Separate ingress IP draft

* task name fix

* placeholder
pull/1714/head
Jack Ivanov 4 years ago committed by GitHub
parent 78cc708435
commit 2abbf22196
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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

@ -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

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

@ -0,0 +1,288 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
#
# (c) 2015, Patrick F. Marques <patrickfmarques@gmail.com>
# 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()

@ -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

@ -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

@ -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 }}

@ -22,3 +22,6 @@
- name: restart iptables
service: name=netfilter-persistent state=restarted
- name: netplan apply
command: netplan apply

@ -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 }}"

@ -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.

@ -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

@ -0,0 +1,6 @@
network:
version: 2
ethernets:
{{ ansible_default_ipv6.interface }}:
addresses:
- {{ ipv6_egress_ip }}

@ -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

@ -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

@ -35,6 +35,7 @@
IdentityFile {{ SSH_keys.private }}
KeepAlive yes
ServerAliveInterval 30
when: inventory_hostname != 'localhost'
become: false
delegate_to: localhost

Loading…
Cancel
Save