nixos/keycloak: Init

This commit is contained in:
talyz 2020-10-05 15:58:44 +02:00
parent ad9c8e6f04
commit 513599a6d7
No known key found for this signature in database
GPG Key ID: 2DED2151F4671A2B
3 changed files with 499 additions and 7 deletions

@ -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

@ -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 <literal>/auth</literal>.
See <link xlink:href="https://www.keycloak.org/docs/latest/server_installation/#_hostname">the
Hostname section of the Keycloak server installation
manual</link> 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 <xref
linkend="opt-services.keycloak.frontendUrl" />. 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 <link
xlink:href="https://www.keycloak.org/docs/latest/server_installation/#_hostname">the
Hostname section of the Keycloak server installation
manual</link> 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 <literal>admin</literal>
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
<literal>standalone.xml</literal>.
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
<literal>null</literal>.
The example configuration does the equivalent of the following
script, which removes the hostname provider
<literal>default</literal>, adds the deprecated hostname
provider <literal>fixed</literal> and defines it the default:
<programlisting>
/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")
</programlisting>
You can discover available options by using the <link
xlink:href="http://docs.wildfly.org/21/Admin_Guide.html#Command_Line_Interface">jboss-cli.sh</link>
program and by referring to the <link
xlink:href="https://www.keycloak.org/docs/latest/server_installation/index.html">Keycloak
Server Installation and Configuration Guide</link>.
'';
};
};
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="$(</run/keycloak/secrets/db_password)"
${pkgs.replace}/bin/replace-literal -fe '@db-password@' "$db_password" /run/keycloak/configuration/standalone.xml
export JAVA_OPTS=-Djboss.server.config.user.dir=/run/keycloak/configuration
${cfg.package}/bin/add-user-keycloak.sh -u admin -p '${cfg.initialAdminPassword}'
'' + lib.optionalString (cfg.certificatePrivateKeyBundle != null) ''
pushd /run/keycloak/ssl/
cat /run/keycloak/secrets/ssl_cert_pk_bundle <(echo) /etc/ssl/certs/ca-certificates.crt > 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;
};
}

@ -1,5 +1,21 @@
{ stdenv, fetchzip, makeWrapper, jre }:
{ stdenv, fetchzip, makeWrapper, jre, writeText
, postgresql_jdbc ? null
}:
let
mkModuleXml = name: jarFile: writeText "module.xml" ''
<?xml version="1.0" ?>
<module xmlns="urn:jboss:module:1.3" name="org.${name}">
<resources>
<resource-root path="${jarFile}"/>
</resources>
<dependencies>
<module name="javax.api"/>
<module name="javax.transaction.api"/>
</dependencies>
</module>
'';
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 ];
};
}