├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── holepunch ├── __init__.py └── version.py ├── setup.py └── tests ├── __init__.py └── test_cli.py /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Run Tests 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | # Currently supported Python versions: 12 | # https://devguide.python.org/#status-of-python-branches 13 | python-version: 14 | - '3.6' # EOL: 2021-12-23 15 | - '3.7' # EOL: 2023-06-27 16 | - '3.8' # EOL: 2024-10 17 | - '3.9' # EOL: 2025-10 18 | - '3.10' # EOL: 2026-10 19 | steps: 20 | - uses: actions/checkout@v2 21 | - uses: actions/setup-python@v1 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install -e . 28 | pip install pytest mock pylint 29 | - name: Run tests 30 | run: | 31 | python -m pytest -v 32 | - name: Run lint 33 | run: | 34 | python -m pylint -E holepunch 35 | 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | build/ 3 | dist/ 4 | *.egg-info/ 5 | .tox/ 6 | .cache/ 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Erik Price 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # holepunch 2 | 3 | Punch holes in your AWS account security. 4 | 5 | `holepunch` is meant for times when you need to temporarily open ingress to an 6 | AWS security group, perhaps for development or testing remotely without a VPN 7 | set up. 8 | 9 | This is really bad practice, but `holepunch` will make sure that security group 10 | rules will be reverted when you are done. 11 | 12 | After running `holepunch`, just hit `^D` to clear out the modified rules. 13 | 14 | You can also run `holepunch` only for the duration of a shell command with 15 | `--command`. 16 | 17 | By default, `holepunch` will refuse to remove rules that existed before it 18 | was run. This can be toggled with the `--remove-existing` flag. Holepunch 19 | will only remove rules that match the provided arguments. Eg source, port, 20 | protocol and description must all match or the rule will not be removed. 21 | 22 | ## Installation 23 | 24 | ``` 25 | pip install holepunch 26 | ``` 27 | 28 | AWS credentials can be set up in any of the places that [Boto3 knows 29 | to 30 | look.](http://boto3.readthedocs.io/en/latest/guide/configuration.html) 31 | 32 | ## Examples 33 | 34 | To modify security group `foo_bar` to permit inbound traffic from this 35 | machine to TCP port 22 (ssh): 36 | 37 | ``` 38 | holepunch foo_bar 22 --tcp 39 | ``` 40 | 41 | Modifying a security group using its id also works: 42 | 43 | ``` 44 | holepunch sg-62153838 443 --tcp 45 | ``` 46 | 47 | Apply security group rules and then `ssh` into a host. Rules will be 48 | reverted when SSH connection ends. 49 | 50 | ``` 51 | holepunch foo_bar 22 --command "ssh bastion" 52 | ``` 53 | 54 | Adding multiple TCP port ranges: 55 | 56 | ``` 57 | holepunch foo_bar 22 80 8080-8081 --tcp 58 | ``` 59 | 60 | Explicitly setting the IP range the rules apply to: 61 | 62 | ``` 63 | holepunch foo_bar --cidr=192.168.0.0/16 22 80 64 | 65 | # Also works with IPv6 ranges 66 | holepunch foo_bar --cidr=2001:882f::1/128 443 67 | ``` 68 | -------------------------------------------------------------------------------- /holepunch/__init__.py: -------------------------------------------------------------------------------- 1 | '''Punches holes in your security. 2 | 3 | Usage: 4 | holepunch [options] GROUP (PORTS... | --all) 5 | holepunch (-h | --help) 6 | 7 | Arguments: 8 | GROUP Name or group id of security group to modify. 9 | PORTS List of ports or port ranges (e.g. 8080-8082) to open. 10 | 11 | Options: 12 | -4 Use external IPv4 address (useful in dualstack situations). 13 | -6 Use external IPv6 address. 14 | --all Open ports 0-65535. 15 | -c --command=CMD Run command after applying ingress rules and revert when it exits. 16 | --cidr ADDR Address range (CIDR notation) ingress applies to [defaults to external_ip] 17 | -d --description=DESC Description of security group ingress [default: holepunch]. 18 | -h --help Show this screen. 19 | -p --profile=NAME Use a specific AWS profile, equivalent to setting `AWS_PROFILE=NAME` 20 | -r --remove-existing Remove ingress rules at exit even if they weren't created by holepunch. 21 | -t --tcp Open TCP ports to ingress [default]. 22 | -u --udp Open UDP ports to ingress. 23 | -y --yes Don't prompt before writing rules. 24 | ''' 25 | 26 | import atexit 27 | from difflib import SequenceMatcher 28 | import ipaddress 29 | import json 30 | import signal 31 | import subprocess 32 | import sys 33 | from urllib.request import urlopen 34 | 35 | import boto3 36 | from docopt import docopt 37 | 38 | from holepunch.version import __version__ 39 | 40 | 41 | def find_intended_security_group(security_groups, group_name): 42 | '''If there's a typo, try to return the intended security group name''' 43 | if not len(security_groups): 44 | return 45 | 46 | scores = [ 47 | SequenceMatcher(None, group_name, grp['GroupName']).ratio() 48 | for grp in security_groups 49 | ] 50 | 51 | # Find the closest match 52 | distances = sorted(zip(scores, security_groups), key=lambda tpl: tpl[0]) 53 | score, best_match = distances[-1] 54 | 55 | if score > 0.35: 56 | return best_match['GroupName'] 57 | 58 | 59 | def get_external_ip(proto=None): 60 | ''' 61 | Query external service to find public facing IP address. 62 | Optional `proto` argument specifies whether to return IPv4 or IPv6 address. 63 | ''' 64 | 65 | url = { 66 | 4: 'http://ipv4.icanhazip.com', 67 | 6: 'http://ipv6.icanhazip.com' 68 | }.get(proto, 'http://icanhazip.com') 69 | 70 | ip_str = urlopen(url).read().decode('utf-8').strip() 71 | return ipaddress.ip_address(ip_str) 72 | 73 | 74 | def parse_cidr_expression(cidr_or_ip): 75 | ''' 76 | Convert from string or CIDR notation or an Ipv{4,6}Address to 77 | Ipv{4,6}Interface. 78 | ''' 79 | return ipaddress.ip_interface(cidr_or_ip) 80 | 81 | 82 | def parse_port_ranges(port_strings): 83 | ''' 84 | Convert a list of strings describing port ranges to a list of tuples 85 | of (low, high). 86 | 87 | parse_port_range(['80-8082', '443']) == [(80, 8082), (443, 443)] 88 | ''' 89 | 90 | ranges = [] 91 | 92 | for s in port_strings: 93 | split = list(map(int, s.split('-'))) 94 | 95 | if len(split) not in [1, 2]: 96 | raise ValueError('Expected port or port range (e.g `80`, `8080-8082`)') 97 | 98 | # Single port, convert to range, e.g. 80 -> 80-80 99 | if len(split) == 1: 100 | (p1, p2) = (split[0], split[0]) 101 | 102 | elif len(split) == 2: 103 | (p1, p2) = split 104 | 105 | if p1 > p2: 106 | raise ValueError('Port range must be ordered from low to high') 107 | 108 | if not all(0 <= p <= 65535 for p in [p1, p2]): 109 | raise ValueError('Ports must be in range 0-65535') 110 | 111 | ranges.append((p1, p2)) 112 | 113 | return ranges 114 | 115 | 116 | def apply_ingress_rules(ec2_client, group, ip_permissions): 117 | print('Applying rules... ', end='') 118 | 119 | ec2_client.authorize_security_group_ingress(**{ 120 | 'GroupId': group['GroupId'], 121 | 'IpPermissions': ip_permissions 122 | }) 123 | 124 | print('Done') 125 | 126 | 127 | def revert_ingress_rules(boto_args, group, ip_permissions): 128 | print('Reverting rules... ', end='') 129 | 130 | # Create a new boto session instead of reusing existing one, which 131 | # may have expired while we were asleep. 132 | boto_session = boto3.session.Session(**boto_args) 133 | ec2_client = boto_session.client('ec2') 134 | 135 | ec2_client.revoke_security_group_ingress(**{ 136 | 'GroupId': group['GroupId'], 137 | 'IpPermissions': ip_permissions, 138 | }) 139 | 140 | print('Done') 141 | 142 | 143 | def confirm(message): 144 | resp = input('%s [y/N] ' % message) 145 | return resp.lower() in ['yes', 'y'] 146 | 147 | 148 | def find_matching_security_groups(ec2_client, name): 149 | groups = [] 150 | 151 | # Try to lookup based on group name and group id 152 | for filter_name in ['group-name', 'group-id']: 153 | matches = ec2_client.describe_security_groups(Filters=[{ 154 | 'Name': filter_name, 155 | 'Values': [name] 156 | }]) 157 | 158 | groups.extend(matches['SecurityGroups']) 159 | 160 | return groups 161 | 162 | 163 | def build_ingress_permissions(security_group, cidr, port_ranges, protocols, description): 164 | new_perms, existing_perms = [], [] 165 | cidr_str = str(cidr) 166 | 167 | for proto in protocols: 168 | for from_port, to_port in port_ranges: 169 | permission = { 170 | 'IpProtocol': proto, 171 | 'FromPort': from_port, 172 | 'ToPort': to_port 173 | } 174 | 175 | # AWS uses different keys for IPv4 and IPv6 ranges. 176 | if cidr.version == 4: 177 | permission['IpRanges'] = [ 178 | {'CidrIp': cidr_str, 'Description': description} 179 | ] 180 | elif cidr.version == 6: 181 | permission['Ipv6Ranges'] = [ 182 | {'CidrIpv6': cidr_str, 'Description': description} 183 | ] 184 | 185 | # We don't want to (and cannot) duplicate rules 186 | for perm in security_group['IpPermissions']: 187 | 188 | # These keys are checked for simple equality, if they're not 189 | # all the same no need to check IpRanges. 190 | keys = ['IpProtocol', 'FromPort', 'ToPort'] 191 | 192 | if not all(perm.get(k) == permission[k] for k in keys): 193 | continue 194 | 195 | # For IpRanges / Ipv6Ranges, we need to ignore the Description 196 | # and check if the CidrIp is the same. 197 | if cidr.version == 4: 198 | ip_ranges = perm.get('IpRanges', []) 199 | cidr_key = 'CidrIp' 200 | elif cidr.version == 6: 201 | ip_ranges = perm.get('Ipv6Ranges', []) 202 | cidr_key = 'CidrIpv6' 203 | 204 | if any(ip[cidr_key] == cidr_str for ip in ip_ranges): 205 | existing_perms.append(permission) 206 | print('Not adding existing permission: %s' % json.dumps(permission)) 207 | break 208 | else: 209 | new_perms.append(permission) 210 | 211 | return new_perms, existing_perms 212 | 213 | 214 | def holepunch(args): 215 | group_name = args['GROUP'] 216 | 217 | if args['--all']: 218 | port_ranges = [(0, 65535)] 219 | else: 220 | try: 221 | port_ranges = parse_port_ranges(args['PORTS']) 222 | except ValueError as exc: 223 | print('invalid port range: %s' % exc) 224 | return False 225 | 226 | profile_name = args['--profile'] 227 | 228 | boto_session = boto3.session.Session(profile_name=profile_name) 229 | ec2_client = boto_session.client('ec2') 230 | 231 | groups = find_matching_security_groups(ec2_client, group_name) 232 | 233 | if not groups: 234 | print('Unknown security group: %s' % group_name) 235 | all_groups = ec2_client.describe_security_groups()['SecurityGroups'] 236 | intended = find_intended_security_group(all_groups, group_name) 237 | 238 | if intended: 239 | print('\nDid you mean: "%s"?' % intended) 240 | 241 | return False 242 | 243 | elif len(groups) > 1: 244 | print('More than one group matches "%s", use group id instead' % 245 | group_name) 246 | 247 | for grp in groups: 248 | print('- %s %s' % (grp['GroupId'], grp['GroupName'])) 249 | 250 | return False 251 | 252 | group = groups[0] 253 | 254 | if args['--cidr']: 255 | cidr_str = args['--cidr'] 256 | else: 257 | proto = None 258 | if args['-4']: 259 | proto = 4 260 | elif args['-6']: 261 | proto = 6 262 | 263 | cidr_str = get_external_ip(proto) 264 | 265 | cidr = parse_cidr_expression(cidr_str) 266 | 267 | protocols = set() 268 | 269 | if args['--udp']: 270 | protocols.add('udp') 271 | if args['--tcp']: 272 | protocols.add('tcp') 273 | 274 | # Default to TCP 275 | if not protocols: 276 | protocols.add('tcp') 277 | 278 | new_perms, existing_perms = build_ingress_permissions( 279 | group, cidr, port_ranges, protocols, args['--description']) 280 | 281 | # At exit, we want to remove everything we added (plus everything 282 | # that was already there, if using --remove-existing) 283 | to_remove = new_perms[:] 284 | if args['--remove-existing']: 285 | to_remove.extend(existing_perms) 286 | 287 | if not new_perms and not to_remove: 288 | print('No changes to make.') 289 | return True 290 | 291 | print('Changes to be made to: {group_name} [{group_id}]' 292 | '\n{hr}\n{perms}\n{hr}'.format( 293 | hr='='*60, group_name=group['GroupName'], 294 | group_id=group['GroupId'], 295 | perms=json.dumps(new_perms, indent=4))) 296 | 297 | if not args['--yes'] and not confirm('Apply security group ingress?'): 298 | print('Okay, aborting...') 299 | return True 300 | 301 | # Ensure that we revert ingress rules when the program exits 302 | atexit.register(revert_ingress_rules, 303 | boto_args={'profile_name': profile_name}, 304 | group=group, 305 | ip_permissions=to_remove) 306 | 307 | # Make sure we have a chance to clean up the security group 308 | # rules gracefully by ignoring common signals. 309 | def signal_handler(sig_num, proc): 310 | print(f"\nSignal received: {sig_num}") 311 | 312 | # Received a signal while subprocess was still running, terminate. 313 | if proc.poll() is None: 314 | proc.terminate() 315 | 316 | if new_perms: 317 | apply_ingress_rules(ec2_client, group, new_perms) 318 | 319 | command = args['--command'] or 'cat' 320 | if args['--command'] is not None: 321 | print(f'Rules will revert when `{command}` terminates.') 322 | else: 323 | print('^D to revert') 324 | 325 | proc = subprocess.Popen(command, shell=True) 326 | 327 | for sig in [signal.SIGINT, signal.SIGTERM, signal.SIGHUP]: 328 | signal.signal(sig, lambda sig_num, _frame: signal_handler(sig_num, proc)) 329 | 330 | # Sleep until we receive a SIGINT 331 | return proc.wait() == 0 332 | 333 | 334 | def main(): 335 | args = docopt(__doc__, version=__version__) 336 | success = holepunch(args) 337 | 338 | if not success: 339 | sys.exit(1) 340 | 341 | 342 | if __name__ == '__main__': 343 | main() 344 | -------------------------------------------------------------------------------- /holepunch/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.0.0' 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | # Define __version__ 5 | with open('holepunch/version.py', 'r') as fp: 6 | exec(fp.read()) 7 | 8 | 9 | setup( 10 | name='holepunch', 11 | version=__version__, 12 | description="Punch holes in your AWS account security", 13 | author='Erik Price', 14 | url='https://github.com/erik/holepunch', 15 | packages=['holepunch'], 16 | entry_points={ 17 | 'console_scripts': [ 18 | 'holepunch = holepunch:main', 19 | ], 20 | }, 21 | license='MIT', 22 | install_requires=[ 23 | 'boto3==1.20.2', 24 | 'docopt==0.6.2', 25 | ], 26 | classifiers=[ 27 | 'Programming Language :: Python :: 3 :: Only', 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik/holepunch/4bbb617e4560ac4d6709dfbee6791105d50f336e/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import pytest 4 | import mock 5 | 6 | import ipaddress 7 | 8 | import holepunch 9 | 10 | 11 | def _str_to_cidr(s): 12 | return ipaddress.ip_interface(s) 13 | 14 | 15 | class TestPortRanges: 16 | def test_success_cases(self): 17 | # (input, expected_output) 18 | cases = [ 19 | (['5'], [(5, 5)]), 20 | (['20-22'], [(20, 22)]), 21 | (['90', '1-120', '80-80'], [(90, 90), (1, 120), (80, 80)]) 22 | ] 23 | 24 | for port_strings, expected in cases: 25 | output = holepunch.parse_port_ranges(port_strings) 26 | assert output == expected 27 | 28 | def test_error_cases(self): 29 | # (input, error message) 30 | cases = [ 31 | (['5-2'], 'Port range must be ordered from low to high'), 32 | (['1', '2-4', '5-2'], 'Port range must be ordered from low to high'), 33 | (['apples'], r'.*invalid literal.*'), 34 | (['1-2-3'], r'Expected port or port range'), 35 | (['9999999'], r'Ports must be in range') 36 | ] 37 | 38 | for port_strings, msg in cases: 39 | with pytest.raises(ValueError, match=msg): 40 | holepunch.parse_port_ranges(port_strings) 41 | 42 | 43 | def test_find_intended_security_group(): 44 | # list of (security_groups, group_name, expected_output) 45 | cases = [ 46 | (['1', '2', '3', '4', 'pretty close_'], 'pretty_close', 'pretty close_'), 47 | ([], 'foo', None), 48 | (['1', '2', '3'], 'pretty_far', None) 49 | ] 50 | 51 | for groups, name, expected in cases: 52 | output = holepunch.find_intended_security_group( 53 | [{'GroupName': g} for g in groups], name) 54 | 55 | assert output == expected 56 | 57 | 58 | # mostly to ensure py2 gets bytes right 59 | def test_get_external_ip(): 60 | read_mock = mock.Mock() 61 | read_mock.read.return_value = b'192.168.1.1' 62 | 63 | with mock.patch('holepunch.urlopen', return_value=read_mock): 64 | assert holepunch.get_external_ip() == ipaddress.ip_address('192.168.1.1') 65 | 66 | 67 | def test_get_external_ipv6(): 68 | read_mock = mock.Mock() 69 | read_mock.read.return_value = b'2001:db8::2:1' 70 | 71 | with mock.patch('holepunch.urlopen', return_value=read_mock): 72 | assert holepunch.get_external_ip() == ipaddress.ip_address('2001:db8:0:0:0:0:2:1') 73 | 74 | 75 | def test_find_matching_security_groups(): 76 | client_mock = mock.Mock() 77 | client_mock.describe_security_groups.return_value = {'SecurityGroups': ['bar']} 78 | 79 | output = holepunch.find_matching_security_groups(client_mock, 'foo') 80 | 81 | assert output == ['bar', 'bar'] 82 | 83 | for filter_name in ['group-name', 'group-id']: 84 | client_mock.describe_security_groups.assert_any_call(Filters=[{ 85 | 'Name': filter_name, 86 | 'Values': ['foo'] 87 | }]) 88 | 89 | 90 | class TestBuildIngressPermissions: 91 | def test_adding_ips(self): 92 | sg_permissions = [ 93 | dict(zip(['IpProtocol', 'FromPort', 'ToPort', 'IpRanges'], vals)) 94 | for vals in [ 95 | ('tcp', 90, 90, [{'CidrIp': '1.1.1.1/32', 'Description': 'foo'}]), 96 | ('udp', 91, 91, [{'CidrIp': '1.1.1.1/32', 'Description': 'foo'}]), 97 | ] 98 | ] 99 | 100 | new, existing = holepunch.build_ingress_permissions( 101 | {'IpPermissions': sg_permissions}, 102 | _str_to_cidr('1.1.1.1'), 103 | [(90, 9090)], 104 | ['tcp', 'udp'], 105 | 'bar') 106 | 107 | assert new == [{ 108 | 'IpProtocol': proto, 109 | 'FromPort': 90, 110 | 'ToPort': 9090, 111 | 'IpRanges': [{'CidrIp': '1.1.1.1/32', 'Description': 'bar'}] 112 | } for proto in ['tcp', 'udp']] 113 | 114 | assert existing == [] 115 | 116 | def test_ignores_existing_ips(self): 117 | sg_permissions = [ 118 | dict(zip(['IpProtocol', 'FromPort', 'ToPort', 'IpRanges'], vals)) 119 | for vals in [ 120 | ('tcp', 90, 9090, [{'CidrIp': '1.1.1.1/32', 'Description': 'foo'}]), 121 | ('udp', 90, 9090, [{'CidrIp': '1.1.1.1/32', 'Description': 'foo'}]), 122 | ] 123 | ] 124 | 125 | new, existing = holepunch.build_ingress_permissions( 126 | {'IpPermissions': sg_permissions}, 127 | _str_to_cidr('1.1.1.1'), 128 | [(90, 9090), (91,91)], 129 | {'tcp'}, 130 | 'bar') 131 | 132 | assert new == [{ 133 | 'IpProtocol': 'tcp', 134 | 'FromPort': 91, 135 | 'ToPort': 91, 136 | 'IpRanges': [{'CidrIp': '1.1.1.1/32', 'Description': 'bar'}] 137 | }] 138 | 139 | assert existing == [{ 140 | 'IpProtocol': 'tcp', 141 | 'FromPort': 90, 142 | 'ToPort': 9090, 143 | 'IpRanges': [{'CidrIp': '1.1.1.1/32', 'Description': 'bar'}] 144 | }] 145 | 146 | def test_ignores_existing_ips_when_some_dont_match(self): 147 | sg_permissions = [ 148 | dict(zip(['IpProtocol', 'FromPort', 'ToPort', 'IpRanges'], vals)) 149 | for vals in [ 150 | ('tcp', 90, 9090, [{'CidrIp': '1.1.1.1/32', 'Description': 'bar'}, 151 | {'CidrIp': '2.2.2.2/32', 'Description': 'foo'}]), 152 | ] 153 | ] 154 | 155 | new, existing = holepunch.build_ingress_permissions( 156 | {'IpPermissions': sg_permissions}, 157 | _str_to_cidr('1.1.1.1'), 158 | [(90, 9090)], 159 | {'tcp'}, 160 | 'bar') 161 | 162 | assert new == [] 163 | assert existing == [{ 164 | 'IpProtocol': 'tcp', 165 | 'FromPort': 90, 166 | 'ToPort': 9090, 167 | 'IpRanges': [{'CidrIp': '1.1.1.1/32', 'Description': 'bar'}] 168 | }] 169 | 170 | def test_ipv6_support(self): 171 | sg_permissions = [ 172 | dict(zip(['IpProtocol', 'FromPort', 'ToPort', 'Ipv6Ranges'], vals)) 173 | for vals in [ 174 | ('tcp', 90, 9090, [{'CidrIpv6': '1:1:1:1::/32', 'Description': 'bar'}, 175 | {'CidrIpv6': '2:2:2:2::/32', 'Description': 'foo'}]), 176 | ] 177 | ] 178 | 179 | new, existing = holepunch.build_ingress_permissions( 180 | {'IpPermissions': sg_permissions}, 181 | ipaddress.ip_interface('1:1:1:1::/32'), 182 | [(90, 9090)], 183 | {'tcp'}, 184 | 'bar') 185 | 186 | assert new == [] 187 | assert existing == [{ 188 | 'IpProtocol': 'tcp', 189 | 'FromPort': 90, 190 | 'ToPort': 9090, 191 | 'Ipv6Ranges': [{'CidrIpv6': '1:1:1:1::/32', 'Description': 'bar'}] 192 | }] 193 | --------------------------------------------------------------------------------