Repository: ansible-collections/amazon.aws

Issue:
Multiple listener certificates for alb
### Summary

I am trying to add multiple certificates for a single load balancer, i.e. I'd like a load balancer that would be able to host sites that have the host name of foo.com and bar.com

In cloudformation, this can be done by using [AWS::ElasticLoadBalancingV2::ListenerCertificate](https://awstut.com/en/2022/02/19/attaching-multiple-acm-certificates-to-alb-to-support-multiple-domains-2/#toc9) and in boto3 this can be done with [add_listener_certificates()](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/elbv2.html#ElasticLoadBalancingv2.Client.add_listener_certificates)



### Issue Type

Feature Idea

### Component Name

amazon.aws.elb_application_lb_certificate

### Additional Information

<!--- Paste example playbooks or commands between quotes below -->
```yaml
amazon.aws.elb_application_lb_certificate:
  listener: "{{ load_balancer.listeners[0].listener_arn }}"
  certificates:
    - CertificateArn: "{{ foo_certificate_arn }}"
      IsDefault: true
    - CertificateArn: "{{ bar_certificate_arn }}"
      IsDefault: false
```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
An alternative approach would be to modify elb_application_lb to take listener_certificates as a parameter, however, this would have to be done after the load balancer was created, I'm not sure how clean this is.
Hi @omarqureshi,

The `elb_application_lb` module already accepts `Certificates` as part of the definition of a listener:

https://ansible-collections.github.io/amazon.aws/branch/main/collections/amazon/aws/elb_application_lb_module.html#parameter-listeners

This looks like the functionality you're describing, Is there a reason that this doesn't meet your needs?
From what I gather from how the elb api works, Certificates only accepts a dictionary of size one, from the boto v3 API on create_listener()

> Certificates (list) --
> [HTTPS and TLS listeners] The default certificate for the listener. You must provide exactly one certificate. Set CertificateArn to the certificate ARN but do not set IsDefault .
> 
> (dict) --
> Information about an SSL server certificate.
> 
> CertificateArn (string) --
> The Amazon Resource Name (ARN) of the certificate.
> 
> IsDefault (boolean) --
> Indicates whether the certificate is the default certificate. Do not set this value when specifying a certificate as an input. This value is not included in the output when describing a listener, but is included when describing listener certificates.

From looking at the code, I could not see an explicit use of add_listener_certificates() so, I'm very sure that listeners only support one certificate through that module
This is also mentioned in the example documentation in the link provided

> listeners:
>       - Protocol: HTTP # Required. The protocol for connections from clients to the load balancer (HTTP or HTTPS) (case-sensitive).
>         Port: 80 # Required. The port on which the load balancer is listening.
>         # The security policy that defines which ciphers and protocols are supported. The default is the current predefined security policy.
>         SslPolicy: ELBSecurityPolicy-2015-05
>         Certificates: # The ARN of the certificate (only one certficate ARN should be provided)
>           - CertificateArn: arn:aws:iam::123456789012:server-certificate/test.domain.com
@omarqureshi I'm not sure if it's a recent change, but the boto3 docs now say:

> The certificate to add. You can specify one certificate **per call**. Set CertificateArn to the certificate ARN but do not set IsDefault.

I'd read that as being able to add multiple certs if done as multiple calls. What's strange is that the isDefault does not seem to be setable, which leave the default as pretty ambiguous. The first one maybe? 
> @omarqureshi I'm not sure if it's a recent change, but the boto3 docs now say:
> 
> > The certificate to add. You can specify one certificate **per call**. Set CertificateArn to the certificate ARN but do not set IsDefault.

Interesting - I think this has always been the case. Multiple calls do not appear to work, This is the bit of code that does certificate ARN comparison https://github.com/ansible-collections/amazon.aws/blob/main/plugins/module_utils/elbv2.py#L879-L896

I may have some spare time to look into this at the weekend, however looking at the test suite there's two things that have popped up.

1. The tests around listener rules only do path based checks and not host based tests
2. There do not seem to be any tests around TLS

I would guess getting those in would be a good first step before adding tests around my use case.
The final thing is a question of style - is it better to modify amazon.aws.elb_application_lb to accept multiple listener certificates? or is it more appropriate to create something new? For example, something like community.aws.elb_listener
@omarqureshi Thank you for your attention on this idea. 

My first inclination is that since amazon.aws.elb_application_lb already does _everything_ else around a lb and its listeners, it would seem the shortest path.

If it's easier to get a new module done vs modify the existing, I think it's a reasonable argument that the complexity of listeners warrants a separate module. The only downside I can see is confusion of a user with initial use of amazon.aws.elb_application_lb to setup a multi-cert listener vs modifying an existing one to be multi cert.

Getting a lb with multi-cert would then be a two step process, which is inelegant.
> > @omarqureshi I'm not sure if it's a recent change, but the boto3 docs now say:
> > > The certificate to add. You can specify one certificate **per call**. Set CertificateArn to the certificate ARN but do not set IsDefault.
> 
> Interesting - I think this has always been the case. Multiple calls do not appear to work, This is the bit of code that does certificate ARN comparison https://github.com/ansible-collections/amazon.aws/blob/main/plugins/module_utils/elbv2.py#L879-L896

Maybe a small detail... You are obviously correct that the ansible module is operating only on the first list item. My thought on "multiple calls" was in multiple calls to the boto API, which may have been a recent change (per the doc's change of wording). I wasn't sure if your comment above was in reference to "multiple tasks execs of the ansible module" vs "multiple boto calls by the ansible module".

Resolving patch:
diff --git a/plugins/module_utils/elbv2.py b/plugins/module_utils/elbv2.py
--- a/plugins/module_utils/elbv2.py
+++ b/plugins/module_utils/elbv2.py
@@ -964,7 +964,23 @@ def add(self):
             # Rules is not a valid parameter for create_listener
             if "Rules" in self.listener:
                 self.listener.pop("Rules")
-            AWSRetry.jittered_backoff()(self.connection.create_listener)(LoadBalancerArn=self.elb_arn, **self.listener)
+
+            # handle multiple certs by adding only 1 cert during listener creation and make calls to add_listener_certificates to add other certs
+            listener_certificates = self.listener.get("Certificates", [])
+            first_certificate, other_certs = [], []
+            if len(listener_certificates) > 0:
+                first_certificate, other_certs = listener_certificates[0], listener_certificates[1:]
+                self.listener["Certificates"] = [first_certificate]
+            # create listener
+            create_listener_result = AWSRetry.jittered_backoff()(self.connection.create_listener)(
+                LoadBalancerArn=self.elb_arn, **self.listener
+            )
+            # only one cert can be specified per call to add_listener_certificates
+            for cert in other_certs:
+                AWSRetry.jittered_backoff()(self.connection.add_listener_certificates)(
+                    ListenerArn=create_listener_result["Listeners"][0]["ListenerArn"], Certificates=[cert]
+                )
+
         except (BotoCoreError, ClientError) as e:
             self.module.fail_json_aws(e)
 
diff --git a/plugins/modules/elb_application_lb.py b/plugins/modules/elb_application_lb.py
--- a/plugins/modules/elb_application_lb.py
+++ b/plugins/modules/elb_application_lb.py
@@ -236,7 +236,7 @@
         Port: 80 # Required. The port on which the load balancer is listening.
         # The security policy that defines which ciphers and protocols are supported. The default is the current predefined security policy.
         SslPolicy: ELBSecurityPolicy-2015-05
-        Certificates: # The ARN of the certificate (only one certficate ARN should be provided)
+        Certificates: # The ARN of the certificate
           - CertificateArn: arn:aws:iam::123456789012:server-certificate/test.domain.com
         DefaultActions:
           - Type: forward # Required.
@@ -260,7 +260,7 @@
         Port: 80 # Required. The port on which the load balancer is listening.
         # The security policy that defines which ciphers and protocols are supported. The default is the current predefined security policy.
         SslPolicy: ELBSecurityPolicy-2015-05
-        Certificates: # The ARN of the certificate (only one certficate ARN should be provided)
+        Certificates: # The ARN of the certificate
           - CertificateArn: arn:aws:iam::123456789012:server-certificate/test.domain.com
         DefaultActions:
           - Type: forward # Required.
@@ -330,6 +330,29 @@
                 Type: forward
     state: present
 
+# Create an ALB with a listener having multiple listener certificates
+- amazon.aws.elb_application_lb:
+    name: myalb
+    security_groups:
+      - sg-12345678
+      - my-sec-group
+    subnets:
+      - subnet-012345678
+      - subnet-abcdef000
+    listeners:
+      - Protocol: HTTP # Required. The protocol for connections from clients to the load balancer (HTTP or HTTPS) (case-sensitive).
+        Port: 80 # Required. The port on which the load balancer is listening.
+        # The security policy that defines which ciphers and protocols are supported. The default is the current predefined security policy.
+        SslPolicy: ELBSecurityPolicy-2015-05
+        Certificates: # The ARN of the certificate (first certificate in the list will be set as default certificate)
+          - CertificateArn: arn:aws:iam::123456789012:server-certificate/test.domain.com
+          - CertificateArn: arn:aws:iam::123456789012:server-certificate/secondtest.domain.com
+          - CertificateArn: arn:aws:iam::123456789012:server-certificate/thirdtest.domain.com
+        DefaultActions:
+          - Type: forward # Required.
+            TargetGroupName: # Required. The name of the target group
+    state: present
+
 # Remove an ALB
 - amazon.aws.elb_application_lb:
     name: myalb


Test patch:
diff --git a/tests/integration/targets/elb_application_lb/defaults/main.yml b/tests/integration/targets/elb_application_lb/defaults/main.yml
--- a/tests/integration/targets/elb_application_lb/defaults/main.yml
+++ b/tests/integration/targets/elb_application_lb/defaults/main.yml
@@ -4,6 +4,7 @@
 resource_short: "{{ '%0.8x' % ((16**8) | random(seed=resource_prefix)) }}"
 alb_name: alb-test-{{ resource_short }}
 alb_2_name: alb-test-2-{{ resource_short }}
+alb_name_multiple_listener_test: alb-test-{{ resource_short }}-lt
 tg_name: alb-test-{{ resource_short }}
 tg_2_name: alb-test-2-{{ resource_short }}
 vpc_cidr: 10.{{ 256 | random(seed=resource_prefix) }}.0.0/16
@@ -26,3 +27,15 @@ elb_access_log_account_id_map:
   us-gov-west-1: "048591011584"
 
 elb_account_id: "{{ elb_access_log_account_id_map[aws_region] }}"
+
+local_certs:
+  - priv_key: "{{ remote_tmp_dir }}/private-1.pem"
+    cert: "{{ remote_tmp_dir }}/public-1.pem"
+    csr: "{{ remote_tmp_dir }}/csr-1.csr"
+    domain: elb-classic.{{ tiny_prefix }}.ansible.test
+    name: "{{ resource_prefix }}_{{ resource_prefix }}_1"
+  - priv_key: "{{ remote_tmp_dir }}/private-2.pem"
+    cert: "{{ remote_tmp_dir }}/public-2.pem"
+    csr: "{{ remote_tmp_dir }}/csr-2.csr"
+    domain: elb-classic.{{ tiny_prefix }}.ansible.test
+    name: "{{ resource_prefix }}_{{ resource_prefix }}_2"
diff --git a/tests/integration/targets/elb_application_lb/meta/main.yml b/tests/integration/targets/elb_application_lb/meta/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/elb_application_lb/meta/main.yml
@@ -0,0 +1,4 @@
+---
+dependencies:
+  - setup_ec2_facts
+  - setup_remote_tmp_dir
diff --git a/tests/integration/targets/elb_application_lb/tasks/alb_with_multiple_listener_certs.yml b/tests/integration/targets/elb_application_lb/tasks/alb_with_multiple_listener_certs.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/elb_application_lb/tasks/alb_with_multiple_listener_certs.yml
@@ -0,0 +1,127 @@
+- name: Run tests
+  block:
+    - name: Generate private key for local certs
+      community.crypto.openssl_privatekey:
+        path: "{{ item.priv_key }}"
+        type: RSA
+        size: 2048
+      with_items: "{{ local_certs }}"
+
+    - name: Generate an OpenSSL Certificate Signing Request for own certs
+      community.crypto.openssl_csr:
+        path: "{{ item.csr }}"
+        privatekey_path: "{{ item.priv_key }}"
+        common_name: "{{ item.domain }}"
+      with_items: "{{ local_certs }}"
+
+    - name: Generate a Self Signed OpenSSL certificate for own certs
+      community.crypto.x509_certificate:
+        provider: selfsigned
+        path: "{{ item.cert }}"
+        csr_path: "{{ item.csr }}"
+        privatekey_path: "{{ item.priv_key }}"
+        selfsigned_digest: sha256
+      register: cert_create_result
+      with_items: "{{ local_certs }}"
+
+    - name: upload certificates
+      community.aws.acm_certificate:
+        name_tag: "{{ item.name }}"
+        certificate: "{{ lookup('file', item.cert ) }}"
+        private_key: "{{ lookup('file', item.priv_key ) }}"
+        state: present
+        tags:
+          Application: search
+          Environment: development
+        purge_tags: false
+      register: upload
+      with_items: "{{ local_certs }}"
+      until: upload is succeeded
+      retries: 5
+      delay: 10
+
+    - ansible.builtin.set_fact:
+        cert_1_arn: "{{ upload.results[0].certificate.arn }}"
+        cert_2_arn: "{{ upload.results[1].certificate.arn }}"
+
+    - name: Create a target group for testing
+      community.aws.elb_target_group:
+        name: "{{ tg_name }}"
+        protocol: http
+        port: 80
+        vpc_id: "{{ vpc_id }}"
+        state: present
+
+    - name: Create an ALB with listener having multiple certificates
+      amazon.aws.elb_application_lb:
+        name: "{{ alb_name_multiple_listener_test }}"
+        subnets: "{{ public_subnets }}"
+        security_groups: "{{ sec_group.group_id }}"
+        state: present
+        purge_listeners: False
+        listeners:
+          - Protocol: HTTPS
+            Port: 446
+            SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06
+            Certificates: # The ARN of the certificate
+              - CertificateArn: "{{ cert_1_arn }}"
+              - CertificateArn: "{{ cert_2_arn }}"
+            DefaultActions:
+              - Type: forward
+                TargetGroupName: "{{ tg_name }}"
+      register: alb
+
+    - name: Gather information about a particular ALB given its ARN #returns only default cert
+      amazon.aws.elb_application_lb_info:
+        load_balancer_arns:
+          - "{{ alb.load_balancer_arn }}"
+      register: alb_info
+
+    - name: obtain information about a certificate 1
+      community.aws.acm_certificate_info:
+        certificate_arn:  "{{ cert_1_arn }}"
+      register: cert_1_info
+
+    - name: obtain information about a certificate 2
+      community.aws.acm_certificate_info:
+        certificate_arn:  "{{ cert_2_arn }}"
+      register: cert_2_info
+
+    - name: Assert that both certificiates are in use by test load balancer
+      ansible.builtin.assert:
+        that:
+          - cert_1_info.certificates[0].in_use_by[0] == alb_info.load_balancers[0].load_balancer_arn
+          - cert_2_info.certificates[0].in_use_by[0] == alb_info.load_balancers[0].load_balancer_arn
+
+  always:
+    - name: Delete test ALB
+      amazon.aws.elb_application_lb:
+        name: "{{ alb_name_multiple_listener_test }}"
+        subnets: "{{ public_subnets }}"
+        security_groups: "{{ sec_group.group_id }}"
+        state: absent
+        purge_listeners: False
+        listeners:
+          - Protocol: HTTPS
+            Port: 446
+            SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06
+            Certificates: # The ARN of the certificate
+              - CertificateArn: "{{ cert_1_arn }}"
+              - CertificateArn: "{{ cert_2_arn }}"
+            DefaultActions:
+              - Type: forward
+                TargetGroupName: "{{ tg_name }}"
+      ignore_errors: true
+
+    - name: delete a certificate with a particular ARN
+      community.aws.acm_certificate:
+        certificate_arn: "{{ item }}"
+        state: absent
+      register: delete_acm
+      with_items:
+        - "{{ cert_1_arn }}"
+        - "{{ cert_2_arn }}"
+      retries: 5
+      delay: 5
+      until: delete_acm is not failed
+      ignore_errors: true
diff --git a/tests/integration/targets/elb_application_lb/tasks/main.yml b/tests/integration/targets/elb_application_lb/tasks/main.yml
--- a/tests/integration/targets/elb_application_lb/tasks/main.yml
+++ b/tests/integration/targets/elb_application_lb/tasks/main.yml
@@ -133,6 +133,10 @@
         encryption: aws:kms
         policy: "{{ lookup('template', 'policy.json') }}"
 
+
+    - name: Run tests for creating ALB with listener having multiple certificates
+      ansible.builtin.import_tasks: alb_with_multiple_listener_certs.yml
+
     - name: Create an ALB (invalid - SslPolicy is required when Protocol == HTTPS)
       amazon.aws.elb_application_lb:
         name: "{{ alb_name }}"