diff --git a/.github/actions/build_aws_eif/action.yaml b/.github/actions/build_aws_eif/action.yaml index f17523a44..3d08f3942 100644 --- a/.github/actions/build_aws_eif/action.yaml +++ b/.github/actions/build_aws_eif/action.yaml @@ -96,8 +96,8 @@ runs: cp ${{ steps.buildFolder.outputs.BUILD_FOLDER }}/identity_scope.txt ${ARTIFACTS_OUTPUT_DIR}/ cp ${{ steps.buildFolder.outputs.BUILD_FOLDER }}/version_number.txt ${ARTIFACTS_OUTPUT_DIR}/ - cp ./scripts/aws/start.sh ${ARTIFACTS_OUTPUT_DIR}/ - cp ./scripts/aws/stop.sh ${ARTIFACTS_OUTPUT_DIR}/ + cp ./scripts/confidential_compute.py ${ARTIFACTS_OUTPUT_DIR}/ + cp ./scripts/aws/ec2.py ${ARTIFACTS_OUTPUT_DIR}/ cp ./scripts/aws/proxies.host.yaml ${ARTIFACTS_OUTPUT_DIR}/ cp ./scripts/aws/sockd.conf ${ARTIFACTS_OUTPUT_DIR}/ cp ./scripts/aws/uid2operator.service ${ARTIFACTS_OUTPUT_DIR}/ diff --git a/scripts/aws/EUID_CloudFormation.template.yml b/scripts/aws/EUID_CloudFormation.template.yml index 9c5982488..519665e26 100644 --- a/scripts/aws/EUID_CloudFormation.template.yml +++ b/scripts/aws/EUID_CloudFormation.template.yml @@ -155,7 +155,7 @@ Resources: KmsKeyId: !GetAtt KMSKey.Arn Name: !Sub 'euid-config-stack-${AWS::StackName}' SecretString: !Sub '{ - "api_token":"${APIToken}", + "operator_key":"${APIToken}", "service_instances":6, "enclave_cpu_count":6, "enclave_memory_mb":24576, diff --git a/scripts/aws/UID_CloudFormation.template.yml b/scripts/aws/UID_CloudFormation.template.yml index 711d1ab0e..468afa7a7 100644 --- a/scripts/aws/UID_CloudFormation.template.yml +++ b/scripts/aws/UID_CloudFormation.template.yml @@ -183,7 +183,7 @@ Resources: KmsKeyId: !GetAtt KMSKey.Arn Name: !Sub 'uid2-config-stack-${AWS::StackName}' SecretString: !Sub '{ - "api_token":"${APIToken}", + "operator_key":"${APIToken}", "service_instances":6, "enclave_cpu_count":6, "enclave_memory_mb":24576, diff --git a/scripts/aws/config-server/requirements.txt b/scripts/aws/config-server/requirements.txt index 57652a258..5355382c1 100644 --- a/scripts/aws/config-server/requirements.txt +++ b/scripts/aws/config-server/requirements.txt @@ -1,3 +1,6 @@ Flask==2.3.2 Werkzeug==3.0.3 setuptools==70.0.0 +requests==2.32.3 +boto3==1.35.59 +urllib3==2.2.3 \ No newline at end of file diff --git a/scripts/aws/ec2.py b/scripts/aws/ec2.py new file mode 100755 index 000000000..e6b29cb62 --- /dev/null +++ b/scripts/aws/ec2.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 + +import boto3 +import json +import os +import subprocess +import re +import multiprocessing +import requests #need requests[socks] +import signal +import argparse +from botocore.exceptions import ClientError +from typing import Dict +import sys +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from confidential_compute import ConfidentialCompute, ConfidentialComputeConfig + + +class EC2(ConfidentialCompute): + + def __init__(self): + super().__init__() + self.configs: ConfidentialComputeConfig = {} + + def __get_aws_token(self) -> str: + """Fetches a temporary AWS EC2 metadata token.""" + try: + token_url = "http://169.254.169.254/latest/api/token" + response = requests.put( + token_url, headers={"X-aws-ec2-metadata-token-ttl-seconds": "3600"}, timeout=2 + ) + return response.text + except requests.RequestException as e: + raise RuntimeError(f"Failed to fetch aws token: {e}") + + def __get_current_region(self) -> str: + """Fetches the current AWS region from EC2 instance metadata.""" + token = self.__get_aws_token() + metadata_url = "http://169.254.169.254/latest/dynamic/instance-identity/document" + headers = {"X-aws-ec2-metadata-token": token} + try: + response = requests.get(metadata_url, headers=headers, timeout=2) + response.raise_for_status() + return response.json()["region"] + except requests.RequestException as e: + raise RuntimeError(f"Failed to fetch region: {e}") + + def _get_secret(self, secret_identifier: str) -> ConfidentialComputeConfig: + secret_identifier = "uid2-config-stack-tjm-unvalidate-eif-test1" + """Fetches a secret value from AWS Secrets Manager.""" + region = self.__get_current_region() + client = boto3.client("secretsmanager", region_name=region) + try: + secret = client.get_secret_value(SecretId=secret_identifier) + return self.__add_defaults(json.loads(secret["SecretString"])) + except ClientError as e: + raise RuntimeError(f"Unable to access Secrets Manager {secret_identifier}: {e}") + + @staticmethod + def __add_defaults(configs: Dict[str, any]) -> ConfidentialComputeConfig: + """Adds default values to configuration if missing.""" + configs.setdefault("enclave_memory_mb", 24576) + configs.setdefault("enclave_cpu_count", 6) + configs.setdefault("debug_mode", False) + configs.setdefault("core_base_url", "https://core.uidapi.com" if configs["environment"] == "prod" else "https://core-integ.uidapi.com") + configs.setdefault("optout_base_url", "https://optout.uidapi.com" if configs["environment"] == "prod" else "https://optout-integ.uidapi.com") + return configs + + def __setup_vsockproxy(self, log_level: int) -> None: + """ + Sets up the vsock proxy service. + TODO: Evaluate adding vsock logging based on log_level here + """ + thread_count = (multiprocessing.cpu_count() + 1) // 2 + command = [ + "/usr/bin/vsockpx", "-c", "/etc/uid2operator/proxy.yaml", + "--workers", str(thread_count), "--log-level", str(log_level), "--daemon" + ] + subprocess.run(command) + + def __run_config_server(self,log_level) -> None: + """ + Starts the Flask configuration server. + TODO: Based on log level add logging to flask + """ + os.makedirs("/etc/secret/secret-value", exist_ok=True) + config_path = "/etc/secret/secret-value/config" + with open(config_path, 'w') as config_file: + json.dump(self.configs, config_file) + os.chdir("/opt/uid2operator/config-server") + command = ["./bin/flask", "run", "--host", "127.0.0.1", "--port", "27015"] + try: + subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception as e: + print(f"Failed to start the Flask config server.\n '{' '.join(command)}': {e}") + raise RuntimeError ("Failed to start required flask server") + + def __run_socks_proxy(self, log_level) -> None: + """ + Starts the SOCKS proxy service. + TODO: Based on log level add logging to sockd + """ + command = ["sockd", "-D"] + subprocess.run(command) + + def __get_secret_name_from_userdata(self) -> str: + """Extracts the secret name from EC2 user data.""" + token = self.__get_aws_token() + user_data_url = "http://169.254.169.254/latest/user-data" + response = requests.get(user_data_url, headers={"X-aws-ec2-metadata-token": token}) + user_data = response.text + + with open("/opt/uid2operator/identity_scope.txt") as file: + identity_scope = file.read().strip() + + default_name = f"{identity_scope.lower()}-operator-config-key" + hardcoded_value = f"{identity_scope.upper()}_CONFIG_SECRET_KEY" + match = re.search(rf'^export {hardcoded_value}="(.+?)"$', user_data, re.MULTILINE) + return match.group(1) if match else default_name + + def _setup_auxiliaries(self) -> None: + """Sets up the necessary auxiliary services and configurations.""" + hostname = os.getenv("HOSTNAME", default=os.uname()[1]) + try: + with open("HOSTNAME", "w") as file: + file.write(hostname) + print(f"Hostname '{hostname}' written to file.") + except Exception as e: + """ + Ignoring error here, as we are currently not using this information anywhere. + But can be added in future for tracibility on debug + """ + print(f"Error writing hostname: {e}") + self.configs = self._get_secret(self.__get_secret_name_from_userdata()) + log_level = 3 if self.configs["debug_mode"] else 1 + self.__setup_vsockproxy(log_level) + self.__run_config_server(log_level) + self.__run_socks_proxy(log_level) + + def _validate_auxiliaries(self) -> None: + """Validates auxiliary services.""" + proxy = "socks5://127.0.0.1:3306" + config_url = "http://127.0.0.1:27015/getConfig" + try: + response = requests.get(config_url) + response.raise_for_status() + except requests.RequestException as e: + raise RuntimeError(f"Config server unreachable: {e}") + proxies = {"http": proxy, "https": proxy} + try: + response = requests.get(config_url, proxies=proxies) + response.raise_for_status() + except requests.RequestException as e: + raise RuntimeError(f"Cannot connect to config server via SOCKS proxy: {e}") + + def run_compute(self) -> None: + """Main execution flow for confidential compute.""" + self._setup_auxiliaries() + self._validate_auxiliaries() + self.validate_connectivity(self.configs) + command = [ + "nitro-cli", "run-enclave", + "--eif-path", "/opt/uid2operator/uid2operator.eif", + "--memory", str(self.configs["enclave_memory_mb"]), + "--cpu-count", str(self.configs["enclave_cpu_count"]), + "--enclave-cid", "42", + "--enclave-name", "uid2operator" + ] + if self.configs["debug_mode"]: + command += ["--debug-mode", "--attach-console"] + subprocess.run(command, check=True) + + def cleanup(self) -> None: + """Terminates the Nitro Enclave and auxiliary processes.""" + try: + describe_output = subprocess.check_output(["nitro-cli", "describe-enclaves"], text=True) + enclaves = json.loads(describe_output) + enclave_id = enclaves[0].get("EnclaveID") if enclaves else None + if enclave_id: + subprocess.run(["nitro-cli", "terminate-enclave", "--enclave-id", enclave_id]) + print(f"Terminated enclave with ID: {enclave_id}") + else: + print("No active enclaves found.") + self.__kill_auxiliaries() + except subprocess.SubprocessError as e: + raise (f"Error during cleanup: {e}") + + def __kill_auxiliaries(self) -> None: + """Kills a process by its name.""" + try: + for process_name in ["vsockpx", "sockd"]: + result = subprocess.run(["pgrep", "-f", process_name], stdout=subprocess.PIPE, text=True, check=False) + if result.stdout.strip(): + for pid in result.stdout.strip().split("\n"): + os.kill(int(pid), signal.SIGKILL) + print(f"Killed process '{process_name}'.") + else: + print(f"No process named '{process_name}' found.") + except Exception as e: + print(f"Error killing process '{process_name}': {e}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Manage EC2-based confidential compute workflows.") + parser.add_argument("-o", "--operation", choices=["stop", "start"], default="start", help="Operation to perform.") + args = parser.parse_args() + ec2 = EC2() + if args.operation == "stop": + ec2.cleanup() + else: + ec2.run_compute() + \ No newline at end of file diff --git a/scripts/aws/entrypoint.sh b/scripts/aws/entrypoint.sh index 37214388b..26fdcf594 100755 --- a/scripts/aws/entrypoint.sh +++ b/scripts/aws/entrypoint.sh @@ -80,6 +80,7 @@ else exit 1 fi +# DO WE NEED THIS? do we expect customers to change URL? # -- replace base URLs if both CORE_BASE_URL and OPTOUT_BASE_URL are provided # -- using hardcoded domains is fine because they should not be changed frequently if [ -n "${CORE_BASE_URL}" ] && [ "${CORE_BASE_URL}" != "null" ] && [ -n "${OPTOUT_BASE_URL}" ] && [ "${OPTOUT_BASE_URL}" != "null" ] && [ "${DEPLOYMENT_ENVIRONMENT}" != "prod" ]; then diff --git a/scripts/aws/load_config.py b/scripts/aws/load_config.py deleted file mode 100644 index 9f0446a49..000000000 --- a/scripts/aws/load_config.py +++ /dev/null @@ -1,41 +0,0 @@ -import os -import boto3 -import base64 -import json -from botocore.exceptions import ClientError - -secret_name = os.environ['UID2_CONFIG_SECRET_KEY'] -region_name = os.environ['AWS_REGION_NAME'] -aws_access_key_id = os.environ['AWS_ACCESS_KEY_ID'] -secret_key = os.environ['AWS_SECRET_KEY'] -session_token = os.environ['AWS_SESSION_TOKEN'] - -def get_secret(): - session = boto3.session.Session() - client = session.client( - service_name='secretsmanager', - region_name=region_name, - aws_access_key_id = aws_access_key_id, - aws_secret_access_key = secret_key, - aws_session_token = session_token - ) - try: - get_secret_value_response = client.get_secret_value( - SecretId=secret_name - ) - except ClientError as e: - raise e - else: - if 'SecretString' in get_secret_value_response: - secret = get_secret_value_response['SecretString'] - else: - decoded_binary_secret = base64.b64decode(get_secret_value_response['SecretBinary']) - - return secret - -def get_config(): - result = get_secret() - conf = json.loads(result) - print(result) - -get_config() diff --git a/scripts/aws/start.sh b/scripts/aws/start.sh deleted file mode 100644 index 429826928..000000000 --- a/scripts/aws/start.sh +++ /dev/null @@ -1,124 +0,0 @@ -#!/bin/bash - -echo "$HOSTNAME" > /etc/uid2operator/HOSTNAME -EIF_PATH=${EIF_PATH:-/opt/uid2operator/uid2operator.eif} -IDENTITY_SCOPE=${IDENTITY_SCOPE:-$(cat /opt/uid2operator/identity_scope.txt)} -CID=${CID:-42} -TOKEN=$(curl --request PUT "http://169.254.169.254/latest/api/token" --header "X-aws-ec2-metadata-token-ttl-seconds: 3600") -USER_DATA=$(curl -s http://169.254.169.254/latest/user-data --header "X-aws-ec2-metadata-token: $TOKEN") -AWS_REGION_NAME=$(curl -s http://169.254.169.254/latest/dynamic/instance-identity/document/ --header "X-aws-ec2-metadata-token: $TOKEN" | jq -r '.region') -if [ "$IDENTITY_SCOPE" = 'UID2' ]; then - UID2_CONFIG_SECRET_KEY=$([[ "$(echo "${USER_DATA}" | grep UID2_CONFIG_SECRET_KEY=)" =~ ^export\ UID2_CONFIG_SECRET_KEY=\"(.*)\"$ ]] && echo "${BASH_REMATCH[1]}" || echo "uid2-operator-config-key") -elif [ "$IDENTITY_SCOPE" = 'EUID' ]; then - UID2_CONFIG_SECRET_KEY=$([[ "$(echo "${USER_DATA}" | grep EUID_CONFIG_SECRET_KEY=)" =~ ^export\ EUID_CONFIG_SECRET_KEY=\"(.*)\"$ ]] && echo "${BASH_REMATCH[1]}" || echo "euid-operator-config-key") -else - echo "Unrecognized IDENTITY_SCOPE $IDENTITY_SCOPE" - exit 1 -fi -CORE_BASE_URL=$([[ "$(echo "${USER_DATA}" | grep CORE_BASE_URL=)" =~ ^export\ CORE_BASE_URL=\"(.*)\"$ ]] && echo "${BASH_REMATCH[1]}" || echo "") -OPTOUT_BASE_URL=$([[ "$(echo "${USER_DATA}" | grep OPTOUT_BASE_URL=)" =~ ^export\ OPTOUT_BASE_URL=\"(.*)\"$ ]] && echo "${BASH_REMATCH[1]}" || echo "") - -echo "UID2_CONFIG_SECRET_KEY=${UID2_CONFIG_SECRET_KEY}" -echo "CORE_BASE_URL=${CORE_BASE_URL}" -echo "OPTOUT_BASE_URL=${OPTOUT_BASE_URL}" -echo "AWS_REGION_NAME=${AWS_REGION_NAME}" - -function terminate_old_enclave() { - ENCLAVE_ID=$(nitro-cli describe-enclaves | jq -r ".[0].EnclaveID") - [ "$ENCLAVE_ID" != "null" ] && nitro-cli terminate-enclave --enclave-id ${ENCLAVE_ID} -} - -function config_aws() { - aws configure set default.region $AWS_REGION_NAME -} - -function default_cpu() { - target=$(( $(nproc) * 3 / 4 )) - if [ $target -lt 2 ]; then - target="2" - fi - echo $target -} - -function default_mem() { - target=$(( $(grep MemTotal /proc/meminfo | awk '{print $2}') * 3 / 4000 )) - if [ $target -lt 24576 ]; then - target="24576" - fi - echo $target -} - -function read_allocation() { - USER_CUSTOMIZED=$(aws secretsmanager get-secret-value --secret-id "$UID2_CONFIG_SECRET_KEY" | jq -r '.SecretString' | jq -r '.customize_enclave') - shopt -s nocasematch - if [ "$USER_CUSTOMIZED" = "true" ]; then - echo "Applying user customized CPU/Mem allocation..." - CPU_COUNT=${CPU_COUNT:-$(aws secretsmanager get-secret-value --secret-id "$UID2_CONFIG_SECRET_KEY" | jq -r '.SecretString' | jq -r '.enclave_cpu_count')} - MEMORY_MB=${MEMORY_MB:-$(aws secretsmanager get-secret-value --secret-id "$UID2_CONFIG_SECRET_KEY" | jq -r '.SecretString' | jq -r '.enclave_memory_mb')} - else - echo "Applying default CPU/Mem allocation..." - CPU_COUNT=6 - MEMORY_MB=24576 - fi - shopt -u nocasematch -} - - -function update_allocation() { - ALLOCATOR_YAML=/etc/nitro_enclaves/allocator.yaml - if [ -z "$CPU_COUNT" ] || [ -z "$MEMORY_MB" ]; then - echo 'No CPU_COUNT or MEMORY_MB set, cannot start enclave' - exit 1 - fi - echo "updating allocator: CPU_COUNT=$CPU_COUNT, MEMORY_MB=$MEMORY_MB..." - systemctl stop nitro-enclaves-allocator.service - sed -r "s/^(\s*memory_mib\s*:\s*).*/\1$MEMORY_MB/" -i $ALLOCATOR_YAML - sed -r "s/^(\s*cpu_count\s*:\s*).*/\1$CPU_COUNT/" -i $ALLOCATOR_YAML - systemctl start nitro-enclaves-allocator.service && systemctl enable nitro-enclaves-allocator.service - echo "nitro-enclaves-allocator restarted" -} - -function setup_vsockproxy() { - VSOCK_PROXY=${VSOCK_PROXY:-/usr/bin/vsockpx} - VSOCK_CONFIG=${VSOCK_CONFIG:-/etc/uid2operator/proxy.yaml} - VSOCK_THREADS=${VSOCK_THREADS:-$(( ( $(nproc) + 1 ) / 2 )) } - VSOCK_LOG_LEVEL=${VSOCK_LOG_LEVEL:-3} - echo "starting vsock proxy at $VSOCK_PROXY with $VSOCK_THREADS worker threads..." - $VSOCK_PROXY -c $VSOCK_CONFIG --workers $VSOCK_THREADS --log-level $VSOCK_LOG_LEVEL --daemon - echo "vsock proxy now running in background." -} - -function setup_dante() { - sockd -D -} - -function run_config_server() { - mkdir -p /etc/secret/secret-value - { - set +x; # Disable tracing within this block - 2>/dev/null; - SECRET_JSON=$(aws secretsmanager get-secret-value --secret-id "$UID2_CONFIG_SECRET_KEY" | jq -r '.SecretString') - echo "${SECRET_JSON}" > /etc/secret/secret-value/config; - } - echo $(jq ".core_base_url = \"$CORE_BASE_URL\"" /etc/secret/secret-value/config) > /etc/secret/secret-value/config - echo $(jq ".optout_base_url = \"$OPTOUT_BASE_URL\"" /etc/secret/secret-value/config) > /etc/secret/secret-value/config - echo "run_config_server" - cd /opt/uid2operator/config-server - ./bin/flask run --host 127.0.0.1 --port 27015 & -} - -function run_enclave() { - echo "starting enclave..." - nitro-cli run-enclave --eif-path $EIF_PATH --memory $MEMORY_MB --cpu-count $CPU_COUNT --enclave-cid $CID --enclave-name uid2operator -} - -terminate_old_enclave -config_aws -read_allocation -# update_allocation -setup_vsockproxy -setup_dante -run_config_server -run_enclave - -echo "Done!" diff --git a/scripts/aws/stop.sh b/scripts/aws/stop.sh deleted file mode 100644 index c37bdc729..000000000 --- a/scripts/aws/stop.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash - -function terminate_old_enclave() { - echo "Terminating Enclave..." - ENCLAVE_ID=$(nitro-cli describe-enclaves | jq -r ".[0].EnclaveID") - if [ "$ENCLAVE_ID" != "null" ]; then - nitro-cli terminate-enclave --enclave-id $ENCLAVE_ID - else - echo "no running enclaves to terminate" - fi -} - -function kill_process() { - echo "Shutting down $1..." - pid=$(pidof $1) - if [ -z "$pid" ]; then - echo "process $1 not found" - else - kill -9 $pid - echo "$1 exited" - fi -} - -terminate_old_enclave -kill_process vsockpx -kill_process sockd -# we start aws vsock-proxy via nohup -kill_process vsock-proxy -kill_process nohup - -echo "Done!" diff --git a/scripts/aws/uid2-operator-ami/ansible/playbook.yml b/scripts/aws/uid2-operator-ami/ansible/playbook.yml index 84c6c6f14..4a8d302d3 100644 --- a/scripts/aws/uid2-operator-ami/ansible/playbook.yml +++ b/scripts/aws/uid2-operator-ami/ansible/playbook.yml @@ -70,26 +70,21 @@ requirements: /opt/uid2operator/config-server/requirements.txt virtualenv_command: 'python3 -m venv' - - name: Install starter script + - name: Install confidential_compute script ansible.builtin.copy: - src: /tmp/artifacts/start.sh - dest: /opt/uid2operator/start.sh + src: /tmp/artifacts/confidential_compute.py + dest: /opt/uid2operator/confidential_compute.py remote_src: yes - - name: Make starter script executable - ansible.builtin.file: - path: /opt/uid2operator/start.sh - mode: '0755' - - - name: Install stopper script + - name: Install starter script ansible.builtin.copy: - src: /tmp/artifacts/stop.sh - dest: /opt/uid2operator/stop.sh + src: /tmp/artifacts/ec2.py + dest: /opt/uid2operator/ec2.py remote_src: yes - name: Make starter script executable ansible.builtin.file: - path: /opt/uid2operator/stop.sh + path: /opt/uid2operator/ec2.py mode: '0755' - name: Install Operator EIF diff --git a/scripts/aws/uid2operator.service b/scripts/aws/uid2operator.service index 1d36b7a91..e92f5d401 100644 --- a/scripts/aws/uid2operator.service +++ b/scripts/aws/uid2operator.service @@ -8,8 +8,8 @@ RemainAfterExit=true StandardOutput=journal StandardError=journal SyslogIdentifier=uid2operator -ExecStart=/opt/uid2operator/start.sh -ExecStop=/opt/uid2operator/stop.sh +ExecStart=/opt/uid2operator/ec2.py +ExecStop=/opt/uid2operator/ec2.py -o stop [Install] WantedBy=multi-user.target \ No newline at end of file diff --git a/scripts/azure-cc/Dockerfile b/scripts/azure-cc/Dockerfile index bb0c96b70..5704f15fc 100644 --- a/scripts/azure-cc/Dockerfile +++ b/scripts/azure-cc/Dockerfile @@ -17,6 +17,17 @@ ENV IMAGE_VERSION=${IMAGE_VERSION} ENV REGION=default ENV LOKI_HOSTNAME=loki +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + python3-pip \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +RUN python3 -m pip install --upgrade pip + +RUN pip install --no-cache-dir \ + azure-identity \ + azure-keyvault-secrets + COPY ./target/${JAR_NAME}-${JAR_VERSION}-jar-with-dependencies.jar /app/${JAR_NAME}-${JAR_VERSION}.jar COPY ./target/${JAR_NAME}-${JAR_VERSION}-sources.jar /app COPY ./target/${JAR_NAME}-${JAR_VERSION}-static.tar.gz /app/static.tar.gz @@ -25,10 +36,10 @@ COPY ./conf/*.xml /app/conf/ RUN tar xzvf /app/static.tar.gz --no-same-owner --no-same-permissions && rm -f /app/static.tar.gz -COPY ./entrypoint.sh /app/ -RUN chmod a+x /app/entrypoint.sh +COPY ./azure.py /app/ +RUN chmod a+x /app/azure.py RUN adduser -D uid2-operator && mkdir -p /opt/uid2 && chmod 777 -R /opt/uid2 && mkdir -p /app && chmod 705 -R /app && mkdir -p /app/file-uploads && chmod 777 -R /app/file-uploads USER uid2-operator -CMD ["/app/entrypoint.sh"] +CMD ["/app/azure.py"] diff --git a/scripts/azure-cc/conf/azure.py b/scripts/azure-cc/conf/azure.py new file mode 100644 index 000000000..57b1cabed --- /dev/null +++ b/scripts/azure-cc/conf/azure.py @@ -0,0 +1,135 @@ +import os +import subprocess +import time +import json +import sys +import requests +import re +from typing import Dict +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from confidential_compute import ConfidentialCompute, ConfidentialComputeConfig, OperatorConfig +from azure.identity import DefaultAzureCredential +from azure.keyvault.secrets import SecretClient + +class AzureCC(ConfidentialCompute): + + def __init__(self): + super().__init__() + self.configs: ConfidentialComputeConfig = {} + + def _get_secret(self, secret_identifier) -> ConfidentialComputeConfig: + """Fetches a secret value from Azure Key Value, reads environment variables and returns config""" + key_vault_url = "https://{}.vault.azure.net/".format(secret_identifier["key_vault"]) + credential = DefaultAzureCredential() + secret_client = SecretClient(vault_url=key_vault_url, credential=credential) + try: + config = { + "api_key" : secret_client.get_secret(secret_identifier["secret_name"]), + "environment": os.getenv("DEPLOYMENT_ENVIRONMENT"), + "core_base_url": os.getenv("CORE_BASE_URL"), + "optout_base_url": os.getenv("OPTOUT_BASE_URL") + } + return self.__add_defaults({key: value for key, value in config.items() if value is not None}) + except Exception as e: + raise RuntimeError(f"Unable to access Secrets Manager: {e}") + + def _setup_auxiliaries(self, secrets): + """Sets up auxiliary configurations (placeholder for extension).""" + pass + + def __validate_sidecar(self): + """Validates the required sidecar is running""" + url = "http://169.254.169.254/ping" + delay = 1 + max_retries = 15 + while True: + try: + response = requests.get(url, timeout=5) + if response.status_code == 200: + print("Sidecar started") + break + except requests.RequestException: + print(f"Sidecar not started. Retrying in {delay} seconds...") + time.sleep(delay) + if delay > max_retries: + raise RuntimeError("Unable to start operator as sidecar failed to start") + delay += 1 + + + def _validate_auxiliaries(self, secrets): + """Validates the presence of required environment variables, and sidecar is up""" + self.__validate_sidecar() + config_env_vars = [ + "VAULT_NAME", + "OPERATOR_KEY_SECRET_NAME", + "DEPLOYMENT_ENVIRONMENT" + ] + pre_set_env_vars = [ + "JAR_NAME", + "JAR_VERSION" + ] + for variable in (config_env_vars + pre_set_env_vars): + value = os.getenv(variable) + if not value: + raise ValueError("{} is not set. Please update it".format(variable)) + if os.getenv("DEPLOYMENT_ENVIRONMENT") not in ["prod","integ"]: + raise ValueError("DEPLOYMENT_ENVIRONMENT should be prod/integ. It is currently set to {}".format(os.getenv("DEPLOYMENT_ENVIRONMENT"))) + + @staticmethod + def __add_defaults(configs: Dict[str, any]) -> ConfidentialComputeConfig: + """Adds default values to configuration if missing.""" + configs.setdefault("enclave_memory_mb", -1) + configs.setdefault("enclave_cpu_count", -1) + configs.setdefault("debug_mode", False) + configs.setdefault("core_base_url", "https://core.uidapi.com" if configs["environment"] == "prod" else "https://core-integ.uidapi.com") + configs.setdefault("optout_base_url", "https://optout.uidapi.com" if configs["environment"] == "prod" else "https://optout-integ.uidapi.com") + return configs + + #TODO: This is repeated in GCP, EC2 + def __get_overriden_configs(self, config_path) -> OperatorConfig: + """Returns the required configurations for operator. Only overrides if environment is integ""" + if not os.path.exists(config_path): + raise FileNotFoundError(f"Configuration file not found: {config_path}") + with open(config_path) as f: + config_data = json.load(f) + if all([os.getenv("CORE_BASE_URL"), os.getenv("OPTOUT_BASE_URL")]) and self.configs["environment"] != "prod": + config_data = re.sub(r"https://core-integ\.uidapi\.com", os.getenv("CORE_BASE_URL"), config_data) + config_data = re.sub(r"https://optout-integ\.uidapi\.com", os.getenv("OPTOUT_BASE_URL"), config_data) + return config_data + + def run_compute(self): + """Main execution flow for confidential compute.""" + self._setup_auxiliaries(None) + self._validate_auxiliaries(None) + secret_identifier = { + "key_vault": os.getenv("OPERATOR_KEY_SECRET_NAME"), + "secret_name": os.getenv("VAULT_NAME") + } + self.configs = self._get_secret(secret_identifier) + self.validate_operator_key(self.configs) + self.validate_connectivity(self.configs) + os.environ["azure_vault_name"] = os.getenv("VAULT_NAME") + os.environ["azure_secret_name"] = os.getenv("OPERATOR_KEY_SECRET_NAME") + config_path="/app/conf/${}-uid2-config.json".format(os.getenv("DEPLOYMENT_ENVIRONMENT")) + with open(config_path, "w") as file: + file.write(self.__get_overriden_configs(config_path=config_path)) + java_command = [ + "java", + "-XX:MaxRAMPercentage=95", + "-XX:-UseCompressedOops", + "-XX:+PrintFlagsFinal", + "-Djava.security.egd=file:/dev/./urandom", + "-Dvertx.logger-delegate-factory-class-name=io.vertx.core.logging.SLF4JLogDelegateFactory", + "-Dlogback.configurationFile=/app/conf/logback.xml", + "-Dvertx-config-path={}".format(config_path), + "-jar", + "{}-{}.jar".format(os.getenv("JAR_NAME"), os.getenv("JAR_VERSION")) + ] + try: + subprocess.run(java_command, check=True) + except subprocess.CalledProcessError as e: + print(f"Error starting the Java application: {e}") + + +if __name__ == "__main__": + AzureCC().run_compute() \ No newline at end of file diff --git a/scripts/confidential_compute.py b/scripts/confidential_compute.py new file mode 100644 index 000000000..c2fa44055 --- /dev/null +++ b/scripts/confidential_compute.py @@ -0,0 +1,95 @@ +import requests +import re +import socket +from urllib.parse import urlparse +from abc import ABC, abstractmethod +from typing import TypedDict, NotRequired + + +class ConfidentialComputeConfig(TypedDict): + enclave_memory_mb: int + enclave_cpu_count: int + debug_mode: bool + operator_key: str + core_base_url: str + optout_base_url: str + environment: str + +class OperatorConfig(TypedDict): + sites_metadata_path: str + clients_metadata_path: str + keysets_metadata_path: str + keyset_keys_metadata_path: str + salts_metadata_path: str + services_metadata_path: str + service_links_metadata_path: str + optout_metadata_path: str + core_attest_url: str + optout_api_uri: str + optout_s3_folder: str + identity_token_expires_after_seconds: str + client_side_keypairs_metadata_path: NotRequired[str] + +class ConfidentialCompute(ABC): + @abstractmethod + def _get_secret(self, secret_identifier: str) -> ConfidentialComputeConfig: + """ + Fetches the secret from a secret store. + + Raises: + SecretNotFoundException: If the secret is not found. + """ + pass + + def validate_operator_key(self, secrets: ConfidentialComputeConfig) -> bool: + """ Validates the operator key format and its environment alignment.""" + operator_key = secrets.get("operator_key") + if not operator_key: + raise ValueError("API token is missing from the configuration.") + pattern = r"^(UID2|EUID)-.\-(I|P)-\d+-\*$" + if re.match(pattern, operator_key): + env = secrets.get("environment", "").lower() + debug_mode = secrets.get("debug_mode", False) + expected_env = "I" if debug_mode or env == "integ" else "P" + if operator_key.split("-")[2] != expected_env: + raise ValueError( + f"Operator key does not match the expected environment ({expected_env})." + ) + return True + + @staticmethod + def __resolve_hostname(url: str) -> str: + """ Resolves the hostname of a URL to an IP address.""" + hostname = urlparse(url).netloc + return socket.gethostbyname(hostname) + + def validate_connectivity(self, config: ConfidentialComputeConfig) -> None: + """ Validates that the core and opt-out URLs are accessible.""" + try: + core_url = config["core_base_url"] + optout_url = config["optout_base_url"] + core_ip = self.__resolve_hostname(core_url) + requests.get(core_url, timeout=5) + optout_ip = self.__resolve_hostname(optout_url) + requests.get(optout_url, timeout=5) + except (requests.ConnectionError, requests.Timeout) as e: + raise Exception( + f"Failed to reach required URLs. Consider enabling {core_ip}, {optout_ip} in the egress firewall." + ) + except Exception as e: + raise Exception("Failed to reach the URLs.") from e + + @abstractmethod + def _setup_auxiliaries(self) -> None: + """ Sets up auxiliary processes required for confidential computing. """ + pass + + @abstractmethod + def _validate_auxiliaries(self) -> None: + """ Validates auxiliary services are running.""" + pass + + @abstractmethod + def run_compute(self) -> None: + """ Runs confidential computing.""" + pass