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:
Yann Autissier
2026-09-05 13:41:16 +02:00
parent be777fc9e6
commit f429b8c38d
14 changed files with 355 additions and 9 deletions
+98
View File
@@ -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 '\*\.'; }
+91
View File
@@ -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
View File
@@ -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 }
' "$@"
}