Skip to content

Commit 4942bc9

Browse files
committed
feat(dstack-ingress): add tls-alpn-01 mode that needs no DNS credentials
1 parent a61cb54 commit 4942bc9

11 files changed

Lines changed: 1511 additions & 45 deletions

File tree

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: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,19 @@ 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 |
174174
| `CERTBOT_STAGING` | `false` | Use Let's Encrypt staging server |
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` | lego default | Days of remaining lifetime that trigger renewal |
175185
| `MAXCONN` | `4096` | HAProxy max connections |
176186
| `TIMEOUT_CONNECT` | `10s` | Backend connect timeout |
177187
| `TIMEOUT_CLIENT` | `86400s` | Client-side timeout (24h for long-lived connections) |
@@ -225,3 +235,120 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of
225235
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
226236

227237
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.
238+
239+
## Certificates without DNS credentials (tls-alpn-01)
240+
241+
The default `dns-01` flow needs a DNS API token inside the CVM: the container
242+
creates the CNAME, TXT and CAA records itself. `CHALLENGE_TYPE=tls-alpn-01`
243+
removes that requirement — nothing in the container can touch your DNS zone —
244+
at the cost of you creating three records by hand (or via a webhook).
245+
246+
```yaml
247+
services:
248+
dstack-ingress:
249+
image: dstacktee/dstack-ingress:<tag>
250+
environment:
251+
- CHALLENGE_TYPE=tls-alpn-01
252+
- DOMAIN=app.example.com
253+
- TARGET_ENDPOINT=http://app:80
254+
- GATEWAY_DOMAIN=_.dstack-prod5.phala.network
255+
- CERTBOT_EMAIL=you@example.com
256+
# - DNS_SETUP_MODE=wait # default; blocks until the records exist
257+
ports:
258+
- "443:443"
259+
volumes:
260+
- /var/run/dstack.sock:/var/run/dstack.sock
261+
- cert-data:/etc/letsencrypt
262+
- evidences:/evidences
263+
```
264+
265+
On first start the container prints the exact records to create, then polls
266+
public DNS until they are visible:
267+
268+
```
269+
==========================================================================
270+
DNS records required for app.example.com
271+
==========================================================================
272+
CNAME app.example.com
273+
-> _.dstack-prod5.phala.network
274+
TXT _dstack-app-address.app.example.com
275+
-> b1ea785543bbbb19ce9de33744321360992bf63b:443
276+
CAA app.example.com
277+
-> 0 issue "letsencrypt.org;validationmethods=tls-alpn-01;accounturi=https://..."
278+
==========================================================================
279+
```
280+
281+
`DNS_SETUP_MODE` picks what happens after printing: `wait` (default) blocks
282+
until the records resolve or `DNS_SETUP_TIMEOUT` elapses; `print` continues
283+
immediately; `webhook` POSTs the records to `DNS_WEBHOOK_URL` first and then
284+
waits, so a service of yours can create them automatically.
285+
286+
### The TXT record names an *instance*, not an app
287+
288+
Under `dns-01` the TXT record carries the **app ID** and the gateway
289+
load-balances across every instance of the app. tls-alpn-01 cannot work that
290+
way. The challenge is answered by whichever instance holds the ACME order, and
291+
Let's Encrypt validates from several vantage points at once (5 distinct source
292+
IPs within ~2 seconds, measured), while the gateway races connections across the
293+
app's instances. The challenge would land on the wrong replica almost every
294+
time. So this mode publishes the **instance ID** instead, pinning the hostname
295+
to one instance.
296+
297+
Two consequences:
298+
299+
- **tls-alpn-01 mode is effectively single-instance.** All traffic for the
300+
hostname goes to the pinned instance; you lose the gateway's failover.
301+
- **The TXT record changes when the CVM instance is replaced.** Redeploying
302+
means updating DNS. `DNS_SETUP_MODE=webhook` exists so this can be automated;
303+
doing it by hand means downtime on every redeploy.
304+
305+
### Limitations
306+
307+
- **No wildcards.** RFC 8737 forbids tls-alpn-01 for wildcard identifiers, and
308+
the CA will not offer the challenge. Use `dns-01` for `*.example.com`.
309+
- **The gateway must be reachable on port 443.** The CA connects to port 443 of
310+
whatever the CNAME resolves to; the port is fixed by the protocol.
311+
- **CAA must permit `tls-alpn-01`.** A record left over from a `dns-01`
312+
deployment says `validationmethods=dns-01` and will make the CA refuse. The
313+
container checks this before asking for a certificate, so you get a clear
314+
message instead of a failed validation.
315+
- **Losing the `cert-data` volume changes the account URI.** With `dns-01` the
316+
container just rewrites the CAA record; here it cannot, so a pinned
317+
`accounturi` would start rejecting issuance until you update it by hand.
318+
319+
### Webhook payload
320+
321+
`DNS_SETUP_MODE=webhook` POSTs this envelope to `DNS_WEBHOOK_URL`:
322+
323+
```json
324+
{
325+
"payload": "{\"version\":1,\"domain\":\"app.example.com\",\"records\":[...]}",
326+
"hmac_sha256": "…",
327+
"attestation": { "quote": "…", "report_data": "…" }
328+
}
329+
```
330+
331+
`payload` is a *string* so you sign and hash exactly the bytes you received.
332+
Verify `hmac_sha256` with `DNS_WEBHOOK_TOKEN`, and — since this request asks you
333+
to point a hostname at the instance it names — verify `attestation` too: the
334+
quote's `report_data` is `sha256(payload)`, so it proves which enclave produced
335+
those records. Check the app ID and measurements in it before changing DNS.
336+
337+
### Why lego and not certbot
338+
339+
certbot cannot do tls-alpn-01. Its standalone plugin is HTTP-01 only, the
340+
maintainers declined to implement the challenge
341+
([certbot#6724](https://github.com/certbot/certbot/issues/6724)), and the `acme`
342+
library removed it outright in 4.2.0 — the last release carrying
343+
`acme.standalone.TLSALPN01Server` is 4.1.1, where it is already marked
344+
deprecated. This path therefore runs [lego](https://github.com/go-acme/lego),
345+
pinned by checksum in the Dockerfile. The `dns-01` path still uses certbot and
346+
is untouched.
347+
348+
Because haproxy owns the public port, the proxy peeks at each ClientHello and
349+
forwards only connections advertising the `acme-tls/1` ALPN protocol to lego's
350+
responder on loopback; everything else goes to the normal TLS frontend. Issuance
351+
and renewal therefore never interrupt serving traffic. In this mode haproxy
352+
starts on a self-signed placeholder certificate — it has to be listening before
353+
the first certificate can be issued — and reloads onto the real one as soon as
354+
it arrives.

custom-domain/dstack-ingress/scripts/build-combined-pems.sh

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ all_domains=$(get-all-domains.sh)
1313

1414
while IFS= read -r domain; do
1515
[[ -n "$domain" ]] || continue
16-
le_dir="/etc/letsencrypt/live/$(cert_dir_name "$domain")"
16+
fullchain=$(cert_fullchain_path "$domain")
17+
privkey=$(cert_privkey_path "$domain")
1718
combined="${CERT_DIR}/${domain}.pem"
18-
if [ -f "${le_dir}/fullchain.pem" ] && [ -f "${le_dir}/privkey.pem" ]; then
19-
cat "${le_dir}/fullchain.pem" "${le_dir}/privkey.pem" > "$combined"
19+
if [ -f "$fullchain" ] && [ -f "$privkey" ]; then
20+
cat "$fullchain" "$privkey" > "$combined"
2021
chmod 600 "$combined"
2122
echo "Combined PEM created: ${combined}"
2223
else

0 commit comments

Comments
 (0)