diff --git a/README.md b/README.md index 2a6cc17..19c8d91 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,63 @@ module "plane_infra" { } ``` +### Email Service NLB (optional) + +Plane includes a built-in email service for receiving inbound email. To expose it externally via a dedicated AWS Network Load Balancer, set `enable_email_nlb = true`. + +> **Important:** Deploy in two stages. The NLB targets EKS worker nodes via fixed NodePorts, so the EKS cluster and the email service must exist before the NLB is created. + +**Stage 1 — Deploy infrastructure and the Plane application:** + +```hcl +module "plane_infra" { + source = "git::https://github.com/makeplane/commercial-deployments.git//terraform?ref=main" + + cluster_name = "plane-eks-cluster" + region = "us-west-2" + enable_aws_lb_controller = true + # enable_email_nlb = false # default — omit until Stage 2 +} +``` + +```bash +terraform apply +aws eks update-kubeconfig --region us-west-2 --name plane-eks-cluster +kubectl apply -k kustomize/overlays/example # deploys email-service with fixed NodePorts +``` + +**Stage 2 — Create the NLB once the email service is running:** + +```hcl +module "plane_infra" { + source = "git::https://github.com/makeplane/commercial-deployments.git//terraform?ref=main" + + cluster_name = "plane-eks-cluster" + region = "us-west-2" + enable_aws_lb_controller = true + enable_email_nlb = true # creates NLB + registers EKS nodes in target groups +} +``` + +```bash +terraform apply +terraform output email_nlb_dns_name # use this value for your MX record +``` + +**Route53 MX record:** + +After apply, create the following records in Route53 for your domain: + +| Type | Name | TTL | Value | +|------|------|-----|-------| +| MX | `yourdomain.com` | 300 | `10 .` | +| TXT | `yourdomain.com` | 300 | `"v=spf1 include: ~all"` | + +The NLB listens on: +- Port **25** — SMTP (inbound email from other mail servers) +- Port **465** — SMTPS +- Port **587** — Submission + Override defaults by passing `eks`, `cache`, `mq`, `opensearch`, `object_store`, or `db` objects. See [terraform/README.md](terraform/README.md) for all options. ### Outputs diff --git a/kustomize/components/email-service/service.yaml b/kustomize/components/email-service/service.yaml index 97c68ff..d872827 100644 --- a/kustomize/components/email-service/service.yaml +++ b/kustomize/components/email-service/service.yaml @@ -7,8 +7,7 @@ metadata: app.kubernetes.io/name: plane-enterprise app.kubernetes.io/component: email-service spec: - type: LoadBalancer - externalTrafficPolicy: Local # Important for email servers + type: NodePort selector: app.kubernetes.io/name: plane-enterprise app.kubernetes.io/component: email-service @@ -16,12 +15,15 @@ spec: - name: smtp port: 25 targetPort: 10025 + nodePort: 30025 protocol: TCP - name: smtps port: 465 targetPort: 10465 + nodePort: 30465 protocol: TCP - name: submission port: 587 targetPort: 10587 + nodePort: 30587 protocol: TCP \ No newline at end of file diff --git a/terraform/main.tf b/terraform/main.tf index f3fb4f7..31f659a 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -61,6 +61,19 @@ module "aws_lb_controller" { depends_on = [module.eks] } +module "email_nlb" { + count = var.enable_email_nlb ? 1 : 0 + source = "./modules/email-nlb" + + cluster_name = var.cluster_name + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.public_subnet_ids + node_security_group_id = module.eks.node_security_group_id + tags = var.tags + + depends_on = [module.vpc, module.eks] +} + resource "random_password" "opensearch" { length = 32 special = true diff --git a/terraform/modules/email-nlb/main.tf b/terraform/modules/email-nlb/main.tf new file mode 100644 index 0000000..96c1581 --- /dev/null +++ b/terraform/modules/email-nlb/main.tf @@ -0,0 +1,160 @@ +# Discover the EKS node group ASG so we can attach target groups to it. +# EKS managed node groups tag their ASGs with eks:cluster-name automatically. +data "aws_autoscaling_groups" "eks_nodes" { + filter { + name = "tag:eks:cluster-name" + values = [var.cluster_name] + } +} + +# Allow inbound traffic from NLB to EKS node NodePorts +resource "aws_security_group_rule" "smtp_nodeport" { + type = "ingress" + from_port = var.smtp_node_port + to_port = var.smtp_node_port + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + security_group_id = var.node_security_group_id + description = "Email NLB -> SMTP NodePort" +} + +resource "aws_security_group_rule" "smtps_nodeport" { + type = "ingress" + from_port = var.smtps_node_port + to_port = var.smtps_node_port + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + security_group_id = var.node_security_group_id + description = "Email NLB -> SMTPS NodePort" +} + +resource "aws_security_group_rule" "submission_nodeport" { + type = "ingress" + from_port = var.submission_node_port + to_port = var.submission_node_port + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + security_group_id = var.node_security_group_id + description = "Email NLB -> Submission NodePort" +} + +# Target groups (instance target type — routes to NodePorts on worker nodes) +resource "aws_lb_target_group" "smtp" { + name = "${var.cluster_name}-email-smtp" + port = var.smtp_node_port + protocol = "TCP" + vpc_id = var.vpc_id + target_type = "instance" + + health_check { + protocol = "TCP" + port = tostring(var.smtp_node_port) + healthy_threshold = 2 + unhealthy_threshold = 2 + interval = 10 + } + + tags = merge(var.tags, { Name = "${var.cluster_name}-email-smtp" }) +} + +resource "aws_lb_target_group" "smtps" { + name = "${var.cluster_name}-email-smtps" + port = var.smtps_node_port + protocol = "TCP" + vpc_id = var.vpc_id + target_type = "instance" + + health_check { + protocol = "TCP" + port = tostring(var.smtps_node_port) + healthy_threshold = 2 + unhealthy_threshold = 2 + interval = 10 + } + + tags = merge(var.tags, { Name = "${var.cluster_name}-email-smtps" }) +} + +resource "aws_lb_target_group" "submission" { + name = "${var.cluster_name}-email-sub" + port = var.submission_node_port + protocol = "TCP" + vpc_id = var.vpc_id + target_type = "instance" + + health_check { + protocol = "TCP" + port = tostring(var.submission_node_port) + healthy_threshold = 2 + unhealthy_threshold = 2 + interval = 10 + } + + tags = merge(var.tags, { Name = "${var.cluster_name}-email-sub" }) +} + +# Internet-facing Network Load Balancer +resource "aws_lb" "email" { + name = "${var.cluster_name}-email" + internal = false + load_balancer_type = "network" + subnets = var.subnet_ids + + enable_deletion_protection = false + + tags = merge(var.tags, { Name = "${var.cluster_name}-email-nlb" }) +} + +# Listeners +resource "aws_lb_listener" "smtp" { + load_balancer_arn = aws_lb.email.arn + port = 25 + protocol = "TCP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.smtp.arn + } +} + +resource "aws_lb_listener" "smtps" { + load_balancer_arn = aws_lb.email.arn + port = 465 + protocol = "TCP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.smtps.arn + } +} + +resource "aws_lb_listener" "submission" { + load_balancer_arn = aws_lb.email.arn + port = 587 + protocol = "TCP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.submission.arn + } +} + +# Attach each target group to every EKS node ASG so nodes are registered +# automatically as the node group scales up/down. +resource "aws_autoscaling_attachment" "smtp" { + for_each = toset(data.aws_autoscaling_groups.eks_nodes.names) + autoscaling_group_name = each.value + lb_target_group_arn = aws_lb_target_group.smtp.arn +} + +resource "aws_autoscaling_attachment" "smtps" { + for_each = toset(data.aws_autoscaling_groups.eks_nodes.names) + autoscaling_group_name = each.value + lb_target_group_arn = aws_lb_target_group.smtps.arn +} + +resource "aws_autoscaling_attachment" "submission" { + for_each = toset(data.aws_autoscaling_groups.eks_nodes.names) + autoscaling_group_name = each.value + lb_target_group_arn = aws_lb_target_group.submission.arn +} diff --git a/terraform/modules/email-nlb/outputs.tf b/terraform/modules/email-nlb/outputs.tf new file mode 100644 index 0000000..dc717c8 --- /dev/null +++ b/terraform/modules/email-nlb/outputs.tf @@ -0,0 +1,23 @@ +output "nlb_arn" { + description = "ARN of the email NLB" + value = aws_lb.email.arn +} + +output "nlb_dns_name" { + description = "DNS name of the email NLB — use as MX record target" + value = aws_lb.email.dns_name +} + +output "nlb_zone_id" { + description = "Route53 hosted zone ID of the NLB — use for alias records" + value = aws_lb.email.zone_id +} + +output "target_group_arns" { + description = "Target group ARNs keyed by port name (smtp, smtps, submission)" + value = { + smtp = aws_lb_target_group.smtp.arn + smtps = aws_lb_target_group.smtps.arn + submission = aws_lb_target_group.submission.arn + } +} diff --git a/terraform/modules/email-nlb/variables.tf b/terraform/modules/email-nlb/variables.tf new file mode 100644 index 0000000..6c3f9b5 --- /dev/null +++ b/terraform/modules/email-nlb/variables.tf @@ -0,0 +1,43 @@ +variable "cluster_name" { + description = "EKS cluster name (used for naming and tagging)" + type = string +} + +variable "vpc_id" { + description = "VPC ID where the NLB will be created" + type = string +} + +variable "subnet_ids" { + description = "Public subnet IDs for the internet-facing NLB (one per AZ)" + type = list(string) +} + +variable "node_security_group_id" { + description = "Security group ID of EKS worker nodes — NodePort ingress rules are added here" + type = string +} + +variable "smtp_node_port" { + description = "Fixed NodePort for SMTP (port 25)" + type = number + default = 30025 +} + +variable "smtps_node_port" { + description = "Fixed NodePort for SMTPS (port 465)" + type = number + default = 30465 +} + +variable "submission_node_port" { + description = "Fixed NodePort for Submission (port 587)" + type = number + default = 30587 +} + +variable "tags" { + description = "Additional tags to apply to all resources" + type = map(string) + default = {} +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf index db78bfe..bd42654 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -119,4 +119,9 @@ output "rds_password_secret_arn" { description = "ARN of the RDS master user password secret in Secrets Manager" value = module.rds.master_user_secret[0].secret_arn sensitive = true +} + +output "email_nlb_dns_name" { + description = "DNS name of the email NLB — add as MX record in Route53 (e.g. 10 .). Null when enable_email_nlb = false." + value = var.enable_email_nlb ? module.email_nlb[0].nlb_dns_name : null } \ No newline at end of file diff --git a/terraform/variables.tf b/terraform/variables.tf index 0e2de7b..c1be129 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -72,6 +72,12 @@ variable "enable_aws_lb_controller" { default = false } +variable "enable_email_nlb" { + description = "Create an internet-facing NLB for the email service (ports 25/465/587) and add NodePort ingress rules to EKS nodes" + type = bool + default = false +} + variable "cache" { description = "ElastiCache Redis configuration" type = object({