diff --git a/custom-domain/dstack-ingress/DNS_PROVIDERS.md b/custom-domain/dstack-ingress/DNS_PROVIDERS.md index 845d288..8cb0c9f 100644 --- a/custom-domain/dstack-ingress/DNS_PROVIDERS.md +++ b/custom-domain/dstack-ingress/DNS_PROVIDERS.md @@ -17,13 +17,14 @@ This guide explains how to configure dstack-ingress to work with different DNS p - `GATEWAY_DOMAIN` - dstack gateway domain (e.g., `_.dstack-prod5.phala.network`) - `CERTBOT_EMAIL` - Email for Let's Encrypt registration - `TARGET_ENDPOINT` - Backend application endpoint to proxy to -- `DNS_PROVIDER` - DNS provider to use (`cloudflare`, `linode`, `namecheap`) +- `DNS_PROVIDER` - DNS provider to use (`cloudflare`, `linode`, `namecheap`, `route53`) ### Optional Variables - `SET_CAA` - Enable CAA record setup (default: false) - `PORT` - HTTPS port (default: 443) - `TXT_PREFIX` - Prefix for TXT records (default: "_tapp-address") +- `ALIAS_DOMAIN` - An additional domain to include as a Subject Alternative Name (SAN) on the TLS certificate and in nginx `server_name`. When set, the node's certificate covers both `DOMAIN` and `ALIAS_DOMAIN`, and nginx will accept requests for either hostname. ## Provider-Specific Configuration @@ -104,8 +105,8 @@ PolicyDocument: ``` **Important Notes for Route53:** -- The certbot plugin uses the format `certbot-dns-route53` package -- CAA will merge AWS & Let's Encrypt CA domains to existing records if they exist +- The certbot plugin uses the `certbot-dns-route53` package +- CAA will merge AWS & Let's Encrypt CA domains into existing records if they exist - It is essential that the AWS service account used can only assume the limited role. See cloudformation example. ## Docker Compose Examples @@ -187,7 +188,8 @@ services: CERTBOT_EMAIL: ${CERTBOT_EMAIL} TARGET_ENDPOINT: http://backend:8080 SET_CAA: 'true' - +volumes: + cert-data: ``` ## Migration from Cloudflare-only Setup diff --git a/custom-domain/dstack-ingress/README.md b/custom-domain/dstack-ingress/README.md index e9eae37..dab1ada 100644 --- a/custom-domain/dstack-ingress/README.md +++ b/custom-domain/dstack-ingress/README.md @@ -184,6 +184,7 @@ configs: - `PROXY_BUFFERS`: Optional value for nginx `proxy_buffers` (format: `number size`, e.g. `4 256k`) in single-domain mode - `PROXY_BUSY_BUFFERS_SIZE`: Optional value for nginx `proxy_busy_buffers_size` (numeric with optional `k|m` suffix, e.g. `256k`) in single-domain mode - `CERTBOT_STAGING`: Optional; set this value to the string `true` to set the `--staging` server option on the [`certbot` cli](https://eff-certbot.readthedocs.io/en/stable/using.html#certbot-command-line-options) +- `ALIAS_DOMAIN`: Optional; an additional domain to include as a Subject Alternative Name (SAN) on the TLS certificate and in nginx `server_name`. When set, the node's certificate covers both `DOMAIN` and `ALIAS_DOMAIN`, and nginx will accept requests for either hostname. **Backward Compatibility:** diff --git a/custom-domain/dstack-ingress/scripts/certman.py b/custom-domain/dstack-ingress/scripts/certman.py index 52126d2..b767254 100644 --- a/custom-domain/dstack-ingress/scripts/certman.py +++ b/custom-domain/dstack-ingress/scripts/certman.py @@ -287,7 +287,10 @@ def _build_certbot_command(self, action: str, domain: str, email: str) -> List[s if action == "certonly": base_cmd.extend(["--agree-tos", "--no-eff-email", - "--email", email, "-d", domain]) + "--email", email, "--cert-name", domain, "-d", domain]) + alias_domain = os.environ.get("ALIAS_DOMAIN", "").strip() + if alias_domain: + base_cmd.extend(["--expand", "-d", alias_domain]) if os.environ.get("CERTBOT_STAGING", "false") == "true": base_cmd.extend(["--staging"]) diff --git a/custom-domain/dstack-ingress/scripts/dns_providers/route53.py b/custom-domain/dstack-ingress/scripts/dns_providers/route53.py index 1c22a0f..65367e5 100644 --- a/custom-domain/dstack-ingress/scripts/dns_providers/route53.py +++ b/custom-domain/dstack-ingress/scripts/dns_providers/route53.py @@ -6,7 +6,6 @@ from .base import DNSProvider, DNSRecord, CAARecord, RecordType - class Route53DNSProvider(DNSProvider): """DNS provider implementation for AWS Route53.""" @@ -40,19 +39,8 @@ def __init__(self): self.hosted_zone_id: Optional[str] = None self.hosted_zone_name: Optional[str] = None - def setup_certbot_credentials(self) -> bool: - """Setup AWS credentials file for certbot. - - This container will be provided with aws credentials purely for the purpose - of assuming a role. Doing so will enable the boto platform to provision - temporary access key and secret keys on demand! - - Using this strategy we can impose least permissive and fast expiring access - to our domain. - - """ - + """Setup AWS credentials file for certbot.""" try: # Pre-fetch hosted zone ID if we have a domain domain = os.getenv("DOMAIN") @@ -77,11 +65,7 @@ def validate_credentials(self) -> bool: return False def _get_hosted_zone_info(self, domain: str) -> Optional[tuple[str, str]]: - """Get the hosted zone ID and name for a domain. - - Returns: - Tuple of (hosted_zone_id, hosted_zone_name) or None - """ + """Get the hosted zone ID and name for a domain.""" try: # List all hosted zones paginator = self.client.get_paginator("list_hosted_zones") @@ -174,7 +158,7 @@ def get_dns_records( # Parse record content content = "" - data = None + data = {} if record_type_str == "CAA": # CAA records have special format @@ -187,11 +171,12 @@ def get_dns_records( tag = parts[1] value = parts[2].strip('"') content = caa_value - data = {"flags": flags, "tag": tag, "value": value} + data.update( + {"flags": flags, "tag": tag, "value": value} + ) else: # Standard records if "ResourceRecords" in record_set: - # Get first record value (multiple values would need separate DNSRecord objects) content = record_set["ResourceRecords"][0]["Value"] # Remove quotes from TXT records if record_type_str == "TXT": @@ -200,7 +185,7 @@ def get_dns_records( # Alias record (Route53 specific) content = record_set["AliasTarget"]["DNSName"].rstrip(".") - # Route53 doesn't have persistent record IDs, use name+type as identifier + # Route53 doesn't have persistent record IDs record_id = f"{record_name}:{record_type_str}" records.append( @@ -211,8 +196,8 @@ def get_dns_records( content=content, ttl=record_set.get("TTL", 60), proxied=False, # Route53 doesn't have proxy feature - priority=None, # Would be in record value for MX/SRV - data=data, + priority=None, + data=data if data else None, ) ) @@ -241,17 +226,20 @@ def create_dns_record(self, record: DNSRecord) -> bool: else: record_value = record.content + # Build Resource Record Set + resource_record_set = { + "Name": normalized_name, + "Type": record.type.value, + "TTL": record.ttl, + "ResourceRecords": [{"Value": record_value}], + } + # Prepare change batch change_batch = { "Changes": [ { "Action": "UPSERT", # UPSERT creates or updates - "ResourceRecordSet": { - "Name": normalized_name, - "Type": record.type.value, - "TTL": record.ttl, - "ResourceRecords": [{"Value": record_value}], - }, + "ResourceRecordSet": resource_record_set, } ] } @@ -281,7 +269,7 @@ def delete_dns_record(self, record_id: str, domain: str) -> bool: """Delete a DNS record. Args: - record_id: Format is "name:type" since Route53 doesn't have persistent IDs + record_id: Format "name:type" domain: The domain name (for zone lookup) """ hosted_zone_id = self._ensure_hosted_zone_id(domain) @@ -292,13 +280,14 @@ def delete_dns_record(self, record_id: str, domain: str) -> bool: ) return False - # Parse record_id to get name and type - try: - record_name, record_type = record_id.split(":", 1) - except ValueError: + # Parse record_id (format: "name:type") + parts = record_id.split(":") + if len(parts) != 2: print(f"Invalid record_id format: {record_id}", file=sys.stderr) return False + record_name, record_type = parts + try: # First, get the current record to know its full details paginator = self.client.get_paginator("list_resource_record_sets") @@ -306,12 +295,13 @@ def delete_dns_record(self, record_id: str, domain: str) -> bool: for page in paginator.paginate(HostedZoneId=hosted_zone_id): for record_set in page["ResourceRecordSets"]: - if ( - record_set["Name"] == record_name - and record_set["Type"] == record_type - ): + name_match = record_set["Name"] == record_name + type_match = record_set["Type"] == record_type + + if name_match and type_match: record_set_to_delete = record_set break + if record_set_to_delete: break @@ -348,10 +338,6 @@ def delete_dns_record(self, record_id: str, domain: str) -> bool: def create_caa_record(self, caa_record: CAARecord) -> bool: """ Create or merge a CAA record set on the apex of the Route53 hosted zone. - - - Ignores the specific subdomain in caa_record.name for placement - - Uses it only to locate the correct hosted zone - - Merges hard-coded issuers with any existing CAA values on the apex """ # Ensure we know which hosted zone this belongs to hosted_zone_id = self._ensure_hosted_zone_id(caa_record.name) diff --git a/custom-domain/dstack-ingress/scripts/entrypoint.sh b/custom-domain/dstack-ingress/scripts/entrypoint.sh index 25eb559..c507d66 100644 --- a/custom-domain/dstack-ingress/scripts/entrypoint.sh +++ b/custom-domain/dstack-ingress/scripts/entrypoint.sh @@ -40,6 +40,9 @@ fi if ! TXT_PREFIX=$(sanitize_dns_label "$TXT_PREFIX"); then exit 1 fi +if ! ALIAS_DOMAIN=$(sanitize_domain "$ALIAS_DOMAIN"); then + exit 1 +fi PROXY_CMD="proxy" if [[ "${TARGET_ENDPOINT}" == grpc://* ]]; then @@ -141,11 +144,16 @@ setup_nginx_conf() { proxy_busy_buffers_size_conf=" proxy_busy_buffers_size ${PROXY_BUSY_BUFFERS_SIZE};" fi + local server_name_value="${DOMAIN}" + if [ -n "$ALIAS_DOMAIN" ]; then + server_name_value="${DOMAIN} ${ALIAS_DOMAIN}" + fi + cat </etc/nginx/conf.d/default.conf server { listen ${PORT} ssl; http2 on; - server_name ${DOMAIN}; + server_name ${server_name_value}; # SSL certificate configuration ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem; @@ -239,6 +247,7 @@ set_txt_record() { echo "Error: Failed to set TXT record for $domain" exit 1 fi + } set_caa_record() {