diff --git a/.github/workflows/pre-commit-check.yaml b/.github/workflows/pre-commit-check.yaml index ad24bb9..de16f60 100644 --- a/.github/workflows/pre-commit-check.yaml +++ b/.github/workflows/pre-commit-check.yaml @@ -4,19 +4,38 @@ on: push: branches: - master + - develop pull_request: jobs: build: runs-on: macOS-latest steps: - - uses: actions/checkout@v1 - - - name: Install prerequisites - run: | - brew install tfenv tflint terraform-docs pre-commit - pre-commit install - tfenv install - - name: pre-commit run all - run: | - pre-commit run -a + - uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + - name: Install prerequisites + run: ./bin/install-macos.sh + - name: initialize Terraform + run: terraform init --backend=false + - uses: actions/cache@v1 + with: + path: ~/.cache/pre-commit + key: pre-commit|${{ hashFiles('.pre-commit-config.yaml') }} + restore-keys: | + pre-commit + - name: pre-commit run all + run: | + pre-commit run -a + env: + AWS_DEFAULT_REGION: us-east-1 + SKIP: terraform_tflint_deep + - uses: stefanzweifel/git-auto-commit-action@v4 + if: ${{ failure() }} + with: + commit_message: Apply automatic changes + commit_options: "--no-verify" + # Optional commit user and author settings + commit_user_name: Linter Bot + commit_user_email: noreply@rhythmictech.com + commit_author: Linter Bot diff --git a/.gitignore b/.gitignore index 1fef4ab..632fb62 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ # .tfvars files *.tfvars + +*.zip +tmp diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 587ccce..8b81b85 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,67 @@ repos: -- repo: git://github.com/antonbabenko/pre-commit-terraform - rev: v1.30.0 - hooks: - - id: terraform_fmt - - id: terraform_docs -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.0.0 - hooks: - - id: end-of-file-fixer - - id: trailing-whitespace - - id: no-commit-to-branch + - repo: https://github.com/antonbabenko/pre-commit-terraform + rev: v1.31.0 + hooks: + - id: terraform_docs + always_run: true + args: + - --args=--sort-by-required + - id: terraform_fmt + - id: terraform_tflint + alias: terraform_tflint_deep + name: terraform_tflint_deep + args: + - --args=--deep + - id: terraform_tflint + alias: terraform_tflint_nocreds + name: terraform_tflint_nocreds + - id: terraform_tfsec + - repo: local + hooks: + - id: terraform_validate + name: terraform_validate + entry: | + bash -c ' + AWS_DEFAULT_REGION=us-east-1 + declare -a DIRS + for FILE in "$@" + do + DIRS+=($(dirname "$FILE")) + done + for DIR in $(printf "%s\n" "${DIRS[@]}" | sort -u) + do + cd $(dirname "$FILE") + terraform init --backend=false + terraform validate . + done + ' + language: system + verbose: true + files: \.tf(vars)?$ + exclude: examples + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.0.0 + hooks: + - id: check-case-conflict + - id: check-json + - id: check-merge-conflict + - id: check-symlinks + - id: check-yaml + args: + - --unsafe + - id: end-of-file-fixer + - id: trailing-whitespace + - id: mixed-line-ending + args: + - --fix=lf + - id: no-commit-to-branch + - id: pretty-format-json + args: + - --autofix + - --top-keys=name,Name + - id: trailing-whitespace + args: + - --markdown-linebreak-ext=md + exclude: README.md + - id: check-ast + - id: check-builtin-literals diff --git a/README.md b/README.md index e80e16f..8a250d1 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,46 @@ -# terraform-anycloud-template [![](https://github.com/rhythmictech/terraform-anycloud-template/workflows/pre-commit-check/badge.svg)](https://github.com/rhythmictech/terraform-anycloud-template/actions) follow on Twitter -Template repository for terraform modules. Good for any cloud and any provider. +# terraform-aws-cloudflare-restrictor [![](https://github.com/rhythmictech/terraform-aws-cloudflare-restrictor/workflows/pre-commit-check/badge.svg)](https://github.com/rhythmictech/terraform-aws-cloudflare-restrictor/actions) follow on Twitter + +This module will automatically manage the ingress rules for any security groups that are appropriately tagged, only permitting CloudFlare IP addresses. The module will create a Lambda that runs once per day, using the public CloudFlare API for known IP addresses to pull the latest IPs and merge them into the security group. + +By default, the Lambda will update any security group with the tag key `CLOUDFLARE_MANAGED` set to `true`, +though this can be customized. Any existing ingress rules will be removed when this tag key/value match. Since the Lambda only runs once per day, it is recommended that it be manually triggered whenever a new security group is added. ## Example -Here's what using the module will look like +Here's what using the module will look like: + ``` -module "example" { - source = "rhythmictech/terraform-mycloud-mymodule +module "cloudflare-restrictor" { + source = "rhythmictech/terraform-aws-cloudflare-restrictor" } ``` -## About -A bit about this module - ## Requirements -No requirements. +| Name | Version | +|------|---------| +| terraform | >= 0.12.19 | ## Providers -No provider. +| Name | Version | +|------|---------| +| archive | n/a | +| aws | n/a | ## Inputs | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| name | Moniker to apply to all resources in the module | `string` | n/a | yes | +| allowed\_ports | Ports to allow traffic from CloudFlare on (recommended to only use 443) | `list(number)` |
[
443
]
| no | +| execution\_expression | cron expression for how frequently rules should be updated | `string` | `"rate(1 day)"` | no | +| name | Moniker to apply to all resources in the module | `string` | `"cloudflare-restrictor"` | no | +| tag\_key | Tag key to expect on security groups that will be managed by this module | `string` | `"CLOUDFLARE_MANAGED"` | no | +| tag\_value | Tag value to expect on security groups that will be managed by this module | `string` | `"true"` | no | | tags | User-Defined tags | `map(string)` | `{}` | no | ## Outputs -| Name | Description | -|------|-------------| -| tags\_module | Tags Module in it's entirety | +No output. - -## The Giants underneath this module -- pre-commit.com/ -- terraform.io/ -- github.com/tfutils/tfenv -- github.com/segmentio/terraform-docs diff --git a/bin/install-macos.sh b/bin/install-macos.sh index a9ff748..4bc710b 100755 --- a/bin/install-macos.sh +++ b/bin/install-macos.sh @@ -1,10 +1,18 @@ #!/bin/bash echo 'installing brew packages' -brew install tfenv tflint terraform-docs pre-commit +brew update +brew tap liamg/tfsec +brew install tfenv tflint terraform-docs pre-commit liamg/tfsec/tfsec coreutils +brew upgrade tfenv tflint terraform-docs pre-commit liamg/tfsec/tfsec coreutils echo 'installing pre-commit hooks' pre-commit install +echo 'setting pre-commit hooks to auto-install on clone in the future' +git config --global init.templateDir ~/.git-template +pre-commit init-templatedir ~/.git-template + echo 'installing terraform with tfenv' -tfenv install +tfenv install min-required +tfenv use min-required diff --git a/cloudflareupdater.py b/cloudflareupdater.py new file mode 100644 index 0000000..7e8c462 --- /dev/null +++ b/cloudflareupdater.py @@ -0,0 +1,206 @@ +import boto3 +import json +import os +import urllib3 +import logging + +logger = logging.getLogger() +logger.setLevel(os.environ.get('LOG_LEVEL', logging.DEBUG)) + +for handler in logger.handlers: + handler.setFormatter(logging.Formatter( + '%(asctime)s [%(levelname)s](%(name)s) %(message)s')) + +for lib_logger in ['botocore', 'boto3', 'urllib3']: + logging.getLogger(lib_logger).setLevel( + os.environ.get('LIBRARY_LOG_LEVEL', logging.ERROR)) + +ec2 = boto3.client('ec2') + +def get_cloudflare_ip_list(): + """ Call the CloudFlare API and return a list of IPs """ + http = urllib3.PoolManager() + response = http.request('GET', 'https://api.cloudflare.com/client/v4/ips') + payload = json.loads(response.data.decode('utf-8')) + logger.info("Retrieved current CloudFlare IPs: %s" % (payload)) + + if 'result' in payload: + return payload['result'] + + raise Exception("Cloudflare response error") + + +def get_aws_security_groups(tag_key, tag_value): + """ Return security groups based on `tag_key=tag_value """ + + response = ec2.describe_security_groups(Filters=[ + { + 'Name': 'tag:{}'.format(tag_key), + 'Values': [ + tag_value, + ] + }]) + + return response['SecurityGroups'] + + +def check_ipv4_rule_exists(rules, address, port): + """ Check if the rule currently exists """ + logger.debug("Looking for %s and port %i" % (address, port)) + for rule in rules: + for ip_range in rule['IpRanges']: + if ip_range['CidrIp'] == address and rule['FromPort'] == port: + return True + + return False + + +def add_ipv4_rule(group, address, port): + """ Add the IP address/port to the security group """ + ec2.authorize_security_group_ingress( + GroupId=group['GroupId'], + IpPermissions=[{ + 'IpProtocol': "tcp", + 'FromPort': port, + 'ToPort': port, + 'IpRanges': [ + { + 'CidrIp': address + }, + ] + }]) + + logger.info("Added %s : %i to %s " % (address, port, group['GroupId'])) + + +def delete_ipv4_rule(group, address, port): + """ Remove the IP address/port from the security group """ + + ec2.revoke_security_group_ingress( + GroupId=group['GroupId'], + IpPermissions=[{ + 'IpProtocol': "tcp", + 'FromPort': port, + 'ToPort': port, + 'IpRanges': [ + { + 'CidrIp': address + }, + ] + }]) + + logger.info("Removed %s : %i from %s " % (address, port, group['GroupId'])) + + +def check_ipv6_rule_exists(rules, address, port): + """ Check if the rule currently exists """ + for rule in rules: + for ip_range in rule['Ipv6Ranges']: + if ip_range['CidrIpv6'] == address and rule['FromPort'] == port: + return True + return False + + +def add_ipv6_rule(group, address, port): + """ Add the IP address/port to the security group """ + ec2.authorize_security_group_ingress( + GroupId=group['GroupId'], + IpPermissions=[{ + 'IpProtocol': "tcp", + 'FromPort': port, + 'ToPort': port, + 'Ipv6Ranges': [ + { + 'CidrIpv6': address + }, + ] + }]) + + logger.info("Added %s : %i to %s " % (address, port, group['GroupId'])) + + +def delete_ipv6_rule(group, address, port): + """ Remove the IP address/port from the security group """ + + ec2.revoke_security_group_ingress( + GroupId=group['GroupId'], + IpPermissions=[{ + 'IpProtocol': "tcp", + 'FromPort': port, + 'ToPort': port, + 'Ipv6Ranges': [ + { + 'CidrIpv6': address + }, + ] + }]) + + logger.info("Removed %s : %i from %s " % (address, port, group['GroupId'])) + + +def update_security_group_policies(ip_addresses): + """ Update Information of Security Groups """ + logger.info("Checking policies of Security Groups") + + ports = list(map(int, os.environ['PORTS_LIST'].split(","))) + if not ports: + ports = [443] + + logger.debug("Will allow traffic on ports %s" % (ports)) + security_groups = get_aws_security_groups( + os.environ['TAG_KEY'], os.environ['TAG_VALUE']) + + if len(security_groups) == 0: + logger.warn("No security groups matched %s/%s" % ( + os.environ['TAG_KEY'], os.environ['TAG_VALUE'])) + return + + logger.debug("Will scan security groups: %s" % (security_groups)) + + for security_group in security_groups: + logger.info("Processing group %s" % (security_group['GroupId'])) + current_rules = security_group['IpPermissions'] + + # IPv4 + logger.debug("Checking for rules to remove") + # remove old addresses + for port in ports: + for rule in current_rules: + # is it necessary/correct to check both From and To? + if rule['FromPort'] == port and rule['ToPort'] == port: + for ip_range in rule['IpRanges']: + if ip_range['CidrIp'] not in ip_addresses['ipv4_cidrs']: + delete_ipv4_rule( + security_group, ip_range['CidrIp'], port) + + # add new addresses + logger.debug("Checking for rules to add") + for ipv4_cidr in ip_addresses['ipv4_cidrs']: + logger.debug("Looking for %s" % (ipv4_cidr)) + for port in ports: + if not check_ipv4_rule_exists(current_rules, ipv4_cidr, port): + add_ipv4_rule(security_group, ipv4_cidr, port) + + # IPv6 -- because of boto3 syntax, this has to be separate + # remove old addresses + logger.debug("Checking for ipv6 rules to remove") + for port in ports: + for rule in current_rules: + for ip_range in rule['Ipv6Ranges']: + if ip_range['CidrIpv6'] not in ip_addresses['ipv6_cidrs']: + delete_ipv6_rule( + security_group, ip_range['CidrIpv6'], port) + + # add new addresses + logger.debug("Checking for ipv6 rules to add") + for ipv6_cidr in ip_addresses['ipv6_cidrs']: + for port in ports: + if not check_ipv6_rule_exists(current_rules, ipv6_cidr, port): + add_ipv6_rule(security_group, ipv6_cidr, port) + + +def lambda_handler(event, context): + """ AWS Lambda main function """ + + ip_addresses = get_cloudflare_ip_list() + update_security_group_policies(ip_addresses) diff --git a/examples/basic/README.md b/examples/basic/README.md index ac9ffbd..8264399 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -2,58 +2,36 @@ A basic example for this repository ## Code -Look to [main.tf](./main.tf), or be helpful and copy/paste that code here. +``` -## Applying +module "this" { + source = "../.." + + name = "test" +} ``` -> alias tf="terraform" -> tf apply -Apply complete! Resources: 0 added, 0 changed, 0 destroyed. +## Applying +``` +> terraform apply -Outputs: +module.this.aws_iam_role.this: Creating... +module.this.aws_cloudwatch_event_rule.this: Creating... +module.this.aws_iam_policy.this: Creating... +module.this.aws_iam_role.this: Creation complete after 0s [id=test20200604145510752600000002] +module.this.aws_iam_role_policy_attachment.execution: Creating... +module.this.aws_lambda_function.this: Creating... +module.this.aws_cloudwatch_event_rule.this: Creation complete after 0s [id=test-daily] +module.this.aws_iam_policy.this: Creation complete after 0s [id=arn:aws:iam::951703363424:policy/test20200604145510749000000001] +module.this.aws_iam_role_policy_attachment.this: Creating... +module.this.aws_iam_role_policy_attachment.execution: Creation complete after 0s [id=test20200604145510752600000002-20200604145511227700000003] +module.this.aws_iam_role_policy_attachment.this: Creation complete after 0s [id=test20200604145510752600000002-20200604145511332200000004] +module.this.aws_lambda_function.this: Still creating... [10s elapsed] +module.this.aws_lambda_function.this: Creation complete after 14s [id=test-cloudflareupdater] +module.this.aws_lambda_permission.this: Creating... +module.this.aws_cloudwatch_event_target.this: Creating... +module.this.aws_lambda_permission.this: Creation complete after 0s [id=terraform-20200604145524937300000005] +module.this.aws_cloudwatch_event_target.this: Creation complete after 0s [id=test-daily-terraform-20200604145524937300000006] -example = { - "tags_module" = { - "name" = "TEST" - "name32" = "TEST" - "name6" = "TEST" - "namenosymbols" = "TEST" - "tags" = { - "Name" = "TEST" - "terraform_managed" = true - "terraform_module" = "terraform-terraform-tags-1.0.0" - "terraform_root_module" = "." - "terraform_workspace" = "default" - } - "tags_as_list_of_maps" = [ - { - "key" = "Name" - "value" = "TEST" - }, - { - "key" = "terraform_managed" - "value" = true - }, - { - "key" = "terraform_module" - "value" = "terraform-terraform-tags-1.0.0" - }, - { - "key" = "terraform_root_module" - "value" = "." - }, - { - "key" = "terraform_workspace" - "value" = "default" - }, - ] - "tags_no_name" = { - "terraform_managed" = true - "terraform_module" = "terraform-terraform-tags-1.0.0" - "terraform_root_module" = "." - "terraform_workspace" = "default" - } - } -} +Apply complete! Resources: 8 added, 0 changed, 0 destroyed. ``` diff --git a/examples/basic/main.tf b/examples/basic/main.tf index 13ef2c1..659912e 100644 --- a/examples/basic/main.tf +++ b/examples/basic/main.tf @@ -1,10 +1,6 @@ -module "example" { +module "this" { source = "../.." name = "test" } - -output "example" { - value = module.example -} diff --git a/main.tf b/main.tf index e66ee89..abc7806 100644 --- a/main.tf +++ b/main.tf @@ -1,9 +1,115 @@ +data "archive_file" "this" { + type = "zip" + source_file = "${path.module}/cloudflareupdater.py" + output_path = "${path.module}/tmp/cloudflareupdater.zip" +} + +data "aws_iam_policy_document" "assume" { + statement { + actions = [ + "sts:AssumeRole", + ] + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "this" { + name_prefix = "${var.name}-" + assume_role_policy = data.aws_iam_policy_document.assume.json +} + +resource "aws_iam_role_policy_attachment" "execution" { + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + role = aws_iam_role.this.name +} + +data "aws_iam_policy_document" "this" { + statement { + actions = [ + "ec2:AuthorizeSecurityGroupIngress", + "ec2:RevokeSecurityGroupIngress" + ] + + resources = ["*"] + + condition { + test = "StringEquals" + variable = "ec2:ResourceTag/${var.tag_key}" + values = [var.tag_value] + } + } + + statement { + actions = ["ec2:DescribeSecurityGroups"] + resources = ["*"] + } +} + +resource "aws_iam_policy" "this" { + name_prefix = "${var.name}-" + policy = data.aws_iam_policy_document.this.json +} + +resource "aws_iam_role_policy_attachment" "this" { + policy_arn = aws_iam_policy.this.arn + role = aws_iam_role.this.name +} + +resource "aws_lambda_function" "this" { + filename = data.archive_file.this.output_path + function_name = "${var.name}-cloudflareupdater" + handler = "cloudflareupdater.lambda_handler" + role = aws_iam_role.this.arn + runtime = "python3.7" + source_code_hash = data.archive_file.this.output_base64sha256 + tags = var.tags + timeout = 180 + + environment { + variables = { + PORTS_LIST = join(",", var.allowed_ports) + TAG_KEY = var.tag_key + TAG_VALUE = var.tag_value + } + } + + lifecycle { + ignore_changes = [ + filename, + last_modified, + ] + } +} + +resource "aws_cloudwatch_event_rule" "this" { + name_prefix = "${var.name}-scheduled-rule-" + schedule_expression = var.execution_expression + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_cloudwatch_event_target" "this" { + arn = aws_lambda_function.this.arn + rule = aws_cloudwatch_event_rule.this.name + + lifecycle { + create_before_destroy = true + } +} -module "tags" { - source = "rhythmictech/tags/terraform" - version = "1.0.0" +resource "aws_lambda_permission" "this" { + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.this.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.this.arn - enforce_case = "UPPER" - names = [var.name] - tags = var.tags + lifecycle { + create_before_destroy = true + } } diff --git a/outputs.tf b/outputs.tf index 02513ff..e69de29 100644 --- a/outputs.tf +++ b/outputs.tf @@ -1,5 +0,0 @@ - -output "tags_module" { - description = "Tags Module in it's entirety" - value = module.tags -} diff --git a/variables.tf b/variables.tf index dac30d3..6395394 100644 --- a/variables.tf +++ b/variables.tf @@ -1,9 +1,37 @@ +######################################## +# General Vars +######################################## + +variable "allowed_ports" { + default = [443] + description = "Ports to allow traffic from CloudFlare on (recommended to only use 443)" + type = list(number) +} + +variable "execution_expression" { + default = "rate(1 day)" + description = "cron expression for how frequently rules should be updated" + type = string +} variable "name" { + default = "cloudflare-restrictor" description = "Moniker to apply to all resources in the module" type = string } +variable "tag_key" { + default = "CLOUDFLARE_MANAGED" + description = "Tag key to expect on security groups that will be managed by this module" + type = string +} + +variable "tag_value" { + default = "true" + description = "Tag value to expect on security groups that will be managed by this module" + type = string +} + variable "tags" { default = {} description = "User-Defined tags" diff --git a/versions.tf b/versions.tf new file mode 100644 index 0000000..749d1a2 --- /dev/null +++ b/versions.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 0.12.19" +}