The scaffold here was a manual pipeline: copy an APK into repo/, run
`fdroid update`, rsync the result somewhere. Every app already builds and
signs its own APK in CI and publishes it as a Forgejo release, so all of
that copying was re-doing by hand what the forge already had.
Both halves of a listing now come from the same release: the APK from the
release asset, and the title, summary, description, changelog, icon and
screenshots from the fastlane/ tree at the same tag. A listing can no longer
describe a build other than the one it ships, because there is no second copy
of the text to drift.
fdroid-sync.py is the whole reconcile. It is a pure function of what the forge
currently holds — work out what should be published, make it so, re-sign the
index only if something moved — which is what makes it safe to drive from a
webhook that has no delivery guarantee and no retry. A missed delivery costs
latency, not correctness.
Three things were only found by running the real thing with an empty
environment, and all three fail late and confusingly:
- fdroidserver bundles apksigner but no JDK, and shells out to jar, keytool
and jarsigner by bare name. The PATH is now set outright rather than
inherited, because the bundled apksigner is a Gradle-style launcher that
also needs xargs and sed — and without xargs fdroidserver reads every APK
as having an invalid signature and quarantines the lot into archive/.
- index-v2 only carries a changelog for a version matching the last Builds
entry, and a repo of prebuilt APKs has no build recipes. Without a declared
entry every release shipped an empty "What's New".
- fdroidserver does not fall back to the APK's launcher icon, so an app whose
fastlane tree has no images/icon.png was listed with no icon at all, which
is two of these four. The sync now recovers one from the APK.
module.nix keeps the keystore password out of the Nix store via
fdroidserver's {env: ...} indirection, and serves the repo rooted at repo/
rather than at the working directory, which would also have handed out
config.yml and metadata/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
309 lines
12 KiB
Nix
309 lines
12 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}
|
|
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
|
|
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
|
|
'';
|
|
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.";
|
|
};
|
|
|
|
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}";
|
|
ExecStart = "${pkgs.webhook}/bin/webhook -hooks /run/fdroid-webhook/hooks.json -port ${toString cfg.webhookPort}";
|
|
Restart = "on-failure";
|
|
};
|
|
};
|
|
|
|
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
|
|
}
|
|
'';
|
|
};
|
|
};
|
|
}
|