diff --git a/deploy/aws/cloudformation/template.yaml b/deploy/aws/cloudformation/template.yaml index b1bb217b..076d971b 100644 --- a/deploy/aws/cloudformation/template.yaml +++ b/deploy/aws/cloudformation/template.yaml @@ -11,6 +11,9 @@ Metadata: - SubnetId - AllowedApiCidr - ApiPort + - EnableHttpIngress + - EnableHttpsIngress + - AllowedIngressCidr - Label: default: Instance Parameters: @@ -43,6 +46,12 @@ Metadata: default: Hypeman API access CIDR ApiPort: default: Hypeman API port + EnableHttpIngress: + default: Enable HTTP ingress + EnableHttpsIngress: + default: Enable HTTPS ingress + AllowedIngressCidr: + default: Hypeman ingress access CIDR EnableSSH: default: Enable SSH AllowedSshCidr: @@ -89,6 +98,21 @@ Parameters: MinValue: 1 MaxValue: 65535 Description: Hypeman API port exposed to AllowedApiCidr. + EnableHttpIngress: + Type: String + Default: "false" + AllowedValues: ["true", "false"] + Description: Open port 80 from AllowedIngressCidr for Hypeman HTTP ingress traffic. + EnableHttpsIngress: + Type: String + Default: "false" + AllowedValues: ["true", "false"] + Description: Open port 443 from AllowedIngressCidr for Hypeman HTTPS ingress traffic. + AllowedIngressCidr: + Type: String + Default: 127.0.0.1/32 + Description: Client CIDR allowed to reach Hypeman ingress ports when enabled. Use your current public IP /32 or a trusted VPN CIDR; avoid 0.0.0.0/0. + AllowedPattern: "^([0-9]{1,3}\\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$" EnableSSH: Type: String Default: "false" @@ -144,6 +168,8 @@ Parameters: Conditions: UseSSH: !Equals [!Ref EnableSSH, "true"] + UseHttpIngress: !Equals [!Ref EnableHttpIngress, "true"] + UseHttpsIngress: !Equals [!Ref EnableHttpsIngress, "true"] Resources: HypemanSecurityGroup: @@ -157,6 +183,22 @@ Resources: ToPort: !Ref ApiPort CidrIp: !Ref AllowedApiCidr Description: Hypeman API + - !If + - UseHttpIngress + - IpProtocol: tcp + FromPort: 80 + ToPort: 80 + CidrIp: !Ref AllowedIngressCidr + Description: Hypeman HTTP ingress + - !Ref AWS::NoValue + - !If + - UseHttpsIngress + - IpProtocol: tcp + FromPort: 443 + ToPort: 443 + CidrIp: !Ref AllowedIngressCidr + Description: Hypeman HTTPS ingress + - !Ref AWS::NoValue - !If - UseSSH - IpProtocol: tcp @@ -202,9 +244,10 @@ Resources: Roles: - !Ref HypemanInstanceRole - # CloudFormation's typed EC2 resources do not expose CpuOptions.NestedVirtualization yet. - # This helper creates only the launch template that carries that EC2 API option; - # the Hypeman EC2 instance itself remains a normal stack-managed resource. + # CloudFormation's typed EC2 instance block device mapping does not expose gp3 + # throughput, and EC2 resources do not expose CpuOptions.NestedVirtualization + # yet. This helper creates a minimal launch template for those fields; the + # Hypeman EC2 instance itself remains a normal stack-managed resource. NestedVirtualizationLaunchTemplateRole: Type: AWS::IAM::Role Properties: diff --git a/deploy/aws/cloudformation/template_test.go b/deploy/aws/cloudformation/template_test.go index 725de633..69cafd7f 100644 --- a/deploy/aws/cloudformation/template_test.go +++ b/deploy/aws/cloudformation/template_test.go @@ -16,6 +16,9 @@ func TestQuickstartParameters(t *testing.T) { assertDefault(t, parameters, "InstanceType", "c8i.2xlarge") assertDefault(t, parameters, "AllowedApiCidr", "127.0.0.1/32") assertDefault(t, parameters, "ApiPort", "8080") + assertDefault(t, parameters, "EnableHttpIngress", "false") + assertDefault(t, parameters, "EnableHttpsIngress", "false") + assertDefault(t, parameters, "AllowedIngressCidr", "127.0.0.1/32") assertDefault(t, parameters, "EnableSSH", "false") assertDefault(t, parameters, "AllowedSshCidr", "127.0.0.1/32") assertDefault(t, parameters, "RootVolumeSize", "30") @@ -33,6 +36,10 @@ func TestQuickstartParameters(t *testing.T) { assertContains(t, scalar(t, apiCidr["Description"]), "current public IP /32") assertContains(t, scalar(t, apiCidr["Description"]), "avoid 0.0.0.0/0") + ingressCidr := requireMapping(t, parameters["AllowedIngressCidr"]) + assertContains(t, scalar(t, ingressCidr["Description"]), "current public IP /32") + assertContains(t, scalar(t, ingressCidr["Description"]), "avoid 0.0.0.0/0") + metadata := requireMapping(t, requireField(t, root, "Metadata")) cfnInterface := requireMapping(t, requireField(t, metadata, "AWS::CloudFormation::Interface")) groups := requireSequence(t, requireField(t, cfnInterface, "ParameterGroups")) @@ -56,8 +63,8 @@ func TestCloudFormationLaunchContract(t *testing.T) { securityGroup := requireMapping(t, requireField(t, resources, "HypemanSecurityGroup")) sgProperties := requireMapping(t, requireField(t, securityGroup, "Properties")) ingress := requireSequence(t, requireField(t, sgProperties, "SecurityGroupIngress")) - if len(ingress.Content) != 2 { - t.Fatalf("expected API ingress and conditional SSH ingress, got %d entries", len(ingress.Content)) + if len(ingress.Content) != 4 { + t.Fatalf("expected API ingress, HTTP ingress, HTTPS ingress, and SSH ingress, got %d entries", len(ingress.Content)) } apiIngress := requireMapping(t, ingress.Content[0]) @@ -65,7 +72,10 @@ func TestCloudFormationLaunchContract(t *testing.T) { assertRef(t, requireField(t, apiIngress, "ToPort"), "ApiPort") assertRef(t, requireField(t, apiIngress, "CidrIp"), "AllowedApiCidr") - sshIngress := ingress.Content[1] + assertConditionalIngress(t, ingress.Content[1], "UseHttpIngress", "80", "AllowedIngressCidr") + assertConditionalIngress(t, ingress.Content[2], "UseHttpsIngress", "443", "AllowedIngressCidr") + + sshIngress := ingress.Content[3] if sshIngress.Tag != "!If" { t.Fatalf("expected SSH ingress to be conditional !If, got %s", sshIngress.Tag) } @@ -147,6 +157,26 @@ func TestQuickstartOutputs(t *testing.T) { assertContains(t, scalar(t, requireField(t, requireMapping(t, outputs["CreateTokenCommand"]), "Value")), "hypeman-create-token") } +func assertConditionalIngress(t *testing.T, node *yaml.Node, condition, port, cidrRef string) { + t.Helper() + + if node.Tag != "!If" { + t.Fatalf("expected ingress to be conditional !If, got %s", node.Tag) + } + parts := requireSequence(t, node) + if got := scalar(t, parts.Content[0]); got != condition { + t.Fatalf("expected condition %q, got %q", condition, got) + } + rule := requireMapping(t, parts.Content[1]) + if got := scalar(t, requireField(t, rule, "FromPort")); got != port { + t.Fatalf("expected FromPort %s, got %q", port, got) + } + if got := scalar(t, requireField(t, rule, "ToPort")); got != port { + t.Fatalf("expected ToPort %s, got %q", port, got) + } + assertRef(t, requireField(t, rule, "CidrIp"), cidrRef) +} + func loadTemplate(t *testing.T) *yaml.Node { t.Helper()