diff --git a/README.md b/README.md index 3a14e15..37d125f 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,8 @@ Also, the stack fits together nicely thanks to [contracts](#contracts). - Hledger - Home-Assistant - Jellyfin +- Karakeep +- Open WebUI - Pinchflat - Vaultwarden diff --git a/flake.nix b/flake.nix index 9167179..995c687 100644 --- a/flake.nix +++ b/flake.nix @@ -92,6 +92,7 @@ ]; "services/home-assistant" = ./modules/services/home-assistant.nix; "services/jellyfin" = ./modules/services/jellyfin.nix; + "services/karakeep" = ./modules/services/karakeep.nix; "services/nextcloud-server" = { module = ./modules/services/nextcloud-server.nix; optionRoot = [ "shb" "nextcloud" ]; @@ -218,6 +219,7 @@ // (vm_test "immich" ./test/services/immich.nix) // (vm_test "homeassistant" ./test/services/home-assistant.nix) // (vm_test "jellyfin" ./test/services/jellyfin.nix) + // (vm_test "karakeep" ./test/services/karakeep.nix) // (vm_test "monitoring" ./test/services/monitoring.nix) // (vm_test "nextcloud" ./test/services/nextcloud.nix) // (vm_test "open-webui" ./test/services/open-webui.nix) @@ -312,6 +314,7 @@ self.nixosModules.immich self.nixosModules.home-assistant self.nixosModules.jellyfin + self.nixosModules.karakeep self.nixosModules.nextcloud-server self.nixosModules.open-webui self.nixosModules.pinchflat @@ -343,6 +346,7 @@ nixosModules.immich = modules/services/immich.nix; nixosModules.home-assistant = modules/services/home-assistant.nix; nixosModules.jellyfin = modules/services/jellyfin.nix; + nixosModules.karakeep = modules/services/karakeep.nix; nixosModules.nextcloud-server = modules/services/nextcloud-server.nix; nixosModules.open-webui = modules/services/open-webui.nix; nixosModules.pinchflat = modules/services/pinchflat.nix; diff --git a/modules/services/karakeep.nix b/modules/services/karakeep.nix new file mode 100644 index 0000000..47e5ccf --- /dev/null +++ b/modules/services/karakeep.nix @@ -0,0 +1,224 @@ +{ config, lib, pkgs, ... }: +let + cfg = config.shb.karakeep; + + contracts = pkgs.callPackage ../contracts {}; +in +{ + imports = [ + ../blocks/nginx.nix + ]; + + options.shb.karakeep = { + enable = lib.mkEnableOption "the Karakeep service"; + + subdomain = lib.mkOption { + type = lib.types.str; + description = "Subdomain under which Karakeep will be served."; + default = "karakeep"; + }; + + domain = lib.mkOption { + type = lib.types.str; + description = "domain under which Karakeep will be served."; + example = "mydomain.com"; + }; + + ssl = lib.mkOption { + description = "Path to SSL files"; + type = lib.types.nullOr contracts.ssl.certs; + default = null; + }; + + port = lib.mkOption { + type = lib.types.port; + description = "Port Karakeep listens to incoming requests."; + default = 3000; + }; + + environment = lib.mkOption { + default = {}; + type = lib.types.attrsOf lib.types.str; + description = "Extra environment variables. See https://docs.karakeep.app/configuration/"; + example = '' + { + OLLAMA_BASE_URL = "http://127.0.0.1:''${toString config.services.ollama.port}"; + INFERENCE_TEXT_MODEL = "deepseek-r1:1.5b"; + INFERENCE_IMAGE_MODEL = "llava"; + EMBEDDING_TEXT_MODEL = "nomic-embed-text:v1.5"; + INFERENCE_ENABLE_AUTO_SUMMARIZATION = "true"; + INFERENCE_JOB_TIMEOUT_SEC = "200"; + } + ''; + }; + + ldap = lib.mkOption { + description = '' + Setup LDAP integration. + ''; + default = {}; + type = lib.types.submodule { + options = { + userGroup = lib.mkOption { + type = lib.types.str; + description = "Group users must belong to to be able to login."; + default = "karakeep_user"; + }; + }; + }; + }; + + sso = lib.mkOption { + description = '' + Setup SSO integration. + ''; + default = {}; + type = lib.types.submodule { + options = { + enable = lib.mkEnableOption "SSO integration."; + + authEndpoint = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Endpoint to the SSO provider. Leave null to not have SSO configured."; + example = "https://authelia.example.com"; + }; + + clientID = lib.mkOption { + type = lib.types.str; + description = "Client ID for the OIDC endpoint."; + default = "karakeep"; + }; + + authorization_policy = lib.mkOption { + type = lib.types.enum [ "one_factor" "two_factor" ]; + description = "Require one factor (password) or two factor (device) authentication."; + default = "one_factor"; + }; + + nextauthSecret = lib.mkOption { + description = "NextAuth secret."; + type = lib.types.submodule { + options = contracts.secret.mkRequester { + owner = "karakeep"; + restartUnits = [ "karakeep.service" ]; + }; + }; + }; + + sharedSecret = lib.mkOption { + description = "OIDC shared secret for Karakeep."; + type = lib.types.submodule { + options = contracts.secret.mkRequester { + owner = "karakeep"; + restartUnits = [ "karakeep.service" ]; + }; + }; + }; + + sharedSecretForAuthelia = lib.mkOption { + description = "OIDC shared secret for Authelia. Must be the same as `sharedSecret`"; + type = lib.types.submodule { + options = contracts.secret.mkRequester { + mode = "0400"; + ownerText = "config.shb.authelia.autheliaUser"; + owner = config.shb.authelia.autheliaUser; + }; + }; + }; + }; + }; + }; + + backup = lib.mkOption { + description = '' + Backup state directory. + ''; + default = {}; + type = lib.types.submodule { + options = contracts.backup.mkRequester { + user = "karakeep"; + sourceDirectories = [ + "/var/lib/karakeep" + ]; + }; + }; + }; + }; + + config = (lib.mkMerge [ + (lib.mkIf cfg.enable { + services.karakeep = { + enable = true; + + extraEnvironment = { + PORT = toString cfg.port; + DISABLE_NEW_RELEASE_CHECK = "true"; # These are handled by NixOS + } // cfg.environment; + }; + + shb.nginx.vhosts = [ + { + inherit (cfg) subdomain domain ssl; + upstream = "http://127.0.0.1:${toString cfg.port}/"; + } + ]; + }) + (lib.mkIf (cfg.enable && cfg.sso.enable) { + shb.lldap.ensureGroups = { + ${cfg.ldap.userGroup} = {}; + }; + + shb.authelia.extraOidcAuthorizationPolicies.karakeep = { + default_policy = "deny"; + rules = [ + { + subject = [ "group:${cfg.ldap.userGroup}" ]; + policy = cfg.sso.authorization_policy; + } + ]; + }; + shb.authelia.oidcClients = [ + { + client_id = cfg.sso.clientID; + client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path; + scopes = [ "openid" "email" "profile" ]; + authorization_policy = "karakeep"; + redirect_uris = [ + "https://${cfg.subdomain}.${cfg.domain}/api/auth/callback/custom" + ]; + } + ]; + services.karakeep = { + extraEnvironment = { + DISABLE_SIGNUPS = "false"; + DISABLE_PASSWORD_AUTH = "true"; + NEXTAUTH_URL = "https://${cfg.subdomain}.${cfg.domain}"; + OAUTH_WELLKNOWN_URL = "${cfg.sso.authEndpoint}/.well-known/openid-configuration"; + OAUTH_PROVIDER_NAME = "Single Sign-On"; + OAUTH_CLIENT_ID = cfg.sso.clientID; + OAUTH_SCOPE = "openid email profile"; + }; + environmentFile = "/run/karakeep/secrets.env"; + }; + + systemd.tmpfiles.rules = [ + "d '/run/karakeep' 0750 root root - -" + ]; + systemd.services.karakeep-pre = { + script = lib.shb.replaceSecrets { + userConfig = { + NEXTAUTH_SECRET.source = cfg.sso.nextauthSecret.result.path; + OAUTH_CLIENT_SECRET.source = cfg.sso.sharedSecret.result.path; + }; + resultPath = "/run/karakeep/secrets.env"; + generator = lib.shb.toEnvVar; + }; + serviceConfig.Type = "oneshot"; + wantedBy = [ "multi-user.target" ]; + before = [ "karakeep-web.service" "karakeep-workers.service" ]; + requiredBy = [ "karakeep-web.service" "karakeep-workers.service" ]; + }; + }) + ]); +} diff --git a/modules/services/karakeep/docs/default.md b/modules/services/karakeep/docs/default.md new file mode 100644 index 0000000..82bb5de --- /dev/null +++ b/modules/services/karakeep/docs/default.md @@ -0,0 +1,110 @@ +# Karakeep {#services-karakeep} + +Defined in [`/modules/blocks/karakeep.nix`](@REPO@/modules/blocks/karakeep.nix), +found in the `selfhostblocks.nixosModules.karakeep` module. +See [the manual](usage.html#usage-flake) for how to import the module in your code. + +This service sets up [Karakeep][] which is a bookmarking service powered by LLMs. +It integrates well with [Ollama][]. + +[Karakeep]: https://github.com/karakeep-app/karakeep +[Ollama]: https://ollama.com/ + +## Features {#services-karakeep-features} + +- Declarative [LDAP](#services-karakeep-options-shb.karakeep.ldap) Configuration. + - Needed LDAP groups are created automatically. +- Declarative [SSO](#services-karakeep-options-shb.karakeep.sso) Configuration. + - When SSO is enabled, login with user and password is disabled. + - Registration is enabled through SSO. +- Access through [subdomain](#services-karakeep-options-shb.karakeep.subdomain) using reverse proxy. +- Access through [HTTPS](#services-karakeep-options-shb.karakeep.ssl) using reverse proxy. +- [Backup](#services-karakeep-options-shb.karakeep.sso) through the [backup block](./blocks-backup.html). + +## Usage {#services-karakeep-usage} + +The following snippet assumes a few blocks have been setup already: + +- the [secrets block](usage.html#usage-secrets) with SOPS, +- the [`shb.ssl` block](blocks-ssl.html#usage), +- the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup). +- the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup). + +```nix +{ + shb.karakeep = { + enable = true; + domain = "example.com"; + subdomain = "karakeep"; + + ssl = config.shb.certs.certs.letsencrypt.${domain}; + + sso = { + enable = true; + authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; + + nextauthSecret.result = config.shb.sops.secret.nextauthSecret.result; + sharedSecret.result = config.shb.sops.secret.oidcSecret.result; + sharedSecretForAuthelia.result = config.shb.sops.secret.oidcAutheliaSecret.result; + }; + }; + + shb.sops.secret.nextauthSecret.request = config.shb.karakeep.sso.sharedSecret.request; + shb.sops.secret.oidcSecret.request = config.shb.karakeep.sso.sharedSecret.request; + shb.sops.secret.oidcAutheliaSecret = { + request = config.shb.karakeep.sso.sharedSecretForAuthelia.request; + settings.key = oidcSecret; + }; +} +``` + +Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`. + +The [user](#services-open-webui-options-shb.open-webui.ldap.userGroup) +and [admin](#services-open-webui-options-shb.open-webui.ldap.adminGroup) +LDAP groups are created automatically. + +## Integration with Ollama {#services-karakeep-ollama} + +Assuming ollama is enabled, it will be available on port `config.services.ollama.port`. +The following snippet sets up acceleration using an AMD (i)GPU and loads some models. + +```nix +{ + services.ollama = { + enable = true; + + # https://wiki.nixos.org/wiki/Ollama#AMD_GPU_with_open_source_driver + acceleration = "rocm"; + + # https://ollama.com/library + loadModels = [ + "deepseek-r1:1.5b" + "llama3.2:3b" + "llava:7b" + "mxbai-embed-large:335m" + "nomic-embed-text:v1.5" + ]; + }; +} +``` + +Integrating with the ollama service is done with: + +```nix +{ + services.open-webui = { + environment.OLLAMA_BASE_URL = "http://127.0.0.1:${toString config.services.ollama.port}"; + }; +} +``` + +Notice we're using the upstream service here `services.open-webui`, not `shb.open-webui`. + +## Options Reference {#services-karakeep-options} + +```{=include=} options +id-prefix: services-karakeep-options- +list-id: selfhostblocks-services-karakeep-options +source: @OPTIONS_JSON@ +``` diff --git a/modules/services/open-webui/docs/default.md b/modules/services/open-webui/docs/default.md index 1a5c251..83ddd4c 100644 --- a/modules/services/open-webui/docs/default.md +++ b/modules/services/open-webui/docs/default.md @@ -13,11 +13,11 @@ This service sets up [Open WebUI][] which provides a frontend to various LLMs. - Telemetry disabled. - Skip onboarding through custom patch. - Declarative [LDAP](#services-open-webui-options-shb.open-webui.ldap) Configuration. - Needed LDAP groups are created automatically. + - Needed LDAP groups are created automatically. - Declarative [SSO](#services-open-webui-options-shb.open-webui.sso) Configuration. - When SSO is enabled, login with user and password is disabled. - Registration is enabled through SSO. - Correct error message for unauthorized user through custom patch. + - When SSO is enabled, login with user and password is disabled. + - Registration is enabled through SSO. + - Correct error message for unauthorized user through custom patch. - Access through [subdomain](#services-open-webui-options-shb.open-webui.subdomain) using reverse proxy. - Access through [HTTPS](#services-open-webui-options-shb.open-webui.ssl) using reverse proxy. - [Backup](#services-open-webui-options-shb.open-webui.sso) through the [backup block](./blocks-backup.html). diff --git a/test/services/karakeep.nix b/test/services/karakeep.nix new file mode 100644 index 0000000..842b502 --- /dev/null +++ b/test/services/karakeep.nix @@ -0,0 +1,201 @@ +{ pkgs, ... }: +let + nextauthSecret = "nextauthSecret"; + oidcSecret = "oidcSecret"; + + testLib = pkgs.callPackage ../common.nix {}; + + commonTestScript = testLib.mkScripts { + hasSSL = { node, ... }: !(isNull node.config.shb.karakeep.ssl); + waitForServices = { ... }: [ + "karakeep-web.service" + "karakeep-workers.service" + "nginx.service" + ]; + waitForPorts = { node, ... }: [ + node.config.shb.karakeep.port + ]; + }; + + basic = { config, ... }: { + imports = [ + testLib.baseModule + ../../modules/blocks/hardcodedsecret.nix + ../../modules/blocks/lldap.nix + ../../modules/services/karakeep.nix + ]; + + test = { + subdomain = "k"; + }; + + shb.karakeep = { + enable = true; + inherit (config.test) subdomain domain; + }; + + networking.hosts = { + "127.0.0.1" = [ "${config.test.subdomain}.${config.test.domain}" ]; + }; + }; + + https = { config, ... }: { + shb.karakeep = { + ssl = config.shb.certs.certs.selfsigned.n; + }; + }; + + ldap = { config, ... }: { + shb.karakeep = { + ldap = { + userGroup = "user_group"; + }; + }; + }; + + clientLoginSso = { config, ... }: { + imports = [ + testLib.baseModule + testLib.clientLoginModule + ]; + test = { + subdomain = "k"; + }; + + test.login = { + startUrl = "https://${config.test.fqdn}"; + beforeHook = '' + page.get_by_role("button", name="single sign-on").click() + ''; + usernameFieldLabelRegex = "Username"; + passwordFieldLabelRegex = "Password"; + loginButtonNameRegex = "[sS]ign [iI]n"; + testLoginWith = [ + { username = "alice"; password = "NotAlicePassword"; nextPageExpect = [ + "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)" + ]; } + { username = "alice"; password = "AlicePassword"; nextPageExpect = [ + "page.get_by_role('button', name=re.compile('Accept')).click()" + "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)" + "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" + "expect(page.get_by_text('new item')).to_be_visible()" + ]; } + { username = "bob"; password = "NotBobPassword"; nextPageExpect = [ + "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)" + ]; } + { username = "bob"; password = "BobPassword"; nextPageExpect = [ + "page.get_by_role('button', name=re.compile('Accept')).click()" + "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)" + "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" + "expect(page.get_by_text('new item')).to_be_visible()" + ]; } + { username = "charlie"; password = "NotCharliePassword"; nextPageExpect = [ + "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)" + ]; } + { username = "charlie"; password = "CharliePassword"; nextPageExpect = [ + # "page.get_by_role('button', name=re.compile('Accept')).click()" # I don't understand why this is not needed. Maybe it keeps somewhere the previous token? + "expect(page.get_by_text(re.compile('login failed'))).to_be_visible(timeout=10000)" + ]; } + ]; + }; + }; + + sso = { config, ... }: { + shb.karakeep = { + sso = { + enable = true; + authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; + clientID = "karakeep"; + + nextauthSecret.result = config.shb.hardcodedsecret.nextauthSecret.result; + sharedSecret.result = config.shb.hardcodedsecret.oidcSecret.result; + sharedSecretForAuthelia.result = config.shb.hardcodedsecret.oidcAutheliaSecret.result; + }; + }; + + shb.hardcodedsecret.nextauthSecret = { + request = config.shb.karakeep.sso.nextauthSecret.request; + settings.content = nextauthSecret; + }; + shb.hardcodedsecret.oidcSecret = { + request = config.shb.karakeep.sso.sharedSecret.request; + settings.content = oidcSecret; + }; + shb.hardcodedsecret.oidcAutheliaSecret = { + request = config.shb.karakeep.sso.sharedSecretForAuthelia.request; + settings.content = oidcSecret; + }; + }; +in +{ + basic = pkgs.testers.runNixOSTest { + name = "karakeep_basic"; + + nodes.client = {}; + nodes.server = { + imports = [ + basic + ]; + }; + + testScript = commonTestScript.access; + }; + + backup = pkgs.testers.runNixOSTest { + name = "karakeep_backup"; + + nodes.server = { config, ... }: { + imports = [ + basic + (testLib.backup config.shb.karakeep.backup) + ]; + }; + + nodes.client = {}; + + testScript = commonTestScript.backup; + }; + + https = pkgs.testers.runNixOSTest { + name = "karakeep_https"; + + nodes.client = {}; + nodes.server = { + imports = [ + basic + testLib.certs + https + ]; + }; + + testScript = commonTestScript.access; + }; + + sso = pkgs.testers.runNixOSTest { + name = "karakeep_sso"; + interactive.sshBackdoor.enable = true; + + nodes.client = { + imports = [ + clientLoginSso + ]; + + virtualisation.memorySize = 4096; + }; + nodes.server = { config, pkgs, ... }: { + imports = [ + basic + testLib.certs + https + testLib.ldap + ldap + (testLib.sso config.shb.certs.certs.selfsigned.n) + sso + ]; + + virtualisation.memorySize = 4096; + }; + + testScript = commonTestScript.access; + }; +}