Repository navigation
Expand file tree
/
Copy pathcert_monitor.sh
More file actions
executable file
·368 lines (322 loc) · 10.8 KB
/
Copy pathcert_monitor.sh
File metadata and controls
executable file
·368 lines (322 loc) · 10.8 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
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
#!/usr/bin/env bash
# NPM/NPMplus Certificate Monitor
# Version: 2.0.0
# Updated: 2026-07-29
#
# Monitors a certificate managed by Nginx Proxy Manager (NPM) or NPMplus and
# publishes it as a PKCS#12/PFX file. Both products expose a certificate
# directory containing fullchain.pem and privkey.pem; the script intentionally
# depends on that stable file contract instead of installation-specific paths.
#
# Configuration precedence is:
# built-in operational defaults < environment variables < CLI arguments
#
# The companion systemd unit loads cert_monitor.env beside the script.
# Deployment-specific paths have no built-in defaults.
set -Eeuo pipefail
IFS=$'\n\t'
umask 077
source_dir=${CERT_MONITOR_SOURCE_DIR:-}
output_file=${CERT_MONITOR_OUTPUT:-}
settle_seconds=${CERT_MONITOR_SETTLE_SECONDS:-10}
lock_file=${CERT_MONITOR_LOCK_FILE:-"${XDG_RUNTIME_DIR:-/tmp}/cert_monitor_${UID}.lock"}
output_mode=${CERT_MONITOR_OUTPUT_MODE:-0600}
password_file=${CERT_MONITOR_PASSWORD_FILE:-}
run_once=false
# Derived paths are populated only after configuration has been validated.
fullchain_file=
private_key_file=
output_dir=
output_name=
# The currently unpublished temporary PFX is tracked globally so signal and
# error exits can remove it without touching the last known-good output.
active_temp=
log() {
local level=${1}
shift
printf '[%(%Y-%m-%d %H:%M:%S)T] [%s] %s\n' -1 "${level}" "$*" >&2
}
die() {
log "ERROR" "$*"
exit 1
}
usage() {
cat <<'EOF'
Usage:
cert_monitor.sh --source-dir DIR --output FILE [OPTIONS]
Required configuration:
--source-dir DIR Directory containing fullchain.pem and privkey.pem
--output FILE Absolute path of the PFX file to publish
Optional configuration:
--settle-seconds N Delay after a filesystem event (default: 10)
--lock-file FILE Lock file used to prevent concurrent instances
--output-mode MODE Published PFX permissions (default: 0600)
--password-file FILE File containing the PFX password; empty by default
--once Convert once and exit without monitoring
-h, --help Show this help
Equivalent environment variables:
Required:
CERT_MONITOR_SOURCE_DIR
CERT_MONITOR_OUTPUT
Optional:
CERT_MONITOR_SETTLE_SECONDS
CERT_MONITOR_LOCK_FILE
CERT_MONITOR_OUTPUT_MODE
CERT_MONITOR_PASSWORD_FILE
CLI arguments override environment variables. All configured paths must be
absolute. Password contents are never accepted through CLI arguments or
environment variables; only a password file path may be provided.
EOF
}
require_option_value() {
local option=${1}
local value=${2-}
[[ -n ${value} ]] || die "${option} requires a non-empty value"
}
parse_args() {
while (($# > 0)); do
case ${1} in
--source-dir)
require_option_value "${1}" "${2-}"
source_dir=${2}
shift 2
;;
--source-dir=*)
source_dir=${1#*=}
shift
;;
--output)
require_option_value "${1}" "${2-}"
output_file=${2}
shift 2
;;
--output=*)
output_file=${1#*=}
shift
;;
--settle-seconds)
require_option_value "${1}" "${2-}"
settle_seconds=${2}
shift 2
;;
--settle-seconds=*)
settle_seconds=${1#*=}
shift
;;
--lock-file)
require_option_value "${1}" "${2-}"
lock_file=${2}
shift 2
;;
--lock-file=*)
lock_file=${1#*=}
shift
;;
--output-mode)
require_option_value "${1}" "${2-}"
output_mode=${2}
shift 2
;;
--output-mode=*)
output_mode=${1#*=}
shift
;;
--password-file)
require_option_value "${1}" "${2-}"
password_file=${2}
shift 2
;;
--password-file=*)
password_file=${1#*=}
shift
;;
--once)
run_once=true
shift
;;
-h | --help)
usage
exit 0
;;
--)
shift
(($# == 0)) || die "positional arguments are not supported"
break
;;
-*)
die "unknown option: ${1}"
;;
*)
die "unexpected positional argument: ${1}"
;;
esac
done
}
require_absolute_path() {
local label=${1}
local path=${2}
[[ -n ${path} ]] || die "${label} is required"
[[ ${path} == /* ]] || die "${label} must be an absolute path"
}
check_dependencies() {
local -a commands=(basename chmod dirname flock mktemp mv openssl)
local command
if [[ ${run_once} == false ]]; then
commands+=(inotifywait)
fi
if [[ -n ${NOTIFY_SOCKET-} ]]; then
commands+=(systemd-notify)
fi
for command in "${commands[@]}"; do
command -v "${command}" >/dev/null 2>&1 ||
die "required command not found: ${command}"
done
}
validate_config() {
local lock_dir
require_absolute_path "source directory" "${source_dir}"
require_absolute_path "output file" "${output_file}"
require_absolute_path "lock file" "${lock_file}"
if [[ -n ${password_file} ]]; then
require_absolute_path "password file" "${password_file}"
fi
[[ ${settle_seconds} =~ ^[0-9]+$ ]] ||
die "settle seconds must be a non-negative integer"
[[ ${output_mode} =~ ^0?[0-7]{3}$ ]] ||
die "output mode must be a three-digit octal mode, optionally prefixed by 0"
# Remove one trailing slash without turning the filesystem root into an
# empty string.
if [[ ${source_dir} != / ]]; then
source_dir=${source_dir%/}
fi
fullchain_file=${source_dir}/fullchain.pem
private_key_file=${source_dir}/privkey.pem
output_dir=$(dirname -- "${output_file}")
output_name=$(basename -- "${output_file}")
lock_dir=$(dirname -- "${lock_file}")
[[ -d ${source_dir} ]] ||
die "source directory does not exist: ${source_dir}"
[[ -r ${fullchain_file} ]] ||
die "certificate is not readable: ${fullchain_file}"
[[ -r ${private_key_file} ]] ||
die "private key is not readable: ${private_key_file}"
[[ -d ${output_dir} ]] ||
die "output directory does not exist: ${output_dir}"
[[ -w ${output_dir} ]] ||
die "output directory is not writable: ${output_dir}"
[[ ! -d ${output_file} ]] ||
die "output path is a directory: ${output_file}"
[[ -d ${lock_dir} && -w ${lock_dir} ]] ||
die "lock directory does not exist or is not writable: ${lock_dir}"
if [[ -n ${password_file} ]]; then
[[ -f ${password_file} && -r ${password_file} && -s ${password_file} ]] ||
die "password file must be a readable, non-empty regular file"
fi
}
acquire_lock() {
# Keep the lock file in place. Removing a locked path can create a second
# inode and allow another process to acquire a different lock concurrently.
exec 9>"${lock_file}"
flock -n 9 || die "another certificate monitor instance is already running"
}
cleanup() {
if [[ -n ${active_temp} ]]; then
rm -f -- "${active_temp}"
fi
}
convert_certificate() {
local passout=pass:
active_temp=$(mktemp "${output_dir}/.${output_name}.XXXXXX")
if [[ -n ${password_file} ]]; then
passout=file:${password_file}
fi
# OpenSSL verifies that the certificate and private key form a valid pair.
# The destination is not replaced unless the complete PFX was generated.
if ! openssl pkcs12 -export \
-in "${fullchain_file}" \
-inkey "${private_key_file}" \
-out "${active_temp}" \
-name "${output_name}" \
-passin pass: \
-passout "${passout}"; then
log "ERROR" "failed to create PFX from the current certificate pair"
rm -f -- "${active_temp}"
active_temp=
return 1
fi
if ! chmod "${output_mode}" "${active_temp}"; then
log "ERROR" "failed to set permissions on the temporary PFX"
rm -f -- "${active_temp}"
active_temp=
return 1
fi
# mktemp creates the file beside the destination, so rename(2) publishes it
# atomically on the same filesystem.
if ! mv -f -- "${active_temp}" "${output_file}"; then
log "ERROR" "failed to publish PFX: ${output_file}"
rm -f -- "${active_temp}"
active_temp=
return 1
fi
active_temp=
log "INFO" "PFX updated successfully: ${output_file}"
}
notify_ready() {
if [[ -n ${NOTIFY_SOCKET-} ]]; then
systemd-notify --ready --status="Monitoring ${source_dir}"
log "INFO" "systemd notified that the monitor is ready"
fi
}
monitor_source() {
local changed_file
log "INFO" "Watching certificate directory: ${source_dir}"
# A continuous watch avoids missing events while conversion is running.
# Multiple renewal events may intentionally cause a second harmless
# conversion; correctness is preferred over a fragile debounce mechanism.
inotifywait \
--monitor \
--quiet \
--event attrib \
--event close_write \
--event create \
--event delete \
--event moved_from \
--event moved_to \
--format '%f' \
-- "${source_dir}" |
while IFS= read -r changed_file; do
case ${changed_file} in
fullchain.pem | privkey.pem)
log "INFO" "Detected certificate update: ${changed_file}"
sleep "${settle_seconds}"
# Conversion failure is non-fatal while monitoring: the
# last known-good PFX remains published and a later event can
# retry the conversion.
# shellcheck disable=SC2310
if ! convert_certificate; then
log "WARN" "PFX update failed; keeping the previous output"
fi
;;
*) ;;
esac
done
die "certificate filesystem monitor stopped unexpectedly"
}
main() {
parse_args "$@"
check_dependencies
validate_config
acquire_lock
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
log "INFO" "Creating the initial PFX"
convert_certificate
notify_ready
if [[ ${run_once} == true ]]; then
log "INFO" "One-shot conversion completed"
return 0
fi
monitor_source
}
main "$@"