about summary refs log tree commit diff
path: root/nixos
diff options
context:
space:
mode:
authorWeijia Wang <9713184+wegank@users.noreply.github.com>2023-12-01 08:30:36 +0100
committerGitHub <noreply@github.com>2023-12-01 08:30:36 +0100
commit07183914f1edda52c198f9ab9265ee74f116d467 (patch)
tree520cd5a0dc0fb28759fca4ac90398920a39f8683 /nixos
parentcc5a7bb4cfe02aeb300b69c6eb4be7a0ae461190 (diff)
parentb474de47797ef17172dfb1b4c0015696c59cf82d (diff)
Merge pull request #257692 from telotortium/anki-sync-server
nixos/anki-sync-server: init
Diffstat (limited to 'nixos')
-rw-r--r--nixos/doc/manual/release-notes/rl-2405.section.md2
-rw-r--r--nixos/modules/module-list.nix1
-rw-r--r--nixos/modules/services/misc/anki-sync-server.md68
-rw-r--r--nixos/modules/services/misc/anki-sync-server.nix140
-rw-r--r--nixos/tests/all-tests.nix1
-rw-r--r--nixos/tests/anki-sync-server.nix71
6 files changed, 283 insertions, 0 deletions
diff --git a/nixos/doc/manual/release-notes/rl-2405.section.md b/nixos/doc/manual/release-notes/rl-2405.section.md
index f25999f607d9e..749ebc5cb13b6 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}
 
 <!-- To avoid merge conflicts, consider adding your item at an arbitrary place in the list instead. -->
diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index 4dd7563aa27e9..7f708f6e57c70 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -637,6 +637,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 0000000000000..5d2b4da4d2fc2
--- /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 0000000000000..a653820094176
--- /dev/null
+++ b/nixos/modules/services/misc/anki-sync-server.nix
@@ -0,0 +1,140 @@
+{
+  config,
+  lib,
+  pkgs,
+  ...
+}:
+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 "anki-sync-server";
+
+    package = mkPackageOption pkgs "anki-sync-server" { };
+
+    address = mkOption {
+      type = types.str;
+      default = "::1";
+      description = ''
+        IP address anki-sync-server listens to.
+        Note host names are not resolved.
+      '';
+    };
+
+    port = mkOption {
+      type = types.port;
+      default = 27701;
+      description = "Port number anki-sync-server listens to.";
+    };
+
+    openFirewall = mkOption {
+      default = false;
+      type = types.bool;
+      description = "Whether to open the firewall for the specified port.";
+    };
+
+    users = mkOption {
+      type = with types;
+        listOf (submodule {
+          options = {
+            username = mkOption {
+              type = str;
+              description = "User name accepted by anki-sync-server.";
+            };
+            password = mkOption {
+              type = nullOr str;
+              default = null;
+              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*.
+                See {option}`services.anki-sync-server.users.passwordFile` for
+                a more secure option.
+              '';
+            };
+            passwordFile = mkOption {
+              type = nullOr path;
+              default = null;
+              description = ''
+                File containing the password accepted by anki-sync-server for
+                the associated username.  Make sure to make readable only by
+                root.
+              '';
+            };
+          };
+        });
+      description = "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;
+  };
+}
diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix
index 9caffa97ec8c1..1ed0f760c9a21 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 0000000000000..7d08cc9cb878e
--- /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}")
+    '';
+})