1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8let
9 cfg = config.services.lldap;
10 format = pkgs.formats.toml { };
11in
12{
13 options.services.lldap = with lib; {
14 enable = mkEnableOption "lldap, a lightweight authentication server that provides an opinionated, simplified LDAP interface for authentication";
15
16 package = mkPackageOption pkgs "lldap" { };
17
18 environment = mkOption {
19 type = with types; attrsOf str;
20 default = { };
21 example = {
22 LLDAP_JWT_SECRET_FILE = "/run/lldap/jwt_secret";
23 LLDAP_LDAP_USER_PASS_FILE = "/run/lldap/user_password";
24 };
25 description = ''
26 Environment variables passed to the service.
27 Any config option name prefixed with `LLDAP_` takes priority over the one in the configuration file.
28 '';
29 };
30
31 environmentFile = mkOption {
32 type = types.nullOr types.path;
33 default = null;
34 description = ''
35 Environment file as defined in {manpage}`systemd.exec(5)` passed to the service.
36 '';
37 };
38
39 settings = mkOption {
40 description = ''
41 Free-form settings written directly to the `lldap_config.toml` file.
42 Refer to <https://github.com/lldap/lldap/blob/main/lldap_config.docker_template.toml> for supported values.
43 '';
44
45 default = { };
46
47 type = types.submodule {
48 freeformType = format.type;
49 options = {
50 ldap_host = mkOption {
51 type = types.str;
52 description = "The host address that the LDAP server will be bound to.";
53 default = "::";
54 };
55
56 ldap_port = mkOption {
57 type = types.port;
58 description = "The port on which to have the LDAP server.";
59 default = 3890;
60 };
61
62 http_host = mkOption {
63 type = types.str;
64 description = "The host address that the HTTP server will be bound to.";
65 default = "::";
66 };
67
68 http_port = mkOption {
69 type = types.port;
70 description = "The port on which to have the HTTP server, for user login and administration.";
71 default = 17170;
72 };
73
74 http_url = mkOption {
75 type = types.str;
76 description = "The public URL of the server, for password reset links.";
77 default = "http://localhost";
78 };
79
80 ldap_base_dn = mkOption {
81 type = types.str;
82 description = "Base DN for LDAP.";
83 example = "dc=example,dc=com";
84 };
85
86 ldap_user_dn = mkOption {
87 type = types.str;
88 description = "Admin username";
89 default = "admin";
90 };
91
92 ldap_user_email = mkOption {
93 type = types.str;
94 description = "Admin email.";
95 default = "admin@example.com";
96 };
97
98 database_url = mkOption {
99 type = types.str;
100 description = "Database URL.";
101 default = "sqlite://./users.db?mode=rwc";
102 example = "postgres://postgres-user:password@postgres-server/my-database";
103 };
104
105 ldap_user_pass = mkOption {
106 type = types.nullOr types.str;
107 default = null;
108 description = ''
109 Password for default admin password.
110
111 Unsecure: Use `ldap_user_pass_file` settings instead.
112 '';
113 };
114
115 ldap_user_pass_file = mkOption {
116 type = types.nullOr types.str;
117 default = null;
118 description = ''
119 Path to a file containing the default admin password.
120
121 If you want to update the default admin password through this setting,
122 you must set `force_ldap_user_pass_reset` to `true`.
123 Otherwise changing this setting will have no effect
124 unless this is the very first time LLDAP is started and its database is still empty.
125 '';
126 };
127
128 force_ldap_user_pass_reset = mkOption {
129 type = types.oneOf [
130 types.bool
131 (types.enum [ "always" ])
132 ];
133 default = false;
134 description = ''
135 Force reset of the admin password.
136
137 Set this setting to `"always"` to update the admin password when `ldap_user_pass_file` changes.
138 Setting to `"always"` also means any password update in the UI will be overwritten next time the service restarts.
139
140 The difference between `true` and `"always"` is the former is intended for a one time fix
141 while the latter is intended for a declarative workflow. In practice, the result
142 is the same: the password gets reset. The only practical difference is the former
143 outputs a warning message while the latter outputs an info message.
144 '';
145 };
146
147 jwt_secret_file = mkOption {
148 type = types.nullOr types.str;
149 default = null;
150 description = ''
151 Path to a file containing the JWT secret.
152 '';
153 };
154 };
155 };
156
157 # TOML does not allow null values, so we use null to omit those fields
158 apply = lib.filterAttrsRecursive (_: v: v != null);
159 };
160
161 silenceForceUserPassResetWarning = mkOption {
162 type = types.bool;
163 default = false;
164 description = ''
165 Disable warning when the admin password is set declaratively with the `ldap_user_pass_file` setting
166 but the `force_ldap_user_pass_reset` is set to `false`.
167
168 This can lead to the admin password to drift from the one given declaratively.
169 If that is okay for you and you want to silence the warning, set this option to `true`.
170 '';
171 };
172 };
173
174 config = lib.mkIf cfg.enable {
175 assertions = [
176 {
177 assertion =
178 (cfg.settings.ldap_user_pass_file or null) != null
179 || (cfg.settings.ldap_user_pass or null) != null
180 || (cfg.environment.LLDAP_LDAP_USER_PASS_FILE or null) != null;
181 message = "lldap: Default admin user password must be set. Please set the `ldap_user_pass` or better the `ldap_user_pass_file` setting. Alternatively, you can set the `LLDAP_LDAP_USER_PASS_FILE` environment variable.";
182 }
183 {
184 assertion =
185 (cfg.settings.ldap_user_pass_file or null) == null || (cfg.settings.ldap_user_pass or null) == null;
186 message = "lldap: Both `ldap_user_pass` and `ldap_user_pass_file` settings should not be set at the same time. Set one to `null`.";
187 }
188 ];
189
190 warnings =
191 lib.optionals (cfg.settings.ldap_user_pass or null != null) [
192 ''
193 lldap: Unsecure `ldap_user_pass` setting is used. Prefer `ldap_user_pass_file` instead.
194 ''
195 ]
196 ++
197 lib.optionals
198 (cfg.settings.force_ldap_user_pass_reset == false && cfg.silenceForceUserPassResetWarning == false)
199 [
200 ''
201 lldap: The `force_ldap_user_pass_reset` setting is set to `false` which means
202 the admin password can be changed through the UI and will drift from the one defined in your nix config.
203 It also means changing the setting `ldap_user_pass` or `ldap_user_pass_file` will have no effect on the admin password.
204 Either set `force_ldap_user_pass_reset` to `"always"` or silence this warning by setting the option `services.lldap.silenceForceUserPassResetWarning` to `true`.
205 ''
206 ];
207
208 systemd.services.lldap = {
209 description = "Lightweight LDAP server (lldap)";
210 wants = [ "network-online.target" ];
211 after = [ "network-online.target" ];
212 wantedBy = [ "multi-user.target" ];
213 # lldap defaults to a hardcoded `jwt_secret` value if none is provided, which is bad, because
214 # an attacker could create a valid admin jwt access token fairly trivially.
215 # Because there are 3 different ways `jwt_secret` can be provided, we check if any one of them is present,
216 # and if not, bootstrap a secret in `/var/lib/lldap/jwt_secret_file` and give that to lldap.
217 script =
218 lib.optionalString (!cfg.settings ? jwt_secret) ''
219 if [[ -z "$LLDAP_JWT_SECRET_FILE" ]] && [[ -z "$LLDAP_JWT_SECRET" ]]; then
220 if [[ ! -e "./jwt_secret_file" ]]; then
221 ${lib.getExe pkgs.openssl} rand -base64 -out ./jwt_secret_file 32
222 fi
223 export LLDAP_JWT_SECRET_FILE="./jwt_secret_file"
224 fi
225 ''
226 + ''
227 ${lib.getExe cfg.package} run --config-file ${format.generate "lldap_config.toml" cfg.settings}
228 '';
229 serviceConfig = {
230 StateDirectory = "lldap";
231 StateDirectoryMode = "0750";
232 WorkingDirectory = "%S/lldap";
233 UMask = "0027";
234 User = "lldap";
235 Group = "lldap";
236 DynamicUser = true;
237 EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile;
238 };
239 inherit (cfg) environment;
240 };
241 };
242}