diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index ed6201237b3..c0c8429608b 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -863,6 +863,7 @@ ./services/web-apps/ihatemoney ./services/web-apps/jirafeau.nix ./services/web-apps/jitsi-meet.nix + ./services/web-apps/keycloak.nix ./services/web-apps/limesurvey.nix ./services/web-apps/mattermost.nix ./services/web-apps/mediawiki.nix diff --git a/nixos/modules/services/web-apps/keycloak.nix b/nixos/modules/services/web-apps/keycloak.nix new file mode 100644 index 00000000000..766df48d55f --- /dev/null +++ b/nixos/modules/services/web-apps/keycloak.nix @@ -0,0 +1,465 @@ +{ config, pkgs, lib, ... }: + +let + cfg = config.services.keycloak; +in +{ + options.services.keycloak = { + + enable = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Whether to enable the Keycloak identity and access management + server. + ''; + }; + + bindAddress = lib.mkOption { + type = lib.types.str; + default = "\${jboss.bind.address:0.0.0.0}"; + example = "127.0.0.1"; + description = '' + On which address Keycloak should accept new connections. + + A special syntax can be used to allow command line Java system + properties to override the value: ''${property.name:value} + ''; + }; + + httpPort = lib.mkOption { + type = lib.types.str; + default = "\${jboss.http.port:80}"; + example = "8080"; + description = '' + On which port Keycloak should listen for new HTTP connections. + + A special syntax can be used to allow command line Java system + properties to override the value: ''${property.name:value} + ''; + }; + + httpsPort = lib.mkOption { + type = lib.types.str; + default = "\${jboss.https.port:443}"; + example = "8443"; + description = '' + On which port Keycloak should listen for new HTTPS connections. + + A special syntax can be used to allow command line Java system + properties to override the value: ''${property.name:value} + ''; + }; + + frontendUrl = lib.mkOption { + type = lib.types.str; + example = "keycloak.example.com/auth"; + description = '' + The public URL used as base for all frontend requests. Should + normally include a trailing /auth. + + See the + Hostname section of the Keycloak server installation + manual for more information. + ''; + }; + + forceBackendUrlToFrontendUrl = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Whether Keycloak should force all requests to go through the + frontend URL configured in . By default, + Keycloak allows backend requests to instead use its local + hostname or IP address and may also advertise it to clients + through its OpenID Connect Discovery endpoint. + + See the + Hostname section of the Keycloak server installation + manual for more information. + ''; + }; + + certificatePrivateKeyBundle = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/keys/ssl_cert"; + description = '' + The path to a PEM formatted bundle of the private key and + certificate to use for TLS connections. + + This should be a string, not a Nix path, since Nix paths are + copied into the world-readable Nix store. + ''; + }; + + databaseHost = lib.mkOption { + type = lib.types.str; + default = "localhost"; + description = '' + Hostname of the PostgreSQL database to connect to. + ''; + }; + + databaseCreateLocally = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether a database should be automatically created on the + local host. Set this to false if you plan on provisioning a + local database yourself. This has no effect if + services.keycloak.databaseHost is customized. + ''; + }; + + databaseUsername = lib.mkOption { + type = lib.types.str; + default = "keycloak"; + description = '' + Username to use when connecting to an external or manually + provisioned database; has no effect when a local database is + automatically provisioned. + ''; + }; + + databasePasswordFile = lib.mkOption { + type = lib.types.path; + example = "/run/keys/db_password"; + description = '' + File containing the database password. + + This should be a string, not a Nix path, since Nix paths are + copied into the world-readable Nix store. + ''; + }; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.keycloak; + description = '' + Keycloak package to use. + ''; + }; + + initialAdminPassword = lib.mkOption { + type = lib.types.str; + default = "changeme"; + description = '' + Initial password set for the admin + user. The password is not stored safely and should be changed + immediately in the admin panel. + ''; + }; + + extraConfig = lib.mkOption { + type = lib.types.attrs; + default = { }; + example = lib.literalExample '' + { + "subsystem=keycloak-server" = { + "spi=hostname" = { + "provider=default" = null; + "provider=fixed" = { + enabled = true; + properties.hostname = "keycloak.example.com"; + }; + default-provider = "fixed"; + }; + }; + } + ''; + description = '' + Additional Keycloak configuration options to set in + standalone.xml. + + Options are expressed as a Nix attribute set which matches the + structure of the jboss-cli configuration. The configuration is + effectively overlayed on top of the default configuration + shipped with Keycloak. To remove existing nodes and undefine + attributes from the default configuration, set them to + null. + + The example configuration does the equivalent of the following + script, which removes the hostname provider + default, adds the deprecated hostname + provider fixed and defines it the default: + + + /subsystem=keycloak-server/spi=hostname/provider=default:remove() + /subsystem=keycloak-server/spi=hostname/provider=fixed:add(enabled = true, properties = { hostname = "keycloak.example.com" }) + /subsystem=keycloak-server/spi=hostname:write-attribute(name=default-provider, value="fixed") + + + You can discover available options by using the jboss-cli.sh + program and by referring to the Keycloak + Server Installation and Configuration Guide. + ''; + }; + + }; + + config = + let + # We only want to create a database if we're actually going to connect to it. + databaseActuallyCreateLocally = cfg.databaseCreateLocally && cfg.databaseHost == "localhost"; + + keycloakConfig' = builtins.foldl' lib.recursiveUpdate { + "interface=public".inet-address = cfg.bindAddress; + "socket-binding-group=standard-sockets"."socket-binding=http".port = cfg.httpPort; + "subsystem=keycloak-server"."spi=hostname" = { + "provider=default" = { + enabled = true; + properties = { + inherit (cfg) frontendUrl forceBackendUrlToFrontendUrl; + }; + }; + }; + "subsystem=datasources"."jdbc-driver=postgresql" = { + driver-module-name = "org.postgresql"; + driver-name = "postgresql"; + driver-xa-datasource-class-name = "org.postgresql.xa.PGXADataSource"; + }; + "subsystem=datasources"."data-source=KeycloakDS" = { + connection-url = "jdbc:postgresql://${cfg.databaseHost}/keycloak"; + driver-name = "postgresql"; + max-pool-size = "20"; + user-name = if databaseActuallyCreateLocally then "keycloak" else cfg.databaseUsername; + password = "@db-password@"; + }; + } [ + (lib.optionalAttrs (cfg.certificatePrivateKeyBundle != null) { + "socket-binding-group=standard-sockets"."socket-binding=https".port = cfg.httpsPort; + "core-service=management"."security-realm=UndertowRealm"."server-identity=ssl" = { + keystore-path = "/run/keycloak/ssl/certificate_private_key_bundle.p12"; + keystore-password = "notsosecretpassword"; + }; + "subsystem=undertow"."server=default-server"."https-listener=https".security-realm = "UndertowRealm"; + }) + cfg.extraConfig + ]; + + mkJbossScript = attrs: + let + writeAttributes = path: set: + let + prefixExpression = string: + let + match = (builtins.match ''"\$\{.*}"'' string); + in + if match != null then + "expression " + string + else + string; + + writeAttribute = attribute: value: + let + type = builtins.typeOf value; + in + if type == "set" then + let + names = builtins.attrNames value; + in + builtins.foldl' (text: name: text + (writeAttribute "${attribute}.${name}" value.${name})) "" names + else if value == null then '' + if (outcome == success) of ${path}:read-attribute(name="${attribute}") + ${path}:undefine-attribute(name="${attribute}") + end-if + '' + else if builtins.elem type [ "string" "path" "bool" ] then + let + value' = if type == "bool" then lib.boolToString value else ''"${value}"''; + in '' + if (result != ${prefixExpression value'}) of ${path}:read-attribute(name="${attribute}") + ${path}:write-attribute(name=${attribute}, value=${value'}) + end-if + '' + else throw "Unsupported type '${type}' for path '${path}'!"; + in + lib.concatStrings + (lib.mapAttrsToList + (attribute: value: (writeAttribute attribute value)) + set); + + makeArgList = set: + let + makeArg = attribute: value: + let + type = builtins.typeOf value; + in + if type == "set" then + "${attribute} = { " + (makeArgList value) + " }" + else if builtins.elem type [ "string" "path" "bool" ] then + "${attribute} = ${if type == "bool" then lib.boolToString value else ''"${value}"''}" + else if value == null then + "" + else + throw "Unsupported type '${type}' for attribute '${attribute}'!"; + in + lib.concatStringsSep ", " (lib.mapAttrsToList makeArg set); + + recurse = state: node: + let + path = state.path ++ (lib.optional (node != null) node); + isPath = name: + let + value = lib.getAttrFromPath (path ++ [ name ]) attrs; + in + if (builtins.match ".*([=]).*" name) == [ "=" ] then + if builtins.isAttrs value || value == null then + true + else + throw "Parsing path '${lib.concatStringsSep "." (path ++ [ name ])}' failed: JBoss attributes cannot contain '='!" + else + false; + jbossPath = "/" + (lib.concatStringsSep "/" path); + nodeValue = lib.getAttrFromPath path attrs; + children = if !builtins.isAttrs nodeValue then {} else nodeValue; + subPaths = builtins.filter isPath (builtins.attrNames children); + jbossAttrs = lib.filterAttrs (name: _: !(isPath name)) children; + in + state // { + text = state.text + ( + if nodeValue != null then '' + if (outcome != success) of ${jbossPath}:read-resource() + ${jbossPath}:add(${makeArgList jbossAttrs}) + end-if + '' + (writeAttributes jbossPath jbossAttrs) + else '' + if (outcome == success) of ${jbossPath}:read-resource() + ${jbossPath}:remove() + end-if + '') + (builtins.foldl' recurse { text = ""; inherit path; } subPaths).text; + }; + in + (recurse { text = ""; path = []; } null).text; + + + jbossCliScript = pkgs.writeText "jboss-cli-script" (mkJbossScript keycloakConfig'); + + keycloakConfig = pkgs.runCommand "keycloak-config" {} '' + export JBOSS_BASE_DIR="$(pwd -P)"; + export JBOSS_MODULEPATH="${cfg.package}/modules"; + export JBOSS_LOG_DIR="$JBOSS_BASE_DIR/log"; + + cp -r ${cfg.package}/standalone/configuration . + chmod -R u+rwX ./configuration + + mkdir -p {deployments,ssl} + + "${cfg.package}/bin/standalone.sh"& + + attempt=1 + max_attempts=30 + while ! ${cfg.package}/bin/jboss-cli.sh --connect ':read-attribute(name=server-state)'; do + if [[ "$attempt" == "$max_attempts" ]]; then + echo "ERROR: Could not connect to Keycloak after $attempt attempts! Failing.." >&2 + exit 1 + fi + echo "Keycloak not fully started yet, retrying.. ($attempt/$max_attempts)" + sleep 1 + (( attempt++ )) + done + + ${cfg.package}/bin/jboss-cli.sh --connect --file=${jbossCliScript} --echo-command + + cp configuration/standalone.xml $out + ''; + in + lib.mkIf cfg.enable { + + environment.systemPackages = [ cfg.package ]; + + systemd.services.keycloakDatabaseInit = lib.mkIf databaseActuallyCreateLocally { + after = [ "postgresql.service" ]; + before = [ "keycloak.service" ]; + bindsTo = [ "postgresql.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = "postgres"; + Group = "postgres"; + }; + script = '' + set -eu + + PSQL=${config.services.postgresql.package}/bin/psql + + db_password="$(<'${cfg.databasePasswordFile}')" + $PSQL -tAc "SELECT 1 FROM pg_roles WHERE rolname='keycloak'" | grep -q 1 || $PSQL -tAc "CREATE ROLE keycloak WITH LOGIN PASSWORD '$db_password' CREATEDB" + $PSQL -tAc "SELECT 1 FROM pg_database WHERE datname = 'keycloak'" | grep -q 1 || $PSQL -tAc 'CREATE DATABASE "keycloak" OWNER "keycloak"' + ''; + }; + + systemd.services.keycloak = { + after = lib.optionals databaseActuallyCreateLocally [ + "keycloakDatabaseInit.service" "postgresql.service" + ]; + bindsTo = lib.optionals databaseActuallyCreateLocally [ + "keycloakDatabaseInit.service" "postgresql.service" + ]; + wantedBy = [ "multi-user.target" ]; + environment = { + JBOSS_LOG_DIR = "/var/log/keycloak"; + JBOSS_BASE_DIR = "/run/keycloak"; + JBOSS_MODULEPATH = "${cfg.package}/modules"; + }; + serviceConfig = { + ExecStartPre = let + startPreFullPrivileges = '' + set -eu + + install -T -m 0400 -o keycloak -g keycloak '${cfg.databasePasswordFile}' /run/keycloak/secrets/db_password + '' + lib.optionalString (cfg.certificatePrivateKeyBundle != null) '' + install -T -m 0400 -o keycloak -g keycloak '${cfg.certificatePrivateKeyBundle}' /run/keycloak/secrets/ssl_cert_pk_bundle + ''; + startPre = '' + set -eu + + install -m 0600 ${cfg.package}/standalone/configuration/*.properties /run/keycloak/configuration + install -T -m 0600 ${keycloakConfig} /run/keycloak/configuration/standalone.xml + + db_password="$( allcerts.pem + ${pkgs.openssl}/bin/openssl pkcs12 -export -in /run/keycloak/secrets/ssl_cert_pk_bundle -chain \ + -name "${cfg.frontendUrl}" -out certificate_private_key_bundle.p12 \ + -CAfile allcerts.pem -passout pass:notsosecretpassword + popd + ''; + in [ + "+${pkgs.writeShellScript "keycloak-start-pre-full-privileges" startPreFullPrivileges}" + "${pkgs.writeShellScript "keycloak-start-pre" startPre}" + ]; + ExecStart = "${cfg.package}/bin/standalone.sh"; + User = "keycloak"; + Group = "keycloak"; + DynamicUser = true; + RuntimeDirectory = map (p: "keycloak/" + p) [ + "secrets" + "configuration" + "deployments" + "data" + "ssl" + "log" + "tmp" + ]; + RuntimeDirectoryMode = 0700; + LogsDirectory = "keycloak"; + AmbientCapabilities = "CAP_NET_BIND_SERVICE"; + }; + }; + + services.postgresql.enable = lib.mkDefault databaseActuallyCreateLocally; + }; +} diff --git a/pkgs/servers/keycloak/default.nix b/pkgs/servers/keycloak/default.nix index 614eb2a4679..c694b6a419f 100644 --- a/pkgs/servers/keycloak/default.nix +++ b/pkgs/servers/keycloak/default.nix @@ -1,5 +1,21 @@ -{ stdenv, fetchzip, makeWrapper, jre }: +{ stdenv, fetchzip, makeWrapper, jre, writeText +, postgresql_jdbc ? null +}: +let + mkModuleXml = name: jarFile: writeText "module.xml" '' + + + + + + + + + + + ''; +in stdenv.mkDerivation rec { pname = "keycloak"; version = "11.0.2"; @@ -16,12 +32,22 @@ stdenv.mkDerivation rec { cp -r * $out rm -rf $out/bin/*.{ps1,bat} - rm -rf $out/bin/add-user-keycloak.sh - rm -rf $out/bin/jconsole.sh - chmod +x $out/bin/standalone.sh - wrapProgram $out/bin/standalone.sh \ - --prefix PATH ":" ${jre}/bin ; + module_path=$out/modules/system/layers/keycloak/org + if ! [[ -d $module_path ]]; then + echo "The module path $module_path not found!" + exit 1 + fi + + ${if postgresql_jdbc != null then '' + mkdir -p $module_path/postgresql/main + ln -s ${postgresql_jdbc}/share/java/postgresql-jdbc.jar $module_path/postgresql/main + ln -s ${mkModuleXml "postgresql" "postgresql-jdbc.jar"} $module_path/postgresql/main/module.xml + '' else ""} + + wrapProgram $out/bin/standalone.sh --set JAVA_HOME ${jre} + wrapProgram $out/bin/add-user-keycloak.sh --set JAVA_HOME ${jre} + wrapProgram $out/bin/jboss-cli.sh --set JAVA_HOME ${jre} ''; meta = with stdenv.lib; { @@ -29,7 +55,7 @@ stdenv.mkDerivation rec { description = "Identity and access management for modern applications and services"; license = licenses.asl20; platforms = jre.meta.platforms; - maintainers = [ maintainers.ngerstle ]; + maintainers = with maintainers; [ ngerstle talyz ]; }; }