Skip to content

Commit 3c6c971

Browse files
author
Kevin Wang
committed
feat(dstack-ingress): make the ACME contact address optional
1 parent 70c248f commit 3c6c971

3 files changed

Lines changed: 46 additions & 34 deletions

File tree

custom-domain/dstack-ingress/README.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ environment:
159159
| `DOMAIN` | Your domain (single-domain mode). Supports wildcards (`*.example.com`) |
160160
| `TARGET_ENDPOINT` | Backend address, e.g. `app:80` or `http://app:80` |
161161
| `GATEWAY_DOMAIN` | dstack gateway domain (e.g. `_.dstack-prod5.phala.network`) |
162-
| `CERTBOT_EMAIL` | Email for Let's Encrypt registration. In tls-alpn-01 mode the preferred name is `ACME_EMAIL` |
162+
| `CERTBOT_EMAIL` | Email for Let's Encrypt registration. Not required in tls-alpn-01 mode, where the preferred name is `ACME_EMAIL` |
163163
| `DNS_PROVIDER` | DNS provider (`cloudflare`, `linode`, `namecheap`) |
164164

165165
### Optional
@@ -172,7 +172,7 @@ environment:
172172
| `SET_CAA` | `false` | Enable CAA DNS record (dns-01 only; tls-alpn-01 cannot write DNS) |
173173
| `TXT_PREFIX` | `_dstack-app-address` | DNS TXT record prefix |
174174
| `CERTBOT_STAGING` | `false` | Use Let's Encrypt staging server. `ACME_STAGING` is the preferred name in tls-alpn-01 mode |
175-
| `ACME_EMAIL` | | ACME account email in tls-alpn-01 mode. Falls back to `CERTBOT_EMAIL`; there is no certbot on that path |
175+
| `ACME_EMAIL` | | Optional ACME contact address in tls-alpn-01 mode. Falls back to `CERTBOT_EMAIL`; there is no certbot on that path |
176176
| `CHALLENGE_TYPE` | `dns-01` | `dns-01` (certbot + DNS credentials) or `tls-alpn-01` (lego, no DNS credentials) |
177177
| `DNS_SETUP_MODE` | `wait` | tls-alpn-01 only: `wait`, `print` or `webhook` — see below |
178178
| `DNS_SETUP_TIMEOUT` | `1800` | tls-alpn-01 only: seconds to wait for the records to appear |
@@ -257,7 +257,7 @@ services:
257257
# Printed as the CNAME target, and used to verify that the hostname
258258
# really resolves to the gateway before issuance starts.
259259
- GATEWAY_DOMAIN=_.dstack-prod5.phala.network
260-
- ACME_EMAIL=you@example.com
260+
# - ACME_EMAIL=you@example.com # optional, and published (see below)
261261
# - DNS_SETUP_MODE=wait # default; blocks until the records exist
262262
ports:
263263
- "443:443"
@@ -307,6 +307,17 @@ Two consequences:
307307
means updating DNS. `DNS_SETUP_MODE=webhook` exists so this can be automated;
308308
doing it by hand means downtime on every redeploy.
309309

310+
### The ACME contact address is optional, and public
311+
312+
`ACME_EMAIL` may be left unset. RFC 8555 makes the ACME `contact` field
313+
optional, and Let's Encrypt stopped sending expiry notification mail in 2025, so
314+
setting one buys little.
315+
316+
It also does not stay private. The ACME account document is published as
317+
attestation evidence at `/evidences/acme-account.json`, so an address set here
318+
is readable by anyone who fetches the evidence endpoint. Leave it unset unless
319+
you specifically want a contact on the account.
320+
310321
### Limitations
311322

312323
- **No wildcards.** RFC 8737 forbids tls-alpn-01 for wildcard identifiers, and

custom-domain/dstack-ingress/scripts/legoman.py

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"""
2626

2727
import argparse
28+
import glob
2829
import json
2930
import os
3031
import subprocess
@@ -60,13 +61,20 @@ def server_dir() -> str:
6061
return f"{host}_{parsed.port}" if parsed.port else host
6162

6263

63-
def account_file(email: str) -> Optional[str]:
64-
path = os.path.join(LEGO_PATH, "accounts", server_dir(), email, "account.json")
65-
return path if os.path.isfile(path) else None
64+
def account_file() -> Optional[str]:
65+
"""Find the account document by globbing rather than by building the path.
6666
67+
lego names the directory after the account email, and after a placeholder of
68+
its own choosing when there is no email. Globbing means we neither have to
69+
know that placeholder nor track it across lego versions.
70+
"""
71+
pattern = os.path.join(LEGO_PATH, "accounts", server_dir(), "*", "account.json")
72+
matches = sorted(glob.glob(pattern))
73+
return matches[0] if matches else None
6774

68-
def account_uri(email: str) -> Optional[str]:
69-
path = account_file(email)
75+
76+
def account_uri() -> Optional[str]:
77+
path = account_file()
7078
if not path:
7179
return None
7280
try:
@@ -96,15 +104,14 @@ def _renewed_marker(domain: str) -> str:
96104

97105

98106
def _common_flags(email: str) -> List[str]:
99-
return [
100-
"--path",
101-
LEGO_PATH,
102-
"--server",
103-
acme_server(),
104-
"--email",
105-
email,
106-
"--accept-tos",
107-
]
107+
flags = ["--path", LEGO_PATH, "--server", acme_server(), "--accept-tos"]
108+
# The ACME contact address is optional (RFC 8555 §7.3), and Let's Encrypt
109+
# stopped sending expiry notifications in 2025, so it buys little. It is
110+
# also published: the account document is served as evidence, so an address
111+
# set here becomes public. Omit it unless the operator wants it.
112+
if email:
113+
flags += ["--email", email]
114+
return flags
108115

109116

110117
def _run(cmd: List[str], timeout: int = RUN_TIMEOUT) -> Tuple[int, str]:
@@ -126,7 +133,7 @@ def _run(cmd: List[str], timeout: int = RUN_TIMEOUT) -> Tuple[int, str]:
126133

127134
def register(email: str) -> int:
128135
"""Create the ACME account without issuing anything."""
129-
if account_file(email):
136+
if account_file():
130137
print("ACME account already exists")
131138
return EXIT_UNCHANGED
132139

@@ -135,7 +142,7 @@ def register(email: str) -> int:
135142
[LEGO_BIN, "accounts", "register"] + _common_flags(email),
136143
timeout=REGISTER_TIMEOUT,
137144
)
138-
if code == 0 and account_file(email):
145+
if code == 0 and account_file():
139146
print("✓ ACME account registered")
140147
return EXIT_CHANGED
141148
print(f"✗ ACME account registration failed (exit code {code})", file=sys.stderr)
@@ -214,10 +221,7 @@ def main() -> int:
214221
email = args.email or os.environ.get("ACME_EMAIL") or os.environ.get("CERTBOT_EMAIL", "")
215222

216223
if args.action == "account-uri":
217-
if not email:
218-
print("Error: --email or ACME_EMAIL is required", file=sys.stderr)
219-
return EXIT_ERROR
220-
uri = account_uri(email)
224+
uri = account_uri()
221225
if not uri:
222226
return EXIT_ERROR
223227
print(uri)
@@ -231,9 +235,6 @@ def main() -> int:
231235
print(f"{crt} {key}")
232236
return EXIT_CHANGED
233237

234-
if not email:
235-
print("Error: --email or ACME_EMAIL is required", file=sys.stderr)
236-
return EXIT_ERROR
237238
if not os.path.isfile(LEGO_BIN):
238239
print(f"Error: lego binary not found at {LEGO_BIN}", file=sys.stderr)
239240
return EXIT_ERROR

custom-domain/dstack-ingress/scripts/tlsalpn.sh

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@ source /scripts/evidence-lib.sh
1515
# ACME account settings. CERTBOT_EMAIL / CERTBOT_STAGING are the historical
1616
# names, kept working for anyone migrating a compose file from dns-01, but there
1717
# is no certbot on this path -- the ACME client here is lego.
18+
#
19+
# The contact address is optional. RFC 8555 makes it optional, Let's Encrypt
20+
# stopped sending expiry notifications in 2025, and it does not stay private:
21+
# the account document is published at /evidences/acme-account.json, so an
22+
# address set here is readable by anyone who fetches the evidence.
1823
ACME_EMAIL=${ACME_EMAIL:-${CERTBOT_EMAIL:-}}
1924
ACME_STAGING=${ACME_STAGING:-${CERTBOT_STAGING:-false}}
2025
export ACME_EMAIL ACME_STAGING
2126

22-
if [ -z "$ACME_EMAIL" ]; then
23-
echo "Error: ACME_EMAIL must be set (CERTBOT_EMAIL is accepted as an alias)" >&2
24-
exit 1
25-
fi
26-
2727
LEGO_BIN=${LEGO_BIN:-/usr/local/bin/lego}
2828
LEGO_PATH=${LEGO_PATH:-/etc/letsencrypt/lego}
2929
TLSALPN_ADDRESS=${TLSALPN_ADDRESS:-127.0.0.1}
@@ -194,13 +194,13 @@ process_domain() {
194194
# Register the ACME account first so the CAA record we print can already
195195
# pin accounturi. Without this the operator would have to add CAA in a
196196
# second pass, after the account exists.
197-
if ! legoman.py register --email "$ACME_EMAIL"; then
197+
if ! legoman.py register ${ACME_EMAIL:+--email "$ACME_EMAIL"}; then
198198
echo "Error: failed to register the ACME account" >&2
199199
exit 1
200200
fi
201201

202202
local account_uri caa_value
203-
account_uri=$(legoman.py account-uri --email "$ACME_EMAIL" 2>/dev/null || true)
203+
account_uri=$(legoman.py account-uri 2>/dev/null || true)
204204
if [ -n "$account_uri" ]; then
205205
caa_value="letsencrypt.org;validationmethods=tls-alpn-01;accounturi=$account_uri"
206206
else
@@ -244,7 +244,7 @@ process_domain() {
244244
sleep "${DNS_SETTLE_SECONDS}"
245245
fi
246246

247-
legoman.py auto --domain "$domain" --email "$ACME_EMAIL"
247+
legoman.py auto --domain "$domain" ${ACME_EMAIL:+--email "$ACME_EMAIL"}
248248
}
249249

250250
# One pass over every domain: make sure the records exist, then issue or renew.

0 commit comments

Comments
 (0)