mirror of
https://github.com/NixOS/nixpkgs.git
synced 2024-09-29 15:43:00 +00:00
nixos/kanidm: add provisioning of groups, persons and oauth2 systems
This commit is contained in:
parent
59a463f593
commit
558fa6abc6
@ -332,6 +332,8 @@
|
||||
|
||||
- `nixosTests` now provide a working IPv6 setup for VLAN 1 by default.
|
||||
|
||||
- Kanidm can now be provisioned using the new [`services.kanidm.provision`] option, but requires using a patched version available via `pkgs.kanidm.withSecretProvisioning`.
|
||||
|
||||
- To facilitate dependency injection, the `imgui` package now builds a static archive using vcpkg' CMake rules.
|
||||
The derivation now installs "impl" headers selectively instead of by a wildcard.
|
||||
Use `imgui.src` if you just want to access the unpacked sources.
|
||||
|
@ -62,6 +62,94 @@ let
|
||||
#UMask = "0066";
|
||||
};
|
||||
|
||||
mkPresentOption = what:
|
||||
lib.mkOption {
|
||||
description = "Whether to ensure that this ${what} is present or absent.";
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
};
|
||||
|
||||
filterPresent = lib.filterAttrs (_: v: v.present);
|
||||
|
||||
provisionStateJson = pkgs.writeText "provision-state.json" (builtins.toJSON {
|
||||
inherit (cfg.provision) groups persons systems;
|
||||
});
|
||||
|
||||
# Only recover the admin account if a password should explicitly be provisioned
|
||||
# for the account. Otherwise it is not needed for provisioning.
|
||||
maybeRecoverAdmin = lib.optionalString (cfg.provision.adminPasswordFile != null) ''
|
||||
KANIDM_ADMIN_PASSWORD=$(< ${cfg.provision.adminPasswordFile})
|
||||
# We always reset the admin account password if a desired password was specified.
|
||||
if ! KANIDM_RECOVER_ACCOUNT_PASSWORD=$KANIDM_ADMIN_PASSWORD ${cfg.package}/bin/kanidmd recover-account -c ${serverConfigFile} admin --from-environment >/dev/null; then
|
||||
echo "Failed to recover admin account" >&2
|
||||
exit 1
|
||||
fi
|
||||
'';
|
||||
|
||||
# Recover the idm_admin account. If a password should explicitly be provisioned
|
||||
# for the account we set it, otherwise we generate a new one because it is required
|
||||
# for provisioning.
|
||||
recoverIdmAdmin = if cfg.provision.idmAdminPasswordFile != null
|
||||
then ''
|
||||
KANIDM_IDM_ADMIN_PASSWORD=$(< ${cfg.provision.idmAdminPasswordFile})
|
||||
# We always reset the idm_admin account password if a desired password was specified.
|
||||
if ! KANIDM_RECOVER_ACCOUNT_PASSWORD=$KANIDM_IDM_ADMIN_PASSWORD ${cfg.package}/bin/kanidmd recover-account -c ${serverConfigFile} idm_admin --from-environment >/dev/null; then
|
||||
echo "Failed to recover idm_admin account" >&2
|
||||
exit 1
|
||||
fi
|
||||
''
|
||||
else ''
|
||||
# Recover idm_admin account
|
||||
if ! recover_out=$(${cfg.package}/bin/kanidmd recover-account -c ${serverConfigFile} idm_admin -o json); then
|
||||
echo "$recover_out" >&2
|
||||
echo "kanidm provision: Failed to recover admin account" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! KANIDM_IDM_ADMIN_PASSWORD=$(grep '{"password' <<< "$recover_out" | ${lib.getExe pkgs.jq} -r .password); then
|
||||
echo "$recover_out" >&2
|
||||
echo "kanidm provision: Failed to parse password for idm_admin account" >&2
|
||||
exit 1
|
||||
fi
|
||||
'';
|
||||
|
||||
postStartScript = pkgs.writeShellScript "post-start" ''
|
||||
set -euo pipefail
|
||||
|
||||
# Wait for the kanidm server to come online
|
||||
count=0
|
||||
while ! ${lib.getExe pkgs.curl} -L --silent --max-time 1 --connect-timeout 1 --fail \
|
||||
${lib.optionalString cfg.provision.acceptInvalidCerts "--insecure"} \
|
||||
${cfg.provision.instanceUrl} >/dev/null
|
||||
do
|
||||
sleep 1
|
||||
if [[ "$count" -eq 30 ]]; then
|
||||
echo "Tried for at least 30 seconds, giving up..."
|
||||
exit 1
|
||||
fi
|
||||
count=$((count++))
|
||||
done
|
||||
|
||||
${recoverIdmAdmin}
|
||||
${maybeRecoverAdmin}
|
||||
|
||||
KANIDM_PROVISION_IDM_ADMIN_TOKEN=$KANIDM_IDM_ADMIN_PASSWORD \
|
||||
${lib.getExe pkgs.kanidm-provision} \
|
||||
${lib.optionalString (!cfg.provision.autoRemove) "--no-auto-remove"} \
|
||||
${lib.optionalString cfg.provision.acceptInvalidCerts "--accept-invalid-certs"} \
|
||||
--url "${cfg.provision.instanceUrl}" \
|
||||
--state ${provisionStateJson}
|
||||
'';
|
||||
|
||||
serverPort =
|
||||
# ipv6:
|
||||
if lib.hasInfix "]:" cfg.serverSettings.bindaddress
|
||||
then lib.last (lib.splitString "]:" cfg.serverSettings.bindaddress)
|
||||
else
|
||||
# ipv4:
|
||||
if lib.hasInfix "." cfg.serverSettings.bindaddress
|
||||
then lib.last (lib.splitString ":" cfg.serverSettings.bindaddress)
|
||||
# default is 8443
|
||||
else "8443";
|
||||
in
|
||||
{
|
||||
options.services.kanidm = {
|
||||
@ -207,10 +295,267 @@ in
|
||||
for possible values.
|
||||
'';
|
||||
};
|
||||
|
||||
provision = {
|
||||
enable = lib.mkEnableOption "provisioning of groups, users and oauth2 resource servers";
|
||||
|
||||
instanceUrl = lib.mkOption {
|
||||
description = "The instance url to which the provisioning tool should connect.";
|
||||
default = "https://localhost:${serverPort}";
|
||||
defaultText = ''"https://localhost:<port from serverSettings.bindaddress>"'';
|
||||
type = lib.types.str;
|
||||
};
|
||||
|
||||
acceptInvalidCerts = lib.mkOption {
|
||||
description = ''
|
||||
Whether to allow invalid certificates when provisioning the target instance.
|
||||
By default this is only allowed when the instanceUrl is localhost. This is
|
||||
dangerous when used with an external URL.
|
||||
'';
|
||||
type = lib.types.bool;
|
||||
default = lib.hasPrefix "https://localhost:" cfg.provision.instanceUrl;
|
||||
defaultText = ''lib.hasPrefix "https://localhost:" cfg.provision.instanceUrl'';
|
||||
};
|
||||
|
||||
adminPasswordFile = lib.mkOption {
|
||||
description = "Path to a file containing the admin password for kanidm. Do NOT use a file from the nix store here!";
|
||||
example = "/run/secrets/kanidm-admin-password";
|
||||
default = null;
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
};
|
||||
|
||||
idmAdminPasswordFile = lib.mkOption {
|
||||
description = ''
|
||||
Path to a file containing the idm admin password for kanidm. Do NOT use a file from the nix store here!
|
||||
If this is not given but provisioning is enabled, the idm_admin password will be reset on each restart.
|
||||
'';
|
||||
example = "/run/secrets/kanidm-idm-admin-password";
|
||||
default = null;
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
};
|
||||
|
||||
autoRemove = lib.mkOption {
|
||||
description = ''
|
||||
Determines whether deleting an entity in this provisioning config should automatically
|
||||
cause them to be removed from kanidm, too. This works because the provisioning tool tracks
|
||||
all entities it has ever created. If this is set to false, you need to explicitly specify
|
||||
`present = false` to delete an entity.
|
||||
'';
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
};
|
||||
|
||||
groups = lib.mkOption {
|
||||
description = "Provisioning of kanidm groups";
|
||||
default = {};
|
||||
type = lib.types.attrsOf (lib.types.submodule (groupSubmod: {
|
||||
options = {
|
||||
present = mkPresentOption "group";
|
||||
|
||||
members = lib.mkOption {
|
||||
description = "List of kanidm entities (persons, groups, ...) which are part of this group.";
|
||||
type = lib.types.listOf lib.types.str;
|
||||
apply = lib.unique;
|
||||
default = [];
|
||||
};
|
||||
};
|
||||
config.members = lib.concatLists (lib.flip lib.mapAttrsToList cfg.provision.persons (person: personCfg:
|
||||
lib.optional (personCfg.present && builtins.elem groupSubmod.config._module.args.name personCfg.groups) person
|
||||
));
|
||||
}));
|
||||
};
|
||||
|
||||
persons = lib.mkOption {
|
||||
description = "Provisioning of kanidm persons";
|
||||
default = {};
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
options = {
|
||||
present = mkPresentOption "person";
|
||||
|
||||
displayName = lib.mkOption {
|
||||
description = "Display name";
|
||||
type = lib.types.str;
|
||||
example = "My User";
|
||||
};
|
||||
|
||||
legalName = lib.mkOption {
|
||||
description = "Full legal name";
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
example = "Jane Doe";
|
||||
default = null;
|
||||
};
|
||||
|
||||
mailAddresses = lib.mkOption {
|
||||
description = "Mail addresses. First given address is considered the primary address.";
|
||||
type = lib.types.listOf lib.types.str;
|
||||
example = ["jane.doe@example.com"];
|
||||
default = [];
|
||||
};
|
||||
|
||||
groups = lib.mkOption {
|
||||
description = "List of groups this person should belong to.";
|
||||
type = lib.types.listOf lib.types.str;
|
||||
apply = lib.unique;
|
||||
default = [];
|
||||
};
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
systems.oauth2 = lib.mkOption {
|
||||
description = "Provisioning of oauth2 resource servers";
|
||||
default = {};
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
options = {
|
||||
present = mkPresentOption "oauth2 resource server";
|
||||
|
||||
public = lib.mkOption {
|
||||
description = "Whether this is a public client (enforces PKCE, doesn't use a basic secret)";
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
};
|
||||
|
||||
displayName = lib.mkOption {
|
||||
description = "Display name";
|
||||
type = lib.types.str;
|
||||
example = "Some Service";
|
||||
};
|
||||
|
||||
originUrl = lib.mkOption {
|
||||
description = "The origin URL of the service. OAuth2 redirects will only be allowed to sites under this origin. Must end with a slash.";
|
||||
type = lib.types.strMatching ".*://.*/$";
|
||||
example = "https://someservice.example.com/";
|
||||
};
|
||||
|
||||
originLanding = lib.mkOption {
|
||||
description = "When redirecting from the Kanidm Apps Listing page, some linked applications may need to land on a specific page to trigger oauth2/oidc interactions.";
|
||||
type = lib.types.str;
|
||||
example = "https://someservice.example.com/home";
|
||||
};
|
||||
|
||||
basicSecretFile = lib.mkOption {
|
||||
description = ''
|
||||
The basic secret to use for this service. If null, the random secret generated
|
||||
by kanidm will not be touched. Do NOT use a path from the nix store here!
|
||||
'';
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
example = "/run/secrets/some-oauth2-basic-secret";
|
||||
default = null;
|
||||
};
|
||||
|
||||
enableLocalhostRedirects = lib.mkOption {
|
||||
description = "Allow localhost redirects. Only for public clients.";
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
};
|
||||
|
||||
enableLegacyCrypto = lib.mkOption {
|
||||
description = "Enable legacy crypto on this client. Allows JWT signing algorthms like RS256.";
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
};
|
||||
|
||||
allowInsecureClientDisablePkce = lib.mkOption {
|
||||
description = ''
|
||||
Disable PKCE on this oauth2 resource server to work around insecure clients
|
||||
that may not support it. You should request the client to enable PKCE!
|
||||
Only for non-public clients.
|
||||
'';
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
};
|
||||
|
||||
preferShortUsername = lib.mkOption {
|
||||
description = "Use 'name' instead of 'spn' in the preferred_username claim";
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
};
|
||||
|
||||
scopeMaps = lib.mkOption {
|
||||
description = ''
|
||||
Maps kanidm groups to returned oauth scopes.
|
||||
See [Scope Relations](https://kanidm.github.io/kanidm/stable/integrations/oauth2.html#scope-relationships) for more information.
|
||||
'';
|
||||
type = lib.types.attrsOf (lib.types.listOf lib.types.str);
|
||||
default = {};
|
||||
};
|
||||
|
||||
supplementaryScopeMaps = lib.mkOption {
|
||||
description = ''
|
||||
Maps kanidm groups to additionally returned oauth scopes.
|
||||
See [Scope Relations](https://kanidm.github.io/kanidm/stable/integrations/oauth2.html#scope-relationships) for more information.
|
||||
'';
|
||||
type = lib.types.attrsOf (lib.types.listOf lib.types.str);
|
||||
default = {};
|
||||
};
|
||||
|
||||
removeOrphanedClaimMaps = lib.mkOption {
|
||||
description = "Whether claim maps not specified here but present in kanidm should be removed from kanidm.";
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
};
|
||||
|
||||
claimMaps = lib.mkOption {
|
||||
description = ''
|
||||
Adds additional claims (and values) based on which kanidm groups an authenticating party belongs to.
|
||||
See [Claim Maps](https://kanidm.github.io/kanidm/master/integrations/oauth2.html#custom-claim-maps) for more information.
|
||||
'';
|
||||
default = {};
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
options = {
|
||||
joinType = lib.mkOption {
|
||||
description = ''
|
||||
Determines how multiple values are joined to create the claim value.
|
||||
See [Claim Maps](https://kanidm.github.io/kanidm/master/integrations/oauth2.html#custom-claim-maps) for more information.
|
||||
'';
|
||||
type = lib.types.enum ["array" "csv" "ssv"];
|
||||
default = "array";
|
||||
};
|
||||
|
||||
valuesByGroup = lib.mkOption {
|
||||
description = "Maps kanidm groups to values for the claim.";
|
||||
default = {};
|
||||
type = lib.types.attrsOf (lib.types.listOf lib.types.str);
|
||||
};
|
||||
};
|
||||
});
|
||||
};
|
||||
};
|
||||
});
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf (cfg.enableClient || cfg.enableServer || cfg.enablePam) {
|
||||
assertions =
|
||||
assertions = let
|
||||
entityList = type: attrs: lib.flip lib.mapAttrsToList (filterPresent attrs) (name: _: { inherit type name; });
|
||||
entities =
|
||||
entityList "group" cfg.provision.groups
|
||||
++ entityList "person" cfg.provision.persons
|
||||
++ entityList "oauth2" cfg.provision.systems.oauth2;
|
||||
|
||||
# Accumulate entities by name. Track corresponding entity types for later duplicate check.
|
||||
entitiesByName = lib.foldl' (acc: { type, name }:
|
||||
acc // {
|
||||
${name} = (acc.${name} or []) ++ [type];
|
||||
}
|
||||
) {} entities;
|
||||
|
||||
assertGroupsKnown = opt: groups: let
|
||||
knownGroups = lib.attrNames (filterPresent cfg.provision.groups);
|
||||
unknownGroups = lib.subtractLists knownGroups groups;
|
||||
in {
|
||||
assertion = (cfg.enableServer && cfg.provision.enable) -> unknownGroups == [];
|
||||
message = "${opt} refers to unknown groups: ${toString unknownGroups}";
|
||||
};
|
||||
|
||||
assertEntitiesKnown = opt: entities: let
|
||||
unknownEntities = lib.subtractLists (lib.attrNames entitiesByName) entities;
|
||||
in {
|
||||
assertion = (cfg.enableServer && cfg.provision.enable) -> unknownEntities == [];
|
||||
message = "${opt} refers to unknown entities: ${toString unknownEntities}";
|
||||
};
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = !cfg.enableServer || ((cfg.serverSettings.tls_chain or null) == null) || (!lib.isStorePath cfg.serverSettings.tls_chain);
|
||||
@ -251,7 +596,69 @@ in
|
||||
the instance it follows.
|
||||
'';
|
||||
}
|
||||
];
|
||||
{
|
||||
assertion = cfg.provision.enable -> cfg.enableServer;
|
||||
message = "<option>services.kanidm.provision</option> requires <option>services.kanidm.enableServer</option> to be true";
|
||||
}
|
||||
# If any secret is provisioned, the kanidm package must have some required patches applied to it
|
||||
{
|
||||
assertion = (cfg.provision.enable &&
|
||||
(cfg.provision.adminPasswordFile != null
|
||||
|| cfg.provision.idmAdminPasswordFile != null
|
||||
|| lib.any (x: x.basicSecretFile != null) (lib.attrValues (filterPresent cfg.provision.systems.oauth2))
|
||||
)) -> cfg.package.enableSecretProvisioning;
|
||||
message = ''
|
||||
Specifying an admin account password or oauth2 basicSecretFile requires kanidm to be built with the secret provisioning patches.
|
||||
You may want to set `services.kanidm.package = pkgs.kanidm.withSecretProvisioning;`.
|
||||
'';
|
||||
}
|
||||
# Entity names must be globally unique:
|
||||
(let
|
||||
# Filter all names that occurred in more than one entity type.
|
||||
duplicateNames = lib.filterAttrs (_: v: builtins.length v > 1) entitiesByName;
|
||||
in {
|
||||
assertion = cfg.provision.enable -> duplicateNames == {};
|
||||
message = ''
|
||||
services.kanidm.provision requires all entity names (group, person, oauth2, ...) to be unique!
|
||||
${lib.concatLines (lib.mapAttrsToList (name: xs: " - '${name}' used as: ${toString xs}") duplicateNames)}'';
|
||||
})
|
||||
]
|
||||
++ lib.flip lib.mapAttrsToList (filterPresent cfg.provision.persons) (person: personCfg:
|
||||
assertGroupsKnown "services.kanidm.provision.persons.${person}.groups" personCfg.groups
|
||||
)
|
||||
++ lib.flip lib.mapAttrsToList (filterPresent cfg.provision.groups) (group: groupCfg:
|
||||
assertEntitiesKnown "services.kanidm.provision.groups.${group}.members" groupCfg.members
|
||||
)
|
||||
++ lib.concatLists (lib.flip lib.mapAttrsToList (filterPresent cfg.provision.systems.oauth2) (
|
||||
oauth2: oauth2Cfg:
|
||||
[
|
||||
(assertGroupsKnown "services.kanidm.provision.systems.oauth2.${oauth2}.scopeMaps" (lib.attrNames oauth2Cfg.scopeMaps))
|
||||
(assertGroupsKnown "services.kanidm.provision.systems.oauth2.${oauth2}.supplementaryScopeMaps" (lib.attrNames oauth2Cfg.supplementaryScopeMaps))
|
||||
]
|
||||
++ lib.concatLists (lib.flip lib.mapAttrsToList oauth2Cfg.claimMaps (claim: claimCfg: [
|
||||
(assertGroupsKnown "services.kanidm.provision.systems.oauth2.${oauth2}.claimMaps.${claim}.valuesByGroup" (lib.attrNames claimCfg.valuesByGroup))
|
||||
# At least one group must map to a value in each claim map
|
||||
{
|
||||
assertion = (cfg.provision.enable && cfg.enableServer) -> lib.any (xs: xs != []) (lib.attrValues claimCfg.valuesByGroup);
|
||||
message = "services.kanidm.provision.systems.oauth2.${oauth2}.claimMaps.${claim} does not specify any values for any group";
|
||||
}
|
||||
# Public clients cannot define a basic secret
|
||||
{
|
||||
assertion = (cfg.provision.enable && cfg.enableServer && oauth2Cfg.public) -> oauth2Cfg.basicSecretFile == null;
|
||||
message = "services.kanidm.provision.systems.oauth2.${oauth2} is a public client and thus cannot specify a basic secret";
|
||||
}
|
||||
# Public clients cannot disable PKCE
|
||||
{
|
||||
assertion = (cfg.provision.enable && cfg.enableServer && oauth2Cfg.public) -> !oauth2Cfg.allowInsecureClientDisablePkce;
|
||||
message = "services.kanidm.provision.systems.oauth2.${oauth2} is a public client and thus cannot disable PKCE";
|
||||
}
|
||||
# Non-public clients cannot enable localhost redirects
|
||||
{
|
||||
assertion = (cfg.provision.enable && cfg.enableServer && !oauth2Cfg.public) -> !oauth2Cfg.enableLocalhostRedirects;
|
||||
message = "services.kanidm.provision.systems.oauth2.${oauth2} is a non-public client and thus cannot enable localhost redirects";
|
||||
}
|
||||
]))
|
||||
));
|
||||
|
||||
environment.systemPackages = lib.mkIf cfg.enableClient [ cfg.package ];
|
||||
|
||||
@ -277,6 +684,7 @@ in
|
||||
StateDirectoryMode = "0700";
|
||||
RuntimeDirectory = "kanidmd";
|
||||
ExecStart = "${cfg.package}/bin/kanidmd server -c ${serverConfigFile}";
|
||||
ExecStartPost = lib.mkIf cfg.provision.enable postStartScript;
|
||||
User = "kanidm";
|
||||
Group = "kanidm";
|
||||
|
||||
@ -419,6 +827,6 @@ in
|
||||
];
|
||||
};
|
||||
|
||||
meta.maintainers = with lib.maintainers; [ erictapen Flakebi ];
|
||||
meta.maintainers = with lib.maintainers; [ erictapen Flakebi oddlama ];
|
||||
meta.buildDocsInSandbox = false;
|
||||
}
|
||||
|
@ -484,6 +484,7 @@ in {
|
||||
k3s = handleTest ./k3s {};
|
||||
kafka = handleTest ./kafka.nix {};
|
||||
kanidm = handleTest ./kanidm.nix {};
|
||||
kanidm-provisioning = handleTest ./kanidm-provisioning.nix {};
|
||||
karma = handleTest ./karma.nix {};
|
||||
kavita = handleTest ./kavita.nix {};
|
||||
kbd-setfont-decompress = handleTest ./kbd-setfont-decompress.nix {};
|
||||
|
505
nixos/tests/kanidm-provisioning.nix
Normal file
505
nixos/tests/kanidm-provisioning.nix
Normal file
@ -0,0 +1,505 @@
|
||||
import ./make-test-python.nix (
|
||||
{ pkgs, ... }:
|
||||
let
|
||||
certs = import ./common/acme/server/snakeoil-certs.nix;
|
||||
serverDomain = certs.domain;
|
||||
|
||||
provisionAdminPassword = "very-strong-password-for-admin";
|
||||
provisionIdmAdminPassword = "very-strong-password-for-idm-admin";
|
||||
provisionIdmAdminPassword2 = "very-strong-alternative-password-for-idm-admin";
|
||||
in
|
||||
{
|
||||
name = "kanidm-provisioning";
|
||||
meta.maintainers = with pkgs.lib.maintainers; [ oddlama ];
|
||||
|
||||
nodes.provision =
|
||||
{ pkgs, lib, ... }:
|
||||
{
|
||||
services.kanidm = {
|
||||
package = pkgs.kanidm.withSecretProvisioning;
|
||||
enableServer = true;
|
||||
serverSettings = {
|
||||
origin = "https://${serverDomain}";
|
||||
domain = serverDomain;
|
||||
bindaddress = "[::]:443";
|
||||
ldapbindaddress = "[::1]:636";
|
||||
tls_chain = certs."${serverDomain}".cert;
|
||||
tls_key = certs."${serverDomain}".key;
|
||||
};
|
||||
# So we can check whether provisioning did what we wanted
|
||||
enableClient = true;
|
||||
clientSettings = {
|
||||
uri = "https://${serverDomain}";
|
||||
verify_ca = true;
|
||||
verify_hostnames = true;
|
||||
};
|
||||
};
|
||||
|
||||
specialisation.credentialProvision.configuration =
|
||||
{ ... }:
|
||||
{
|
||||
services.kanidm.provision = lib.mkForce {
|
||||
enable = true;
|
||||
adminPasswordFile = pkgs.writeText "admin-pw" provisionAdminPassword;
|
||||
idmAdminPasswordFile = pkgs.writeText "idm-admin-pw" provisionIdmAdminPassword;
|
||||
};
|
||||
};
|
||||
|
||||
specialisation.changedCredential.configuration =
|
||||
{ ... }:
|
||||
{
|
||||
services.kanidm.provision = lib.mkForce {
|
||||
enable = true;
|
||||
idmAdminPasswordFile = pkgs.writeText "idm-admin-pw" provisionIdmAdminPassword2;
|
||||
};
|
||||
};
|
||||
|
||||
specialisation.addEntities.configuration =
|
||||
{ ... }:
|
||||
{
|
||||
services.kanidm.provision = lib.mkForce {
|
||||
enable = true;
|
||||
# Test whether credential recovery works without specific idmAdmin password
|
||||
#idmAdminPasswordFile =
|
||||
|
||||
groups.supergroup1 = {
|
||||
members = [ "testgroup1" ];
|
||||
};
|
||||
|
||||
groups.testgroup1 = { };
|
||||
|
||||
persons.testuser1 = {
|
||||
displayName = "Test User";
|
||||
legalName = "Jane Doe";
|
||||
mailAddresses = [ "jane.doe@example.com" ];
|
||||
groups = [
|
||||
"testgroup1"
|
||||
"service1-access"
|
||||
];
|
||||
};
|
||||
|
||||
persons.testuser2 = {
|
||||
displayName = "Powerful Test User";
|
||||
legalName = "Ryouiki Tenkai";
|
||||
groups = [ "service1-admin" ];
|
||||
};
|
||||
|
||||
groups.service1-access = { };
|
||||
groups.service1-admin = { };
|
||||
systems.oauth2.service1 = {
|
||||
displayName = "Service One";
|
||||
originUrl = "https://one.example.com/";
|
||||
originLanding = "https://one.example.com/landing";
|
||||
basicSecretFile = pkgs.writeText "bs-service1" "very-strong-secret-for-service1";
|
||||
scopeMaps.service1-access = [
|
||||
"openid"
|
||||
"email"
|
||||
"profile"
|
||||
];
|
||||
supplementaryScopeMaps.service1-admin = [ "admin" ];
|
||||
claimMaps.groups = {
|
||||
valuesByGroup.service1-admin = [ "admin" ];
|
||||
};
|
||||
};
|
||||
|
||||
systems.oauth2.service2 = {
|
||||
displayName = "Service Two";
|
||||
originUrl = "https://two.example.com/";
|
||||
originLanding = "https://landing2.example.com/";
|
||||
# Test not setting secret
|
||||
# basicSecretFile =
|
||||
allowInsecureClientDisablePkce = true;
|
||||
preferShortUsername = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
specialisation.changeAttributes.configuration =
|
||||
{ ... }:
|
||||
{
|
||||
services.kanidm.provision = lib.mkForce {
|
||||
enable = true;
|
||||
# Changing admin credentials at any time should not be a problem:
|
||||
idmAdminPasswordFile = pkgs.writeText "idm-admin-pw" provisionIdmAdminPassword;
|
||||
|
||||
groups.supergroup1 = {
|
||||
#members = ["testgroup1"];
|
||||
};
|
||||
|
||||
groups.testgroup1 = { };
|
||||
|
||||
persons.testuser1 = {
|
||||
displayName = "Test User (changed)";
|
||||
legalName = "Jane Doe (changed)";
|
||||
mailAddresses = [
|
||||
"jane.doe@example.com"
|
||||
"second.doe@example.com"
|
||||
];
|
||||
groups = [
|
||||
#"testgroup1"
|
||||
"service1-access"
|
||||
];
|
||||
};
|
||||
|
||||
persons.testuser2 = {
|
||||
displayName = "Powerful Test User (changed)";
|
||||
legalName = "Ryouiki Tenkai (changed)";
|
||||
groups = [ "service1-admin" ];
|
||||
};
|
||||
|
||||
groups.service1-access = { };
|
||||
groups.service1-admin = { };
|
||||
systems.oauth2.service1 = {
|
||||
displayName = "Service One (changed)";
|
||||
originUrl = "https://changed-one.example.com/";
|
||||
originLanding = "https://changed-one.example.com/landing-changed";
|
||||
basicSecretFile = pkgs.writeText "bs-service1" "changed-very-strong-secret-for-service1";
|
||||
scopeMaps.service1-access = [
|
||||
"openid"
|
||||
"email"
|
||||
#"profile"
|
||||
];
|
||||
supplementaryScopeMaps.service1-admin = [ "adminchanged" ];
|
||||
claimMaps.groups = {
|
||||
valuesByGroup.service1-admin = [ "adminchanged" ];
|
||||
};
|
||||
};
|
||||
|
||||
systems.oauth2.service2 = {
|
||||
displayName = "Service Two (changed)";
|
||||
originUrl = "https://changed-two.example.com/";
|
||||
originLanding = "https://changed-landing2.example.com/";
|
||||
# Test not setting secret
|
||||
# basicSecretFile =
|
||||
allowInsecureClientDisablePkce = false;
|
||||
preferShortUsername = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
specialisation.removeAttributes.configuration =
|
||||
{ ... }:
|
||||
{
|
||||
services.kanidm.provision = lib.mkForce {
|
||||
enable = true;
|
||||
idmAdminPasswordFile = pkgs.writeText "idm-admin-pw" provisionIdmAdminPassword;
|
||||
|
||||
groups.supergroup1 = { };
|
||||
|
||||
persons.testuser1 = {
|
||||
displayName = "Test User (changed)";
|
||||
};
|
||||
|
||||
persons.testuser2 = {
|
||||
displayName = "Powerful Test User (changed)";
|
||||
groups = [ "service1-admin" ];
|
||||
};
|
||||
|
||||
groups.service1-access = { };
|
||||
groups.service1-admin = { };
|
||||
systems.oauth2.service1 = {
|
||||
displayName = "Service One (changed)";
|
||||
originUrl = "https://changed-one.example.com/";
|
||||
originLanding = "https://changed-one.example.com/landing-changed";
|
||||
basicSecretFile = pkgs.writeText "bs-service1" "changed-very-strong-secret-for-service1";
|
||||
# Removing maps requires setting them to the empty list
|
||||
scopeMaps.service1-access = [ ];
|
||||
supplementaryScopeMaps.service1-admin = [ ];
|
||||
};
|
||||
|
||||
systems.oauth2.service2 = {
|
||||
displayName = "Service Two (changed)";
|
||||
originUrl = "https://changed-two.example.com/";
|
||||
originLanding = "https://changed-landing2.example.com/";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
specialisation.removeEntities.configuration =
|
||||
{ ... }:
|
||||
{
|
||||
services.kanidm.provision = lib.mkForce {
|
||||
enable = true;
|
||||
idmAdminPasswordFile = pkgs.writeText "idm-admin-pw" provisionIdmAdminPassword;
|
||||
};
|
||||
};
|
||||
|
||||
security.pki.certificateFiles = [ certs.ca.cert ];
|
||||
|
||||
networking.hosts."::1" = [ serverDomain ];
|
||||
networking.firewall.allowedTCPPorts = [ 443 ];
|
||||
|
||||
users.users.kanidm.shell = pkgs.bashInteractive;
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
kanidm
|
||||
openldap
|
||||
ripgrep
|
||||
jq
|
||||
];
|
||||
};
|
||||
|
||||
testScript =
|
||||
{ nodes, ... }:
|
||||
let
|
||||
# We need access to the config file in the test script.
|
||||
filteredConfig = pkgs.lib.converge (pkgs.lib.filterAttrsRecursive (
|
||||
_: v: v != null
|
||||
)) nodes.provision.services.kanidm.serverSettings;
|
||||
serverConfigFile = (pkgs.formats.toml { }).generate "server.toml" filteredConfig;
|
||||
|
||||
specialisations = "${nodes.provision.system.build.toplevel}/specialisation";
|
||||
in
|
||||
''
|
||||
import re
|
||||
|
||||
def assert_contains(haystack, needle):
|
||||
if needle not in haystack:
|
||||
print("The haystack that will cause the following exception is:")
|
||||
print("---")
|
||||
print(haystack)
|
||||
print("---")
|
||||
raise Exception(f"Expected string '{needle}' was not found")
|
||||
|
||||
def assert_matches(haystack, expr):
|
||||
if not re.search(expr, haystack):
|
||||
print("The haystack that will cause the following exception is:")
|
||||
print("---")
|
||||
print(haystack)
|
||||
print("---")
|
||||
raise Exception(f"Expected regex '{expr}' did not match")
|
||||
|
||||
def assert_lacks(haystack, needle):
|
||||
if needle in haystack:
|
||||
print("The haystack that will cause the following exception is:")
|
||||
print("---")
|
||||
print(haystack, end="")
|
||||
print("---")
|
||||
raise Exception(f"Unexpected string '{needle}' was found")
|
||||
|
||||
provision.start()
|
||||
|
||||
def provision_login(pw):
|
||||
provision.wait_for_unit("kanidm.service")
|
||||
provision.wait_until_succeeds("curl -Lsf https://${serverDomain} | grep Kanidm")
|
||||
if pw is None:
|
||||
pw = provision.succeed("su - kanidm -c 'kanidmd recover-account -c ${serverConfigFile} idm_admin 2>&1 | rg -o \'[A-Za-z0-9]{48}\' '").strip().removeprefix("'").removesuffix("'")
|
||||
out = provision.succeed(f"KANIDM_PASSWORD={pw} kanidm login -D idm_admin")
|
||||
assert_contains(out, "Login Success for idm_admin")
|
||||
|
||||
with subtest("Test Provisioning - setup"):
|
||||
provision_login(None)
|
||||
provision.succeed("kanidm logout -D idm_admin")
|
||||
|
||||
with subtest("Test Provisioning - credentialProvision"):
|
||||
provision.succeed('${specialisations}/credentialProvision/bin/switch-to-configuration test')
|
||||
provision_login("${provisionIdmAdminPassword}")
|
||||
|
||||
# Test provisioned admin pw
|
||||
out = provision.succeed("KANIDM_PASSWORD=${provisionAdminPassword} kanidm login -D admin")
|
||||
assert_contains(out, "Login Success for admin")
|
||||
provision.succeed("kanidm logout -D admin")
|
||||
provision.succeed("kanidm logout -D idm_admin")
|
||||
|
||||
with subtest("Test Provisioning - changedCredential"):
|
||||
provision.succeed('${specialisations}/changedCredential/bin/switch-to-configuration test')
|
||||
provision_login("${provisionIdmAdminPassword2}")
|
||||
provision.succeed("kanidm logout -D idm_admin")
|
||||
|
||||
with subtest("Test Provisioning - addEntities"):
|
||||
provision.succeed('${specialisations}/addEntities/bin/switch-to-configuration test')
|
||||
# Unspecified idm admin password
|
||||
provision_login(None)
|
||||
|
||||
out = provision.succeed("kanidm group get testgroup1")
|
||||
assert_contains(out, "name: testgroup1")
|
||||
|
||||
out = provision.succeed("kanidm group get supergroup1")
|
||||
assert_contains(out, "name: supergroup1")
|
||||
assert_contains(out, "member: testgroup1")
|
||||
|
||||
out = provision.succeed("kanidm person get testuser1")
|
||||
assert_contains(out, "name: testuser1")
|
||||
assert_contains(out, "displayname: Test User")
|
||||
assert_contains(out, "legalname: Jane Doe")
|
||||
assert_contains(out, "mail: jane.doe@example.com")
|
||||
assert_contains(out, "memberof: testgroup1")
|
||||
assert_contains(out, "memberof: service1-access")
|
||||
|
||||
out = provision.succeed("kanidm person get testuser2")
|
||||
assert_contains(out, "name: testuser2")
|
||||
assert_contains(out, "displayname: Powerful Test User")
|
||||
assert_contains(out, "legalname: Ryouiki Tenkai")
|
||||
assert_contains(out, "memberof: service1-admin")
|
||||
assert_lacks(out, "mail:")
|
||||
|
||||
out = provision.succeed("kanidm group get service1-access")
|
||||
assert_contains(out, "name: service1-access")
|
||||
|
||||
out = provision.succeed("kanidm group get service1-admin")
|
||||
assert_contains(out, "name: service1-admin")
|
||||
|
||||
out = provision.succeed("kanidm system oauth2 get service1")
|
||||
assert_contains(out, "name: service1")
|
||||
assert_contains(out, "displayname: Service One")
|
||||
assert_contains(out, "oauth2_rs_origin: https://one.example.com/")
|
||||
assert_contains(out, "oauth2_rs_origin_landing: https://one.example.com/landing")
|
||||
assert_matches(out, 'oauth2_rs_scope_map: service1-access.*{"email", "openid", "profile"}')
|
||||
assert_matches(out, 'oauth2_rs_sup_scope_map: service1-admin.*{"admin"}')
|
||||
assert_matches(out, 'oauth2_rs_claim_map: groups:.*"admin"')
|
||||
|
||||
out = provision.succeed("kanidm system oauth2 show-basic-secret service1")
|
||||
assert_contains(out, "very-strong-secret-for-service1")
|
||||
|
||||
out = provision.succeed("kanidm system oauth2 get service2")
|
||||
assert_contains(out, "name: service2")
|
||||
assert_contains(out, "displayname: Service Two")
|
||||
assert_contains(out, "oauth2_rs_origin: https://two.example.com/")
|
||||
assert_contains(out, "oauth2_rs_origin_landing: https://landing2.example.com/")
|
||||
assert_contains(out, "oauth2_allow_insecure_client_disable_pkce: true")
|
||||
assert_contains(out, "oauth2_prefer_short_username: true")
|
||||
|
||||
provision.succeed("kanidm logout -D idm_admin")
|
||||
|
||||
with subtest("Test Provisioning - changeAttributes"):
|
||||
provision.succeed('${specialisations}/changeAttributes/bin/switch-to-configuration test')
|
||||
provision_login("${provisionIdmAdminPassword}")
|
||||
|
||||
out = provision.succeed("kanidm group get testgroup1")
|
||||
assert_contains(out, "name: testgroup1")
|
||||
|
||||
out = provision.succeed("kanidm group get supergroup1")
|
||||
assert_contains(out, "name: supergroup1")
|
||||
assert_lacks(out, "member: testgroup1")
|
||||
|
||||
out = provision.succeed("kanidm person get testuser1")
|
||||
assert_contains(out, "name: testuser1")
|
||||
assert_contains(out, "displayname: Test User (changed)")
|
||||
assert_contains(out, "legalname: Jane Doe (changed)")
|
||||
assert_contains(out, "mail: jane.doe@example.com")
|
||||
assert_contains(out, "mail: second.doe@example.com")
|
||||
assert_lacks(out, "memberof: testgroup1")
|
||||
assert_contains(out, "memberof: service1-access")
|
||||
|
||||
out = provision.succeed("kanidm person get testuser2")
|
||||
assert_contains(out, "name: testuser2")
|
||||
assert_contains(out, "displayname: Powerful Test User (changed)")
|
||||
assert_contains(out, "legalname: Ryouiki Tenkai (changed)")
|
||||
assert_contains(out, "memberof: service1-admin")
|
||||
assert_lacks(out, "mail:")
|
||||
|
||||
out = provision.succeed("kanidm group get service1-access")
|
||||
assert_contains(out, "name: service1-access")
|
||||
|
||||
out = provision.succeed("kanidm group get service1-admin")
|
||||
assert_contains(out, "name: service1-admin")
|
||||
|
||||
out = provision.succeed("kanidm system oauth2 get service1")
|
||||
assert_contains(out, "name: service1")
|
||||
assert_contains(out, "displayname: Service One (changed)")
|
||||
assert_contains(out, "oauth2_rs_origin: https://changed-one.example.com/")
|
||||
assert_contains(out, "oauth2_rs_origin_landing: https://changed-one.example.com/landing")
|
||||
assert_matches(out, 'oauth2_rs_scope_map: service1-access.*{"email", "openid"}')
|
||||
assert_matches(out, 'oauth2_rs_sup_scope_map: service1-admin.*{"adminchanged"}')
|
||||
assert_matches(out, 'oauth2_rs_claim_map: groups:.*"adminchanged"')
|
||||
|
||||
out = provision.succeed("kanidm system oauth2 show-basic-secret service1")
|
||||
assert_contains(out, "changed-very-strong-secret-for-service1")
|
||||
|
||||
out = provision.succeed("kanidm system oauth2 get service2")
|
||||
assert_contains(out, "name: service2")
|
||||
assert_contains(out, "displayname: Service Two (changed)")
|
||||
assert_contains(out, "oauth2_rs_origin: https://changed-two.example.com/")
|
||||
assert_contains(out, "oauth2_rs_origin_landing: https://changed-landing2.example.com/")
|
||||
assert_lacks(out, "oauth2_allow_insecure_client_disable_pkce: true")
|
||||
assert_lacks(out, "oauth2_prefer_short_username: true")
|
||||
|
||||
provision.succeed("kanidm logout -D idm_admin")
|
||||
|
||||
with subtest("Test Provisioning - removeAttributes"):
|
||||
provision.succeed('${specialisations}/removeAttributes/bin/switch-to-configuration test')
|
||||
provision_login("${provisionIdmAdminPassword}")
|
||||
|
||||
out = provision.succeed("kanidm group get testgroup1")
|
||||
assert_lacks(out, "name: testgroup1")
|
||||
|
||||
out = provision.succeed("kanidm group get supergroup1")
|
||||
assert_contains(out, "name: supergroup1")
|
||||
assert_lacks(out, "member: testgroup1")
|
||||
|
||||
out = provision.succeed("kanidm person get testuser1")
|
||||
assert_contains(out, "name: testuser1")
|
||||
assert_contains(out, "displayname: Test User (changed)")
|
||||
assert_lacks(out, "legalname: Jane Doe (changed)")
|
||||
assert_lacks(out, "mail: jane.doe@example.com")
|
||||
assert_lacks(out, "mail: second.doe@example.com")
|
||||
assert_lacks(out, "memberof: testgroup1")
|
||||
assert_lacks(out, "memberof: service1-access")
|
||||
|
||||
out = provision.succeed("kanidm person get testuser2")
|
||||
assert_contains(out, "name: testuser2")
|
||||
assert_contains(out, "displayname: Powerful Test User (changed)")
|
||||
assert_lacks(out, "legalname: Ryouiki Tenkai (changed)")
|
||||
assert_contains(out, "memberof: service1-admin")
|
||||
assert_lacks(out, "mail:")
|
||||
|
||||
out = provision.succeed("kanidm group get service1-access")
|
||||
assert_contains(out, "name: service1-access")
|
||||
|
||||
out = provision.succeed("kanidm group get service1-admin")
|
||||
assert_contains(out, "name: service1-admin")
|
||||
|
||||
out = provision.succeed("kanidm system oauth2 get service1")
|
||||
assert_contains(out, "name: service1")
|
||||
assert_contains(out, "displayname: Service One (changed)")
|
||||
assert_contains(out, "oauth2_rs_origin: https://changed-one.example.com/")
|
||||
assert_contains(out, "oauth2_rs_origin_landing: https://changed-one.example.com/landing")
|
||||
assert_lacks(out, "oauth2_rs_scope_map")
|
||||
assert_lacks(out, "oauth2_rs_sup_scope_map")
|
||||
assert_lacks(out, "oauth2_rs_claim_map")
|
||||
|
||||
out = provision.succeed("kanidm system oauth2 show-basic-secret service1")
|
||||
assert_contains(out, "changed-very-strong-secret-for-service1")
|
||||
|
||||
out = provision.succeed("kanidm system oauth2 get service2")
|
||||
assert_contains(out, "name: service2")
|
||||
assert_contains(out, "displayname: Service Two (changed)")
|
||||
assert_contains(out, "oauth2_rs_origin: https://changed-two.example.com/")
|
||||
assert_contains(out, "oauth2_rs_origin_landing: https://changed-landing2.example.com/")
|
||||
assert_lacks(out, "oauth2_allow_insecure_client_disable_pkce: true")
|
||||
assert_lacks(out, "oauth2_prefer_short_username: true")
|
||||
|
||||
provision.succeed("kanidm logout -D idm_admin")
|
||||
|
||||
with subtest("Test Provisioning - removeEntities"):
|
||||
provision.succeed('${specialisations}/removeEntities/bin/switch-to-configuration test')
|
||||
provision_login("${provisionIdmAdminPassword}")
|
||||
|
||||
out = provision.succeed("kanidm group get testgroup1")
|
||||
assert_lacks(out, "name: testgroup1")
|
||||
|
||||
out = provision.succeed("kanidm group get supergroup1")
|
||||
assert_lacks(out, "name: supergroup1")
|
||||
|
||||
out = provision.succeed("kanidm person get testuser1")
|
||||
assert_lacks(out, "name: testuser1")
|
||||
|
||||
out = provision.succeed("kanidm person get testuser2")
|
||||
assert_lacks(out, "name: testuser2")
|
||||
|
||||
out = provision.succeed("kanidm group get service1-access")
|
||||
assert_lacks(out, "name: service1-access")
|
||||
|
||||
out = provision.succeed("kanidm group get service1-admin")
|
||||
assert_lacks(out, "name: service1-admin")
|
||||
|
||||
out = provision.succeed("kanidm system oauth2 get service1")
|
||||
assert_lacks(out, "name: service1")
|
||||
|
||||
out = provision.succeed("kanidm system oauth2 get service2")
|
||||
assert_lacks(out, "name: service2")
|
||||
|
||||
provision.succeed("kanidm logout -D idm_admin")
|
||||
'';
|
||||
}
|
||||
)
|
@ -9,9 +9,9 @@ import ./make-test-python.nix ({ pkgs, ... }:
|
||||
in
|
||||
{
|
||||
name = "kanidm";
|
||||
meta.maintainers = with pkgs.lib.maintainers; [ erictapen Flakebi ];
|
||||
meta.maintainers = with pkgs.lib.maintainers; [ erictapen Flakebi oddlama ];
|
||||
|
||||
nodes.server = { config, pkgs, lib, ... }: {
|
||||
nodes.server = { pkgs, ... }: {
|
||||
services.kanidm = {
|
||||
enableServer = true;
|
||||
serverSettings = {
|
||||
@ -34,7 +34,7 @@ import ./make-test-python.nix ({ pkgs, ... }:
|
||||
environment.systemPackages = with pkgs; [ kanidm openldap ripgrep ];
|
||||
};
|
||||
|
||||
nodes.client = { pkgs, nodes, ... }: {
|
||||
nodes.client = { nodes, ... }: {
|
||||
services.kanidm = {
|
||||
enableClient = true;
|
||||
clientSettings = {
|
||||
@ -62,10 +62,10 @@ import ./make-test-python.nix ({ pkgs, ... }:
|
||||
(pkgs.lib.filterAttrsRecursive (_: v: v != null))
|
||||
nodes.server.services.kanidm.serverSettings;
|
||||
serverConfigFile = (pkgs.formats.toml { }).generate "server.toml" filteredConfig;
|
||||
|
||||
in
|
||||
''
|
||||
start_all()
|
||||
server.start()
|
||||
client.start()
|
||||
server.wait_for_unit("kanidm.service")
|
||||
client.systemctl("start network-online.target")
|
||||
client.wait_for_unit("network-online.target")
|
||||
@ -122,5 +122,8 @@ import ./make-test-python.nix ({ pkgs, ... }:
|
||||
client.wait_until_succeeds("systemctl is-active user@$(id -u testuser).service")
|
||||
client.send_chars("touch done\n")
|
||||
client.wait_for_file("/home/testuser@${serverDomain}/done")
|
||||
|
||||
server.shutdown()
|
||||
client.shutdown()
|
||||
'';
|
||||
})
|
||||
|
@ -107,7 +107,7 @@ rustPlatform.buildRustPackage rec {
|
||||
|
||||
passthru = {
|
||||
tests = {
|
||||
inherit (nixosTests) kanidm;
|
||||
inherit (nixosTests) kanidm kanidm-provisioning;
|
||||
};
|
||||
|
||||
updateScript = nix-update-script { };
|
||||
|
Loading…
Reference in New Issue
Block a user