-
Notifications
You must be signed in to change notification settings - Fork 25
Expand file tree
/
Copy pathdns01.sh
More file actions
345 lines (296 loc) · 11.5 KB
/
Copy pathdns01.sh
File metadata and controls
345 lines (296 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
#!/bin/bash
# dns01.sh - certificates via certbot and a DNS provider API.
#
# entrypoint.sh validates the shared settings and then execs this file. There is
# no shared orchestration above it and no interface it has to implement -- this
# is simply the program that runs for dns-01, top to bottom.
set -e
source /scripts/functions.sh
source /scripts/haproxy-lib.sh
source /scripts/evidence-lib.sh
# ACME_EMAIL / ACME_STAGING are normalised by entrypoint.sh and exported. The
# contact address is optional; certman.py asks for a contactless account when
# none is given.
# certbot stores certificates under /etc/letsencrypt/live/<domain>/.
cert_fullchain_path() { echo "/etc/letsencrypt/live/$(cert_dir_name "$1")/fullchain.pem"; }
cert_privkey_path() { echo "/etc/letsencrypt/live/$(cert_dir_name "$1")/privkey.pem"; }
setup_py_env() {
if [ ! -d /opt/app-venv ]; then
echo "Creating application virtual environment"
python3 -m venv --system-site-packages /opt/app-venv
fi
# Activate venv for subsequent steps
# shellcheck disable=SC1091
source /opt/app-venv/bin/activate
if [ ! -f /.venv_bootstrapped ]; then
echo "Bootstrapping certbot dependencies"
pip install --upgrade pip
pip install certbot requests boto3 botocore
touch /.venv_bootstrapped
fi
ln -sf /opt/app-venv/bin/certbot /usr/local/bin/certbot
echo 'source /opt/app-venv/bin/activate' > /etc/profile.d/app-venv.sh
}
setup_certbot_env() {
# Ensure the virtual environment is active for certbot configuration
# shellcheck disable=SC1091
source /opt/app-venv/bin/activate
if [ "${DNS_PROVIDER}" = "route53" ]; then
mkdir -p /root/.aws
cat <<EOF >/root/.aws/config
[profile certbot]
role_arn=${AWS_ROLE_ARN}
source_profile=certbot-source
region=${AWS_REGION:-us-east-1}
EOF
cat <<EOF >/root/.aws/credentials
[certbot-source]
aws_access_key_id=${AWS_ACCESS_KEY_ID}
aws_secret_access_key=${AWS_SECRET_ACCESS_KEY}
EOF
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
export AWS_PROFILE=certbot
fi
# Use the unified certbot manager to install plugins and setup credentials
echo "Installing DNS plugins and setting up credentials"
certman.py setup
if [ $? -ne 0 ]; then
echo "Error: Failed to setup certbot environment"
exit 1
fi
}
set_alias_record() {
local domain="$1"
echo "Setting alias record for $domain"
dnsman.py set_alias \
--domain "$domain" \
--content "$GATEWAY_DOMAIN"
if [ $? -ne 0 ]; then
echo "Error: Failed to set alias record for $domain"
exit 1
fi
echo "Alias record set for $domain"
}
txt_record_value() { echo "${APP_ID:-$DSTACK_APP_ID}:${PORT}"; }
set_txt_record() {
local domain="$1"
local txt_domain
txt_domain=$(txt_record_name "$domain")
dnsman.py set_txt \
--domain "$txt_domain" \
--content "$(txt_record_value)"
if [ $? -ne 0 ]; then
echo "Error: Failed to set TXT record for $domain"
exit 1
fi
}
# Challenge delegation: this container holds no token for the served domain's
# zone, so the operator creates those records. dnsguide.py is what prints and
# verifies operator-managed records everywhere else in this image, so use it
# here too rather than a second implementation -- it queries two resolvers
# instead of one, parses both CAA wire formats, and understands DNS_SETUP_MODE.
#
# $1 domain, $2 comma-separated record subset, $3.. extra dnsguide flags.
delegation_guide() {
local domain="$1" include="$2"
shift 2
local caa_domain caa_tag
caa_domain="${domain#\*.}"
caa_tag=$(caa_tag_for "$domain")
dnsguide.py \
--domain "$domain" \
--alias-target "$GATEWAY_DOMAIN" \
--txt-name "$(txt_record_name "$domain")" \
--txt-value "$(txt_record_value)" \
--caa-name "$caa_domain" \
--caa-tag "$caa_tag" \
--challenge dns-01 \
--challenge-alias "$ACME_CHALLENGE_ALIAS" \
--mode "${DNS_SETUP_MODE:-wait}" \
--include "$include" \
"$@"
}
# The accounturi CAA is what stops anyone else who can satisfy the delegated
# challenge from getting a certificate for this name, and we cannot write it, so
# a confirmed-absent record is fatal. ALLOW_MISSING_CAA downgrades it to a
# warning for operators who accept that risk.
delegation_verify_caa() {
local domain="$1" account_file account_uri
if ! account_file=$(get_letsencrypt_account_file); then
echo "ERROR: cannot read the ACME account file to determine the required CAA" >&2
return 1
fi
account_uri=$(jq -j '.uri' "$account_file")
# Absent is the state to block on here, not "absent means unrestricted":
# we cannot create this record, so nothing else protects the name.
local gate=(--caa-required)
if [ "${ALLOW_MISSING_CAA:-false}" = "true" ]; then
gate=(--caa-required --caa-advisory)
fi
delegation_guide "$domain" caa \
--caa-value "letsencrypt.org;validationmethods=dns-01;accounturi=$account_uri" \
--account-uri "$account_uri" \
"${gate[@]}"
}
set_caa_record() {
local domain="$1"
# Delegation is handled separately and is deliberately NOT gated by
# SET_CAA: there we cannot write the record, so verifying it is the only
# protection left.
if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then
delegation_verify_caa "$domain" || exit 1
return
fi
if [ "$SET_CAA" != "true" ]; then
echo "Skipping CAA record setup"
return
fi
local ACCOUNT_URI
local account_file
if ! account_file=$(get_letsencrypt_account_file); then
echo "Warning: Cannot set CAA record - account file not found"
echo "This is not critical - certificates can still be issued without CAA records"
return
fi
local caa_domain caa_tag
caa_domain="${domain#\*.}"
caa_tag=$(caa_tag_for "$domain")
ACCOUNT_URI=$(jq -j '.uri' "$account_file")
echo "Adding CAA record ($caa_tag) for $caa_domain, accounturi=$ACCOUNT_URI"
dnsman.py set_caa \
--domain "$caa_domain" \
--caa-tag "$caa_tag" \
--caa-value "letsencrypt.org;validationmethods=dns-01;accounturi=$ACCOUNT_URI"
if [ $? -ne 0 ]; then
echo "Warning: Failed to set CAA record for $domain"
echo "This is not critical - certificates can still be issued without CAA records"
echo "Consider disabling CAA records by setting SET_CAA=false if this continues to fail"
fi
}
process_domain() {
local domain="$1" first status
if [ -n "${ACME_CHALLENGE_ALIAS:-}" ]; then
# Delegation: the operator owns these. Verify them before spending an
# ACME attempt that would fail on records nobody has created yet. CAA
# is checked after the first issuance, once the account URI exists.
if ! delegation_guide "$domain" cname,txt,challenge-cname; then
echo "Error: required DNS records for $domain are not in place" >&2
return 1
fi
else
set_alias_record "$domain"
set_txt_record "$domain"
fi
# The CAA record names our ACME account, so the account has to exist first:
# try once, write CAA, try again. The first attempt is expected to fail when
# a CAA record from an earlier account is still in place.
if issue_certificate "$domain"; then first=0; else first=$?; fi
if [ "$first" -eq 1 ]; then
echo "First certificate attempt failed for $domain, retrying after the CAA record is set"
fi
set_caa_record "$domain"
if issue_certificate "$domain"; then status=0; else status=$?; fi
# Either attempt producing a certificate counts as a change. Reporting only
# the second one hid the common case: on a clean first deployment attempt
# one obtains the certificate and attempt two reports "nothing to renew",
# so the pass looked quiet and skipped evidence generation entirely.
if [ "$first" -eq 0 ] || [ "$status" -eq 0 ]; then
return 0
fi
return "$status"
}
issue_certificate() {
source /opt/app-venv/bin/activate
# The contact address is optional; certman.py asks for a contactless
# account when none is given.
certman.py auto --domain "$1" ${ACME_EMAIL:+--email "$ACME_EMAIL"}
}
build_combined_pems() {
local domain fullchain privkey combined
mkdir -p /etc/haproxy/certs
while IFS= read -r domain; do
[[ -n "$domain" ]] || continue
fullchain=$(cert_fullchain_path "$domain")
privkey=$(cert_privkey_path "$domain")
combined="/etc/haproxy/certs/${domain}.pem"
if [ -f "$fullchain" ] && [ -f "$privkey" ]; then
cat "$fullchain" "$privkey" > "$combined"
chmod 600 "$combined"
echo "Combined PEM created: ${combined}"
else
echo "Warning: Cert files missing for ${domain}, skipping"
fi
done <<<"$(get-all-domains.sh)"
}
collect_evidence() {
local account domain
if ! account=$(get_letsencrypt_account_file); then
echo "Error: cannot generate evidence without the ACME account file" >&2
return 1
fi
evidence_reset
evidence_collect_account "$account"
while IFS= read -r domain; do
[[ -n "$domain" ]] || continue
evidence_collect_cert "$domain" "$(cert_fullchain_path "$domain")"
done <<<"$(get-all-domains.sh)"
evidence_finalize
}
# One pass over every domain: make the DNS records right, then issue or renew.
# Idempotent, so the first pass and every later one are the same code.
run_pass() {
local domain status failed=0 changed=0
while IFS= read -r domain; do
[[ -n "$domain" ]] || continue
# `if` context, not a bare command: process_domain returns 2 for
# "nothing to do", and under `set -e` a bare non-zero would kill the
# whole script on every quiet renewal pass.
if ( process_domain "$domain" ); then status=0; else status=$?; fi
case $status in
0) changed=1 ;;
2) ;;
*) echo "Certificate management failed for $domain" >&2; failed=1 ;;
esac
done <<<"$(get-all-domains.sh)"
if [ "$changed" -eq 1 ]; then
collect_evidence || echo "Evidence generation failed" >&2
build_combined_pems || echo "Combined PEM build failed" >&2
haproxy_reload || true
fi
[ "$failed" -eq 0 ]
}
# $1: seconds to wait before the first pass. dns-01 issues before it serves, so
# the caller has already run a pass by the time we get here; without this the
# loop would immediately repeat it -- two full passes, and for a single domain
# four certbot invocations plus a duplicate round of DNS writes, on every start.
cert_loop() {
local attempt=0 delay="${1:-0}"
while true; do
if [ "$delay" -gt 0 ]; then
echo "[$(date)] Next certificate check in ${delay}s"
sleep "$delay"
fi
if run_pass; then
attempt=0
delay=${RENEW_INTERVAL:-43200}
else
attempt=$((attempt + 1))
delay=$((60 * attempt))
[ "$delay" -gt 1800 ] && delay=1800
echo "[$(date)] Pass failed (attempt ${attempt}); retrying" >&2
fi
done
}
setup_py_env
setup_certbot_env
load_dstack_identity
# dns-01 needs no inbound traffic, so keep the original order: certificate
# first, serving second. A failure here is fatal, as it always was.
run_pass
build_combined_pems
haproxy_emit_global
haproxy_emit_tls_frontend ":${PORT}"
haproxy_emit_backends
evidence_start_server
cert_loop "${RENEW_INTERVAL:-43200}" &
exec "$@"