Skip to content
Closed
10 changes: 6 additions & 4 deletions custom-domain/dstack-ingress/DNS_PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions custom-domain/dstack-ingress/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down
5 changes: 4 additions & 1 deletion custom-domain/dstack-ingress/scripts/certman.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down
72 changes: 29 additions & 43 deletions custom-domain/dstack-ingress/scripts/dns_providers/route53.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from .base import DNSProvider, DNSRecord, CAARecord, RecordType



class Route53DNSProvider(DNSProvider):
"""DNS provider implementation for AWS Route53."""

Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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":
Expand All @@ -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(
Expand All @@ -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,
)
)

Expand Down Expand Up @@ -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,
}
]
}
Expand Down Expand Up @@ -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)
Expand All @@ -292,26 +280,28 @@ 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")
record_set_to_delete = None

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

Expand Down Expand Up @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion custom-domain/dstack-ingress/scripts/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <<EOF >/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;
Expand Down Expand Up @@ -239,6 +247,7 @@ set_txt_record() {
echo "Error: Failed to set TXT record for $domain"
exit 1
fi

}

set_caa_record() {
Expand Down