From 8fe9c18ed3986a8e0f4e738db1ee7b82d30ce68b Mon Sep 17 00:00:00 2001 From: Robert Irelan Date: Mon, 27 Nov 2023 13:55:35 -0800 Subject: [PATCH 1/3] nixos/anki-sync-server: init Provide a NixOS module for the [built-in Anki Sync Server](https://docs.ankiweb.net/sync-server.html) included in recent versions of Anki. This supersedes the `ankisyncd` module, but we should keep that for now because `ankisyncd` supports older versions of Anki clients than this module. --- .../manual/release-notes/rl-2405.section.md | 2 + nixos/modules/module-list.nix | 1 + .../modules/services/misc/anki-sync-server.md | 68 ++++++++ .../services/misc/anki-sync-server.nix | 146 ++++++++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 nixos/modules/services/misc/anki-sync-server.md create mode 100644 nixos/modules/services/misc/anki-sync-server.nix diff --git a/nixos/doc/manual/release-notes/rl-2405.section.md b/nixos/doc/manual/release-notes/rl-2405.section.md index b6b343145d78..8599f54cf479 100644 --- a/nixos/doc/manual/release-notes/rl-2405.section.md +++ b/nixos/doc/manual/release-notes/rl-2405.section.md @@ -16,6 +16,8 @@ In addition to numerous new and upgraded packages, this release has the followin - [maubot](https://github.com/maubot/maubot), a plugin-based Matrix bot framework. Available as [services.maubot](#opt-services.maubot.enable). +- [Anki Sync Server](https://docs.ankiweb.net/sync-server.html), the official sync server built into recent versions of Anki. Available as [services.anki-sync-server](#opt-services.anki-sync-server.enable). + ## Backward Incompatibilities {#sec-release-24.05-incompatibilities} diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index e40b7ed8015f..87129de4ca34 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -635,6 +635,7 @@ ./services/misc/amazon-ssm-agent.nix ./services/misc/ananicy.nix ./services/misc/ankisyncd.nix + ./services/misc/anki-sync-server.nix ./services/misc/apache-kafka.nix ./services/misc/atuin.nix ./services/misc/autofs.nix diff --git a/nixos/modules/services/misc/anki-sync-server.md b/nixos/modules/services/misc/anki-sync-server.md new file mode 100644 index 000000000000..5d2b4da4d2fc --- /dev/null +++ b/nixos/modules/services/misc/anki-sync-server.md @@ -0,0 +1,68 @@ +# Anki Sync Server {#module-services-anki-sync-server} + +[Anki Sync Server](https://docs.ankiweb.net/sync-server.html) is the built-in +sync server, present in recent versions of Anki. Advanced users who cannot or +do not wish to use AnkiWeb can use this sync server instead of AnkiWeb. + +This module is compatible only with Anki versions >=2.1.66, due to [recent +enhancements to the Nix anki +package](https://github.com/NixOS/nixpkgs/commit/05727304f8815825565c944d012f20a9a096838a). + +## Basic Usage {#module-services-anki-sync-server-basic-usage} + +By default, the module creates a +[`systemd`](https://www.freedesktop.org/wiki/Software/systemd/) +unit which runs the sync server with an isolated user using the systemd +`DynamicUser` option. + +This can be done by enabling the `anki-sync-server` service: +``` +{ ... }: + +{ + services.anki-sync-server.enable = true; +} +``` + +It is necessary to set at least one username-password pair under +{option}`services.anki-sync-server.users`. For example + +``` +{ + services.anki-sync-server.users = [ + { + username = "user"; + passwordFile = /etc/anki-sync-server/user; + } + ]; +} +``` + +Here, `passwordFile` is the path to a file containing just the password in +plaintext. Make sure to set permissions to make this file unreadable to any +user besides root. + +By default, the server listen address {option}`services.anki-sync-server.host` +is set to localhost, listening on port +{option}`services.anki-sync-server.port`, and does not open the firewall. This +is suitable for purely local testing, or to be used behind a reverse proxy. If +you want to expose the sync server directly to other computers (not recommended +in most circumstances, because the sync server doesn't use HTTPS), then set the +following options: + +``` +{ + services.anki-sync-server.host = "0.0.0.0"; + services.anki-sync-server.openFirewall = true; +} +``` + + +## Alternatives {#module-services-anki-sync-server-alternatives} + +The [`ankisyncd` NixOS +module](https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/services/misc/ankisyncd.nix) +provides similar functionality, but using a third-party implementation, +[`anki-sync-server-rs`](https://github.com/ankicommunity/anki-sync-server-rs/). +According to that project's README, it is "no longer maintained", and not +recommended for Anki 2.1.64+. diff --git a/nixos/modules/services/misc/anki-sync-server.nix b/nixos/modules/services/misc/anki-sync-server.nix new file mode 100644 index 000000000000..d4805253834b --- /dev/null +++ b/nixos/modules/services/misc/anki-sync-server.nix @@ -0,0 +1,146 @@ +{ + config, + lib, + pkgs, + utils, + ... +}: +with lib; let + cfg = config.services.anki-sync-server; + name = "anki-sync-server"; + specEscape = replaceStrings ["%"] ["%%"]; + usersWithIndexes = + lists.imap1 (i: user: { + i = i; + user = user; + }) + cfg.users; + usersWithIndexesFile = filter (x: x.user.passwordFile != null) usersWithIndexes; + usersWithIndexesNoFile = filter (x: x.user.passwordFile == null && x.user.password != null) usersWithIndexes; + anki-sync-server-run = pkgs.writeShellScriptBin "anki-sync-server-run" '' + # When services.anki-sync-server.users.passwordFile is set, + # each password file is passed as a systemd credential, which is mounted in + # a file system exposed to the service. Here we read the passwords from + # the credential files to pass them as environment variables to the Anki + # sync server. + ${ + concatMapStringsSep + "\n" + (x: ''export SYNC_USER${toString x.i}=${escapeShellArg x.user.username}:"''$(cat "''${CREDENTIALS_DIRECTORY}/"${escapeShellArg x.user.username})"'') + usersWithIndexesFile + } + # For users where services.anki-sync-server.users.password isn't set, + # export passwords in environment variables in plaintext. + ${ + concatMapStringsSep + "\n" + (x: ''export SYNC_USER${toString x.i}=${escapeShellArg x.user.username}:${escapeShellArg x.user.password}'') + usersWithIndexesNoFile + } + exec ${cfg.package}/bin/anki-sync-server + ''; +in { + options.services.anki-sync-server = { + enable = mkEnableOption (lib.mdDoc "anki-sync-server"); + + package = mkOption { + type = types.package; + default = pkgs.anki-sync-server; + defaultText = literalExpression "pkgs.anki-sync-server"; + description = lib.mdDoc "The package to use for the anki-sync-server command."; + }; + + address = mkOption { + type = types.str; + default = "::1"; + description = lib.mdDoc '' + IP address anki-sync-server listens to. + Note host names are not resolved. + ''; + }; + + port = mkOption { + type = types.port; + default = 27701; + description = lib.mdDoc "port anki-sync-server listens to"; + }; + + openFirewall = mkOption { + default = false; + type = types.bool; + description = lib.mdDoc "Whether to open the firewall for the specified port."; + }; + + users = mkOption { + type = with types; + listOf (submodule { + options = { + username = mkOption { + type = str; + description = lib.mdDoc "User name accepted by anki-sync-server."; + }; + password = mkOption { + type = nullOr str; + default = null; + description = lib.mdDoc '' + Password accepted by anki-sync-server for the associated username. + **WARNING**: This option is **not secure**. This password will + be stored in *plaintext* and will be visible to *all users*. + See {option}`services.anki-sync-server.users.passwordFile` for + a more secure option. + ''; + }; + passwordFile = mkOption { + type = nullOr path; + default = null; + description = lib.mdDoc '' + File containing the password accepted by anki-sync-server for + the associated username. Make sure to make readable only by + root. + ''; + }; + }; + }); + description = lib.mdDoc "List of user-password pairs to provide to the sync server."; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = (builtins.length usersWithIndexesFile) + (builtins.length usersWithIndexesNoFile) > 0; + message = "At least one username-password pair must be set."; + } + ]; + networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [cfg.port]; + + systemd.services.anki-sync-server = { + description = "anki-sync-server: Anki sync server built into Anki"; + after = ["network.target"]; + wantedBy = ["multi-user.target"]; + path = [cfg.package]; + environment = { + SYNC_BASE = "%S/%N"; + SYNC_HOST = specEscape cfg.address; + SYNC_PORT = toString cfg.port; + }; + + serviceConfig = { + Type = "simple"; + DynamicUser = true; + StateDirectory = name; + ExecStart = "${anki-sync-server-run}/bin/anki-sync-server-run"; + Restart = "always"; + LoadCredential = + map + (x: "${specEscape x.user.username}:${specEscape (toString x.user.passwordFile)}") + usersWithIndexesFile; + }; + }; + }; + + meta = { + maintainers = with maintainers; [telotortium]; + doc = ./anki-sync-server.md; + }; +} From f0f6c7778197ad6e8bd775e2dbfcbe74a00f3aa1 Mon Sep 17 00:00:00 2001 From: Dominique Martinet Date: Mon, 25 Sep 2023 22:01:02 +0900 Subject: [PATCH 2/3] nixos/tests/anki-sync-server: add anki sync test Start anki-sync-server service and drive anki manually through its python lib to test sync. The anki python part isn't a stable API and might require freqent rework, let's see if it holds up... --- nixos/tests/all-tests.nix | 1 + nixos/tests/anki-sync-server.nix | 71 ++++++++++++++++++++++++++++++++ pkgs/games/anki/default.nix | 2 + 3 files changed, 74 insertions(+) create mode 100644 nixos/tests/anki-sync-server.nix diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 480439c2a25e..c716510cc585 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -120,6 +120,7 @@ in { amazon-ssm-agent = handleTest ./amazon-ssm-agent.nix {}; amd-sev = runTest ./amd-sev.nix; anbox = runTest ./anbox.nix; + anki-sync-server = handleTest ./anki-sync-server.nix {}; anuko-time-tracker = handleTest ./anuko-time-tracker.nix {}; apcupsd = handleTest ./apcupsd.nix {}; apfs = runTest ./apfs.nix; diff --git a/nixos/tests/anki-sync-server.nix b/nixos/tests/anki-sync-server.nix new file mode 100644 index 000000000000..7d08cc9cb878 --- /dev/null +++ b/nixos/tests/anki-sync-server.nix @@ -0,0 +1,71 @@ +import ./make-test-python.nix ({ pkgs, ... }: + let + ankiSyncTest = pkgs.writeScript "anki-sync-test.py" '' + #!${pkgs.python3}/bin/python + + import sys + + # get site paths from anki itself + from runpy import run_path + run_path("${pkgs.anki}/bin/.anki-wrapped") + import anki + + col = anki.collection.Collection('test_collection') + endpoint = 'http://localhost:27701' + + # Sanity check: verify bad login fails + try: + col.sync_login('baduser', 'badpass', endpoint) + print("bad user login worked?!") + sys.exit(1) + except anki.errors.SyncError: + pass + + # test logging in to users + col.sync_login('user', 'password', endpoint) + col.sync_login('passfileuser', 'passfilepassword', endpoint) + + # Test actual sync. login apparently doesn't remember the endpoint... + login = col.sync_login('user', 'password', endpoint) + login.endpoint = endpoint + sync = col.sync_collection(login, False) + assert sync.required == sync.NO_CHANGES + # TODO: create an archive with server content including a test card + # and check we got it? + ''; + testPasswordFile = pkgs.writeText "anki-password" "passfilepassword"; + in + { + name = "anki-sync-server"; + meta = with pkgs.lib.maintainers; { + maintainers = [ martinetd ]; + }; + + nodes.machine = { pkgs, ...}: { + services.anki-sync-server = { + enable = true; + users = [ + { username = "user"; + password = "password"; + } + { username = "passfileuser"; + passwordFile = testPasswordFile; + } + ]; + }; + }; + + + testScript = + '' + start_all() + + with subtest("Server starts successfully"): + # service won't start without users + machine.wait_for_unit("anki-sync-server.service") + machine.wait_for_open_port(27701) + + with subtest("Can sync"): + machine.succeed("${ankiSyncTest}") + ''; +}) diff --git a/pkgs/games/anki/default.nix b/pkgs/games/anki/default.nix index 8a04d7fc3489..90f2ee9e53bf 100644 --- a/pkgs/games/anki/default.nix +++ b/pkgs/games/anki/default.nix @@ -9,6 +9,7 @@ , lame , mpv-unwrapped , ninja +, nixosTests , nodejs , nodejs-slim , prefetch-yarn-deps @@ -270,6 +271,7 @@ python3.pkgs.buildPythonApplication { passthru = { # cargoLock is reused in anki-sync-server inherit cargoLock; + tests.anki-sync-server = nixosTests.anki-sync-server; }; meta = with lib; { From b474de47797ef17172dfb1b4c0015696c59cf82d Mon Sep 17 00:00:00 2001 From: Weijia Wang <9713184+wegank@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:32:03 +0100 Subject: [PATCH 3/3] nixos/anki-sync-server: minor cleanup --- .../services/misc/anki-sync-server.nix | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/nixos/modules/services/misc/anki-sync-server.nix b/nixos/modules/services/misc/anki-sync-server.nix index d4805253834b..a65382009417 100644 --- a/nixos/modules/services/misc/anki-sync-server.nix +++ b/nixos/modules/services/misc/anki-sync-server.nix @@ -2,7 +2,6 @@ config, lib, pkgs, - utils, ... }: with lib; let @@ -41,19 +40,14 @@ with lib; let ''; in { options.services.anki-sync-server = { - enable = mkEnableOption (lib.mdDoc "anki-sync-server"); + enable = mkEnableOption "anki-sync-server"; - package = mkOption { - type = types.package; - default = pkgs.anki-sync-server; - defaultText = literalExpression "pkgs.anki-sync-server"; - description = lib.mdDoc "The package to use for the anki-sync-server command."; - }; + package = mkPackageOption pkgs "anki-sync-server" { }; address = mkOption { type = types.str; default = "::1"; - description = lib.mdDoc '' + description = '' IP address anki-sync-server listens to. Note host names are not resolved. ''; @@ -62,13 +56,13 @@ in { port = mkOption { type = types.port; default = 27701; - description = lib.mdDoc "port anki-sync-server listens to"; + description = "Port number anki-sync-server listens to."; }; openFirewall = mkOption { default = false; type = types.bool; - description = lib.mdDoc "Whether to open the firewall for the specified port."; + description = "Whether to open the firewall for the specified port."; }; users = mkOption { @@ -77,12 +71,12 @@ in { options = { username = mkOption { type = str; - description = lib.mdDoc "User name accepted by anki-sync-server."; + description = "User name accepted by anki-sync-server."; }; password = mkOption { type = nullOr str; default = null; - description = lib.mdDoc '' + description = '' Password accepted by anki-sync-server for the associated username. **WARNING**: This option is **not secure**. This password will be stored in *plaintext* and will be visible to *all users*. @@ -93,7 +87,7 @@ in { passwordFile = mkOption { type = nullOr path; default = null; - description = lib.mdDoc '' + description = '' File containing the password accepted by anki-sync-server for the associated username. Make sure to make readable only by root. @@ -101,7 +95,7 @@ in { }; }; }); - description = lib.mdDoc "List of user-password pairs to provide to the sync server."; + description = "List of user-password pairs to provide to the sync server."; }; };