Skip to content

Commit ab6d7e1

Browse files
authored
Merge pull request #105 from Dstack-TEE/feat/tls-alpn-01
feat(dstack-ingress): TLS-ALPN-01 mode that needs no DNS credentials
2 parents e574366 + 42a7bbd commit ab6d7e1

18 files changed

Lines changed: 2661 additions & 608 deletions

custom-domain/dstack-ingress/Dockerfile

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,22 @@ RUN --mount=type=bind,source=pinned-packages.txt,target=/tmp/pinned-packages.txt
3434
mini-httpd && \
3535
rm -rf /var/lib/apt/lists/* /var/log/* /var/cache/ldconfig/aux-cache
3636

37+
# lego, for the tls-alpn-01 challenge. certbot cannot do tls-alpn-01: its
38+
# standalone plugin is HTTP-01 only and the acme library removed the challenge
39+
# in 4.2.0. Pinned by checksum so the build stays reproducible; bump both the
40+
# version and the digest together.
41+
ARG LEGO_VERSION=5.3.1
42+
ARG LEGO_SHA256=b3c71b122ee1947eacfe0b809b955647f6377239fe4bfc49f73b1a091ae1252a
43+
RUN set -eu; \
44+
url="https://github.com/go-acme/lego/releases/download/v${LEGO_VERSION}/lego_v${LEGO_VERSION}_linux_amd64.tar.gz"; \
45+
curl -fsSL -o /tmp/lego.tar.gz "$url"; \
46+
echo "${LEGO_SHA256} /tmp/lego.tar.gz" | sha256sum -c -; \
47+
tar -xzf /tmp/lego.tar.gz -C /usr/local/bin lego; \
48+
rm -f /tmp/lego.tar.gz; \
49+
chmod 755 /usr/local/bin/lego; \
50+
touch -d @0 /usr/local/bin/lego; \
51+
lego --version
52+
3753
RUN mkdir -p \
3854
/etc/letsencrypt \
3955
/var/www/certbot \

custom-domain/dstack-ingress/README.md

Lines changed: 152 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 |
162+
| `ACME_EMAIL` | *(optional)* ACME contact address, in either mode. `CERTBOT_EMAIL` is the historical name and still works. See below — it is optional, and published |
163163
| `DNS_PROVIDER` | DNS provider (`cloudflare`, `linode`, `namecheap`) |
164164

165165
### Optional
@@ -169,9 +169,21 @@ environment:
169169
| `PORT` | `443` | HAProxy listen port |
170170
| `DOMAINS` | | Multiple domains, one per line |
171171
| `ROUTING_MAP` | | Multi-domain routing: `domain=host:port` per line |
172-
| `SET_CAA` | `false` | Enable CAA DNS record |
172+
| `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 |
174-
| `CERTBOT_STAGING` | `false` | Use Let's Encrypt staging server |
174+
| `ACME_STAGING` | `false` | Use Let's Encrypt staging, in either mode. `CERTBOT_STAGING` is the historical name and still works |
175+
| `CHALLENGE_TYPE` | `dns-01` | `dns-01` (certbot + DNS credentials) or `tls-alpn-01` (lego, no DNS credentials) |
176+
| `DNS_SETUP_MODE` | `wait` | tls-alpn-01 only: `wait`, `print` or `webhook` — see below |
177+
| `DNS_SETUP_TIMEOUT` | `1800` | tls-alpn-01 only: seconds to wait for the records to appear |
178+
| `DNS_SETUP_INTERVAL` | `15` | tls-alpn-01 only: seconds between DNS checks |
179+
| `DNS_WEBHOOK_URL` | | tls-alpn-01 + `DNS_SETUP_MODE=webhook`: endpoint to notify |
180+
| `DNS_WEBHOOK_TOKEN` | | Shared secret; the payload is HMAC-SHA256 signed with it |
181+
| `DOH_RESOLVERS` | Google + Cloudflare | Comma-separated DoH endpoints used to verify records |
182+
| `TLSALPN_PORT` | `9443` | Loopback port lego's ACME responder binds to |
183+
| `TLS_TERMINATE_PORT` | `9444` | Loopback port the TLS frontend moves to in tls-alpn-01 mode |
184+
| `RENEW_DAYS_BEFORE` | client default | Days of remaining lifetime that trigger renewal. Applies to both modes: passed to lego as `--renew-days`, and written to certbot's `renew_before_expiry` |
185+
| `RENEW_INTERVAL` | `43200` | Seconds between successful certificate passes |
186+
| `DNS_SETTLE_SECONDS` | `30` | Wait after DNS verifies, so the gateway's own TXT cache expires |
175187
| `MAXCONN` | `4096` | HAProxy max connections |
176188
| `TIMEOUT_CONNECT` | `10s` | Backend connect timeout |
177189
| `TIMEOUT_CLIENT` | `86400s` | Client-side timeout (24h for long-lived connections) |
@@ -266,3 +278,140 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of
266278
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
267279

268280
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
281+
282+
## Certificates without DNS credentials (tls-alpn-01)
283+
284+
The default `dns-01` flow needs a DNS API token inside the CVM: the container
285+
creates the CNAME, TXT and CAA records itself. `CHALLENGE_TYPE=tls-alpn-01`
286+
removes that requirement — nothing in the container can touch your DNS zone —
287+
at the cost of you creating three records by hand (or via a webhook).
288+
289+
```yaml
290+
services:
291+
dstack-ingress:
292+
image: dstacktee/dstack-ingress:<tag>
293+
environment:
294+
- CHALLENGE_TYPE=tls-alpn-01
295+
- DOMAIN=app.example.com
296+
- TARGET_ENDPOINT=http://app:80
297+
# Printed as the CNAME target, and used to verify that the hostname
298+
# really resolves to the gateway before issuance starts.
299+
- GATEWAY_DOMAIN=_.dstack-prod5.phala.network
300+
# - ACME_EMAIL=you@example.com # optional, and published (see below)
301+
# - DNS_SETUP_MODE=wait # default; blocks until the records exist
302+
ports:
303+
- "443:443"
304+
volumes:
305+
- /var/run/dstack.sock:/var/run/dstack.sock
306+
- cert-data:/etc/letsencrypt
307+
- evidences:/evidences
308+
```
309+
310+
On first start the container prints the exact records to create, then polls
311+
public DNS until they are visible:
312+
313+
```
314+
==========================================================================
315+
DNS records required for app.example.com
316+
==========================================================================
317+
CNAME app.example.com
318+
-> _.dstack-prod5.phala.network
319+
TXT _dstack-app-address.app.example.com
320+
-> b1ea785543bbbb19ce9de33744321360992bf63b:443
321+
CAA app.example.com
322+
-> 0 issue "letsencrypt.org;validationmethods=tls-alpn-01;accounturi=https://..."
323+
==========================================================================
324+
```
325+
326+
`DNS_SETUP_MODE` picks what happens after printing: `wait` (default) blocks
327+
until the records resolve or `DNS_SETUP_TIMEOUT` elapses; `print` continues
328+
immediately; `webhook` POSTs the records to `DNS_WEBHOOK_URL` first and then
329+
waits, so a service of yours can create them automatically.
330+
331+
### The TXT record names an *instance*, not an app
332+
333+
Under `dns-01` the TXT record carries the **app ID** and the gateway
334+
load-balances across every instance of the app. tls-alpn-01 cannot work that
335+
way. The challenge is answered by whichever instance holds the ACME order, and
336+
Let's Encrypt validates from several vantage points at once (5 distinct source
337+
IPs within ~2 seconds, measured), while the gateway races connections across the
338+
app's instances. The challenge would land on the wrong replica almost every
339+
time. So this mode publishes the **instance ID** instead, pinning the hostname
340+
to one instance.
341+
342+
Two consequences:
343+
344+
- **tls-alpn-01 mode is effectively single-instance.** All traffic for the
345+
hostname goes to the pinned instance; you lose the gateway's failover.
346+
- **The TXT record changes when the CVM instance is replaced.** Redeploying
347+
means updating DNS. `DNS_SETUP_MODE=webhook` exists so this can be automated;
348+
doing it by hand means downtime on every redeploy.
349+
350+
## The ACME contact address is optional, and public
351+
352+
`ACME_EMAIL` (or `CERTBOT_EMAIL`) may be left unset in **either** mode. RFC 8555
353+
makes the ACME `contact` field optional, and Let's Encrypt stopped sending expiry
354+
notification mail in 2025, so setting one buys little.
355+
356+
It also does not stay private. The ACME account document is published as
357+
attestation evidence at `/evidences/acme-account.json`, so an address set here is
358+
readable by anyone who fetches the evidence endpoint. Leave it unset unless you
359+
specifically want a contact on the account.
360+
361+
Under the hood the two clients differ: lego simply omits the flag, while certbot
362+
needs `--register-unsafely-without-email` — its "unsafely" naming predates Let's
363+
Encrypt dropping expiry mail, and the container passes it for you.
364+
365+
### Limitations
366+
367+
- **No wildcards.** RFC 8737 forbids tls-alpn-01 for wildcard identifiers, and
368+
the CA will not offer the challenge. The container refuses to start rather
369+
than serve a name it can never obtain a certificate for, so a wildcard
370+
anywhere in `DOMAIN`/`DOMAINS` is a startup error even if the other names are
371+
fine. Use `dns-01` for `*.example.com`.
372+
- **The gateway must be reachable on port 443.** The CA connects to port 443 of
373+
whatever the CNAME resolves to; the port is fixed by the protocol.
374+
- **CAA must permit `tls-alpn-01`.** A record left over from a `dns-01`
375+
deployment says `validationmethods=dns-01` and will make the CA refuse. The
376+
container checks this before asking for a certificate, so you get a clear
377+
message instead of a failed validation.
378+
- **Losing the `cert-data` volume changes the account URI.** With `dns-01` the
379+
container just rewrites the CAA record; here it cannot, so a pinned
380+
`accounturi` would start rejecting issuance until you update it by hand.
381+
382+
### Webhook payload
383+
384+
`DNS_SETUP_MODE=webhook` POSTs this envelope to `DNS_WEBHOOK_URL`:
385+
386+
```json
387+
{
388+
"payload": "{\"app_id\":\"\",\"challenge\":\"tls-alpn-01\",\"domain\":\"app.example.com\",\"instance_id\":\"\",\"records\":[…],\"timestamp\":1234567890,\"version\":1}",
389+
"hmac_sha256": "…",
390+
"attestation": { "quote": "…", "report_data": "…" }
391+
}
392+
```
393+
394+
`payload` is a *string* so you sign and hash exactly the bytes you received.
395+
Verify `hmac_sha256` with `DNS_WEBHOOK_TOKEN`, and — since this request asks you
396+
to point a hostname at the instance it names — verify `attestation` too: the
397+
quote's `report_data` is `sha256(payload)`, so it proves which enclave produced
398+
those records. Check the app ID and measurements in it before changing DNS.
399+
400+
### Why lego and not certbot
401+
402+
certbot cannot do tls-alpn-01. Its standalone plugin is HTTP-01 only, the
403+
maintainers declined to implement the challenge
404+
([certbot#6724](https://github.com/certbot/certbot/issues/6724)), and the `acme`
405+
library removed it outright in 4.2.0 — the last release carrying
406+
`acme.standalone.TLSALPN01Server` is 4.1.1, where it is already marked
407+
deprecated. This path therefore runs [lego](https://github.com/go-acme/lego),
408+
pinned by checksum in the Dockerfile. The `dns-01` path still uses certbot and
409+
is untouched.
410+
411+
Because haproxy owns the public port, the proxy peeks at each ClientHello and
412+
forwards only connections advertising the `acme-tls/1` ALPN protocol to lego's
413+
responder on loopback; everything else goes to the normal TLS frontend. Issuance
414+
and renewal therefore never interrupt serving traffic. In this mode haproxy
415+
starts on a self-signed placeholder certificate — it has to be listening before
416+
the first certificate can be issued — and reloads onto the real one as soon as
417+
it arrives.

0 commit comments

Comments
 (0)