Merge pull request #257692 from telotortium/anki-sync-server

nixos/anki-sync-server: init

Changed files
+285
nixos
pkgs
games
+2
nixos/doc/manual/release-notes/rl-2405.section.md
···
- [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. -->
+1
nixos/modules/module-list.nix
···
./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
+68
nixos/modules/services/misc/anki-sync-server.md
···
+
# 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+.
+140
nixos/modules/services/misc/anki-sync-server.nix
···
+
{
+
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;
+
};
+
}
+1
nixos/tests/all-tests.nix
···
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;
+71
nixos/tests/anki-sync-server.nix
···
+
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}")
+
'';
+
})
+2
pkgs/games/anki/default.nix
···
, lame
, mpv-unwrapped
, ninja
+
, nixosTests
, nodejs
, nodejs-slim
, prefetch-yarn-deps
···
passthru = {
# cargoLock is reused in anki-sync-server
inherit cargoLock;
+
tests.anki-sync-server = nixosTests.anki-sync-server;
};
meta = with lib; {