The apex is getting its own content, so it cannot be spent forwarding to /fdroid/repo/. The short, typeable way in is a separate name that redirects here -- fdroid.randogoth.com, defined in bucur's modules/services/fdroid.nix. Until that content lands, / falls through to the existing 404, which is deliberate. repo/index.html is unaffected and still the repo's landing page at /fdroid/repo/, the address the signed index names and the one clients subscribe to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
365 lines
15 KiB
Nix
365 lines
15 KiB
Nix
{ config, lib, pkgs, ... }:
|
|
|
|
let
|
|
cfg = config.services.fdroidRepo;
|
|
|
|
# fdroidserver bundles its own apksigner but no JDK, and `fdroid update`
|
|
# shells out to keytool, jar and jarsigner to build and sign the index. On a
|
|
# host with no JDK on PATH that failure arrives late — after every APK has
|
|
# been downloaded and parsed — so the JDK is wired in here rather than left
|
|
# to the ambient environment.
|
|
#
|
|
# androguard comes from the same package set so the two agree on it: the sync
|
|
# script imports it to recover a launcher icon from an APK whose fastlane
|
|
# tree ships none.
|
|
pythonEnv = pkgs.python3.withPackages (ps: [ ps.androguard ]);
|
|
|
|
syncConfig = pkgs.writeText "fdroid-sync.json" (builtins.toJSON {
|
|
forge = cfg.forgeUrl;
|
|
root = cfg.stateDir;
|
|
keep = cfg.keepVersions;
|
|
locale = cfg.locale;
|
|
keystorePassFile = cfg.keystorePassFile;
|
|
fdroid = "${pkgs.fdroidserver}/bin/fdroid";
|
|
apps = cfg.apps;
|
|
});
|
|
|
|
# No secrets in here, which is the point of the {env: ...} indirection:
|
|
# fdroidserver resolves those from the environment at run time, so the file
|
|
# can live in the world-readable Nix store while the keystore password stays
|
|
# in sops. Writing the password in literally — which is what `fdroid init`
|
|
# does — would publish it to every user on the host.
|
|
repoConfig = pkgs.writeText "fdroid-config.yml" ''
|
|
repo_url: https://${cfg.domain}/fdroid/repo
|
|
repo_name: ${cfg.repoName}
|
|
repo_description: ${cfg.repoDescription}
|
|
repo_keyalias: ${cfg.keyAlias}
|
|
# fdroidserver's legacy index builder checks this path for existence
|
|
# relative to the cwd `fdroid update` runs in (stateDir) and then
|
|
# `shutil.copyfile`s it into repo/icons/ under its own basename — which
|
|
# raises SameFileError if source and destination are the same path. So
|
|
# this has to name a *source* file outside repo/icons/, kept at
|
|
# ${cfg.stateDir}/icon.png by the sync script below.
|
|
repo_icon: icon.png
|
|
keystore: ${cfg.keystoreFile}
|
|
keystorepass: {env: FDROID_KEYSTORE_PASS}
|
|
keypass: {env: FDROID_KEY_PASS}
|
|
# Pruning is done by the sync script, which deletes old APKs outright. The
|
|
# alternative is this knob, which moves them to archive/ and expects a
|
|
# second signed index served alongside the first — a lot of moving parts so
|
|
# that nobody can install four-releases-ago builds.
|
|
archive_older: 0
|
|
# Would drop a symlink named after the app next to the repo on every
|
|
# update. Harmless, but it clutters the directory Caddy serves.
|
|
make_current_version_link: false
|
|
'';
|
|
|
|
syncScript = pkgs.writeShellScript "fdroid-sync" ''
|
|
set -euo pipefail
|
|
# Set outright rather than prepended to systemd's default PATH, because
|
|
# what fdroidserver needs here is not obvious and inheriting it hides a
|
|
# missing entry until the thing that needs it runs:
|
|
#
|
|
# jdk — `fdroid update` calls jar, keytool and jarsigner by bare
|
|
# name to build and sign the index
|
|
# findutils — the bundled apksigner is a Gradle-style launcher that
|
|
# dies with "xargs is not available" without it. The
|
|
# failure is not a clean one: fdroidserver reads it as
|
|
# every APK having an invalid signature and quarantines
|
|
# the lot into archive/ before giving up on the index.
|
|
# coreutils — the same launcher runs `uname`; install(1) below
|
|
export PATH=${
|
|
lib.makeBinPath [ pkgs.jdk pkgs.coreutils pkgs.findutils pkgs.gnused pkgs.gnugrep ]
|
|
}
|
|
install -d -m 0755 ${cfg.stateDir}/repo ${cfg.stateDir}/metadata ${cfg.stateDir}/tmp
|
|
# 0600, because fdroidserver warns about a group-readable config.yml on
|
|
# every run. There is nothing secret in ours, but a warning that is always
|
|
# printed is a warning nobody reads when it finally matters.
|
|
install -m 0600 ${repoConfig} ${cfg.stateDir}/config.yml
|
|
# Kept populated every run rather than once, so an icon change here
|
|
# propagates on the next sync without anyone having to touch the server.
|
|
# Lives next to repo/, not inside repo/icons/ itself — see repo_icon
|
|
# above for why that distinction matters.
|
|
install -m 0644 ${cfg.repoIcon} ${cfg.stateDir}/icon.png
|
|
exec ${pythonEnv}/bin/python3 ${./fdroid-sync.py} ${syncConfig} "$@"
|
|
'';
|
|
|
|
# Templated with a placeholder; the real HMAC secret is substituted at
|
|
# service start from the sops-managed file, so it never enters the store.
|
|
hooksTemplate = pkgs.writeText "fdroid-hooks.json" (builtins.toJSON [{
|
|
id = "rebuild-fdroid";
|
|
execute-command = "/run/current-system/sw/bin/systemctl";
|
|
pass-arguments-to-command = [
|
|
{ source = "string"; name = "start"; }
|
|
{ source = "string"; name = "--no-block"; }
|
|
{ source = "string"; name = "fdroid-sync.service"; }
|
|
];
|
|
response-message = "Queued an F-Droid repo rebuild";
|
|
trigger-rule.match = {
|
|
type = "payload-hmac-sha256";
|
|
secret = "placeholder";
|
|
# Forgejo signs with the webhook's secret under Gitea's header name.
|
|
parameter = { source = "header"; name = "X-Gitea-Signature"; };
|
|
};
|
|
}]);
|
|
|
|
makeHooks = pkgs.writeShellScript "fdroid-make-hooks" ''
|
|
set -euo pipefail
|
|
${pkgs.jq}/bin/jq \
|
|
--arg s "$(cat ${cfg.webhookSecretFile})" \
|
|
'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
|
|
}
|
|
|
|
# No redirect from /. The apex is being given its own content, so it
|
|
# cannot be spent forwarding to the repo; the short, typeable way in
|
|
# is a separate name that redirects here (fdroid.randogoth.com, in
|
|
# bucur's modules/services/fdroid.nix). Until that content lands the
|
|
# apex falls through to the 404 below, which is deliberate.
|
|
#
|
|
# repo/index.html, which `fdroid update` writes, is still the repo's
|
|
# landing page and still reachable at /fdroid/repo/ -- the address the
|
|
# signed index names and the one clients subscribe to.
|
|
|
|
# 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 = {
|
|
enable = lib.mkEnableOption "self-hosted F-Droid repo fed from Forgejo releases";
|
|
|
|
domain = lib.mkOption {
|
|
type = lib.types.str;
|
|
example = "apps.randogoth.com";
|
|
description = ''
|
|
Host the repo is served from. Baked into the signed index as the
|
|
repo's address, so changing it after people have subscribed points
|
|
their clients at the old one until they re-add the repo.
|
|
'';
|
|
};
|
|
|
|
forgeUrl = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "https://code.randogoth.com";
|
|
description = "Base URL of the Forgejo instance holding the releases.";
|
|
};
|
|
|
|
apps = lib.mkOption {
|
|
type = lib.types.listOf (lib.types.attrsOf lib.types.anything);
|
|
default = import ./apps.nix;
|
|
description = "Apps to publish. See apps.nix for the field reference.";
|
|
};
|
|
|
|
stateDir = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "/var/lib/fdroid";
|
|
description = ''
|
|
fdroidserver working directory: config.yml, metadata/ and the repo/
|
|
tree that gets served. Holds every published APK, so size it for
|
|
`keepVersions` x the number of apps x APK size.
|
|
'';
|
|
};
|
|
|
|
keepVersions = lib.mkOption {
|
|
type = lib.types.ints.positive;
|
|
default = 3;
|
|
description = ''
|
|
How many versions of each app to keep. Older APKs are deleted and drop
|
|
out of the index on the next update.
|
|
'';
|
|
};
|
|
|
|
locale = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "en-US";
|
|
description = ''
|
|
Locale treated as the default when filling gaps — currently only for
|
|
deciding where a recovered APK icon goes.
|
|
'';
|
|
};
|
|
|
|
repoName = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "randogoth apps";
|
|
description = "Repo name shown in the F-Droid client.";
|
|
};
|
|
|
|
repoDescription = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "Apps by randogoth, built and signed by their own CI on code.randogoth.com.";
|
|
description = "Repo description shown in the F-Droid client.";
|
|
};
|
|
|
|
repoIcon = lib.mkOption {
|
|
type = lib.types.path;
|
|
default = ./assets/icon.png;
|
|
description = ''
|
|
Icon shown next to the repo in the F-Droid client. Must be a raster
|
|
image (PNG) — the client decodes it as a bitmap, so an SVG renders as
|
|
a broken icon rather than falling back to anything.
|
|
'';
|
|
};
|
|
|
|
keyAlias = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "nanuqsaurus";
|
|
description = "Alias of the index signing key inside the keystore.";
|
|
};
|
|
|
|
keystoreFile = lib.mkOption {
|
|
type = lib.types.str;
|
|
example = "/run/secrets/fdroid_keystore";
|
|
description = ''
|
|
Path to the PKCS#12 keystore holding the repo's index signing key.
|
|
This key *is* the repo's identity: clients pin its fingerprint on
|
|
subscribe, so replacing it orphans every existing subscriber. Back it
|
|
up somewhere that is not this server.
|
|
'';
|
|
};
|
|
|
|
keystorePassFile = lib.mkOption {
|
|
type = lib.types.str;
|
|
example = "/run/secrets/fdroid_keystore_pass";
|
|
description = "File containing the keystore password (used for both store and key).";
|
|
};
|
|
|
|
webhookSecretFile = lib.mkOption {
|
|
type = lib.types.str;
|
|
example = "/run/secrets/fdroid_webhook_secret";
|
|
description = "File containing the shared secret Forgejo signs webhook payloads with.";
|
|
};
|
|
|
|
webhookPort = lib.mkOption {
|
|
type = lib.types.port;
|
|
default = 9001;
|
|
description = ''
|
|
Localhost port for the rebuild webhook, reverse-proxied at
|
|
/hooks/ on `domain`. Separate from bucur's site-deploy webhook on
|
|
9000 so that neither can take the other down.
|
|
'';
|
|
};
|
|
|
|
serveCaddy = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = true;
|
|
description = ''
|
|
Write a Caddyfile fragment to /etc/caddy/Caddyfile.d/, which bucur's
|
|
Caddyfile glob-imports.
|
|
'';
|
|
};
|
|
};
|
|
|
|
config = lib.mkIf cfg.enable {
|
|
environment.systemPackages = [ (pkgs.writeShellScriptBin "fdroid-sync" "exec ${syncScript} \"$@\"") ];
|
|
|
|
systemd.services.fdroid-sync = {
|
|
description = "Publish the latest ${toString (builtins.length cfg.apps)} app releases to the F-Droid repo";
|
|
after = [ "network-online.target" ];
|
|
wants = [ "network-online.target" ];
|
|
|
|
# A system activation must not kill a run in flight: it is a oneshot
|
|
# triggered by a webhook, so there is nothing for an activation to
|
|
# restart, and interrupting one mid-`fdroid update` would leave a
|
|
# half-written index being served.
|
|
restartIfChanged = false;
|
|
stopIfChanged = false;
|
|
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
ExecStart = syncScript;
|
|
StateDirectory = "fdroid";
|
|
StateDirectoryMode = "0755"; # Caddy reads repo/ out of here
|
|
# Generous, because a run that arrives via webhook may sit waiting for
|
|
# CI to finish uploading an 80 MB APK before it can do anything.
|
|
TimeoutStartSec = "45min";
|
|
# One CPU on this host, shared with Forgejo and the runner that just
|
|
# built the APK. Signing an index is not worth starving them for.
|
|
Nice = 10;
|
|
IOSchedulingClass = "idle";
|
|
};
|
|
};
|
|
|
|
systemd.timers.fdroid-sync = {
|
|
description = "Reconcile the F-Droid repo with the forge daily";
|
|
wantedBy = [ "timers.target" ];
|
|
timerConfig = {
|
|
OnCalendar = "daily";
|
|
RandomizedDelaySec = "2h";
|
|
# The webhook is the fast path and this is the floor under it: a
|
|
# delivery that failed or was never sent is never retried by Forgejo,
|
|
# so without a timer a missed release sits unpublished until the next
|
|
# one happens to carry it along.
|
|
Persistent = true;
|
|
};
|
|
};
|
|
|
|
systemd.services.fdroid-webhook = {
|
|
description = "Webhook receiver for F-Droid repo rebuilds";
|
|
wantedBy = [ "multi-user.target" ];
|
|
after = [ "network.target" "sops-nix.service" ];
|
|
serviceConfig = {
|
|
Type = "simple";
|
|
RuntimeDirectory = "fdroid-webhook";
|
|
ExecStartPre = "+${makeHooks}";
|
|
# -verbose, because without it this logs nothing at all about
|
|
# requests: not a delivery, not a rejection, not a 404 for an unknown
|
|
# hook id. A webhook whose secret or content type is wrong fails the
|
|
# HMAC check and is dropped, and the only remaining symptom is that
|
|
# releases quietly arrive a day late via the timer instead of at once.
|
|
# Silence is the one thing this must not do.
|
|
ExecStart = "${pkgs.webhook}/bin/webhook -hooks /run/fdroid-webhook/hooks.json -port ${toString cfg.webhookPort} -verbose";
|
|
Restart = "on-failure";
|
|
};
|
|
};
|
|
|
|
environment.etc = lib.mkIf cfg.serveCaddy {
|
|
"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 ];
|
|
};
|
|
}
|
|
|