diff --git a/README.md b/README.md index f630222be..67388dffd 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ + # terraform-aws-components [![Latest Release](https://img.shields.io/github/release/cloudposse/terraform-aws-components.svg)](https://github.com/cloudposse/terraform-aws-components/releases/latest) [![Slack Community](https://slack.cloudposse.com/badge.svg)](https://slack.cloudposse.com) @@ -29,7 +30,6 @@ This is a collection of reusable Terraform components and blueprints for provisioning reference architectures. - --- This project is part of our comprehensive ["SweetOps"](https://cpco.io/sweetops) approach towards DevOps. @@ -94,6 +94,7 @@ make rebuild-docs + ## Usage @@ -136,6 +137,7 @@ Available targets: help/all Display help for all targets help/short This help short screen rebuild-docs Rebuild README for all Terraform components + upstream-component Upstream a given component ``` @@ -174,6 +176,7 @@ Like this project? Please give it a ★ on [our GitHub](https://github.com/cloud Are you using this project or any of our other projects? Consider [leaving a testimonial][testimonial]. =) + ## Related Projects Check out these related projects. @@ -185,8 +188,6 @@ Check out these related projects. - [dev.cloudposse.co](https://github.com/cloudposse/dev.cloudposse.co) - Example Terraform Reference Architecture of a Geodesic Module for a Development Sandbox Organization in AWS. - - ## References For additional context, refer to some of these links. diff --git a/docs/targets.md b/docs/targets.md index f25e6a838..6a0aa2578 100644 --- a/docs/targets.md +++ b/docs/targets.md @@ -7,6 +7,7 @@ Available targets: help/all Display help for all targets help/short This help short screen rebuild-docs Rebuild README for all Terraform components + upstream-component Upstream a given component ``` diff --git a/modules/bastion/README.md b/modules/bastion/README.md new file mode 100644 index 000000000..250fb3c67 --- /dev/null +++ b/modules/bastion/README.md @@ -0,0 +1,134 @@ +# Component: `bastion` + +This component is responsible for provisioning a generic Bastion host with parameterized `user_data` and support for AWS SSM Session Manager for remote access with IAM authentication. + +If a special `container.sh` script is desired to run, set `container_enabled` to `true`, and set the `image_repository` and `image_container` variables. + +## Usage + +**Stack Level**: Regional + +Here's an example snippet for how to use this component. + +```yaml +components: + terraform: + bastion: + vars: + enabled: true + associate_public_ip_address: true + custom_bastion_hostname: bastion + vanity_domain: example.com + security_group_rules: + - type : "ingress" + from_port : 22 + to_port : 22 + protocol : tcp + cidr_blocks : ["1.2.3.4/32"] + - type : "egress" + from_port : 0 + to_port : 0 + protocol : -1 + cidr_blocks : ["0.0.0.0/0"] +``` + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 0.13.0 | +| [aws](#requirement\_aws) | >= 3.0 | +| [null](#requirement\_null) | >= 3.0 | +| [random](#requirement\_random) | >= 3.0 | +| [template](#requirement\_template) | >= 2.2 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | >= 3.0 | +| [cloudinit](#provider\_cloudinit) | n/a | + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [aws\_key\_pair](#module\_aws\_key\_pair) | cloudposse/key-pair/aws | 0.18.0 | +| [ec2\_bastion](#module\_ec2\_bastion) | cloudposse/ec2-bastion-server/aws | 0.28.0 | +| [iam\_roles](#module\_iam\_roles) | ../account-map/modules/iam-roles | n/a | +| [this](#module\_this) | cloudposse/label/null | 0.24.1 | +| [vpc](#module\_vpc) | cloudposse/stack-config/yaml//modules/remote-state | 0.17.0 | + +## Resources + +| Name | Type | +|------|------| +| [aws_ebs_volume.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ebs_volume) | resource | +| [aws_eip.static](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/eip) | resource | +| [aws_eip_association.static](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/eip_association) | resource | +| [aws_iam_instance_profile.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_instance_profile) | resource | +| [aws_iam_role.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_route53_record.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource | +| [aws_ssm_parameter.ssh_private_key](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [aws_volume_attachment.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/volume_attachment) | resource | +| [aws_iam_policy_document.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_route53_zone.vanity](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/route53_zone) | data source | +| [cloudinit_config.config](https://registry.terraform.io/providers/hashicorp/cloudinit/latest/docs/data-sources/config) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [additional\_tag\_map](#input\_additional\_tag\_map) | Additional tags for appending to tags\_as\_list\_of\_maps. Not added to `tags`. | `map(string)` | `{}` | no | +| [associate\_public\_ip\_address](#input\_associate\_public\_ip\_address) | Whether to associate public IP to the instance. | `bool` | `false` | no | +| [attributes](#input\_attributes) | Additional attributes (e.g. `1`) | `list(string)` | `[]` | no | +| [container\_enabled](#input\_container\_enabled) | Enable or disable container functionality. | `bool` | `false` | no | +| [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"name": null,
"namespace": null,
"regex_replace_chars": null,
"stage": null,
"tags": {}
}
| no | +| [custom\_bastion\_hostname](#input\_custom\_bastion\_hostname) | Hostname to assign with bastion instance | `string` | `null` | no | +| [delimiter](#input\_delimiter) | Delimiter to be used between `namespace`, `environment`, `stage`, `name` and `attributes`.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no | +| [ebs\_block\_device\_volume\_size](#input\_ebs\_block\_device\_volume\_size) | The volume size (in GiB) to provision for the EBS block device. Creation skipped if size is 0 | `number` | `0` | no | +| [ebs\_delete\_on\_termination](#input\_ebs\_delete\_on\_termination) | Whether the EBS volume should be destroyed on instance termination | `bool` | `false` | no | +| [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | +| [environment](#input\_environment) | Environment, e.g. 'uw2', 'us-west-2', OR 'prod', 'staging', 'dev', 'UAT' | `string` | `null` | no | +| [generate\_ssh\_key](#input\_generate\_ssh\_key) | Whether or not to generate an SSH key | `bool` | `true` | no | +| [id\_length\_limit](#input\_id\_length\_limit) | Limit `id` to this many characters (minimum 6).
Set to `0` for unlimited length.
Set to `null` for default, which is `0`.
Does not affect `id_full`. | `number` | `null` | no | +| [image\_container](#input\_image\_container) | The image container to use in `container.sh`. This is required if `container_enabled` is `true`. | `string` | `null` | no | +| [image\_repository](#input\_image\_repository) | The image repository to use in `container.sh`. This is required if `container_enabled` is `true`. | `string` | `null` | no | +| [import\_profile\_name](#input\_import\_profile\_name) | IAM Profile to use when importing a resource | `string` | `null` | no | +| [instance\_type](#input\_instance\_type) | Bastion instance type | `string` | `"t2.micro"` | no | +| [kms\_alias\_name\_ssm](#input\_kms\_alias\_name\_ssm) | KMS alias name for SSM | `string` | `"alias/aws/ssm"` | no | +| [label\_key\_case](#input\_label\_key\_case) | The letter case of label keys (`tag` names) (i.e. `name`, `namespace`, `environment`, `stage`, `attributes`) to use in `tags`.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no | +| [label\_order](#input\_label\_order) | The naming order of the id output and Name tag.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 5 elements, but at least one must be present. | `list(string)` | `null` | no | +| [label\_value\_case](#input\_label\_value\_case) | The letter case of output label values (also used in `tags` and `id`).
Possible values: `lower`, `title`, `upper` and `none` (no transformation).
Default value: `lower`. | `string` | `null` | no | +| [name](#input\_name) | Solution name, e.g. 'app' or 'jenkins' | `string` | `null` | no | +| [namespace](#input\_namespace) | Namespace, which could be your organization name or abbreviation, e.g. 'eg' or 'cp' | `string` | `null` | no | +| [regex\_replace\_chars](#input\_regex\_replace\_chars) | Regex to replace chars with empty string in `namespace`, `environment`, `stage` and `name`.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | +| [region](#input\_region) | AWS region | `string` | n/a | yes | +| [root\_block\_device\_volume\_size](#input\_root\_block\_device\_volume\_size) | The volume size (in GiB) to provision for the root block device. It cannot be smaller than the AMI it refers to. | `number` | `8` | no | +| [security\_group\_rules](#input\_security\_group\_rules) | A list of maps of Security Group rules.
The values of map is fully complated with `aws_security_group_rule` resource.
To get more info see https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group_rule . | `list(any)` |
[
{
"cidr_blocks": [
"0.0.0.0/0"
],
"from_port": 0,
"protocol": -1,
"to_port": 0,
"type": "egress"
},
{
"cidr_blocks": [
"0.0.0.0/0"
],
"from_port": 22,
"protocol": "tcp",
"to_port": 22,
"type": "ingress"
}
]
| no | +| [ssh\_key\_path](#input\_ssh\_key\_path) | Save location for ssh public keys generated by the module | `string` | `"./secrets"` | no | +| [ssh\_pub\_keys](#input\_ssh\_pub\_keys) | Enable ssh pub keys from chamber. | `bool` | `false` | no | +| [stage](#input\_stage) | Stage, e.g. 'prod', 'staging', 'dev', OR 'source', 'build', 'test', 'deploy', 'release' | `string` | `null` | no | +| [tags](#input\_tags) | Additional tags (e.g. `map('BusinessUnit','XYZ')` | `map(string)` | `{}` | no | +| [user\_data](#input\_user\_data) | User data content | `list(string)` | `[]` | no | +| [vanity\_domain](#input\_vanity\_domain) | Vanity domain | `string` | `null` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [bastion\_fqdn](#output\_bastion\_fqdn) | Bastion server custom hostname FQDN | +| [instance\_id](#output\_instance\_id) | Instance ID | +| [private\_ip](#output\_private\_ip) | Private IP of the instance | +| [public\_ip](#output\_public\_ip) | Public IP of the instance (or EIP) | +| [role](#output\_role) | Name of AWS IAM Role associated with the instance | +| [security\_group\_ids](#output\_security\_group\_ids) | IDs on the AWS Security Groups associated with the instance | + + +## References +* [cloudposse/terraform-aws-components](https://github.com/cloudposse/terraform-aws-components/tree/master/modules/TODO) - Cloud Posse's upstream component + +[](https://cpco.io/component) diff --git a/modules/bastion/context.tf b/modules/bastion/context.tf new file mode 100644 index 000000000..81f99b4e3 --- /dev/null +++ b/modules/bastion/context.tf @@ -0,0 +1,202 @@ +# +# ONLY EDIT THIS FILE IN github.com/cloudposse/terraform-null-label +# All other instances of this file should be a copy of that one +# +# +# Copy this file from https://github.com/cloudposse/terraform-null-label/blob/master/exports/context.tf +# and then place it in your Terraform module to automatically get +# Cloud Posse's standard configuration inputs suitable for passing +# to Cloud Posse modules. +# +# Modules should access the whole context as `module.this.context` +# to get the input variables with nulls for defaults, +# for example `context = module.this.context`, +# and access individual variables as `module.this.`, +# with final values filled in. +# +# For example, when using defaults, `module.this.context.delimiter` +# will be null, and `module.this.delimiter` will be `-` (hyphen). +# + +module "this" { + source = "cloudposse/label/null" + version = "0.24.1" # requires Terraform >= 0.13.0 + + enabled = var.enabled + namespace = var.namespace + environment = var.environment + stage = var.stage + name = var.name + delimiter = var.delimiter + attributes = var.attributes + tags = var.tags + additional_tag_map = var.additional_tag_map + label_order = var.label_order + regex_replace_chars = var.regex_replace_chars + id_length_limit = var.id_length_limit + label_key_case = var.label_key_case + label_value_case = var.label_value_case + + context = var.context +} + +# Copy contents of cloudposse/terraform-null-label/variables.tf here + +variable "context" { + type = any + default = { + enabled = true + namespace = null + environment = null + stage = null + name = null + delimiter = null + attributes = [] + tags = {} + additional_tag_map = {} + regex_replace_chars = null + label_order = [] + id_length_limit = null + label_key_case = null + label_value_case = null + } + description = <<-EOT + Single object for setting entire context at once. + See description of individual variables for details. + Leave string and numeric variables as `null` to use default value. + Individual variable settings (non-null) override settings in context object, + except for attributes, tags, and additional_tag_map, which are merged. + EOT + + validation { + condition = lookup(var.context, "label_key_case", null) == null ? true : contains(["lower", "title", "upper"], var.context["label_key_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`." + } + + validation { + condition = lookup(var.context, "label_value_case", null) == null ? true : contains(["lower", "title", "upper", "none"], var.context["label_value_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "enabled" { + type = bool + default = null + description = "Set to false to prevent the module from creating any resources" +} + +variable "namespace" { + type = string + default = null + description = "Namespace, which could be your organization name or abbreviation, e.g. 'eg' or 'cp'" +} + +variable "environment" { + type = string + default = null + description = "Environment, e.g. 'uw2', 'us-west-2', OR 'prod', 'staging', 'dev', 'UAT'" +} + +variable "stage" { + type = string + default = null + description = "Stage, e.g. 'prod', 'staging', 'dev', OR 'source', 'build', 'test', 'deploy', 'release'" +} + +variable "name" { + type = string + default = null + description = "Solution name, e.g. 'app' or 'jenkins'" +} + +variable "delimiter" { + type = string + default = null + description = <<-EOT + Delimiter to be used between `namespace`, `environment`, `stage`, `name` and `attributes`. + Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. + EOT +} + +variable "attributes" { + type = list(string) + default = [] + description = "Additional attributes (e.g. `1`)" +} + +variable "tags" { + type = map(string) + default = {} + description = "Additional tags (e.g. `map('BusinessUnit','XYZ')`" +} + +variable "additional_tag_map" { + type = map(string) + default = {} + description = "Additional tags for appending to tags_as_list_of_maps. Not added to `tags`." +} + +variable "label_order" { + type = list(string) + default = null + description = <<-EOT + The naming order of the id output and Name tag. + Defaults to ["namespace", "environment", "stage", "name", "attributes"]. + You can omit any of the 5 elements, but at least one must be present. + EOT +} + +variable "regex_replace_chars" { + type = string + default = null + description = <<-EOT + Regex to replace chars with empty string in `namespace`, `environment`, `stage` and `name`. + If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. + EOT +} + +variable "id_length_limit" { + type = number + default = null + description = <<-EOT + Limit `id` to this many characters (minimum 6). + Set to `0` for unlimited length. + Set to `null` for default, which is `0`. + Does not affect `id_full`. + EOT + validation { + condition = var.id_length_limit == null ? true : var.id_length_limit >= 6 || var.id_length_limit == 0 + error_message = "The id_length_limit must be >= 6 if supplied (not null), or 0 for unlimited length." + } +} + +variable "label_key_case" { + type = string + default = null + description = <<-EOT + The letter case of label keys (`tag` names) (i.e. `name`, `namespace`, `environment`, `stage`, `attributes`) to use in `tags`. + Possible values: `lower`, `title`, `upper`. + Default value: `title`. + EOT + + validation { + condition = var.label_key_case == null ? true : contains(["lower", "title", "upper"], var.label_key_case) + error_message = "Allowed values: `lower`, `title`, `upper`." + } +} + +variable "label_value_case" { + type = string + default = null + description = <<-EOT + The letter case of output label values (also used in `tags` and `id`). + Possible values: `lower`, `title`, `upper` and `none` (no transformation). + Default value: `lower`. + EOT + + validation { + condition = var.label_value_case == null ? true : contains(["lower", "title", "upper", "none"], var.label_value_case) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} +#### End of copy of cloudposse/terraform-null-label/variables.tf diff --git a/modules/bastion/default.auto.tfvars b/modules/bastion/default.auto.tfvars new file mode 100644 index 000000000..6e25ee314 --- /dev/null +++ b/modules/bastion/default.auto.tfvars @@ -0,0 +1,7 @@ +enabled = false + +instance_type = "t3a.medium" + +ebs_block_device_volume_size = 100 + +root_block_device_volume_size = 8 diff --git a/modules/bastion/iam.tf b/modules/bastion/iam.tf new file mode 100644 index 000000000..70d5adc67 --- /dev/null +++ b/modules/bastion/iam.tf @@ -0,0 +1,120 @@ +resource "aws_iam_instance_profile" "default" { + count = local.enabled ? 1 : 0 + name = module.this.id + role = aws_iam_role.default[0].name +} + +resource "aws_iam_role" "default" { + count = local.enabled ? 1 : 0 + name = module.this.id + path = "/" + tags = module.this.tags + + assume_role_policy = data.aws_iam_policy_document.default[0].json +} + +resource "aws_iam_role_policy" "main" { + count = local.enabled ? 1 : 0 + name = module.this.id + role = aws_iam_role.default[0].id + policy = data.aws_iam_policy_document.main[0].json +} + +data "aws_iam_policy_document" "default" { + count = local.enabled ? 1 : 0 + + statement { + sid = "" + + actions = [ + "sts:AssumeRole", + ] + + principals { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + + effect = "Allow" + } +} + +data "aws_iam_policy_document" "main" { + count = local.enabled ? 1 : 0 + + statement { + effect = "Allow" + + actions = [ + "ssm:DescribeAssociation", + "ssm:GetDeployablePatchSnapshotForInstance", + "ssm:GetDocument", + "ssm:DescribeDocument", + "ssm:DescribeParameters", + "ssm:GetManifest", + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:ListAssociations", + "ssm:ListInstanceAssociations", + "ssm:PutInventory", + "ssm:PutComplianceItems", + "ssm:PutConfigurePackageResult", + "ssm:UpdateAssociationStatus", + "ssm:UpdateInstanceAssociationStatus", + "ssm:UpdateInstanceInformation" + ] + + resources = ["*"] + } + + statement { + effect = "Allow" + + actions = [ + "ssmmessages:CreateControlChannel", + "ssmmessages:CreateDataChannel", + "ssmmessages:OpenControlChannel", + "ssmmessages:OpenDataChannel" + ] + + resources = ["*"] + } + + statement { + effect = "Allow" + + actions = [ + "ec2messages:AcknowledgeMessage", + "ec2messages:DeleteMessage", + "ec2messages:FailMessage", + "ec2messages:GetEndpoint", + "ec2messages:GetMessages", + "ec2messages:SendReply" + ] + + resources = ["*"] + } + + statement { + effect = "Allow" + + actions = [ + "s3:GetEncryptionConfiguration" + ] + + resources = ["*"] + } + + statement { + effect = "Allow" + + actions = [ + "ecr:GetAuthorizationToken", + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage" + ] + + resources = ["*"] + } +} diff --git a/modules/bastion/main.tf b/modules/bastion/main.tf new file mode 100644 index 000000000..64ac98ef2 --- /dev/null +++ b/modules/bastion/main.tf @@ -0,0 +1,144 @@ +locals { + enabled = module.this.enabled + route53_enabled = local.enabled && var.associate_public_ip_address && var.custom_bastion_hostname != null && var.vanity_domain != null + + vpc_id = module.vpc.outputs.vpc_id + vpc_private_subnet_ids = module.vpc.outputs.private_subnet_ids + vpc_public_subnet_ids = module.vpc.outputs.public_subnet_ids + vpc_subnet_ids = var.associate_public_ip_address ? local.vpc_public_subnet_ids : local.vpc_private_subnet_ids + + bastion_subnet = slice(local.vpc_subnet_ids, 0, 1) + bastion_az = module.vpc.outputs.availability_zones[0] + + userdata_template = "${path.module}/templates/user-data.sh" + container_template = "${path.module}/templates/container.sh" + + cloudwatch_agent_config = <<-END + #cloud-config + ${jsonencode({ + write_files = [ + { + path = "/tmp/container.sh" + permissions = "0755" + owner = "root:root" + encoding = "b64" + content = base64encode(templatefile(local.container_template, { + region = var.region + image_repository = var.image_repository + image_container = var.image_container + })) + }, + ] +})} + END +} + +data "aws_route53_zone" "vanity" { + count = local.route53_enabled ? 1 : 0 + name = var.vanity_domain +} + +resource "aws_route53_record" "default" { + count = local.route53_enabled ? 1 : 0 + zone_id = join("", data.aws_route53_zone.vanity.*.zone_id) + name = "${var.custom_bastion_hostname}.${var.vanity_domain}" + type = "A" + ttl = "300" + records = aws_eip.static.*.public_ip +} + +module "aws_key_pair" { + source = "cloudposse/key-pair/aws" + version = "0.18.0" + attributes = ["ssh", "key"] + ssh_public_key_path = var.ssh_key_path + generate_ssh_key = var.generate_ssh_key + + context = module.this.context +} + +data "cloudinit_config" "config" { + count = local.enabled ? 1 : 0 + gzip = false + base64_encode = true + + dynamic "part" { + for_each = var.container_enabled ? [1] : [] + + content { + content_type = "text/cloud-config" + filename = "cloud-config.yaml" + content = local.cloudwatch_agent_config + } + } + + part { + content_type = "text/x-shellscript" + filename = "user-data.sh" + content = templatefile(local.userdata_template, { + ssh_pub_keys = var.ssh_pub_keys + }) + } +} + +resource "aws_eip" "static" { + count = local.enabled ? 1 : 0 + vpc = true +} + +module "ec2_bastion" { + source = "cloudposse/ec2-bastion-server/aws" + version = "0.28.0" + + instance_type = var.instance_type + ## Use only one availability zone to be sure volume will be in the same zone + subnets = local.bastion_subnet + vpc_id = local.vpc_id + root_block_device_volume_size = var.root_block_device_volume_size + associate_public_ip_address = var.associate_public_ip_address + # Version 0.25.0 of this module did not assign an EIP, so we do it + # in this component. Preserve backward compatibility by disabling it. + assign_eip_address = false + + ebs_block_device_volume_size = 0 + ebs_delete_on_termination = var.ebs_delete_on_termination + user_data_base64 = data.cloudinit_config.config[0].rendered + security_group_rules = var.security_group_rules + key_name = module.aws_key_pair.key_name + instance_profile = aws_iam_instance_profile.default[0].name + + context = module.this.context +} + +resource "aws_ebs_volume" "default" { + count = local.enabled ? 1 : 0 + availability_zone = local.bastion_az + size = var.ebs_block_device_volume_size + encrypted = true +} + +resource "aws_volume_attachment" "default" { + count = local.enabled ? 1 : 0 + + ## Use /dev/sdh as this is default device name for bastion + device_name = "/dev/sdh" + instance_id = module.ec2_bastion.instance_id + volume_id = aws_ebs_volume.default[0].id +} + +resource "aws_eip_association" "static" { + count = local.enabled ? 1 : 0 + instance_id = module.ec2_bastion.instance_id + allocation_id = join("", aws_eip.static.*.id) +} + +resource "aws_ssm_parameter" "ssh_private_key" { + count = var.generate_ssh_key ? 1 : 0 + + name = format("/%s/%s", "bastion", "ssh_private_key") + value = module.aws_key_pair.private_key + description = "SSH Private key for bastion key-pair" + type = "SecureString" + key_id = var.kms_alias_name_ssm + overwrite = true +} diff --git a/modules/bastion/outputs.tf b/modules/bastion/outputs.tf new file mode 100644 index 000000000..a17f43bb5 --- /dev/null +++ b/modules/bastion/outputs.tf @@ -0,0 +1,29 @@ +output "instance_id" { + value = module.ec2_bastion.instance_id + description = "Instance ID" +} + +output "role" { + value = module.ec2_bastion.role + description = "Name of AWS IAM Role associated with the instance" +} + +output "private_ip" { + value = module.ec2_bastion.private_ip + description = "Private IP of the instance" +} + +output "public_ip" { + value = module.ec2_bastion.public_ip + description = "Public IP of the instance (or EIP)" +} + +output "bastion_fqdn" { + value = join("", aws_route53_record.default.*.fqdn) + description = "Bastion server custom hostname FQDN" +} + +output "security_group_ids" { + value = module.ec2_bastion.security_group_ids + description = "IDs on the AWS Security Groups associated with the instance" +} diff --git a/modules/bastion/providers.tf b/modules/bastion/providers.tf new file mode 100644 index 000000000..908fbd595 --- /dev/null +++ b/modules/bastion/providers.tf @@ -0,0 +1,17 @@ +provider "aws" { + region = var.region + + # `terraform import` will not use data from a data source, so on import we have to explicitly specify the profile + profile = coalesce(var.import_profile_name, module.iam_roles.terraform_profile_name) +} + +module "iam_roles" { + source = "../account-map/modules/iam-roles" + context = module.this.context +} + +variable "import_profile_name" { + type = string + default = null + description = "IAM Profile to use when importing a resource" +} diff --git a/modules/bastion/remote-state.tf b/modules/bastion/remote-state.tf new file mode 100644 index 000000000..d201f7551 --- /dev/null +++ b/modules/bastion/remote-state.tf @@ -0,0 +1,9 @@ +module "vpc" { + source = "cloudposse/stack-config/yaml//modules/remote-state" + version = "0.17.0" + + stack_config_local_path = "../../../stacks" + component = "vpc" + + context = module.this.context +} diff --git a/modules/bastion/templates/container-cloud-init.sh b/modules/bastion/templates/container-cloud-init.sh new file mode 100644 index 000000000..6af78f840 --- /dev/null +++ b/modules/bastion/templates/container-cloud-init.sh @@ -0,0 +1,15 @@ + #cloud-config + ${jsonencode({ + write_files = [ + { + path = "/tmp/container.sh" + permissions = "0755" + owner = "root:root" + encoding = "b64" + content = base64encode(templatefile("${path.module}/templates/container.sh"), { + region = var.region + image_repository = var.image_repository + image_container = var.image_container + }) + }, + ] diff --git a/modules/bastion/templates/container.sh b/modules/bastion/templates/container.sh new file mode 100644 index 000000000..8f261245e --- /dev/null +++ b/modules/bastion/templates/container.sh @@ -0,0 +1,10 @@ +#!/bin/bash +REGION=${ region } +REPOSITORY=${ image_repository } +IMAGE=$REPOSITORY/${ image_container } + +aws ecr get-login-password --region $REGION | docker login --username AWS --password-stdin $REPOSITORY + +docker pull $IMAGE +docker run --rm \ + -it $IMAGE bash -c "bash" diff --git a/modules/bastion/templates/user-data.sh b/modules/bastion/templates/user-data.sh new file mode 100644 index 000000000..3c798cf6c --- /dev/null +++ b/modules/bastion/templates/user-data.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +# Mount additional volume +echo "Mounting additional volume..." +while [ ! -b $(readlink -f /dev/sdh) ]; do echo 'waiting for device /dev/sdh'; sleep 5 ; done +blkid $(readlink -f /dev/sdh) || mkfs -t ext4 $(readlink -f /dev/sdh) +e2label $(readlink -f /dev/sdh) sdh-volume +grep -q ^LABEL=sdh-volume /etc/fstab || echo 'LABEL=sdh-volume /mnt ext4 defaults' >> /etc/fstab +grep -q \"^$(readlink -f /dev/sdh) /mnt \" /proc/mounts || mount /mnt + +# Install docker +echo "Installing docker..." +amazon-linux-extras install docker +echo "Installing postgresql11..." +amazon-linux-extras install postgresql11 +amazon-linux-extras enable docker + +mkdir -p ~/.docker +echo '{ "credsStore": "ecr-login" }' > ~/.docker/config.json + +service docker start +usermod -a -G docker ec2-user + +# Additional Packages +echo "Installing Additional Packages" +yum install -y curl jq git gcc amazon-ecr-credential-helper + +# Script +echo "Moving script to /usr/bin/container.sh" +sudo mv /tmp/container.sh /usr/bin/container.sh + +curl -1sLf 'https://dl.cloudsmith.io/public/cloudposse/packages/setup.rpm.sh' | sudo -E bash +yum install -y chamber + +%{ if ssh_pub_keys == true } +for user_name in $(chamber list bastion/ssh_pub_keys | cut -d$'\t' -f1 | tail -n +2); +do + groupadd $user_name; + useradd -m -g $user_name $user_name + mkdir /home/$user_name/.ssh + chmod 700 /home/$user_name/.ssh + cd /home/$user_name/.ssh + touch authorized_keys + chmod 600 authorized_keys + chamber read bastion/ssh_pub_keys $user_name -q > authorized_keys + chown $user_name:$user_name -R /home/$user_name +done +%{ endif } + +echo "-----------------------" +echo "END OF CUSTOM USER DATA" +echo "-----------------------" diff --git a/modules/bastion/variables.tf b/modules/bastion/variables.tf new file mode 100644 index 000000000..2181f2945 --- /dev/null +++ b/modules/bastion/variables.tf @@ -0,0 +1,120 @@ +variable "region" { + type = string + description = "AWS region" +} + +variable "instance_type" { + type = string + default = "t2.micro" + description = "Bastion instance type" +} + +variable "root_block_device_volume_size" { + type = number + default = 8 + description = "The volume size (in GiB) to provision for the root block device. It cannot be smaller than the AMI it refers to." +} + +variable "associate_public_ip_address" { + type = bool + default = false + description = "Whether to associate public IP to the instance." +} + +variable "ebs_block_device_volume_size" { + type = number + default = 0 + description = "The volume size (in GiB) to provision for the EBS block device. Creation skipped if size is 0" +} + +variable "ebs_delete_on_termination" { + type = bool + default = false + description = "Whether the EBS volume should be destroyed on instance termination" +} + +variable "user_data" { + type = list(string) + default = [] + description = "User data content" +} + +variable "security_group_rules" { + type = list(any) + default = [ + { + type = "egress" + from_port = 0 + to_port = 0 + protocol = -1 + cidr_blocks = ["0.0.0.0/0"] + }, + { + type = "ingress" + protocol = "tcp" + from_port = 22 + to_port = 22 + cidr_blocks = ["0.0.0.0/0"] + } + ] + description = <<-EOT + A list of maps of Security Group rules. + The values of map is fully complated with `aws_security_group_rule` resource. + To get more info see https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group_rule . + EOT +} + +variable "custom_bastion_hostname" { + type = string + default = null + description = "Hostname to assign with bastion instance" +} + +variable "vanity_domain" { + type = string + default = null + description = "Vanity domain" +} + +variable "ssh_key_path" { + type = string + default = "./secrets" + description = "Save location for ssh public keys generated by the module" +} + +variable "generate_ssh_key" { + type = bool + default = true + description = "Whether or not to generate an SSH key" +} + +# AWS KMS alias used for encryption/decryption of SSM secure strings +variable "kms_alias_name_ssm" { + type = string + default = "alias/aws/ssm" + description = "KMS alias name for SSM" +} + +variable "container_enabled" { + type = bool + default = false + description = "Enable or disable container functionality." +} + +variable "image_repository" { + type = string + default = null + description = "The image repository to use in `container.sh`. This is required if `container_enabled` is `true`." +} + +variable "image_container" { + type = string + default = null + description = "The image container to use in `container.sh`. This is required if `container_enabled` is `true`." +} + +variable "ssh_pub_keys" { + type = bool + default = false + description = "Enable ssh pub keys from chamber." +} diff --git a/modules/bastion/versions.tf b/modules/bastion/versions.tf new file mode 100644 index 000000000..c26d3463e --- /dev/null +++ b/modules/bastion/versions.tf @@ -0,0 +1,22 @@ +terraform { + required_version = ">= 0.13.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 3.0" + } + template = { + source = "hashicorp/template" + version = ">= 2.2" + } + null = { + source = "hashicorp/null" + version = ">= 3.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.0" + } + } +}