Executando verificação de segurança...
7

Como criar um bastion host para acessar seu banco de dados em uma sub-rede privada da AWS com Terraform

Intro

Eu estava trabalhando em um projeto pessoal para aprender sobre Terraform e a nuvem AWS. Eu já havia criado alguns projetos usando o AWS RDS, mas nunca me aprofundei nas configurações e práticas recomendadas — como criar um proxy RDS, configurar grupos de segurança ou colocar meu banco de dados em uma sub-rede privada. Com este projeto, decidi me aprofundar nesses aspectos enquanto aprendia sobre Terraform.

Agora estou aqui para dar um tutorial rápido sobre como criar um bastion host para acessar seu banco de dados. Vamos começar explicando alguns termos que usarei.

Observações

O post principal está no Medium e no Linkedin porém eu não podia deixar de postar aqui no TabNews. Porém aqui não consigo deixar imagens e tenho um limite de 20000 letras. Tentei compactar o máximo possivel mas algumas partes ficaram faltando então para quem tiver mais interesse no assunto basta visitar os links acima.

O que é uma Nuvem Privada Virtual (VPC)?

Como o nome sugere, uma VPC é uma rede virtual que você pode criar e gerenciar dentro de uma nuvem — no nosso caso, a AWS. Por exemplo, você pode criar sub-redes, regras de segurança e executar instâncias dentro dessa rede.

O que é uma sub-rede?

Sub-redes são um segmento dentro da VPC, a ideia é a mesma quando pensamos em dividir, por exemplo, a rede 192.168.1.0 com a máscara 255.255.255.0 (/24) em 2 sub-redes 192.168.1.0 e 192.168.1.128 com a máscara 255.255.255.128 (/25).

Na AWS, podemos designar essas sub-redes como públicas ou privadas. Uma sub-rede pública tem acesso à internet e pode ser acessada pela internet; uma sub-rede privada, não.

Então, se a sub-rede privada não estiver acessível pela internet, como podemos nos conectar ao nosso banco de dados para executar comandos, realizar migrações ou visualizar dados — mantendo o banco de dados seguro em uma sub-rede privada? É aí que entra o bastion host.

O que é um bastion host?

Basicamente, um bastion host é um servidor — normalmente em uma sub-rede pública — que tem acesso a uma sub-rede privada. Ele fornece a usuários controlados e autorizados acesso aos recursos da sub-rede privada, em vez de expor esses recursos diretamente.

No nosso caso, o bastião atuará como um túnel para o banco de dados. Ele fica em uma sub-rede pública, então podemos nos conectar via tunelamento SSH.

O que são Grupos de Segurança?

Os Grupos de Segurança atuam como um firewall virtual, permitindo que você decida qual tráfego é permitido ou negado aos seus recursos — por exemplo, permitindo conexões SSH ao seu bastion host.

O que é Terraform?

Vou pegar a citação do site do Terraform e colá-la aqui porque ela explica melhor do que eu:

O Terraform é uma ferramenta de infraestrutura como código que permite construir, alterar e versionar infraestruturas com segurança e eficiência. Isso inclui componentes de baixo nível, como instâncias de computação, armazenamento e rede; e componentes de alto nível, como entradas de DNS e recursos de SaaS.

Assim, com o Terraform, podemos criar, modificar e destruir todos os nossos recursos com apenas alguns comandos e linhas de código.

Pre-requisites

  • Terraform instalado em sua máquina
  • Conta AWS
  • ID da conta AWS para configurar o Terraform
  • Um perfil AWS com permissões para gerenciar VPCs, Grupos de Segurança e instâncias RDS
  • Conhecimento básico do Terraform para entender os arquivos .tf

Nosso diretório de trabalho

├── db-bastion.tf
├── get-bastion-key.sh
├── main.tf
├── modules
│   ├── db-bastion
│   │   ├── bastion.tf
│   │   ├── main.tf
│   │   ├── output.tf
│   │   ├── variables.tf
│   │   └── versions.tf
│   ├── rds
│   │   ├── outputs.tf
│   │   ├── rds-postgres.tf
│   │   ├── secret-manager.tf
│   │   ├── variables.tf
│   │   └── versions.tf
│   ├── security-group
│   │   ├── bastion.tf
│   │   ├── outputs.tf
│   │   ├── private.tf
│   │   ├── public.tf
│   │   ├── variables.tf
│   │   └── versions.tf
│   └── vpc
│       ├── outputs.tf
│       ├── private.tf
│       ├── public.tf
│       ├── variables.tf
│       ├── versions.tf
│       └── vpc.tf
├── rds.tf
├── security-group.tf
├── terraform.tfstate
├── terraform.tfvars
├── variables.tf
├── versions.tf
└── vpc.tf

Temos uma pasta de módulos contendo os módulos que criamos para organizar nossos recursos do Terraform. Para concluir o tutorial, criaremos uma VPC com sub-redes, um banco de dados RDS Postgres, uma instância EC2 Bastion e grupos de segurança. Pense nos módulos como arquivos ou bibliotecas em JavaScript que você importa ou precisa. Também temos os arquivos principais do Terraform, como: main.tf, versions.tf, variables.tf e terraform.tfvars.

Configuração base do Terraform

No diretório raiz temos arquivos como:

versions.tf que definem os provedores que usaremos no Terraform. O Terraform fornece muitos provedores — Azure, Google Cloud, Cloudflare, etc. — que permitem que você gerencie a infraestrutura nessas plataformas.

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf, onde definimos a configuração base para usar o provedor da AWS no Terraform. Basicamente, passamos a região onde nossos recursos serão criados e especificamos qual perfil da AWS usaremos para executar essas ações. Aqui, você precisa fornecer um perfil com as permissões corretas para criar recursos, caso não esteja usando o perfil root.

provider "aws" {
  region  = "eu-west-1"
  profile = "profile-with-permissions"
}

variables.tf onde definimos as variáveis ​​que usaremos nos arquivos do Terraform

variable "aws_region" {
  description = "AWS Region"
  type        = string
}

variable "aws_account_id" {
  type        = string
  description = "AWS Account ID"
}

variable "master_password" {
  description = "Database master password"
  type      = string
  sensitive = true
}

variable "master_username" {
  description = "Database master username"
  type = string
}

terraform.tfvars este arquivo é onde colocamos as variáveis ​​que foram definidas no arquivo variables.tf

aws_region     = "eu-west-1"
aws_account_id = "YOUR-AWS-ACCOUNT-ID"
master_username = "postgres"
master_password = "examplePassword"

Após todos esses arquivos serem definidos, podemos começar com os arquivos de recursos e módulos.
Configuração do Terraform VPC

Na raiz do nosso projeto, criamos um novo arquivo chamado vpc.tf com este conteúdo

module "vpc" {
  source             = "./modules/vpc"
  aws_region         = var.aws_region
  availability_zones = ["${var.aws_region}a", "${var.aws_region}b", "${var.aws_region}c"]
}

Aqui podemos ver como usar as variáveis ​​definidas em nosso arquivo terraform.tfvars, basicamente usamos ${var.VAR_NAME}.

Agora dentro de ./modules/vpc temos os seguintes arquivos:

vpc.tf aqui definimos nosso recurso VPC com algumas configurações

resource "aws_vpc" "this" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true
}

public.tf aqui definimos um gateway de internet para dar acesso à internet às nossas sub-redes públicas, uma tabela de rotas para enviar todo o tráfego de saída para nosso gateway de internet e nossas sub-redes públicas

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id
}
resource "aws_subnet" "first_public" {
  vpc_id                  = aws_vpc.this.id
  cidr_block              = var.first_public_subnet_cidr
  availability_zone       = var.availability_zones[0]
  map_public_ip_on_launch = true  tags = {
    subnet = "first_public"
  }
}
resource "aws_subnet" "second_public" {
  vpc_id                  = aws_vpc.this.id
  cidr_block              = var.second_public_subnet_cidr
  availability_zone       = var.availability_zones[1]
  map_public_ip_on_launch = true  tags = {
    subnet = "second_public"
  }
}
resource "aws_route_table" "public_route_table" {
  vpc_id = aws_vpc.this.id  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.this.id
  }  tags = {
    subnet = "public_route_table"
  }
}
resource "aws_route_table_association" "first_public" {
  subnet_id      = aws_subnet.first_public.id
  route_table_id = aws_route_table.public_route_table.id
}
resource "aws_route_table_association" "second_public" {
  subnet_id      = aws_subnet.second_public.id
  route_table_id = aws_route_table.public_route_table.id
}

private.tf aqui definimos nossas sub-redes privadas, não precisamos de um gateway de internet ou tabela de rotas porque elas não deveriam ter acesso à internet

resource "aws_subnet" "first_private" {
  vpc_id            = aws_vpc.this.id
  cidr_block        = var.first_private_subnet_cidr
  availability_zone = var.availability_zones[0]

  tags = {
    subnet = "first_private"
  }
}
resource "aws_subnet" "second_private" {
  vpc_id            = aws_vpc.this.id
  cidr_block        = var.second_private_subnet_cidr
  availability_zone = var.availability_zones[1]  tags = {
    subnet = "second_private"
  }
}

Configuração do Terraform RDS

na raiz do nosso diretório temos o rds.tf que passa algumas configurações para o módulo rds e a saída que o Terraform vai nos dar depois de executá-lo

module "rds" {
  source = "./modules/rds"

  availability_zones = ["${var.aws_region}a", "${var.aws_region}b", "${var.aws_region}c"]  identifier              = "postgres"
  engine_version          = "16.8"
  username                = var.master_username
  password                = var.master_password
  allocated_storage       = 20
  instance_class          = "db.t4g.micro"
  storage_encrypted       = false
  publicly_accessible     = false
  skip_final_snapshot     = true
  multi_az                = false
  backup_retention_period = 7
  backup_window           = "00:00-00:30"
  maintenance_window      = "sun:05:00-sun:05:30"
  copy_tags_to_snapshot   = true
  availability_zone       = "${var.aws_region}a"
  apply_immediately       = true
  prevent_destroy         = false  vpc_security_group_ids = [module.security_group.vpc_private_sg_id]
  subnet_ids             = [module.vpc.first_private_subnet_id, module.vpc.second_private_subnet_id]
}output "rds_instance_endpoint" {
  description = "RDS PostgreSQL instance endpoint"
  value       = module.rds.instance_endpoint
}

dentro da pasta do módulo rds:

rds.tf aqui definimos nosso banco de dados e o grupo de sub-rede para ele

resource "aws_db_subnet_group" "postgres" {
  name       = "${var.identifier}-subnet-group"
  subnet_ids = var.subnet_ids

  lifecycle {
    create_before_destroy = true
  }
}
resource "aws_db_instance" "postgres_instance" {
  allocated_storage      = var.allocated_storage
  engine                 = "postgres"
  engine_version         = var.engine_version
  instance_class         = var.instance_class
  identifier             = var.identifier
  username               = var.username
  password               = var.password
  storage_encrypted      = var.storage_encrypted
  publicly_accessible    = var.publicly_accessible
  skip_final_snapshot    = var.skip_final_snapshot
  db_subnet_group_name   = aws_db_subnet_group.postgres.name
  vpc_security_group_ids = var.vpc_security_group_ids
  multi_az               = var.multi_az
  availability_zone      = var.availability_zone  backup_retention_period = var.backup_retention_period
  backup_window           = var.backup_window
  maintenance_window      = var.maintenance_window  copy_tags_to_snapshot = var.copy_tags_to_snapshot
  apply_immediately     = var.apply_immediately  deletion_protection = var.prevent_destroy  tags = var.tags
}

secret-manager.tf aqui salvamos nossas credenciais de banco de dados no gerenciador de segredos da AWS com um nome aleatório

resource "random_integer" "secret_suffix" {
  min = 1000000000000
  max = 9999999999999
}

resource "random_pet" "secret_suffix" {
  length    = 2
  separator = "-"
}
resource "aws_secretsmanager_secret" "db_secret" {
  name        = "db-postgres-secret-for-rds-test-${random_integer.secret_suffix.result}-${random_pet.secret_suffix.id}"
  description = "Secret with the database credentials"
}
resource "aws_secretsmanager_secret_version" "db_secret_version" {
  secret_id = aws_secretsmanager_secret.db_secret.id
  secret_string = jsonencode({
    username             = var.username,
    password             = var.password,
    engine               = aws_db_instance.postgres_instance.engine,
    host                 = aws_db_instance.postgres_instance.address,
    port                 = aws_db_instance.postgres_instance.port,
    dbInstanceIdentifier = aws_db_instance.postgres_instance.identifier
  })
}

Configuração do Terraform Bastion

Na raiz do projeto temos o db-bastion.tf onde passamos as variáveis ​​para o módulo e geramos alguns valores que precisaremos mais tarde

module "db_bastion" {
  source = "./modules/db-bastion"

  vpc_id                                  = module.vpc.vpc_id
  subnet_id                               = module.vpc.first_public_subnet_id
  bastion_sg_id                           = module.security_group.bastion_sg_id
  bastion_instance_connect_endpoint_sg_id = module.security_group.bastion_instance_connect_endpoint_sg_id
}

dentro da pasta do módulo db-bastion:

bastion.tf aqui definimos uma chave privada e pública que usaremos para conectar em nossa máquina via ssh, uma máquina com imagem de servidor Ubuntu e a própria máquina ec2.

resource "tls_private_key" "tls_private_key" {
  algorithm = "RSA"
  rsa_bits  = 4096
}
resource "aws_key_pair" "generated_key" {
  key_name   = "bastion-key-test"
  public_key = tls_private_key.tls_private_key.public_key_openssh
}
data "aws_ami" "ubuntu" {
  most_recent = true  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
  }  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }  owners = ["099720109477"]
}
resource "aws_iam_role" "bastion_role" {
  name = "bastion-role-test"  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      }
    ]
  })  tags = {
    Name      = "bastion-role-test"
    Terraform = "true"
  }
}
resource "aws_instance" "bastion" {
  ami                         = data.aws_ami.ubuntu.id
  instance_type               = "t3.micro"
  subnet_id                   = var.subnet_id
  vpc_security_group_ids      = [var.bastion_sg_id]
  associate_public_ip_address = true
  key_name                    = aws_key_pair.generated_key.key_name  tags = {
    Name      = "bastion-test"
    Terraform = "true"
  }
}
resource "aws_ec2_instance_connect_endpoint" "instance_connect_endpoint" {
  subnet_id          = var.subnet_id
  security_group_ids = [var.bastion_instance_connect_endpoint_sg_id]
  preserve_client_ip = false  tags = {
    Name      = "bastion-connect-test"
    Terraform = "true"
  }
}

Agora tudo está configurado no Terraform. Vamos rodar e ver como funciona.
Executando o Terraform

No módulo raiz, executamos

terraform init

O Terraform irá configurar nosso ambiente e então executaremos

terraform plan

com esse comando o terraform vai criar um plano de implantação e nos dará tudo o que ele vai fazer.

No output podemos ver que o Terraform criará 36 recursos na AWS e nos enviará algumas informações. "(Know after apply)" significa que o Terraform não sabe, por enquanto, o endpoint ou IP dos nossos recursos, pois eles ainda não existem. "(sensitive value)" significa que configuramos este valor como confidencial e não deve ser registrado no console.

Agora a gente executa:

terraform apply

O Terraform fará o plano novamente e perguntará se você realmente deseja executá-lo. Quando a pergunta for feita, você pode digitar "sim" e pressionar Enter. A aplicação leva cerca de 5 minutos para ser concluída.

Agora que temos nosso IP Bastion, podemos nos conectar a ele, mas primeiro precisamos obter nossa private_key para usar em nossa conexão SSH.

Podemos criar um script de shell simples para recuperá-lo

#!/bin/bash
rm -rf .db_bastion_private_key.pem
terraform output -raw db_bastion_private_key > .db_bastion_private_key.pem
chmod 400 .db_bastion_private_key.pem

Este script excluirá a chave se ela já existir em nosso diretório raiz, executará o terraform para gerá-la e gravará em um arquivo chamado: .db_bastion_private_key.pem.

Agora, depois de executar o script acima, podemos criar um túnel executando:

ssh -L 5432:postgres.c10ygmeey4rg.eu-west-1.rds.amazonaws.com:5432 -i .db_bastion_private_key.pem ubuntu@3.253.76.168

não esqueça de substituir o ip e o endpoint rds

Agora, estamos dentro do nosso Bastion e todas as conexões com o host local na porta 5432 serão encaminhadas através do túnel SSH para o ponto de extremidade RDS.

Quando o túnel estiver ativo, qualquer cliente que se conectar a localhost:5432 falará com postgres.c10ygmeey4rg.eu-west-1.rds.amazonaws.com:5432 por meio do bastião SSH.

Podemos verificar isso tentando conectar ao nosso banco de dados, vou usar o DBeaver, mas você pode usar o que quiser.

Você pode usar isso para executar migrações no seu banco de dados a partir do computador local, por exemplo.

Destruindo tudo

Agora precisamos destruir nossos recursos para evitar uma cobrança assustadora na AWS.

Basta executar

terraform destroy

e digite "sim" quando o Terraform perguntar.

Conclusão

O Terraform (ou outras ferramentas que façam o mesmo) é uma ferramenta indispensável ao trabalhar com nuvens. Você pode controlar tudo com código e manter um histórico de modificações quando integrado ao GitHub, como neste artigo, onde conseguimos criar uma VPC, grupos de segurança, banco de dados e um bastion host com poucas configurações.

O projeto completo está no repositório do GitHub, que você pode conferir: https://github.com/HenriqueRamos13/bastion_host_on_AWS_with_terraform

Obrigado pela leitura e bom código!

Carregando publicação patrocinada...
2

Meus 2 cents,

Valeu por compartilhar - conhecimentos sobre infra sempre sao bem vindos, acredito que seja o tipo de informacao que DEV deve procurar entender, ainda mais na epoca atual onde SaaS e VPS sao cada vez mais comuns.

Se me permite um pequeno adendo, quando falamos em rede e servidores temos alguns itens:

  • A internet (rede publica)

  • A rede local (ou rede privada - onde ficam seus hosts, sejam servidores ou nao)

E os intermediarios:

  • o Firewall (o guardiao/porteiro que define quem pode ou nao entrar)

  • A DMZ, uma rede separada da rede privada, onde colocaremos os servidores que serao acessados pela internet (rede publica)

  • E dentro da DMZ, colocamos os servidores e/ou hosts que serao acessados. Este servidores/hosts que ficam na DMZ e que tem como perfil o acesso externo, sao os chamados "bastion hosts".

As vezes nao temos uma DMZ como rede separada - nao eh o ideal, mas nao eh incomum.

Neste caso, temos apenas 2 redes: a internet (publica), a rede local (privada) e o firewall conectando estas duas redes, e tambem funcionando como redirecionador de trafego (ou mesmo balanceador de carga) entre servidores dentro da rede local (privada), e que sao, como indicado, os "bastion hosts".

Enfim - "bastion host" eh apenas um nome tecnico (a bem da verdade, meio em desuso) que damos a qualquer servidor/host que possui algum servico sendo acessado pelo mundo exterior (e que por isso precisa ser configurado da melhor forma possivel para evitar invasoes ou problemas).

Parabens pelo compartilhamento - os scripts ficaram bem legais. Sucesso !!!

1

Obrigado pelo comentário!

Sou um pouco iniciante na área e qualquer adendo é plus!

Já tinha ouvido falar de DMZ estudando sobre redes mas nunca cheguei a perceber oq o termo em si significa ou para que era usado, agora faz um pouco mais de sentido. Não percebi ainda se encaixa no ambiente cloud deste exemplo mas já me deu algo mais para estudar.

2

Meus 2 cents extendidos.

Pois eh, termos como DMZ e bastian host faziam muito sentido quando os servidores eram fisicos.

Atualmente com virtualizacao, aws lambda e acoes serverless fica meio confuso.

Como curiosidade: DMZ vem de Demilitarized Zone/Zona Desmilitarizada e vem de um termo de uso militar, indicando um ponto onde acoes/ameacas sao "proibidas" (uma famosa DMZ eh a fronteira que separa as Coreias do Sul e do Norte). Enfim, seria um terreno "seguro" entre locais extremamente hostis - afinal um servidor exposto ao acesso externo esta totalmente vulneravel a hostilidade. A ideia da DMZ (na area de TI) eh que, isolando este servidor da rede local (privada), mesmo que ele seja comprometido o invasor nao tem acesso os hosts dos usuarios (ou mesmo outros servidores de producao que nao devem ter suas informacoes acessadas externamente).

E bastion Host tambem eh um termo militar (bastion, para ser mais exato) - indicado uma fortificacao avancada que tem contato direto com os inimigos (e por isso, especialmente defendida/armada).

OBS: Durante os anos de 2004 (por ai) fui instrutor de Linux, e abordava especificamente seguranca - por isso este assunto me interessa tanto.

Reforcando - parabens pela publicacao. Sucesso !!!