diff options
author | David Timber <dxdt@dev.snart.me> | 2023-11-21 19:55:39 +0800 |
---|---|---|
committer | David Timber <dxdt@dev.snart.me> | 2023-11-21 19:55:39 +0800 |
commit | 1551dbfde0e329783174b7aa9d1ce9fc93e8470b (patch) | |
tree | 4b503c811afdf935723cbadc713133bc9046f9e0 /toss-aws-eip |
Initial commit
Diffstat (limited to 'toss-aws-eip')
-rw-r--r-- | toss-aws-eip/README.md | 85 | ||||
-rwxr-xr-x | toss-aws-eip/multitoss.sh | 103 | ||||
-rwxr-xr-x | toss-aws-eip/toss-aws-eip.py | 378 |
3 files changed, 566 insertions, 0 deletions
diff --git a/toss-aws-eip/README.md b/toss-aws-eip/README.md new file mode 100644 index 0000000..caa0355 --- /dev/null +++ b/toss-aws-eip/README.md @@ -0,0 +1,85 @@ +# Ranged AWS EIP Allocator +When you request an EIP address, the AWS randomly allocates an EIP address from +one of their IPv4 address pools. The list of the IPv4 pools the AWS uses for +their service is publicly available from the following. + +https://ip-ranges.amazonaws.com/ip-ranges.json + +I also made the tool for converting the JSON data to CSV so you can use it in +spreadsheets. + +https://ashegoulding.github.io/aws-ipblocks-csv + +This is the script you're after if you're trying to get an EIP within a specific +range or block to get away from the lousy neighbours who constantly degrade the +reputation of the address block or just to get a series of contiguous EIP for +your EC2 fleet. + +I recommend running it on an EC2 instance rather than on the local machine to +save the trip to the internet. The request process time from the EC2 endpoint is +already over few hundred milliseconds so you definitely want to reduce the trip +through the internet. + +Please check the pricing rules before considering using this. If they charge for +allocation/release of EIPs, you're screwed and the script is basically useless. + +## This is a Bad Idea! +The script has to be used as a last resort after you have failed to get support +from the AWS in getting the EIP's you want. If you're a corporate user, you can +probably get the support you need. + +The big issue with this approach is that there's no way of knowing how saturated +the EIP block you're trying to get addresses from. You may use tools like nmap, +but there's still the problem of unassociated EIP addresses. + +## How to +Make sure you have done your `aws configure` and given allocate_address and +release_address permissions to the IAM account. You may test the permissions +using `-d` option. You'll get an error and the script will exit with code 1 if +the account lacks the necessary permissions. + +Choose the block you wish to get an EIP address from. Multiple ranges can be +specified and the script will exit if an address from any of the ranges is +allocated. + +```bash +./toss-aws-eip.py \ + -r us-west-1 \ # not required if the default region is set in the profile + -l "tosser" \ # resource name for identification purposes + 52.94.249.80/28 \ # range + 52.95.255.96/28 \ # range + 52.94.248.128/28 # range +``` + +In the example, the script will allocate and release EIP addresses until one +from any of the three blocks is acquired. The name tag on the address will be +"tosser". + +You can even run the script in several processes. The process returns 0 when +successful and it also handles `SIGINT` and `SIGTERM` gracefully without leaving +a "residue" EIP. If you want multiple EIP's, simply count the number of +processes that returned 0. + +Run with `-h` option for more. + +## Why? +I was having issues with the reputations of IP addresses allocated for EC2. It +is a known fact that many EC2 instances are hacked and used as bots for +nefarious activities like SSH brute forcing and sending junk mails. The +reputation is especially important for sending mails because companies take +aggressive measures to combat junk mails. + +I started with an EIP without knowing this and getting my EIP already set for +all my self-hosted services was a long and hard process. Companies like Google +and Microsoft keep a public channel via which sysadmins can file complaints to +get their addresses off their blacklist. But Outlook(Microsoft) has the stronger +measure of blacklisting the entire IP address blocks attacks and junk mails +originate from. There is no way that was legal, but I decided to get a clean EIP +from a clean block this time instead of dealing with AWS and Microsoft Support +because I'd never get anything good out of them. + +My idea is that I could be better of having an EIP from a relatively small +block. Even if I end up getting a dirty EIP, I can go through the support +channels again to delist the EIP and there will be less chance of the entire +block getting blacklisted because of the small size. You can only do this in +trial and error. This is where the script comes in. diff --git a/toss-aws-eip/multitoss.sh b/toss-aws-eip/multitoss.sh new file mode 100755 index 0000000..78f9f46 --- /dev/null +++ b/toss-aws-eip/multitoss.sh @@ -0,0 +1,103 @@ +#!/bin/bash +declare nb_proc +declare nb_runs=1 +declare cmdline +declare flag_help=false + +## Func defs + +print_help () { + cat << EOF +Run the command in pararrel ensuring the number of sucessful exits. +Usage: $1 <OPTIONS> <CMDLINE> +Options: + -h print this message and exit gracefully + -p number of processes to spawn (required) + -n number of successful run to count (default: 1) +EOF +} + +parse_params () { + local name + local delta + + while getopts "hp:n:" name + do + case "$name" in + h) flag_help=true ;; + p) let "nb_proc=$OPTARG" ;; + n) let "nb_runs=$OPTARG" ;; + '?') exit 2;; + esac + done + + let "delta = OPTIND - 1" + shift $delta + + cmdline="$@" +} + +spwan_one () { + $cmdline & +} + +main () { + local procs + local ec + local good_runs=0 + local children + + # spwan initial processes + for (( procs = 0; procs < nb_proc; procs += 1 )) + do + spwan_one + done + + while true + do + wait -n + ec=$? + echo $ec + + if [ $ec -eq 0 ]; then + let "good_runs += 1" + if [ $good_runs -ge $nb_runs ]; then + break + else + spwan_one + fi + elif [ $ec -ne 3 ]; then + # error occurred or no more child left. do not continue + break + fi + done + + children="$(jobs -p)" + [ ! -z "$children" ] && kill -TERM $children 2> /dev/null > /dev/null + while wait -n; do : ; done + + return $ec +} + +## Init script +parse_params $@ + +## Parametre check +if $flag_help; then + print_help + exit 0 +fi +if [ -z "$nb_proc" ]; then + cat << EOF >&2 +-p option not set. Run with -h option for help. +EOF + exit 2 +fi +if [ -z "$cmdline" ]; then + cat << EOF >&2 +CMDLINE not set. Run with -h option for help. +EOF +fi + +## Main start +main diff --git a/toss-aws-eip/toss-aws-eip.py b/toss-aws-eip/toss-aws-eip.py new file mode 100755 index 0000000..2029ae0 --- /dev/null +++ b/toss-aws-eip/toss-aws-eip.py @@ -0,0 +1,378 @@ +#!/bin/env python3 + +# Copyright (c) 2023 David Timber <dxdt@dev.snart.me> +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import getopt +import ipaddress +import math +import signal +import sys +import time +from decimal import Decimal +from enum import Enum +from typing import Callable + +import boto3 + +VER_STR = "0.0.0 (Nov 2023)" + +HELP_STR = '''Usage: {prog} [options] <SPEC> [SPEC ...] +Repeat allocating Elastic IP address until an address in desired range is +acquired. The multiple specs will be OR'd. + +SPEC: <RANGE | NET> + RANGE: <ADDR>-<ADDR> + ADDR: an IPv4 address + NET: <ADDR>/<CIDR> + CIDR: integer [0,32] +Options: + -r <string> aws region name. Used if the default is not set in profile + -p <string> aws profile to use. Use the default profile if unspecified + -l <string> set the name for the allocated EIP. Defaults to an empty string + -x <string> the tag spec in "name=value" format + -n <int> limit number of attempts. Default: -1 + -t <decimal> limit run time in seconds. Default: 'inf' + -d do a dry run + -h print this message and exit gracefully + -v increase verbosity + -q shut up + -V print other info and exit gracefully + +WARNING: check the pricing policy of your region prior to using this tool!''' +V_STR = '''Version: {ver} +by David Timber <dxdt@dev.snart.me> (c) 2023''' + +# Global classes + +class Verbosity (Enum): + '''Verbosity Level Enum''' + Q = ERR = 0 + DEFAULT = WARN = 1 + INFO = 2 + DBG0 = 3 + DBG1 = 4 + +class AddrSpec: + '''The class works in two modes - A to B range mode and CIDR mode. `range()` + and `net()` factory methods can be used to instantiate a working instance. + The default constructor instantiates a unusable dummy instance.''' + def _range_in_op (self, addr: ipaddress.IPv4Address) -> bool: + return self.a <= addr and addr <= self.b + + def _net_in_op (self, addr: ipaddress.IPv4Address) -> bool: + return addr in self.net + + def _range_str_op (self) -> str: + return '''{a}-{b}'''.format(a = str(self.a), b = str(self.b)) + + def _net_str_op (self) -> str: + return str(self.net) + + def range (a: ipaddress.IPv4Address, b: ipaddress.IPv4Address): + ret = AddrSpec() + ret.a = a + ret.b = b + ret.net = None + ret._contains_f = ret._range_in_op + ret._str_f = ret._range_str_op + return ret + + def net (n: ipaddress.IPv4Network): + ret = AddrSpec() + ret.a = ret.b = None + ret.net = n + ret._contains_f = ret._net_in_op + ret._str_f = ret._net_str_op + return ret + + def __init__(self): + self.a = None + self.b = None + self.net = None + + def __contains__ (self, addr: ipaddress.IPv4Address) -> bool: + return self._contains_f(addr) + + def __str__ (self) -> str: + return self._str_f() + +class ProgConf: + '''The program configuration class. The members represent the parametres + from the command line arguments.''' + def __init__(self): + self.range_specs = list[AddrSpec]() + self.profile = None + self.tag_spec = [] + self.nb_runs = math.inf + self.runtime = math.inf + self.verbose = Verbosity.DEFAULT.value + self.help = False + self.dryrun = False + self.region = None + self.ver = False + + def CompVerbosity (self, v: Verbosity) -> bool: + return self.verbose >= v.value + +class EIP: + '''Class for holding the EIP and the allocation id for a successful + iteration. The `str()` operator should return a string that can be more or + less parsed by a YAML parser.''' + def __init__(self, addr: ipaddress.IPv4Address, alloc_id: str): + self.addr = addr + self.alloc_id = alloc_id + + def __str__(self) -> str: + return '''PublicIp: {addr} +AllocationId: {alloc_id}'''.format( + addr = str(self.addr), + alloc_id = self.alloc_id) + +# Exceptions +class FormatError (Exception): + '''Used for cmd line args parse error''' + ... + + +def ParseAddrSpec (x: str) -> AddrSpec: + '''If the string contains a hyphen, try to extract the two addresses. If the + string contains a slash, treat the characters before it as an IP address and + the ones after it a CIDR length.''' + sep = x.find("-") + if sep > 0: # range + a = x[:sep].strip() + b = x[sep + 1:].strip() + return AddrSpec.range(ipaddress.IPv4Address(a), ipaddress.IPv4Address(b)) + + sep = x.find("/") + if sep > 0: # network + return AddrSpec.net(ipaddress.IPv4Network(x)) + + raise FormatError("Invalid address spec: " + x) + +def ParseParam (argv: list[str]) -> ProgConf: + '''The cmd line args parser''' + ret = ProgConf() + opt, args = getopt.getopt(argv, "p:l:x:n:t:hdvqr:V") + + for v in args: + ret.range_specs.append(ParseAddrSpec(v)) + + for k, v in opt: + match k: + case "-r": ret.region = v + case "-p": ret.profile = v + case "-l": ret.tag_spec.append({ 'Key': 'Name', 'Value': v }) + case "-x": + i = v.find("=") + if i < 0: + raise FormatError("Invalid format for option '-x': " + v) + tname = v[:i] + tvalue = v[i + 1:] + ret.tag_spec.append({ 'Key': tname, 'Value': tvalue }) + case '-n': + ret.nb_runs = int(v) + if ret.nb_runs <= 0: ret.nb_runs = math.inf + case '-t': ret.runtime = Decimal(v) * 1000000000 + case '-h': ret.help = True + case '-v': ret.verbose += 1 + case '-q': ret.verbose = Verbosity.Q.value + case '-d': ret.dryrun = True + case '-V': ret.ver = True + + return ret + +def GetRefClock () -> int: + '''A wrapper function for retrieving the monotonic clock tick value used to + measure the process run time.''' + return time.monotonic_ns() + +def DoPrint (s: str, v: Verbosity): + '''Check verbosity level and print the string to stderr.''' + if conf.CompVerbosity(v): + return sys.stderr.write(s) + +try: + conf = ParseParam(sys.argv[1:]) +except (FormatError, ipaddress.AddressValueError) as e: + sys.stderr.write(str(e) + "\n") + sys.exit(2) +run_cnt = 0 # the number of iteration performed +run_start = GetRefClock() # time the process started +it_ret = None # acquired EIP information returned after a successful iteration +flag = True # flag used to stop the main loop to serve exit signals(TERM, INT) + +if conf.help: + print(HELP_STR.format(prog = sys.argv[0])) + sys.exit(0) +if conf.ver: + print(V_STR.format(ver = VER_STR)) + sys.exit(0) + +if not conf.range_specs: + DoPrint("No SPEC specified. Run with '-h' option for help.\n", Verbosity.ERR) + sys.exit(2) + +# Init Boto3 +session = boto3.Session( + region_name = conf.region, + profile_name = conf.profile) +client = session.client("ec2") + +def PrintedCall (func: Callable, fname: str, v: Verbosity, **kwargs): + DoPrint('''CALL {fname}({kwargs})\n'''.format(fname = fname, kwargs = kwargs), v) + return func(**kwargs) + +def PrintReturn (fname: str, ret, v: Verbosity): + return DoPrint('''RET {fname}(): {ret}'''.format(fname = fname, ret = str(ret)), v) + +def ShouldIterate () -> bool: + '''Check if the main loop should continue''' + global run_cnt, conf, flag + + run_elapsed = GetRefClock() - run_start + ret = flag and run_cnt < conf.nb_runs and run_elapsed < conf.runtime + run_cnt += 1 + + return ret + +def OptInSignalHandler (sname: str, handler: Callable): + '''Install the signal handler if the signal with the name exists on the + platform.''' + if hasattr(signal, sname): + return signal.signal(signal.Signals(sname), handler) + +def HandleSignal (sn, sf): + '''Exit signal handler''' + global flag + + flag = False + # Deregister the handler so that the subsequent signals kill the process + signal.signal(sn, signal.SIG_DFL) + + # Signal names are not supported by all platforms + try: + signame = signal.Signals(sn).name + except: + signame = "?" + DoPrint('''CAUGHT {signame}({sn})\n'''.format( + signame = signame, + sn = sn), Verbosity.WARN) + +def ExtractBotoError (e: Exception) -> str: + '''Not many types of Boto3 exceptions are defined for errors. Use the "duck + typing technique" to extract the error code returned from the AWS + endpoint.''' + if (hasattr(e, "response") and + type(e.response) == dict and + type(e.response.get("Error")) == dict): + return e.response["Error"].get("Code") + +def IsDryRunError (e: Exception) -> bool: + return ExtractBotoError(e) == "DryRunOperation" + +def DoIteration () -> EIP | None: + '''Returns the EIP in the desired range if successful. None otherwise.''' + global conf + # Pre-construct the tag spec. + tag_spec = [ { 'ResourceType': 'elastic-ip' , 'Tags': conf.tag_spec } ] if conf.tag_spec else None + + try: + # Send the allocation request! + r = PrintedCall( + client.allocate_address, + "client.allocate_address", + Verbosity.DBG0, + TagSpecifications = tag_spec, + DryRun = conf.dryrun) + PrintReturn("client.allocate_address", r, Verbosity.DBG0) + # The method will return the response object if successful + ip = ipaddress.IPv4Address(r['PublicIp']) + alloc_id = r['AllocationId'] + + DoPrint('''Got {ip}\n'''.format(ip = ip), Verbosity.INFO) + except Exception as e: + if conf.dryrun and IsDryRunError(e): + # This is expected for dry run. Carry on with mock data. + ip = None + alloc_id = "DryIce" + else: + # Propagate other errors + # Could be bad internet connection or insufficient privileges + raise e + + if ip: + # Check if the address allocated is within the desired range + for spec in conf.range_specs: + DoPrint("IS {ip} in {range}?: ".format( + ip = ip, + range = spec + ), Verbosity.DBG1) + ret = ip in spec # this calls `_net_in_op()` or `_range_in_op()` + DoPrint("{verdict}\n".format( + verdict = "yes" if ret else "no"), Verbosity.DBG1) + if ret: + # Instantiate and return the result! + return EIP(ip, alloc_id) + + # Reached because the allocated EIP is not in the desired range + # Release the EIP. + try: + r = PrintedCall( + client.release_address, + "client.release_address", + Verbosity.DBG0, + AllocationId = alloc_id, + DryRun = conf.dryrun) + PrintReturn("client.release_address", r, Verbosity.DBG0) + except Exception as e: + if conf.dryrun and IsDryRunError(e): + # This is expected for dry run. Let the function return. It is + # possible that the user has allocate rights but not release rights. + # If that's the case, this is where the user will find out. + pass + else: + # Propagate other errors + # Could be bad internet connection or insufficient privileges + raise e + +# Catch these normal case signals so that the current iteration can release the +# EIP before coming out of the main loop. +OptInSignalHandler("SIGINT", HandleSignal) +OptInSignalHandler("SIGTERM", HandleSignal) +OptInSignalHandler("SIGHUP", HandleSignal) # multitoss support + +while ShouldIterate(): + DoPrint('''Iteration #{nr_run} ...\n'''.format(nr_run = run_cnt), Verbosity.INFO) + it_ret = DoIteration() + if it_ret: + DoPrint("Got EIP in target range!\n", Verbosity.INFO) + print(it_ret) + break + +run_elapsed = GetRefClock() - run_start +DoPrint( + '''Run complete after {nb_runs} run(s) in {run_elapsed:.3f}\n'''.format( + nb_runs = run_cnt, + run_elapsed = run_elapsed / 1000000000.0 + ), Verbosity.INFO) + +sys.exit(0 if it_ret else 3) |