add declarative lldap using secrets contract

This commit is contained in:
ibizaman 2025-07-17 09:41:04 +02:00 committed by Pierre Penninckx
parent 94a5b7710b
commit b50f45e17e
8 changed files with 1260 additions and 9 deletions

View file

@ -311,8 +311,11 @@
"blocks-lldap-global-setup": [
"blocks-lldap.html#blocks-lldap-global-setup"
],
"blocks-lldap-manage-user-group": [
"blocks-lldap.html#blocks-lldap-manage-user-group"
"blocks-lldap-manage-groups": [
"blocks-lldap.html#blocks-lldap-manage-groups"
],
"blocks-lldap-manage-users": [
"blocks-lldap.html#blocks-lldap-manage-users"
],
"blocks-lldap-options": [
"blocks-lldap.html#blocks-lldap-options"
@ -362,6 +365,105 @@
"blocks-lldap-options-shb.lldap.enable": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.enable"
],
"blocks-lldap-options-shb.lldap.ensureGroupFields": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields"
],
"blocks-lldap-options-shb.lldap.ensureGroupFields._name_.attributeType": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.attributeType"
],
"blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isEditable": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isEditable"
],
"blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isList": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isList"
],
"blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isVisible": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isVisible"
],
"blocks-lldap-options-shb.lldap.ensureGroupFields._name_.name": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.name"
],
"blocks-lldap-options-shb.lldap.ensureGroups": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroups"
],
"blocks-lldap-options-shb.lldap.ensureGroups._name_.name": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroups._name_.name"
],
"blocks-lldap-options-shb.lldap.ensureUserFields": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields"
],
"blocks-lldap-options-shb.lldap.ensureUserFields._name_.attributeType": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.attributeType"
],
"blocks-lldap-options-shb.lldap.ensureUserFields._name_.isEditable": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.isEditable"
],
"blocks-lldap-options-shb.lldap.ensureUserFields._name_.isList": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.isList"
],
"blocks-lldap-options-shb.lldap.ensureUserFields._name_.isVisible": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.isVisible"
],
"blocks-lldap-options-shb.lldap.ensureUserFields._name_.name": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.name"
],
"blocks-lldap-options-shb.lldap.ensureUsers": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers"
],
"blocks-lldap-options-shb.lldap.ensureUsers._name_.avatar_file": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.avatar_file"
],
"blocks-lldap-options-shb.lldap.ensureUsers._name_.avatar_url": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.avatar_url"
],
"blocks-lldap-options-shb.lldap.ensureUsers._name_.displayName": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.displayName"
],
"blocks-lldap-options-shb.lldap.ensureUsers._name_.email": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.email"
],
"blocks-lldap-options-shb.lldap.ensureUsers._name_.firstName": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.firstName"
],
"blocks-lldap-options-shb.lldap.ensureUsers._name_.gravatar_avatar": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.gravatar_avatar"
],
"blocks-lldap-options-shb.lldap.ensureUsers._name_.groups": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.groups"
],
"blocks-lldap-options-shb.lldap.ensureUsers._name_.id": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.id"
],
"blocks-lldap-options-shb.lldap.ensureUsers._name_.lastName": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.lastName"
],
"blocks-lldap-options-shb.lldap.ensureUsers._name_.password": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password"
],
"blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request"
],
"blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.group": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.group"
],
"blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.mode": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.mode"
],
"blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.owner": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.owner"
],
"blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.restartUnits": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.restartUnits"
],
"blocks-lldap-options-shb.lldap.ensureUsers._name_.password.result": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.result"
],
"blocks-lldap-options-shb.lldap.ensureUsers._name_.password.result.path": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.result.path"
],
"blocks-lldap-options-shb.lldap.ensureUsers._name_.weser_avatar": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.weser_avatar"
],
"blocks-lldap-options-shb.lldap.jwtSecret": [
"blocks-lldap.html#blocks-lldap-options-shb.lldap.jwtSecret"
],

View file

@ -15,6 +15,11 @@
let
originPkgs = nixpkgs.legacyPackages.${system};
shbPatches = originPkgs.lib.optionals (system == "x86_64-linux") [
# Get rid of lldap patches when https://github.com/NixOS/nixpkgs/pull/425923 is merged.
./patches/0001-lldap-lldap-0.6.1-unstable-2025-07-16.patch
./patches/0002-lldap-add-options-to-set-important-secrets.patch
./patches/0003-lldap-bootstrap-init-unstable-2025-07-16-lldap-add-e.patch
# Leaving commented out as an example.
# (originPkgs.fetchpatch {
# url = "https://github.com/NixOS/nixpkgs/pull/317107.patch";

View file

@ -6,6 +6,46 @@ let
contracts = pkgs.callPackage ../contracts {};
fqdn = "${cfg.subdomain}.${cfg.domain}";
inherit (lib) mkOption types;
ensureFormat = pkgs.formats.json { };
ensureFieldsOptions = name: {
name = mkOption {
type = types.str;
description = "Name of the field.";
default = name;
};
attributeType = mkOption {
type = types.enum [
"STRING"
"INTEGER"
"JPEG"
"DATE_TIME"
];
description = "Attribute type.";
};
isEditable = mkOption {
type = types.bool;
description = "Is field editable.";
default = true;
};
isList = mkOption {
type = types.bool;
description = "Is field a list.";
default = false;
};
isVisible = mkOption {
type = types.bool;
description = "Is field visible in UI.";
default = true;
};
};
in
{
options.shb.lldap = {
@ -118,10 +158,155 @@ in
};
};
};
ensureUsers = mkOption {
description = ''
Create the users defined here on service startup.
If `enforceEnsure` option is `true`, the groups
users belong to must be present in the `ensureGroups` option.
Non-default options must be added to the `ensureGroupFields` option.
'';
default = { };
type = types.attrsOf (
types.submodule (
{ name, ... }:
{
freeformType = ensureFormat.type;
options = {
id = mkOption {
type = types.str;
description = "Username.";
default = name;
};
email = mkOption {
type = types.str;
description = "Email.";
};
password = mkOption {
description = "Password.";
type = lib.types.submodule {
options = contracts.secret.mkRequester {
mode = "0440";
owner = "lldap";
group = "lldap";
restartUnits = [ "lldap.service" ];
};
};
};
displayName = mkOption {
type = types.nullOr types.str;
default = null;
description = "Display name.";
};
firstName = mkOption {
type = types.nullOr types.str;
default = null;
description = "First name.";
};
lastName = mkOption {
type = types.nullOr types.str;
default = null;
description = "Last name.";
};
avatar_file = mkOption {
type = types.nullOr types.str;
default = null;
description = "Avatar file. Must be a valid path to jpeg file (ignored if avatar_url specified)";
};
avatar_url = mkOption {
type = types.nullOr types.str;
default = null;
description = "Avatar url. must be a valid URL to jpeg file (ignored if gravatar_avatar specified)";
};
gravatar_avatar = mkOption {
type = types.nullOr types.str;
default = null;
description = "Get avatar from Gravatar using the email.";
};
weser_avatar = mkOption {
type = types.nullOr types.str;
default = null;
description = "Convert avatar retrieved by gravatar or the URL.";
};
groups = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Groups the user would be a member of (all the groups must be specified in group config files).";
};
};
}
)
);
};
ensureGroups = mkOption {
description = ''
Create the groups defined here on service startup.
Non-default options must be added to the `ensureGroupFields` option.
'';
default = { };
type = types.attrsOf (
types.submodule (
{ name, ... }:
{
freeformType = ensureFormat.type;
options = {
name = mkOption {
type = types.str;
description = "Name of the group.";
default = name;
};
};
}
)
);
};
ensureUserFields = mkOption {
description = "Extra fields for users";
default = { };
type = types.attrsOf (
types.submodule (
{ name, ... }:
{
options = ensureFieldsOptions name;
}
)
);
};
ensureGroupFields = mkOption {
description = "Extra fields for groups";
default = { };
type = types.attrsOf (
types.submodule (
{ name, ... }:
{
options = ensureFieldsOptions name;
}
)
);
};
};
config = lib.mkIf cfg.enable {
services.nginx = {
enable = true;
@ -151,10 +336,12 @@ in
services.lldap = {
enable = true;
environment = {
LLDAP_JWT_SECRET_FILE = toString cfg.jwtSecret.result.path;
LLDAP_LDAP_USER_PASS_FILE = toString cfg.ldapUserPassword.result.path;
adminPasswordFile = toString cfg.ldapUserPassword.result.path;
jwtSecretFile = toString cfg.jwtSecret.result.path;
resetAdminPassword = "always";
enforceEnsure = true;
environment = {
RUST_LOG = lib.mkIf cfg.debug "debug";
};
@ -170,6 +357,11 @@ in
verbose = cfg.debug;
};
inherit (cfg) ensureGroups ensureUserFields ensureGroupFields;
ensureUsers = lib.mapAttrs (n: v: (lib.removeAttrs v [ "password" ]) // {
"password_file" = v.password.result.path;
}) cfg.ensureUsers;
};
};
}

View file

@ -53,14 +53,94 @@ by adding the following line:
shb.lldap.restrictAccessIPRange = "192.168.50.0/24";
```
## Manage User and Group {#blocks-lldap-manage-user-group}
## Manage Groups {#blocks-lldap-manage-groups}
Currently, managing users and groups must be done manually.
Work is in progress to make this declarative.
The following snippet will create group named "family" if it does not exist yet.
Also, all other groups will be deleted and only the "family" group will remain.
Note that the `lldap_admin`, `lldap_password_manager` and `lldap_strict_readonly` groups, which are internal to LLDAP, will always exist.
```nix
{
shb.lldap.ensureGroups = {
family = {};
};
}
```
Changing the configuration to the following will add a new group "friends":
```nix
{
shb.lldap.ensureGroups = {
family = {};
friends = {};
};
}
```
Switching back the configuration to the previous one will delete the group "friends":
```nix
{
shb.lldap.ensureGroups = {
family = {};
};
}
```
Custom fields can be added to groups as long as they are added to the `ensureGroupFields` field:
```nix
shb.lldap = {
ensureGroupFields = {
mygroupattribute = {
attributeType = "STRING";
};
};
ensureGroups = {
family = {
mygroupattribute = "Managed by NixOS";
};
};
};
```
## Manage Users {#blocks-lldap-manage-users}
The following snippet creates a user and makes it a member of the "family" group.
```nix
{
shb.lldap.ensureUsers = {
dad = {
email = "dad@example.com";
displayName = "Dad";
firstName = "First Name";
lastName = "Last Name";
groups = [ "family" ];
password.result = config.shb.sops.secret."dad".result;
};
};
shb.sops.secret."dad".request =
shb.ldap.ensureUsers.dad.password.request;
}
```
The password field assumes usage of the [sops block][] to provide secrets
although any blocks providing the [secrets contract][] works too.
[sops block]: blocks-sops.html
[secrets contract]: contracts-secrets.html
The user is still editable through the UI.
That being said, any change will be overwritten next time the configuration is applied.
## Troubleshooting {#blocks-lldap-troubleshooting}
To increase logging verbosity, add:
To increase logging verbosity and see the trace of the GraphQL queries, add:
```nix
shb.lldap.debug = true;
@ -69,6 +149,8 @@ shb.lldap.debug = true;
Note that verbosity is truly verbose here
so you will want to revert this at some point.
To see the logs, then run `journalctl -u lldap.service`.
## Tests {#blocks-lldap-tests}
Specific integration tests are defined in [`/test/blocks/lldap.nix`](@REPO@/test/blocks/lldap.nix).

View file

@ -0,0 +1,163 @@
From 5bc3588204e71a77d7e0bfccc4cb70e6ccfbb54b Mon Sep 17 00:00:00 2001
From: ibizaman <ibizaman@tiserbox.com>
Date: Tue, 15 Jul 2025 18:42:42 +0200
Subject: [PATCH 1/3] lldap: lldap 0.6.1 -> unstable-2025-07-16
---
.../0001-parameterize-frontend-location.patch | 64 -------------------
pkgs/by-name/ll/lldap/package.nix | 30 +++++----
2 files changed, 16 insertions(+), 78 deletions(-)
delete mode 100644 pkgs/by-name/ll/lldap/0001-parameterize-frontend-location.patch
diff --git a/pkgs/by-name/ll/lldap/0001-parameterize-frontend-location.patch b/pkgs/by-name/ll/lldap/0001-parameterize-frontend-location.patch
deleted file mode 100644
index c33f5a7afa10..000000000000
--- a/pkgs/by-name/ll/lldap/0001-parameterize-frontend-location.patch
+++ /dev/null
@@ -1,64 +0,0 @@
-From a09babb0cd9dd532ad2de920a2a35aa03d740dc6 Mon Sep 17 00:00:00 2001
-From: Herwig Hochleitner <herwig@bendlas.net>
-Date: Thu, 8 Aug 2024 00:29:14 +0200
-Subject: [PATCH] parameterize frontend location
-
----
- server/src/infra/tcp_server.rs | 14 +++++++-------
- 1 file changed, 7 insertions(+), 7 deletions(-)
-
-diff --git a/server/src/infra/tcp_server.rs b/server/src/infra/tcp_server.rs
-index fa5f11f..16e64c5 100644
---- a/server/src/infra/tcp_server.rs
-+++ b/server/src/infra/tcp_server.rs
-@@ -25,7 +25,7 @@ use std::sync::RwLock;
- use tracing::info;
-
- async fn index<Backend>(data: web::Data<AppState<Backend>>) -> actix_web::Result<impl Responder> {
-- let mut file = std::fs::read_to_string(r"./app/index.html")?;
-+ let mut file = std::fs::read_to_string(r"@frontend@/index.html")?;
-
- if data.server_url.path() != "/" {
- file = file.replace(
-@@ -80,7 +80,7 @@ pub(crate) fn error_to_http_response(error: TcpError) -> HttpResponse {
- async fn main_js_handler<Backend>(
- data: web::Data<AppState<Backend>>,
- ) -> actix_web::Result<impl Responder> {
-- let mut file = std::fs::read_to_string(r"./app/static/main.js")?;
-+ let mut file = std::fs::read_to_string(r"@frontend@/static/main.js")?;
-
- if data.server_url.path() != "/" {
- file = file.replace("/pkg/", format!("{}/pkg/", data.server_url.path()).as_str());
-@@ -92,12 +92,12 @@ async fn main_js_handler<Backend>(
- }
-
- async fn wasm_handler() -> actix_web::Result<impl Responder> {
-- Ok(actix_files::NamedFile::open_async("./app/pkg/lldap_app_bg.wasm").await?)
-+ Ok(actix_files::NamedFile::open_async("@frontend@/pkg/lldap_app_bg.wasm").await?)
- }
-
- async fn wasm_handler_compressed() -> actix_web::Result<impl Responder> {
- Ok(
-- actix_files::NamedFile::open_async("./app/pkg/lldap_app_bg.wasm.gz")
-+ actix_files::NamedFile::open_async("@frontend@/pkg/lldap_app_bg.wasm.gz")
- .await?
- .customize()
- .insert_header(header::ContentEncoding::Gzip)
-@@ -143,11 +143,11 @@ fn http_config<Backend>(
- .service(web::resource("/pkg/lldap_app_bg.wasm").route(web::route().to(wasm_handler)))
- .service(web::resource("/static/main.js").route(web::route().to(main_js_handler::<Backend>)))
- // Serve the /pkg path with the compiled WASM app.
-- .service(Files::new("/pkg", "./app/pkg"))
-+ .service(Files::new("/pkg", "@frontend@/pkg"))
- // Serve static files
-- .service(Files::new("/static", "./app/static"))
-+ .service(Files::new("/static", "@frontend@/static"))
- // Serve static fonts
-- .service(Files::new("/static/fonts", "./app/static/fonts"))
-+ .service(Files::new("/static/fonts", "@frontend@/static/fonts"))
- // Default to serve index.html for unknown routes, to support routing.
- .default_service(web::route().guard(guard::Get()).to(index::<Backend>));
- }
---
-2.45.2
-
diff --git a/pkgs/by-name/ll/lldap/package.nix b/pkgs/by-name/ll/lldap/package.nix
index 6931256080b2..41e4a332018e 100644
--- a/pkgs/by-name/ll/lldap/package.nix
+++ b/pkgs/by-name/ll/lldap/package.nix
@@ -3,29 +3,30 @@
fetchFromGitHub,
lib,
lldap,
+ makeWrapper,
nixosTests,
rustPlatform,
rustc,
- wasm-bindgen-cli_0_2_95,
+ wasm-bindgen-cli_0_2_100,
wasm-pack,
which,
}:
let
- commonDerivationAttrs = rec {
+ commonDerivationAttrs = {
pname = "lldap";
- version = "0.6.1";
+ version = "unstable-2025-07-16";
src = fetchFromGitHub {
owner = "lldap";
repo = "lldap";
- rev = "v${version}";
- hash = "sha256-iQ+Vv9kx/pWHoa/WZChBK+FD2r1avzWWz57bnnzRjUg=";
+ rev = "78337bce722c3573d9fc6eafe345a3dbce4b9119";
+ hash = "sha256-/djLboAQwK/KQ0u9vzoOdDHwh/BQSvMa8lQkABn10Cw=";
};
useFetchCargoVendor = true;
- cargoHash = "sha256-qXYgr9uRswuo9hwVROUX9KUKpkzR0VEcXImbdyOgxsY=";
+ cargoHash = "sha256-/dyrtX2FUHSGkJ6AkCM81iPqI03IWA0tecR4KHSx8gA=";
};
@@ -36,7 +37,7 @@ let
nativeBuildInputs = [
wasm-pack
- wasm-bindgen-cli_0_2_95
+ wasm-bindgen-cli_0_2_100
binaryen
which
rustc
@@ -69,12 +70,10 @@ rustPlatform.buildRustPackage (
"lldap_set_password"
];
- patches = [
- ./0001-parameterize-frontend-location.patch
- ];
-
- postPatch = ''
- substituteInPlace server/src/infra/tcp_server.rs --subst-var-by frontend '${frontend}'
+ nativeBuildInputs = [ makeWrapper ];
+ postInstall = ''
+ wrapProgram $out/bin/lldap \
+ --set LLDAP_ASSETS_PATH ${frontend}
'';
passthru = {
@@ -90,7 +89,10 @@ rustPlatform.buildRustPackage (
changelog = "https://github.com/lldap/lldap/blob/v${lldap.version}/CHANGELOG.md";
license = licenses.gpl3Only;
platforms = platforms.linux;
- maintainers = with maintainers; [ bendlas ];
+ maintainers = with maintainers; [
+ bendlas
+ ibizaman
+ ];
mainProgram = "lldap";
};
}
--
2.49.0

View file

@ -0,0 +1,133 @@
From f898e786bfb07399abf4e8173ee525c57eb41984 Mon Sep 17 00:00:00 2001
From: ibizaman <ibizaman@tiserbox.com>
Date: Wed, 16 Jul 2025 03:04:44 +0200
Subject: [PATCH 2/3] lldap: add options to set important secrets
---
nixos/modules/services/databases/lldap.nix | 64 ++++++++++++++++++++++
nixos/tests/lldap.nix | 16 +++++-
2 files changed, 77 insertions(+), 3 deletions(-)
diff --git a/nixos/modules/services/databases/lldap.nix b/nixos/modules/services/databases/lldap.nix
index a9fbe8f7e11a..518b39ba7a86 100644
--- a/nixos/modules/services/databases/lldap.nix
+++ b/nixos/modules/services/databases/lldap.nix
@@ -37,6 +37,47 @@ in
'';
};
+ adminPasswordFile = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = ''
+ Path to a file containing the default admin password.
+ '';
+ };
+
+ resetAdminPassword = mkOption {
+ type = types.nullOr (
+ types.oneOf [
+ types.bool
+ (types.enum [ "always" ])
+ ]
+ );
+ default = false;
+ description = ''
+ Force reset of the admin password.
+
+ Break glass in case of emergency: if you lost the admin password, you
+ can set this to true to force a reset of the admin password to the value
+ of `adminPasswordFile`.
+
+ Alternatively, you can set it to `"always"` to reset every time the server starts
+ which makes for a more declarative configuration.
+
+ The difference between `true` and `"always"` is the former is intended for a one time fix
+ while the latter is intended for a declarative workflow. In practice, the result
+ is the same: the password gets reset. The only practical difference is the former
+ outputs a warning message while the latter outputs an info message.
+ '';
+ };
+
+ jwtSecretFile = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = ''
+ Path to a file containing the default admin password.
+ '';
+ };
+
settings = mkOption {
description = ''
Free-form settings written directly to the `lldap_config.toml` file.
@@ -108,6 +149,29 @@ in
};
config = lib.mkIf cfg.enable {
+ assertions = [
+ {
+ assertion = cfg.adminPasswordFile == null || cfg.resetAdminPassword != false;
+ message = ''
+ The default admin password is set declaratively with `adminPasswordFile` option but the `resetAdminPassword` is set to `false`.
+ This means the admin password can be changed through the UI and will drift from the one defined in your nix config.
+ Please set the `resetAdminPassword` option to `true` or `"always"`.
+ '';
+ }
+ ];
+
+ services.lldap.environment = {
+ LLDAP_JWT_SECRET_FILE = lib.mkIf (cfg.jwtSecretFile != null) cfg.jwtSecretFile;
+ LLDAP_LDAP_USER_PASS_FILE = lib.mkIf (cfg.adminPasswordFile != null) cfg.adminPasswordFile;
+ LLDAP_FORCE_LDAP_USER_PASS_RESET =
+ if builtins.isString cfg.resetAdminPassword then
+ cfg.resetAdminPassword
+ else if cfg.resetAdminPassword then
+ "true"
+ else
+ "false";
+ };
+
systemd.services.lldap = {
description = "Lightweight LDAP server (lldap)";
wants = [ "network-online.target" ];
diff --git a/nixos/tests/lldap.nix b/nixos/tests/lldap.nix
index c2e48525a5f3..e88fa37ab83d 100644
--- a/nixos/tests/lldap.nix
+++ b/nixos/tests/lldap.nix
@@ -1,4 +1,7 @@
{ ... }:
+let
+ adminPassword = "mySecretPassword";
+in
{
name = "lldap";
@@ -7,6 +10,11 @@
{
services.lldap = {
enable = true;
+
+ adminPasswordFile = toString (pkgs.writeText "adminPasswordFile" adminPassword);
+ resetAdminPassword = "always";
+ enforceEnsure = true;
+
settings = {
verbose = true;
ldap_base_dn = "dc=example,dc=com";
@@ -22,8 +30,10 @@
machine.succeed("curl --location --fail http://localhost:17170/")
- print(
- machine.succeed('ldapsearch -H ldap://localhost:3890 -D uid=admin,ou=people,dc=example,dc=com -b "ou=people,dc=example,dc=com" -w password')
- )
+ response = machine.fail('ldapsearch -H ldap://localhost:3890 -D uid=admin,ou=people,dc=example,dc=com -b "ou=people,dc=example,dc=com" -w password')
+ print(response)
+
+ response = machine.succeed('ldapsearch -H ldap://localhost:3890 -D uid=admin,ou=people,dc=example,dc=com -b "ou=people,dc=example,dc=com" -w ${adminPassword}')
+ print(response)
'';
}
--
2.49.0

View file

@ -0,0 +1,549 @@
From 75b9437558a22eabff74339569f98b567f0a0d04 Mon Sep 17 00:00:00 2001
From: ibizaman <ibizaman@tiserbox.com>
Date: Wed, 16 Jul 2025 17:51:57 +0200
Subject: [PATCH 3/3] lldap-bootstrap: init unstable-2025-07-17, lldap: add
ensure options
---
nixos/modules/services/databases/lldap.nix | 282 +++++++++++++++++++-
nixos/tests/lldap.nix | 116 +++++++-
pkgs/by-name/ll/lldap-bootstrap/package.nix | 54 ++++
3 files changed, 446 insertions(+), 6 deletions(-)
create mode 100644 pkgs/by-name/ll/lldap-bootstrap/package.nix
diff --git a/nixos/modules/services/databases/lldap.nix b/nixos/modules/services/databases/lldap.nix
index 518b39ba7a86..b7c4c85f751f 100644
--- a/nixos/modules/services/databases/lldap.nix
+++ b/nixos/modules/services/databases/lldap.nix
@@ -2,13 +2,82 @@
config,
lib,
pkgs,
- utils,
...
}:
let
cfg = config.services.lldap;
format = pkgs.formats.toml { };
+
+ inherit (lib) mkOption types;
+
+ ensureFormat = pkgs.formats.json { };
+ ensureGenerate =
+ name: source: ensureFormat.generate name (lib.filterAttrsRecursive (n: v: v != null) source);
+
+ ensureFieldsOptions = name: {
+ name = mkOption {
+ type = types.str;
+ description = "Name of the field.";
+ default = name;
+ };
+
+ attributeType = mkOption {
+ type = types.enum [
+ "STRING"
+ "INTEGER"
+ "JPEG"
+ "DATE_TIME"
+ ];
+ description = "Attribute type.";
+ };
+
+ isEditable = mkOption {
+ type = types.bool;
+ description = "Is field editable.";
+ default = true;
+ };
+
+ isList = mkOption {
+ type = types.bool;
+ description = "Is field a list.";
+ default = false;
+ };
+
+ isVisible = mkOption {
+ type = types.bool;
+ description = "Is field visible in UI.";
+ default = true;
+ };
+ };
+
+ allUserGroups = lib.flatten (lib.mapAttrsToList (n: u: u.groups) cfg.ensureUsers);
+ # The three hardcoded groups are always created when the service starts.
+ allGroups = lib.mapAttrsToList (n: g: g.name) cfg.ensureGroups ++ [
+ "lldap_admin"
+ "lldap_password_manager"
+ "lldap_strict_readonly"
+ ];
+ userGroupNotInEnsuredGroup = lib.sortOn lib.id (
+ lib.unique (lib.subtractLists allGroups allUserGroups)
+ );
+ someUsersBelongToNonEnsuredGroup = (lib.lists.length userGroupNotInEnsuredGroup) > 0;
+
+ generateEnsureConfigDir =
+ name: source:
+ let
+ genOne =
+ name: sourceOne:
+ pkgs.writeTextDir "configs/${name}.json" (
+ builtins.readFile (ensureGenerate "configs/${name}.json" sourceOne)
+ );
+ in
+ "${
+ pkgs.symlinkJoin {
+ inherit name;
+ paths = lib.mapAttrsToList genOne source;
+ }
+ }/configs";
in
{
options.services.lldap = with lib; {
@@ -16,6 +85,8 @@ in
package = mkPackageOption pkgs "lldap" { };
+ bootstrap-package = mkPackageOption pkgs "lldap-bootstrap" { };
+
environment = mkOption {
type = with types; attrsOf str;
default = { };
@@ -146,6 +217,172 @@ in
};
};
};
+
+ ensureUsers = mkOption {
+ description = ''
+ Create the users defined here on service startup.
+
+ If `enforceEnsure` option is `true`, the groups
+ users belong to must be present in the `ensureGroups` option.
+
+ Non-default options must be added to the `ensureGroupFields` option.
+ '';
+ default = { };
+ type = types.attrsOf (
+ types.submodule (
+ { name, ... }:
+ {
+ freeformType = ensureFormat.type;
+
+ options = {
+ id = mkOption {
+ type = types.str;
+ description = "Username.";
+ default = name;
+ };
+
+ email = mkOption {
+ type = types.str;
+ description = "Email.";
+ };
+
+ password_file = mkOption {
+ type = types.str;
+ description = "File containing the password.";
+ };
+
+ displayName = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Display name.";
+ };
+
+ firstName = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "First name.";
+ };
+
+ lastName = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Last name.";
+ };
+
+ avatar_file = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Avatar file. Must be a valid path to jpeg file (ignored if avatar_url specified)";
+ };
+
+ avatar_url = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Avatar url. must be a valid URL to jpeg file (ignored if gravatar_avatar specified)";
+ };
+
+ gravatar_avatar = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Get avatar from Gravatar using the email.";
+ };
+
+ weser_avatar = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Convert avatar retrieved by gravatar or the URL.";
+ };
+
+ groups = mkOption {
+ type = types.listOf types.str;
+ default = [ ];
+ description = "Groups the user would be a member of (all the groups must be specified in group config files).";
+ };
+ };
+ }
+ )
+ );
+ };
+
+ ensureGroups = mkOption {
+ description = ''
+ Create the groups defined here on service startup.
+
+ Non-default options must be added to the `ensureGroupFields` option.
+ '';
+ default = { };
+ type = types.attrsOf (
+ types.submodule (
+ { name, ... }:
+ {
+ freeformType = ensureFormat.type;
+
+ options = {
+ name = mkOption {
+ type = types.str;
+ description = "Name of the group.";
+ default = name;
+ };
+ };
+ }
+ )
+ );
+ };
+
+ ensureUserFields = mkOption {
+ description = "Extra fields for users";
+ default = { };
+ type = types.attrsOf (
+ types.submodule (
+ { name, ... }:
+ {
+ options = ensureFieldsOptions name;
+ }
+ )
+ );
+ };
+
+ ensureGroupFields = mkOption {
+ description = "Extra fields for groups";
+ default = { };
+ type = types.attrsOf (
+ types.submodule (
+ { name, ... }:
+ {
+ options = ensureFieldsOptions name;
+ }
+ )
+ );
+ };
+
+ ensureAdminUsername = mkOption {
+ type = types.str;
+ default = "admin";
+ description = ''
+ Username of an admin user with which to connect to the LLDAP service.
+
+ By default, it is the default admin username `admin`.
+ If using another user, it must be managed manually.
+ '';
+ };
+
+ ensureAdminPasswordFile = mkOption {
+ type = types.nullOr types.str;
+ defaultText = "config.services.lldap.adminPasswordFile";
+ default = cfg.adminPasswordFile;
+ description = ''
+ Path to the file containing the password of an admin user with which to connect to the LLDAP service.
+
+ By default, it is the same as the password for the default admin user 'admin'.
+ If using a password from another user, it must be managed manually.
+ '';
+ };
+
+ enforceEnsure = mkOption {
+ description = "Delete users, groups and fields not in their respective ensure options and remove users from groups they do not belong to.";
+ type = types.bool;
+ default = false;
+ };
};
config = lib.mkIf cfg.enable {
@@ -158,8 +395,40 @@ in
Please set the `resetAdminPassword` option to `true` or `"always"`.
'';
}
+ {
+ assertion =
+ cfg.ensureUsers != { }
+ || cfg.ensureGroups != { }
+ || cfg.ensureUserFields != { }
+ || cfg.ensureGroupFields != { }
+ || cfg.enforceEnsure
+ -> cfg.ensureAdminPasswordFile != null;
+ message = ''
+ Some ensure options are set but no admin user password is set.
+ Add a default password to `adminPasswordFile` to manage the admin user declaratively
+ or create a user manually and set its password in `ensureAdminPasswordFile`.
+ '';
+ }
+ {
+ assertion = cfg.enforceEnsure -> !someUsersBelongToNonEnsuredGroup;
+ message = ''
+ Some users belong to groups not present in the ensureGroups attr,
+ add the following groups or remove them from the groups a user belong to:
+ ${lib.concatStringsSep ", " (map (x: "\"${x}\"") userGroupNotInEnsuredGroup)}
+ '';
+ }
];
+ warnings = (
+ lib.optionals (!cfg.enforceEnsure && (lib.debug.traceValSeq someUsersBelongToNonEnsuredGroup)) [
+ ''
+ Some users belong to groups not managed by the configuration here,
+ make sure the following groups exist or the service will not start properly:
+ ${lib.concatStringsSep ", " (map (x: "\"${x}\"") userGroupNotInEnsuredGroup)}
+ ''
+ ]
+ );
+
services.lldap.environment = {
LLDAP_JWT_SECRET_FILE = lib.mkIf (cfg.jwtSecretFile != null) cfg.jwtSecretFile;
LLDAP_LDAP_USER_PASS_FILE = lib.mkIf (cfg.adminPasswordFile != null) cfg.adminPasswordFile;
@@ -193,6 +462,17 @@ in
+ ''
${lib.getExe cfg.package} run --config-file ${format.generate "lldap_config.toml" cfg.settings}
'';
+ postStart = ''
+ export LLDAP_URL=http://127.0.0.1:${toString cfg.settings.http_port}
+ export LLDAP_ADMIN_USERNAME=${cfg.ensureAdminUsername}
+ export LLDAP_ADMIN_PASSWORD_FILE=${cfg.ensureAdminPasswordFile}
+ export USER_CONFIGS_DIR=${generateEnsureConfigDir "users" cfg.ensureUsers}
+ export GROUP_CONFIGS_DIR=${generateEnsureConfigDir "groups" cfg.ensureGroups}
+ export USER_SCHEMAS_DIR=${generateEnsureConfigDir "userFields" cfg.ensureUserFields}
+ export GROUP_SCHEMAS_DIR=${generateEnsureConfigDir "groupFields" cfg.ensureGroupFields}
+ export DO_CLEANUP=${if cfg.enforceEnsure then "true" else "false"}
+ ${lib.getExe cfg.bootstrap-package}
+ '';
serviceConfig = {
StateDirectory = "lldap";
StateDirectoryMode = "0750";
diff --git a/nixos/tests/lldap.nix b/nixos/tests/lldap.nix
index e88fa37ab83d..7d2e65699a0f 100644
--- a/nixos/tests/lldap.nix
+++ b/nixos/tests/lldap.nix
@@ -1,6 +1,8 @@
{ ... }:
let
adminPassword = "mySecretPassword";
+ alicePassword = "AlicePassword";
+ bobPassword = "BobPassword";
in
{
name = "lldap";
@@ -19,21 +21,125 @@ in
verbose = true;
ldap_base_dn = "dc=example,dc=com";
};
+
+ ensureUsers = {
+ alice = {
+ email = "alice@example.com";
+ password_file = toString (pkgs.writeText "alicePasswordFile" alicePassword);
+ groups = [ "mygroup" ];
+ };
+ };
+
+ ensureGroups = {
+ mygroup = { };
+ };
};
environment.systemPackages = [ pkgs.openldap ];
+
+ specialisation = {
+ withAlice.configuration =
+ { ... }:
+ {
+ services.lldap = {
+ ensureUsers = {
+ alice = {
+ email = "alice@example.com";
+ password_file = toString (pkgs.writeText "alicePasswordFile" alicePassword);
+ groups = [ "mygroup" ];
+ };
+ };
+
+ ensureGroups = {
+ mygroup = { };
+ };
+ };
+ };
+
+ withBob.configuration =
+ { ... }:
+ {
+ services.lldap = {
+ ensureUsers = {
+ bob = {
+ email = "bob@example.com";
+ password_file = toString (pkgs.writeText "bobPasswordFile" bobPassword);
+ groups = [ "othergroup" ];
+ displayName = "Bob";
+ myattribute = 2;
+ };
+ };
+
+ ensureGroups = {
+ othergroup = {
+ mygroupattribute = "Managed by NixOS";
+ };
+ };
+
+ ensureUserFields = {
+ myattribute = {
+ attributeType = "INTEGER";
+ };
+ };
+
+ ensureGroupFields = {
+ mygroupattribute = {
+ attributeType = "STRING";
+ };
+ };
+ };
+ };
+ };
};
- testScript = ''
+ testScript =
+ { nodes, ... }:
+ let
+ specializations = "${nodes.machine.system.build.toplevel}/specialisation";
+ in
+ ''
machine.wait_for_unit("lldap.service")
machine.wait_for_open_port(3890)
machine.wait_for_open_port(17170)
machine.succeed("curl --location --fail http://localhost:17170/")
+ adminPassword="${adminPassword}"
+ alicePassword="${alicePassword}"
+ bobPassword="${bobPassword}"
+
+ def try_login(user, password, expect_success=True):
+ code, response = machine.execute(f'ldapsearch -H ldap://localhost:3890 -D uid={user},ou=people,dc=example,dc=com -b "ou=people,dc=example,dc=com" -w {password}')
+ print(response)
+ if expect_success:
+ if code != 0:
+ raise Exception("Expected failure, had success")
+ else:
+ if code == 0:
+ raise Exception(f"Expected success, had failure {code}")
+
+ with subtest("only default admin user"):
+ print(try_login("admin", "password", expect_success=False))
+ print(try_login("admin", adminPassword, expect_success=True))
+ print(try_login("alice", "password", expect_success=False))
+ print(try_login("alice", alicePassword, expect_success=False))
+ print(try_login("bob", "password", expect_success=False))
+ print(try_login("bob", bobPassword, expect_success=False))
- response = machine.fail('ldapsearch -H ldap://localhost:3890 -D uid=admin,ou=people,dc=example,dc=com -b "ou=people,dc=example,dc=com" -w password')
- print(response)
+ with subtest("with alice"):
+ machine.succeed('${specializations}/withAlice/bin/switch-to-configuration test')
+ print(try_login("admin", "password", expect_success=False))
+ print(try_login("admin", adminPassword, expect_success=True))
+ print(try_login("alice", "password", expect_success=False))
+ print(try_login("alice", alicePassword, expect_success=True))
+ print(try_login("bob", "password", expect_success=False))
+ print(try_login("bob", bobPassword, expect_success=False))
- response = machine.succeed('ldapsearch -H ldap://localhost:3890 -D uid=admin,ou=people,dc=example,dc=com -b "ou=people,dc=example,dc=com" -w ${adminPassword}')
- print(response)
+ with subtest("with attributes"):
+ machine.succeed('${specializations}/withBob/bin/switch-to-configuration test')
+ print(try_login("admin", "password", expect_success=False))
+ print(try_login("admin", adminPassword, expect_success=True))
+ print(try_login("alice", "password", expect_success=False))
+ print(try_login("alice", alicePassword, expect_success=False))
+ print(try_login("bob", "password", expect_success=False))
+ print(try_login("bob", bobPassword, expect_success=True))
'';
}
diff --git a/pkgs/by-name/ll/lldap-bootstrap/package.nix b/pkgs/by-name/ll/lldap-bootstrap/package.nix
new file mode 100644
index 000000000000..1459784bf8e5
--- /dev/null
+++ b/pkgs/by-name/ll/lldap-bootstrap/package.nix
@@ -0,0 +1,54 @@
+{
+ curl,
+ fetchFromGitHub,
+ jq,
+ jo,
+ lib,
+ lldap,
+ lldap-bootstrap,
+ makeWrapper,
+ stdenv,
+}:
+stdenv.mkDerivation {
+ pname = "lldap-bootstrap";
+ version = "unstable-2025-07-17";
+
+ src = fetchFromGitHub {
+ owner = "ibizaman";
+ repo = "lldap";
+ rev = "14b083c9cf6c13802b7477af3b85894f2b7d1c81";
+ hash = "sha256-olQGLgjLE7la5fYiCgC4tpaxyhFT9ZvRWLtASoaqq9k=";
+ };
+
+ dontBuild = true;
+
+ nativeBuildInputs = [ makeWrapper ];
+
+ installPhase = ''
+ mkdir -p $out/bin
+ cp ./scripts/bootstrap.sh $out/bin/lldap-bootstrap
+
+ wrapProgram $out/bin/lldap-bootstrap \
+ --set LLDAP_SET_PASSWORD_PATH ${lldap}/bin/lldap_set_password \
+ --prefix PATH : ${
+ lib.makeBinPath [
+ curl
+ jq
+ jo
+ ]
+ }
+ '';
+
+ meta = {
+ description = "Bootstrap script for LLDAP";
+ homepage = "https://github.com/lldap/lldap";
+ changelog = "https://github.com/lldap/lldap/blob/v${lldap-bootstrap.version}/CHANGELOG.md";
+ license = lib.licenses.gpl3Only;
+ platforms = lib.platforms.linux;
+ maintainers = with lib.maintainers; [
+ bendlas
+ ibizaman
+ ];
+ mainProgram = "lldap-bootstrap";
+ };
+}
--
2.49.0

View file

@ -3,6 +3,7 @@ let
pkgs' = pkgs;
password = "securepassword";
charliePassword = "CharliePassword";
in
{
auth = pkgs.testers.runNixOSTest {
@ -29,6 +30,17 @@ in
ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result;
jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result;
debug = true;
ensureUsers = {
"charlie" = {
email = "charlie@example.com";
password.result = config.shb.hardcodedsecret."charlie".result;
};
};
ensureGroups = {
"family" = {};
};
};
shb.hardcodedsecret.ldapUserPassword = {
request = config.shb.lldap.ldapUserPassword.request;
@ -38,6 +50,10 @@ in
request = config.shb.lldap.jwtSecret.request;
settings.content = "jwtSecret";
};
shb.hardcodedsecret."charlie" = {
request = config.shb.lldap.ensureUsers."charlie".password.request;
settings.content = charliePassword;
};
networking.firewall.allowedTCPPorts = [ 80 ]; # nginx port
};
@ -89,6 +105,15 @@ in
assert data['user']['displayName'] == "Administrator"
assert data['user']['groups'][0]['displayName'] == "lldap_admin"
with subtest("succeed charlie"):
client.succeed(
"curl -f -s -X POST "
+ """ -H "Content-type: application/json" """
+ """ -H "Host: ldap.example.com" """
+ " http://server/auth/simple/login "
+ """ -d '{"username": "charlie", "password": "${charliePassword}"}' """
)
'';
};
}