Reload Caddy when the vhost fragment changes, and 404 unmatched paths

Caddy glob-imports Caddyfile.d/ once, at startup, and its own unit does not
change when a fragment does — so NixOS left it running and the vhost simply
did not exist. No listener, no certificate, and nothing in the log to say why,
because Caddy had never been told to look. The first deploy of this module hit
exactly that: it had to be reloaded by hand before ACME would even try.

reloadTriggers makes the fragment's content part of what decides whether Caddy
is reloaded, so this cannot recur the next time the domain or the port moves.

Also answer unmatched paths with 404. Caddy's default for a request that
matched no route is an empty 200, which made every stray path — /config.yml
among them — look like an empty file that exists. Nothing was ever served
either way, but 404 is the honest answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-10-14 12:42:25 +03:00
parent 1b2eba9535
commit affbb703be

View file

@ -98,6 +98,52 @@ let
'map(.["trigger-rule"].match.secret = $s)' \
${hooksTemplate} > /run/fdroid-webhook/hooks.json
'';
# Served as a Caddyfile.d fragment rather than a virtualHosts entry
# because bucur's Caddyfile is a hand-written file, not the NixOS
# module's attrset. Hoisted into a binding so reloadTriggers below can
# depend on its content.
caddyFragment = ''
${cfg.domain} {
handle /hooks/* {
reverse_proxy localhost:${toString cfg.webhookPort}
}
# F-Droid's convention is <host>/fdroid/repo, and the fingerprint
# clients pin is tied to that exact address via the signed index,
# so it is not a path that can be tidied up later.
#
# Rooted at repo/ rather than at the state directory with /fdroid
# stripped, which would have served the whole fdroidserver working
# tree: config.yml and metadata/ are not secret, but they are not
# part of the repo and nothing should be able to fetch them.
redir /fdroid/repo /fdroid/repo/ permanent
handle_path /fdroid/repo/* {
root * ${cfg.stateDir}/repo
# No `browse`: repo/index.html is the landing page, and a
# directory listing for everything else only advertises paths
# that no client needs to discover.
file_server
}
# `fdroid update` writes repo/index.html: the repo's own landing
# page, listing every app and carrying the QR code that adds the
# repo to a phone. Better than anything hand-written here, and it
# can never fall out of step with the index.
redir / /fdroid/repo/ permanent
# Caddy answers a request that matched no route with an empty 200, so
# without this every stray path — /config.yml included — looks like a
# file that exists and happens to be empty. Nothing is served either
# way, but "not found" is the honest answer and the one a client can
# act on.
handle {
respond 404
}
encode zstd gzip
}
'';
in
{
options.services.fdroidRepo = {
@ -272,38 +318,16 @@ in
};
environment.etc = lib.mkIf cfg.serveCaddy {
"caddy/Caddyfile.d/fdroid.caddyfile".text = ''
${cfg.domain} {
handle /hooks/* {
reverse_proxy localhost:${toString cfg.webhookPort}
}
# F-Droid's convention is <host>/fdroid/repo, and the fingerprint
# clients pin is tied to that exact address via the signed index,
# so it is not a path that can be tidied up later.
#
# Rooted at repo/ rather than at the state directory with /fdroid
# stripped, which would have served the whole fdroidserver working
# tree: config.yml and metadata/ are not secret, but they are not
# part of the repo and nothing should be able to fetch them.
redir /fdroid/repo /fdroid/repo/ permanent
handle_path /fdroid/repo/* {
root * ${cfg.stateDir}/repo
# No `browse`: repo/index.html is the landing page, and a
# directory listing for everything else only advertises paths
# that no client needs to discover.
file_server
}
# `fdroid update` writes repo/index.html: the repo's own landing
# page, listing every app and carrying the QR code that adds the
# repo to a phone. Better than anything hand-written here, and it
# can never fall out of step with the index.
redir / /fdroid/repo/ permanent
encode zstd gzip
}
'';
"caddy/Caddyfile.d/fdroid.caddyfile".text = caddyFragment;
};
# Caddy glob-imports Caddyfile.d/ once, at startup. Its own unit does not
# change when a fragment does, so NixOS leaves it running and the vhost
# silently does not exist — no certificate, no listener, nothing in the log
# to say why, until something unrelated happens to restart it. Which is
# exactly what happened on the first deploy of this module.
systemd.services.caddy.reloadTriggers =
lib.mkIf cfg.serveCaddy [ caddyFragment ];
};
}