Unverified Commit 5c55071c authored by squat's avatar squat
Browse files

nixos: introduce a service for linkding



This commit adds a new NixOS service for the linkding package. This
NixOS service runs 3 systemd services:
1. linkding-setup: this one-shot service bootstraps the creation of the
   runtime data directory and sets up the SQL database by running
   migrations, creating users, etc.
2. linkding: this is the main service, which runs linkding inside of
   uwsgi.
3. linkding-background-tasks: this is a sidecar service that runs
   `huey`, a task manager, which linkding uses to generate previews,
   downloads favicons, etc for your bookmarks.

Wherever possible, the NixOS service mirrors the configuration from the
upstream scripts/Docker compose.

I've validated that following configurations work on my own machines:
1. linkding with the sqlite3: this is the simplest way to run the
   linkding service, storing all DB data in the data directory. Task
   runner is confirmed working and favicons/previews are correctly
   generated.
2. linkding with postgresql: this is a trickier configuration that needs
   to pull in an optional-dependency. This is also confirmed working
   even against a non-local postgres instance.

Signed-off-by: default avatarsquat <lserven@gmail.com>
parent 7687ab56
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -98,6 +98,8 @@

- [Ente Auth](https://ente.io/auth/), an open source 2FA authenticator, with end-to-end encrypted backups. Available as [programs.ente-auth](#opt-programs.ente-auth.enable).

- [linkding](https://linkding.link/), a self-hosted bookmark manager designed to be minimal, fast, and easy to set up. Available as [services.linkding](#opt-services.linkding.enable).

- [Tinyauth](https://tinyauth.app/), a simple authentication middleware for web apps, with OAuth and LDAP support. Available as [services.tinyauth](#opt-services.tinyauth.enable).

- [Dawarich](https://dawarich.app/), a self-hostable location history tracker. Available as [services.dawarich](#opt-services.dawarich.enable).
+1 −0
Original line number Diff line number Diff line
@@ -1697,6 +1697,7 @@
  ./services/web-apps/librespeed.nix
  ./services/web-apps/libretranslate.nix
  ./services/web-apps/limesurvey.nix
  ./services/web-apps/linkding.nix
  ./services/web-apps/linkwarden.nix
  ./services/web-apps/lubelogger.nix
  ./services/web-apps/mainsail.nix
+403 −0
Original line number Diff line number Diff line
{
  config,
  lib,
  pkgs,
  ...
}:
let
  inherit (lib)
    mkEnableOption
    mkIf
    mkOption
    mkPackageOption
    optionalAttrs
    types
    ;

  cfg = config.services.linkding;
in
{
  options.services.linkding = {
    enable = mkEnableOption "linkding, a self-hosted bookmark manager";

    package = mkPackageOption pkgs "linkding" { };

    user = mkOption {
      type = types.str;
      default = "linkding";
      description = ''
        User account under which linkding runs.

        ::: {.note}
        If left as the default value this user will automatically be created
        on system activation, otherwise you are responsible for ensuring the
        user exists before the linkding service starts.
        :::
      '';
    };

    group = mkOption {
      type = types.str;
      default = "linkding";
      description = ''
        Group under which linkding runs.

        ::: {.note}
        If left as the default value this group will automatically be created
        on system activation, otherwise you are responsible for ensuring the
        group exists before the linkding service starts.
        :::
      '';
    };

    dataDir = mkOption {
      type = types.path;
      default = "/var/lib/linkding";
      description = "Directory used for all mutable state: SQLite database, secret key, favicons, previews, and assets.";
    };

    address = mkOption {
      type = types.str;
      default = "127.0.0.1";
      description = "Address on which linkding listens.";
    };

    port = mkOption {
      type = types.port;
      default = 9090;
      description = "Port on which linkding listens.";
    };

    contextPath = mkOption {
      type = types.str;
      default = "";
      example = "linkding/";
      description = ''
        Configures a URL context path under which linkding is accessible.
        When set, linkding is available at `http://host:<port>/<contextPath>`.
        Must end with a `/` when non-empty.
      '';
    };

    environmentFile = mkOption {
      type = types.nullOr types.path;
      default = null;
      example = "/run/secrets/linkding.env";
      description = ''
        Path to an environment file loaded by all linkding services.
        Useful for injecting secrets that should not appear in the Nix store,
        such as `LD_DB_PASSWORD` or `LD_SUPERUSER_PASSWORD`.
      '';
    };

    settings = mkOption {
      type = types.attrsOf types.str;
      default = { };
      example = {
        LD_DISABLE_BACKGROUND_TASKS = "True";
        LD_DISABLE_URL_VALIDATION = "True";
        LD_ENABLE_OIDC = "True";
      };
      description = ''
        Additional environment variables passed to linkding.
        Refer to the [linkding documentation](https://linkding.link/options/)
        for the full list of supported `LD_*` options.
      '';
    };

    database = {
      type = mkOption {
        type = types.enum [
          "sqlite"
          "postgres"
        ];
        default = "sqlite";
        description = "Database engine to use. Defaults to SQLite.";
      };

      host = mkOption {
        type = types.str;
        default = "localhost";
        description = "PostgreSQL server host.";
      };

      port = mkOption {
        type = types.port;
        default = 5432;
        description = "PostgreSQL server port.";
      };

      name = mkOption {
        type = types.str;
        default = "linkding";
        description = "PostgreSQL database name.";
      };

      user = mkOption {
        type = types.str;
        default = "linkding";
        description = "PostgreSQL user name.";
      };

      createLocally = mkOption {
        type = types.bool;
        default = false;
        description = "Whether to automatically create a local PostgreSQL database and user.";
      };
    };

    openFirewall = mkOption {
      type = types.bool;
      default = false;
      description = "Open the linkding port in the firewall.";
    };
  };

  config = mkIf cfg.enable (
    let
      pkg = cfg.package;

      usePostgres = cfg.database.type == "postgres";

      pythonPath =
        "${pkg.passthru.python.pkgs.makePythonPath pkg.passthru.dependencies}:${lib.getBin pkg}/${pkg.passthru.python.sitePackages}"
        + lib.strings.optionalString usePostgres ":${pkg.passthru.python.pkgs.makePythonPath pkg.optional-dependencies.postgres}";

      # Build the environment passed to every linkding process.
      environment = {
        DJANGO_SETTINGS_MODULE = "bookmarks.settings.prod";
        _NIXOS_LINKDING_DATA_DIR = cfg.dataDir;
        LD_SERVER_PORT = toString cfg.port;
      }
      // optionalAttrs (cfg.contextPath != "") {
        LD_CONTEXT_PATH = cfg.contextPath;
      }
      // optionalAttrs usePostgres {
        LD_DB_ENGINE = "postgres";
        LD_DB_DATABASE = cfg.database.name;
        LD_DB_USER = cfg.database.user;
        LD_DB_HOST = "/run/postgresql";
      }
      // optionalAttrs (usePostgres && !cfg.database.createLocally) {
        LD_DB_HOST = cfg.database.host;
        LD_DB_PORT = toString cfg.database.port;
      }
      // cfg.settings;

      environmentFile = pkgs.writeText "linkding-environment" (lib.generators.toKeyValue { } environment);

      # Generate a uwsgi.ini for the linkding instance, adapted for NixOS from
      # the upstream uwsgi.ini. The static-map entries serve pre-generated
      # static files from the Nix store as well as the mutable user-data
      # directories (favicons, previews) from the data directory.
      uwsgiIni = pkgs.writeText "linkding-uwsgi.ini" ''
        [uwsgi]
        plugins-dir = ${pkg.passthru.uwsgiWithPython}/lib/uwsgi
        plugin = python3
        module = bookmarks.wsgi:application
        env = DJANGO_SETTINGS_MODULE=bookmarks.settings.prod
        processes = 2
        threads = 2
        buffer-size = 8192
        die-on-term = true
        mime-file = ${pkgs.mailcap}/etc/mime.types
        http = ${cfg.address}:${toString cfg.port}
        static-map = /${cfg.contextPath}static=${pkg}/${pkg.passthru.python.sitePackages}/bookmarks/static
        static-map = /${cfg.contextPath}static=${cfg.dataDir}/favicons
        static-map = /${cfg.contextPath}static=${cfg.dataDir}/previews
        static-map = /${cfg.contextPath}robots.txt=${pkg}/${pkg.passthru.python.sitePackages}/bookmarks/static/robots.txt

        if-env = LD_REQUEST_TIMEOUT
        http-timeout = %(_)
        socket-timeout = %(_)
        harakiri = %(_)
        endif =

        if-env = LD_REQUEST_MAX_CONTENT_LENGTH
        limit-post = %(_)
        endif =

        if-env = LD_LOG_X_FORWARDED_FOR
        log-x-forwarded-for = %(_)
        endif =

        if-env = LD_DISABLE_REQUEST_LOGS=true
        disable-logging = true
        log-4xx = true
        log-5xx = true
        endif =
      '';

      # Manage wrapper script installed into the system PATH so administrators can
      # run Django management commands as the linkding service user.
      linkdingManageScript =
        let
          args = lib.escapeShellArgs (
            [
              "--uid=${cfg.user}"
              "--gid=${cfg.group}"
              "--working-directory=${cfg.dataDir}"
              "--property=EnvironmentFile=${environmentFile}"
            ]
            ++ lib.optional (cfg.environmentFile != null) "--property=EnvironmentFile=${cfg.environmentFile}"
            ++ [
              "--property=ReadWritePaths=${cfg.dataDir}"
              "--setenv=PYTHONPATH=${pythonPath}"
              "--pty"
              "--wait"
              "--collect"
              "--service-type=exec"
              "--quiet"
              "--"
              "${lib.getExe' pkg "linkding"}"
            ]
          );
        in
        pkgs.writeShellScriptBin "linkding-manage" ''
          exec ${lib.getExe' config.systemd.package "systemd-run"} ${args} "$@"
        '';

      commonServiceConfig = {
        Slice = "system-linkding.slice";
        User = cfg.user;
        Group = cfg.group;
        EnvironmentFile = [
          environmentFile
        ]
        ++ lib.optional (cfg.environmentFile != null) cfg.environmentFile;
        Environment = "PYTHONPATH=${pythonPath}";
        WorkingDirectory = cfg.dataDir;
        StateDirectory = [
          "linkding"
          "linkding/favicons"
          "linkding/previews"
          "linkding/assets"
        ];
        StateDirectoryMode = "0750";
        # Hardening
        NoNewPrivileges = true;
        PrivateTmp = true;
        PrivateDevices = true;
        ProtectSystem = "strict";
        ProtectHome = true;
        ReadWritePaths = [ cfg.dataDir ];
        PrivateMounts = true;
        ProtectClock = true;
        ProtectControlGroups = true;
        ProtectHostname = true;
        ProtectKernelLogs = true;
        ProtectKernelModules = true;
        ProtectKernelTunables = true;
        ProtectProc = "invisible";
        RemoveIPC = true;
        RestrictRealtime = true;
        RestrictSUIDSGID = true;
      };
    in
    {
      assertions = [
        {
          assertion = cfg.database.createLocally -> usePostgres;
          message = "services.linkding.database.createLocally requires services.linkding.database.type = \"postgres\"";
        }
        {
          assertion =
            cfg.database.createLocally -> cfg.database.host == "localhost" || cfg.database.host == "";
          message = "services.linkding.database.host should be empty or \"localhost\" when createLocally is enabled";
        }
        {
          assertion =
            cfg.database.createLocally
            -> cfg.database.user == cfg.user && cfg.database.user == cfg.database.name;
          message = "services.linkding.database.user must match services.linkding.user and services.linkding.database.name when createLocally is enabled";
        }
        {
          assertion = cfg.contextPath == "" || lib.hasSuffix "/" cfg.contextPath;
          message = "services.linkding.contextPath must end with \"/\" when non-empty";
        }
      ];

      networking.firewall = mkIf cfg.openFirewall {
        allowedTCPPorts = [ cfg.port ];
      };

      environment.systemPackages = [ linkdingManageScript ];

      users.users.${cfg.user} = {
        isSystemUser = true;
        group = cfg.group;
        home = cfg.dataDir;
      };

      users.groups.${cfg.group} = { };

      systemd.slices.system-linkding = {
        description = "linkding bookmark manager System Slice";
        documentation = [ "https://linkding.link/" ];
      };

      # One-shot setup service: run database migrations and first-time
      # initialization steps taken from the upstream bootstrap.sh.
      systemd.services.linkding-setup = {
        description = "linkding database migrations and initialization";
        after = [
          "network.target"
        ]
        ++ lib.optionals (usePostgres && cfg.database.createLocally) [ "postgresql.target" ];
        requires = lib.optionals (usePostgres && cfg.database.createLocally) [ "postgresql.target" ];

        serviceConfig = commonServiceConfig // {
          Type = "oneshot";
          ExecStart = "${lib.getExe' pkg "linkding-bootstrap"}";
        };
      };

      # Main WSGI service — starts after setup completes.
      systemd.services.linkding = {
        description = "linkding bookmark manager";
        wantedBy = [ "multi-user.target" ];
        after = [ "linkding-setup.service" ];
        requires = [ "linkding-setup.service" ];
        startLimitBurst = 5;
        startLimitIntervalSec = 60;
        serviceConfig = commonServiceConfig // {
          Type = "exec";
          ExecStart = "${lib.getExe pkgs.uwsgi} --ini ${uwsgiIni}";
          Restart = "on-failure";
        };
      };

      # Background task processor (Huey). Can be disabled via
      # services.linkding.settings.LD_DISABLE_BACKGROUND_TASKS = "True".
      systemd.services.linkding-background-tasks =
        mkIf ((cfg.settings.LD_DISABLE_BACKGROUND_TASKS or "False") != "True")
          {
            description = "linkding background task processor";
            wantedBy = [ "multi-user.target" ];
            after = [ "linkding-setup.service" ];
            requires = [ "linkding-setup.service" ];

            serviceConfig = commonServiceConfig // {
              Type = "exec";
              ExecStart = "${lib.getExe' pkg "linkding"} run_huey -f";
              Restart = "on-failure";
              RestartSec = "5s";
            };
          };

      # Automatically provision a local PostgreSQL database when requested.
      services.postgresql = mkIf cfg.database.createLocally {
        enable = true;
        ensureDatabases = [ cfg.database.name ];
        ensureUsers = [
          {
            name = cfg.database.user;
            ensureDBOwnership = true;
          }
        ];
      };
    }
  );

  meta.maintainers = with lib.maintainers; [ squat ];
}
+2 −0
Original line number Diff line number Diff line
@@ -889,6 +889,8 @@ in
  lighttpd = runTest ./lighttpd.nix;
  limesurvey = runTest ./limesurvey.nix;
  limine = import ./limine { inherit runTest; };
  linkding = runTest ./web-apps/linkding.nix;
  linkding-postgres = runTest ./web-apps/linkding-postgres.nix;
  linkwarden = runTest ./web-apps/linkwarden.nix;
  listmonk = handleTestOn [ "x86_64-linux" "aarch64-linux" ] ./listmonk.nix { };
  litellm = runTest ./litellm.nix;
+42 −0
Original line number Diff line number Diff line
{ lib, ... }:
{
  name = "linkding-postgres";

  meta = {
    maintainers = with lib.maintainers; [ squat ];
  };

  nodes.machine =
    { ... }:
    {
      services.linkding = {
        enable = true;
        port = 9090;
        database = {
          createLocally = true;
          type = "postgres";
        };
      };
    };

  testScript = ''
    machine.start()
    machine.wait_for_unit("linkding.service")
    machine.wait_for_open_port(9090)

    with subtest("Login page loads"):
        machine.succeed(
            "curl -sSfL http://127.0.0.1:9090 | grep -i 'linkding'"
        )

    with subtest("Health endpoint responds"):
        machine.succeed(
            "curl -sSf http://127.0.0.1:9090/health"
        )

    with subtest("linkding-manage works"):
        machine.succeed(
            "linkding-manage version"
        )
  '';
}
Loading