fdroid-repo/module.nix
randogoth e6fa02e7eb Add a repo icon option, rasterized from the randogoth.com favicon
The F-Droid client decodes repo icons as bitmaps, so the site's SVG icon
is shipped as a 512x512 PNG and copied into repo/icons/ on every sync.
2026-10-14 14:03:29 +03:00

357 lines
14 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 checks this path for existence relative to the cwd `fdroid
# update` runs in (stateDir), then copies it into repo/icons/ under its
# own basename — so this is deliberately already that destination path,
# which the sync script keeps populated before every run.
repo_icon: repo/icons/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.
install -D -m 0644 ${cfg.repoIcon} ${cfg.stateDir}/repo/icons/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
}
# `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 = {
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 ];
};
}