|
| 1 | +#!/usr/bin/env python |
| 2 | +""" |
| 3 | +Apply a "Security Group" to the members of an etcd cluster. |
| 4 | +
|
| 5 | +Usage: apply-firewall.py |
| 6 | +""" |
| 7 | +import os |
| 8 | +import re |
| 9 | +import string |
| 10 | +import argparse |
| 11 | +from threading import Thread |
| 12 | +import uuid |
| 13 | + |
| 14 | +import colorama |
| 15 | +from colorama import Fore, Style |
| 16 | +import paramiko |
| 17 | +import requests |
| 18 | +import sys |
| 19 | +import yaml |
| 20 | + |
| 21 | + |
| 22 | +def get_nodes_from_args(args): |
| 23 | + if args.discovery_url is not None: |
| 24 | + return get_nodes_from_discovery_url(args.discovery_url) |
| 25 | + |
| 26 | + return get_nodes_from_discovery_url(get_discovery_url_from_user_data()) |
| 27 | + |
| 28 | + |
| 29 | +def get_nodes_from_discovery_url(discovery_url): |
| 30 | + try: |
| 31 | + nodes = [] |
| 32 | + json = requests.get(discovery_url).json() |
| 33 | + discovery_nodes = json['node']['nodes'] |
| 34 | + for node in discovery_nodes: |
| 35 | + value = node['value'] |
| 36 | + ip = re.search('([0-9]{1,3}\.){3}[0-9]{1,3}', value).group(0) |
| 37 | + nodes.append(ip) |
| 38 | + return nodes |
| 39 | + except: |
| 40 | + raise IOError('Could not load nodes from discovery url ' + discovery_url) |
| 41 | + |
| 42 | + |
| 43 | +def get_discovery_url_from_user_data(): |
| 44 | + name = 'linode-user-data.yaml' |
| 45 | + log_info('Loading discovery url from ' + name) |
| 46 | + try: |
| 47 | + current_dir = os.path.dirname(__file__) |
| 48 | + user_data_file = file(os.path.abspath(os.path.join(current_dir, name)), 'r') |
| 49 | + user_data = yaml.safe_load(user_data_file) |
| 50 | + return user_data['coreos']['etcd']['discovery'] |
| 51 | + except: |
| 52 | + raise IOError('Could not load discovery url from ' + name) |
| 53 | + |
| 54 | + |
| 55 | +def validate_ip_address(ip): |
| 56 | + return True if re.match('([0-9]{1,3}\.){3}[0-9]{1,3}', ip) else False |
| 57 | + |
| 58 | + |
| 59 | +def get_firewall_contents(node_ips, private=False): |
| 60 | + rules_template_text = """*filter |
| 61 | +:INPUT DROP [0:0] |
| 62 | +:FORWARD DROP [0:0] |
| 63 | +:OUTPUT ACCEPT [0:0] |
| 64 | +:DOCKER - [0:0] |
| 65 | +:Firewall-INPUT - [0:0] |
| 66 | +-A INPUT -j Firewall-INPUT |
| 67 | +-A FORWARD -j Firewall-INPUT |
| 68 | +-A Firewall-INPUT -i lo -j ACCEPT |
| 69 | +-A Firewall-INPUT -p icmp --icmp-type echo-reply -j ACCEPT |
| 70 | +-A Firewall-INPUT -p icmp --icmp-type destination-unreachable -j ACCEPT |
| 71 | +-A Firewall-INPUT -p icmp --icmp-type time-exceeded -j ACCEPT |
| 72 | +# Ping |
| 73 | +-A Firewall-INPUT -p icmp --icmp-type echo-request -j ACCEPT |
| 74 | +# Accept any established connections |
| 75 | +-A Firewall-INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT |
| 76 | +# Enable the traffic between the nodes of the cluster |
| 77 | +-A Firewall-INPUT -s $node_ips -j ACCEPT |
| 78 | +# Allow connections from docker container |
| 79 | +-A Firewall-INPUT -i docker0 -j ACCEPT |
| 80 | +# Accept ssh, http, https and git |
| 81 | +-A Firewall-INPUT -m conntrack --ctstate NEW -m multiport$multiport_private -p tcp --dports 22,2222,80,443 -j ACCEPT |
| 82 | +# Log and drop everything else |
| 83 | +-A Firewall-INPUT -j REJECT |
| 84 | +COMMIT |
| 85 | +""" |
| 86 | + |
| 87 | + multiport_private = ' -s 192.168.0.0/16' if private else '' |
| 88 | + |
| 89 | + rules_template = string.Template(rules_template_text) |
| 90 | + return rules_template.substitute(node_ips=string.join(node_ips, ','), multiport_private=multiport_private) |
| 91 | + |
| 92 | + |
| 93 | +def apply_rules_to_all(host_ips, rules, private_key): |
| 94 | + pkey = detect_and_create_private_key(private_key) |
| 95 | + |
| 96 | + threads = [] |
| 97 | + for ip in host_ips: |
| 98 | + t = Thread(target=apply_rules, args=(ip, rules, pkey)) |
| 99 | + t.setDaemon(False) |
| 100 | + t.start() |
| 101 | + threads.append(t) |
| 102 | + for thread in threads: |
| 103 | + thread.join() |
| 104 | + |
| 105 | + |
| 106 | +def detect_and_create_private_key(private_key): |
| 107 | + private_key_text = private_key.read() |
| 108 | + private_key.seek(0) |
| 109 | + if '-----BEGIN RSA PRIVATE KEY-----' in private_key_text: |
| 110 | + return paramiko.RSAKey.from_private_key(private_key) |
| 111 | + elif '-----BEGIN DSA PRIVATE KEY-----' in private_key_text: |
| 112 | + return paramiko.DSSKey.from_private_key(private_key) |
| 113 | + else: |
| 114 | + raise ValueError('Invalid private key file ' + private_key.name) |
| 115 | + |
| 116 | + |
| 117 | +def apply_rules(host_ip, rules, private_key): |
| 118 | + # connect to the server via ssh |
| 119 | + ssh = paramiko.SSHClient() |
| 120 | + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) |
| 121 | + ssh.connect(host_ip, username='core', allow_agent=False, look_for_keys=False, pkey=private_key) |
| 122 | + |
| 123 | + # copy the rules to the temp directory |
| 124 | + temp_file = '/tmp/' + str(uuid.uuid4()) |
| 125 | + |
| 126 | + ssh.open_sftp() |
| 127 | + sftp = ssh.open_sftp() |
| 128 | + sftp.open(temp_file, 'w').write(rules) |
| 129 | + |
| 130 | + # move the rules in to place and enable and run the iptables-restore.service |
| 131 | + commands = [ |
| 132 | + 'sudo mv ' + temp_file + ' /var/lib/iptables/rules-save', |
| 133 | + 'sudo chown root:root /var/lib/iptables/rules-save', |
| 134 | + 'sudo systemctl enable iptables-restore.service', |
| 135 | + 'sudo systemctl start iptables-restore.service' |
| 136 | + ] |
| 137 | + |
| 138 | + for command in commands: |
| 139 | + stdin, stdout, stderr = ssh.exec_command(command) |
| 140 | + stdout.channel.recv_exit_status() |
| 141 | + |
| 142 | + ssh.close() |
| 143 | + |
| 144 | + log_success('Applied rule to ' + host_ip) |
| 145 | + |
| 146 | + |
| 147 | +def main(): |
| 148 | + colorama.init() |
| 149 | + |
| 150 | + parser = argparse.ArgumentParser(description='Apply a "Security Group" to a Deis cluster') |
| 151 | + parser.add_argument('--private-key', required=True, type=file, dest='private_key', help='Cluster SSH Private Key') |
| 152 | + parser.add_argument('--private', action='store_true', dest='private', help='Only allow access to the cluster from the private network') |
| 153 | + parser.add_argument('--discovery-url', dest='discovery_url', help='Etcd discovery url') |
| 154 | + parser.add_argument('--hosts', nargs='+', dest='hosts', help='The IP addresses of the hosts to apply rules to') |
| 155 | + args = parser.parse_args() |
| 156 | + |
| 157 | + nodes = get_nodes_from_args(args) |
| 158 | + hosts = args.hosts if args.hosts is not None else nodes |
| 159 | + |
| 160 | + node_ips = [] |
| 161 | + for ip in nodes: |
| 162 | + if validate_ip_address(ip): |
| 163 | + node_ips.append(ip) |
| 164 | + else: |
| 165 | + log_warning('Invalid IP will not be added to security group: ' + ip) |
| 166 | + |
| 167 | + if not len(node_ips) > 0: |
| 168 | + raise ValueError('No valid IP addresses in security group.') |
| 169 | + |
| 170 | + host_ips = [] |
| 171 | + for ip in hosts: |
| 172 | + if validate_ip_address(ip): |
| 173 | + host_ips.append(ip) |
| 174 | + else: |
| 175 | + log_warning('Host has invalid IP address: ' + ip) |
| 176 | + |
| 177 | + if not len(host_ips) > 0: |
| 178 | + raise ValueError('No valid host addresses.') |
| 179 | + |
| 180 | + log_info('Generating iptables rules...') |
| 181 | + rules = get_firewall_contents(node_ips, args.private) |
| 182 | + log_success('Generated rules:') |
| 183 | + log_debug(rules) |
| 184 | + |
| 185 | + log_info('Applying rules...') |
| 186 | + apply_rules_to_all(host_ips, rules, args.private_key) |
| 187 | + log_success('Done!') |
| 188 | + |
| 189 | + |
| 190 | +def log_debug(message): |
| 191 | + print(Style.DIM + Fore.MAGENTA + message + Fore.RESET + Style.RESET_ALL) |
| 192 | + |
| 193 | + |
| 194 | +def log_info(message): |
| 195 | + print(Fore.CYAN + message + Fore.RESET) |
| 196 | + |
| 197 | + |
| 198 | +def log_warning(message): |
| 199 | + print(Fore.YELLOW + message + Fore.RESET) |
| 200 | + |
| 201 | + |
| 202 | +def log_success(message): |
| 203 | + print(Style.BRIGHT + Fore.GREEN + message + Fore.RESET + Style.RESET_ALL) |
| 204 | + |
| 205 | + |
| 206 | +def log_error(message): |
| 207 | + print(Style.BRIGHT + Fore.RED + message + Fore.RESET + Style.RESET_ALL) |
| 208 | + |
| 209 | +if __name__ == "__main__": |
| 210 | + try: |
| 211 | + main() |
| 212 | + except Exception as e: |
| 213 | + log_error(e.message) |
| 214 | + sys.exit(1) |
0 commit comments