derive the certificates a server needs from what it routes
The hostnames are already declared, once, in the fabio route tags. A domains.txt would be a second source of truth free to disagree with what is actually served, so myos cert reads the tags instead and decides on its own which name needs a wildcard: one is asked for where a tag uses one, and it absorbs the concrete names it covers. A wildcard covers a single label, so a.b.example.org keeps its own certificate. dehydrated issues them: a shell script, no python, which fits a tool that has to install on any server. It answers http-01 itself on a port bound to the loopback and routed by fabio, and delegates dns-01 to a provider hook. The deploy hook writes the two file names fabio watches for, through a temporary name so fabio never reads half a certificate. Fixed on the way: the port parser wanted six spaces of indentation and the catalogue writes four, so every stack that binds its ports was reported unbound.
This commit is contained in:
+98
@@ -0,0 +1,98 @@
|
||||
#shellcheck shell=sh
|
||||
# cert: which certificates a server needs, derived from what its stacks route.
|
||||
#
|
||||
# The hostnames are already declared, once, in the fabio route tags a stack
|
||||
# publishes: urlprefix-<host>/<path>. Asking for them a second time in a
|
||||
# domains.txt would be a second source of truth, free to disagree with what is
|
||||
# actually served. They are read from the resolved compose configuration
|
||||
# instead.
|
||||
#
|
||||
# A name written *.example.org needs a wildcard, which ACME only issues over
|
||||
# dns-01; a concrete name can be had over http-01. That is the whole of
|
||||
# "per-site or wildcard according to need": the tags say which.
|
||||
|
||||
# myos_cert_names the hostnames the requested stacks route, one per line
|
||||
myos_cert_names() {
|
||||
for _ref in $MYOS_STACKS; do
|
||||
_files=$(myos_stack_compose_files "$_ref" 2>/dev/null) || continue
|
||||
[ -n "$_files" ] || continue
|
||||
_fw=$(myos_framework_compose_files)
|
||||
[ -n "$_fw" ] && _files="$_files
|
||||
$_fw"
|
||||
_app=$(myos_stack_name "$_ref")
|
||||
_project=$(myos_project_name "$(myos_scope "$_ref")" "$USER" "$ENV" "$_app")
|
||||
DRYRUN=false myos_compose "$_project" "$_files" -- config 2>/dev/null
|
||||
done | myos_cert_parse
|
||||
}
|
||||
|
||||
# myos_cert_parse (compose config on stdin) -> hostnames
|
||||
# A tag is urlprefix-<host>[:<port>]/<path> with options after a space; the
|
||||
# bare "*" is fabio's catch-all and names nothing.
|
||||
myos_cert_parse() {
|
||||
grep -oE 'urlprefix-[^",[:space:]]*' 2>/dev/null |
|
||||
sed -e 's/^urlprefix-//' -e 's|/.*||' -e 's/:[0-9]*$//' |
|
||||
grep -vE '^\*?$' |
|
||||
sort -u
|
||||
}
|
||||
|
||||
# myos_cert_covers WILDCARD_PARENT NAME does *.parent cover this name?
|
||||
# A wildcard matches one label, so *.example.org covers a.example.org but
|
||||
# neither example.org nor a.b.example.org.
|
||||
myos_cert_covers() {
|
||||
case $2 in
|
||||
*".$1")
|
||||
_head=${2%".$1"}
|
||||
case $_head in *.*|'') return 1 ;; *) return 0 ;; esac ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# myos_cert_groups the certificates to ask for, one per line, in the shape
|
||||
# dehydrated reads: the common name first, then its subject alternative names.
|
||||
#
|
||||
# MYOS_CERT_MODE:
|
||||
# auto a wildcard where the tags use one, a certificate per name otherwise
|
||||
# wildcard one wildcard per domain, whether or not a tag asked for it
|
||||
# per-site never a wildcard: one certificate per name, dns-01 not required
|
||||
myos_cert_groups() {
|
||||
_names=$(myos_cert_names)
|
||||
[ -n "$_names" ] || return 0
|
||||
_mode=${MYOS_CERT_MODE:-auto}
|
||||
|
||||
# the parents a wildcard is wanted for
|
||||
_wild=
|
||||
for _n in $_names; do
|
||||
case $_n in
|
||||
\*.*) [ "$_mode" = per-site ] || _wild="$_wild ${_n#\*.}" ;;
|
||||
esac
|
||||
done
|
||||
if [ "$_mode" = wildcard ]; then
|
||||
for _n in $_names; do
|
||||
case $_n in
|
||||
\*.*) ;;
|
||||
*.*.*) _wild="$_wild ${_n#*.}" ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
_wild=$(printf '%s' "$_wild" | tr ' ' '\n' | sed '/^$/d' | sort -u)
|
||||
|
||||
# one line per wildcard, the parent first so it is the common name
|
||||
for _p in $_wild; do
|
||||
printf '%s *.%s\n' "$_p" "$_p"
|
||||
done
|
||||
|
||||
# the concrete names a wildcard does not already cover
|
||||
for _n in $_names; do
|
||||
case $_n in \*.*) continue ;; esac
|
||||
_covered=no
|
||||
for _p in $_wild; do
|
||||
[ "$_n" = "$_p" ] && { _covered=yes; break; }
|
||||
myos_cert_covers "$_p" "$_n" && { _covered=yes; break; }
|
||||
done
|
||||
[ "$_covered" = no ] && printf '%s\n' "$_n"
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# myos_cert_needs_dns true when any certificate asked for is a wildcard
|
||||
myos_cert_needs_dns() { myos_cert_groups | grep -q '\*\.'; }
|
||||
@@ -0,0 +1,91 @@
|
||||
#shellcheck shell=sh
|
||||
# shellcheck disable=SC1091 # lib/cmd files are sourced by path at run time
|
||||
# shellcheck disable=SC3028 # HOSTNAME is a myos variable, set by bin/myos
|
||||
# myos cert <list|domains|issue|renew|show> the certificates a server needs
|
||||
#
|
||||
# The hostnames come from the route tags of the stacks, so a site gets a
|
||||
# certificate by being routed, not by being written down a second time.
|
||||
myos_cmd_cert() {
|
||||
_sub=$(myos_firstword "${MYOS_VARS:-}${MYOS_ARGS:+ $MYOS_ARGS}")
|
||||
[ -n "$_sub" ] || _sub=list
|
||||
case $_sub in
|
||||
list) myos_cert_list ;;
|
||||
domains) myos_cert_write_domains ;;
|
||||
issue) myos_cert_run "" ;;
|
||||
renew) myos_cert_run "--cron" ;;
|
||||
show) myos_cert_show ;;
|
||||
*) myos_die "$MYOS_E_USAGE" "myos cert <list|domains|issue|renew|show>" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# myos_cert_list the certificates that would be asked for, and how
|
||||
myos_cert_list() {
|
||||
_groups=$(myos_cert_groups)
|
||||
[ -n "$_groups" ] || {
|
||||
printf 'no routed hostname: nothing to certify\n'
|
||||
return 0
|
||||
}
|
||||
printf '%s%-46s %-9s %s%s\n' "$MYOS_C_HIGHLIGHT" CERTIFICATE CHALLENGE NAMES "$MYOS_C_RESET"
|
||||
printf '%s\n' "$_groups" | while IFS= read -r _line; do
|
||||
_cn=$(myos_firstword "$_line")
|
||||
case $_line in
|
||||
*'*.'*) _ch=dns-01 ;;
|
||||
*) _ch=http-01 ;;
|
||||
esac
|
||||
printf '%-46s %-9s %s\n' "$_cn" "$_ch" "$_line"
|
||||
done
|
||||
myos_cert_needs_dns &&
|
||||
myos_info "a wildcard is asked for: dns-01 needs MYOS_CERT_HOOK to talk to your dns provider"
|
||||
return 0
|
||||
}
|
||||
|
||||
# myos_cert_write_domains the domains.txt dehydrated reads
|
||||
myos_cert_write_domains() {
|
||||
_dir=${MYOS_CERT_DIR:-$WORKDIR/.myos/dehydrated}
|
||||
_file=$_dir/domains.txt
|
||||
_groups=$(myos_cert_groups)
|
||||
[ -n "$_groups" ] || { myos_warning "no routed hostname: not writing $_file"; return 0; }
|
||||
myos_run mkdir -p "$_dir"
|
||||
if [ "${DRYRUN:-false}" = true ]; then
|
||||
printf 'would write %s:\n%s\n' "$_file" "$_groups"
|
||||
else
|
||||
printf '%s\n' "$_groups" > "$_file"
|
||||
printf '%s\n' "$_file"
|
||||
fi
|
||||
}
|
||||
|
||||
# myos_cert_run ARGS run dehydrated in the host stack, on the domains derived
|
||||
myos_cert_run() {
|
||||
myos_cert_write_domains >/dev/null || return $?
|
||||
_args=$1
|
||||
[ -n "${MYOS_CERT_STAGING:-}" ] && _args="$_args --staging"
|
||||
case ${MYOS_ARGS:-} in
|
||||
*--staging*) _args="$_args --staging" ;;
|
||||
esac
|
||||
case ${MYOS_ARGS:-} in
|
||||
*--force*) _args="$_args --force" ;;
|
||||
esac
|
||||
MYOS_ARGS="$_args" SERVICE=${SERVICE:-dehydrated} \
|
||||
MYOS_STACKS="host/dehydrated" myos_cert_exec
|
||||
}
|
||||
|
||||
myos_cert_exec() {
|
||||
# shellcheck source=lib/cmd/exec.sh
|
||||
. "$MYOS_ROOT/lib/cmd/exec.sh"
|
||||
myos_cmd_exec
|
||||
}
|
||||
|
||||
# myos_cert_show the certificates that exist, and when they expire
|
||||
myos_cert_show() {
|
||||
_vol=${HOST_DOCKER_VOLUME:-${HOSTNAME:-localhost}}
|
||||
# shellcheck disable=SC2016 # the script runs in the container, not here
|
||||
myos_run docker run --rm -v "$_vol:/host" alpine:3.20 sh -c '
|
||||
apk add -q openssl 2>/dev/null
|
||||
for c in /host/certs/*-cert.pem; do
|
||||
[ -f "$c" ] || continue
|
||||
n=$(basename "$c" -cert.pem)
|
||||
e=$(openssl x509 -in "$c" -noout -enddate 2>/dev/null | sed "s/notAfter=//")
|
||||
i=$(openssl x509 -in "$c" -noout -issuer 2>/dev/null | sed "s/.*CN *= *//;s/,.*//")
|
||||
printf "%-46s %-28s %s\n" "$n" "$e" "$i"
|
||||
done'
|
||||
}
|
||||
+15
-8
@@ -87,26 +87,33 @@ myos_stack_prefix() {
|
||||
# 0.0.0.0 exactly like a deliberate public one.
|
||||
myos_expose_declared() {
|
||||
awk '
|
||||
function emit(entry, e, scope, target) {
|
||||
function indent(line, n) { match(line, /^ */); return RLENGTH }
|
||||
function emit(entry, e, scope, target, n, parts) {
|
||||
e = entry
|
||||
gsub(/^[ \t"'"'"'-]+/, "", e); gsub(/["'"'"']+$/, "", e)
|
||||
sub(/^ *- */, "", e)
|
||||
gsub(/^["'"'"']|["'"'"']$/, "", e)
|
||||
if (e ~ /\$\{MYOS_BIND_PUBLIC[^}]*\}/) scope = "public"
|
||||
else if (e ~ /\$\{MYOS_BIND_MESH[^}]*\}/) scope = "mesh"
|
||||
else if (e ~ /\$\{MYOS_BIND_PRIVATE[^}]*\}/) scope = "private"
|
||||
else if (e ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:/) scope = "pinned"
|
||||
else if (e ~ /^\[/) scope = "pinned"
|
||||
else scope = "unbound"
|
||||
# the container port is the last field, minus any /protocol
|
||||
target = e
|
||||
sub(/\/[a-z]+$/, "", target)
|
||||
n = split(target, parts, ":")
|
||||
target = parts[n]
|
||||
if (target ~ /^[0-9]+(-[0-9]+)?$/) printf "%s|%s|%s\n", svc, target, scope
|
||||
}
|
||||
/^services:[ \t]*$/ { insvc = 1; next }
|
||||
insvc && /^ [a-zA-Z0-9_.-]+:[ \t]*$/ { svc = $1; sub(/:$/, "", svc); inports = 0 }
|
||||
insvc && /^ ports:/ { inports = 1; next }
|
||||
inports && /^ [a-zA-Z]/ { inports = 0 }
|
||||
inports && /^ *-/ { emit($0) }
|
||||
/^services:[ \t]*$/ { insvc = 1; svcind = -1; next }
|
||||
!insvc { next }
|
||||
# a service is the first level of keys under services:
|
||||
/^ *[a-zA-Z0-9_.-]+:[ \t]*$/ && (svcind == -1 || indent($0) == svcind) {
|
||||
if (svcind == -1) svcind = indent($0)
|
||||
svc = $1; sub(/:$/, "", svc); inports = 0; next
|
||||
}
|
||||
/^ *ports:/ { inports = 1; portind = indent($0); next }
|
||||
# the list items of a ports: block, whatever indent they use
|
||||
inports && /^ *- / && indent($0) >= portind { emit($0); next }
|
||||
inports && /^ *[a-zA-Z0-9_.-]+:/ { inports = 0 }
|
||||
' "$@"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user