1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7with lib;
8let
9 cfg = config.services.bitwarden-directory-connector-cli;
10in
11{
12 options.services.bitwarden-directory-connector-cli = {
13 enable = mkEnableOption "Bitwarden Directory Connector";
14
15 package = mkPackageOption pkgs "bitwarden-directory-connector-cli" { };
16
17 domain = mkOption {
18 type = types.str;
19 description = "The domain the Bitwarden/Vaultwarden is accessible on.";
20 example = "https://vaultwarden.example.com";
21 };
22
23 user = mkOption {
24 type = types.str;
25 description = "User to run the program.";
26 default = "bwdc";
27 };
28
29 interval = mkOption {
30 type = types.str;
31 default = "*:0,15,30,45";
32 description = "The interval when to run the connector. This uses systemd's OnCalendar syntax.";
33 };
34
35 ldap = mkOption {
36 description = ''
37 Options to configure the LDAP connection.
38 If you used the desktop application to test the configuration you can find the settings by searching for `ldap` in `~/.config/Bitwarden\ Directory\ Connector/data.json`.
39 '';
40 default = { };
41 type = types.submodule (
42 {
43 config,
44 options,
45 ...
46 }:
47 {
48 freeformType = types.attrsOf (pkgs.formats.json { }).type;
49
50 config.finalJSON = builtins.toJSON (
51 removeAttrs config (
52 filter (x: x == "finalJSON" || !options.${x}.isDefined or false) (attrNames options)
53 )
54 );
55
56 options = {
57 finalJSON = mkOption {
58 type = (pkgs.formats.json { }).type;
59 internal = true;
60 readOnly = true;
61 visible = false;
62 };
63
64 ssl = mkOption {
65 type = types.bool;
66 default = false;
67 description = "Whether to use TLS.";
68 };
69 startTls = mkOption {
70 type = types.bool;
71 default = false;
72 description = "Whether to use STARTTLS.";
73 };
74
75 hostname = mkOption {
76 type = types.str;
77 description = "The host the LDAP is accessible on.";
78 example = "ldap.example.com";
79 };
80
81 port = mkOption {
82 type = types.port;
83 default = 389;
84 description = "Port LDAP is accessible on.";
85 };
86
87 ad = mkOption {
88 type = types.bool;
89 default = false;
90 description = "Whether the LDAP Server is an Active Directory.";
91 };
92
93 pagedSearch = mkOption {
94 type = types.bool;
95 default = false;
96 description = "Whether the LDAP server paginates search results.";
97 };
98
99 rootPath = mkOption {
100 type = types.str;
101 description = "Root path for LDAP.";
102 example = "dc=example,dc=com";
103 };
104
105 username = mkOption {
106 type = types.str;
107 description = "The user to authenticate as.";
108 example = "cn=admin,dc=example,dc=com";
109 };
110 };
111 }
112 );
113 };
114
115 sync = mkOption {
116 description = ''
117 Options to configure what gets synced.
118 If you used the desktop application to test the configuration you can find the settings by searching for `sync` in `~/.config/Bitwarden\ Directory\ Connector/data.json`.
119 '';
120 default = { };
121 type = types.submodule (
122 {
123 config,
124 options,
125 ...
126 }:
127 {
128 freeformType = types.attrsOf (pkgs.formats.json { }).type;
129
130 config.finalJSON = builtins.toJSON (
131 removeAttrs config (
132 filter (x: x == "finalJSON" || !options.${x}.isDefined or false) (attrNames options)
133 )
134 );
135
136 options = {
137 finalJSON = mkOption {
138 type = (pkgs.formats.json { }).type;
139 internal = true;
140 readOnly = true;
141 visible = false;
142 };
143
144 removeDisabled = mkOption {
145 type = types.bool;
146 default = true;
147 description = "Remove users from bitwarden groups if no longer in the ldap group.";
148 };
149
150 overwriteExisting = mkOption {
151 type = types.bool;
152 default = false;
153 description = "Remove and re-add users/groups, See https://bitwarden.com/help/user-group-filters/#overwriting-syncs for more details.";
154 };
155
156 largeImport = mkOption {
157 type = types.bool;
158 default = false;
159 description = "Enable if you are syncing more than 2000 users/groups.";
160 };
161
162 memberAttribute = mkOption {
163 type = types.str;
164 description = "Attribute that lists members in a LDAP group.";
165 example = "uniqueMember";
166 };
167
168 creationDateAttribute = mkOption {
169 type = types.str;
170 description = "Attribute that lists a user's creation date.";
171 example = "whenCreated";
172 };
173
174 useEmailPrefixSuffix = mkOption {
175 type = types.bool;
176 default = false;
177 description = "If a user has no email address, combine a username prefix with a suffix value to form an email.";
178 };
179 emailPrefixAttribute = mkOption {
180 type = types.str;
181 description = "The attribute that contains the users username.";
182 example = "accountName";
183 };
184 emailSuffix = mkOption {
185 type = types.str;
186 description = "Suffix for the email, normally @example.com.";
187 example = "@example.com";
188 };
189
190 users = mkOption {
191 type = types.bool;
192 default = false;
193 description = "Sync users.";
194 };
195 userPath = mkOption {
196 type = types.str;
197 description = "User directory, relative to root.";
198 default = "ou=users";
199 };
200 userObjectClass = mkOption {
201 type = types.str;
202 description = "Class that users must have.";
203 default = "inetOrgPerson";
204 };
205 userEmailAttribute = mkOption {
206 type = types.str;
207 description = "Attribute for a users email.";
208 default = "mail";
209 };
210 userFilter = mkOption {
211 type = types.str;
212 description = "LDAP filter for users.";
213 example = "(memberOf=cn=sales,ou=groups,dc=example,dc=com)";
214 default = "";
215 };
216
217 groups = mkOption {
218 type = types.bool;
219 default = false;
220 description = "Whether to sync ldap groups into BitWarden.";
221 };
222 groupPath = mkOption {
223 type = types.str;
224 description = "Group directory, relative to root.";
225 default = "ou=groups";
226 };
227 groupObjectClass = mkOption {
228 type = types.str;
229 description = "A class that groups will have.";
230 default = "groupOfNames";
231 };
232 groupNameAttribute = mkOption {
233 type = types.str;
234 description = "Attribute for a name of group.";
235 default = "cn";
236 };
237 groupFilter = mkOption {
238 type = types.str;
239 description = "LDAP filter for groups.";
240 example = "(cn=sales)";
241 default = "";
242 };
243 };
244 }
245 );
246 };
247
248 secrets = {
249 ldap = mkOption {
250 type = types.str;
251 description = "Path to file that contains LDAP password for user in {option}`ldap.username";
252 };
253
254 bitwarden = {
255 client_path_id = mkOption {
256 type = types.str;
257 description = "Path to file that contains Client ID.";
258 };
259 client_path_secret = mkOption {
260 type = types.str;
261 description = "Path to file that contains Client Secret.";
262 };
263 };
264 };
265 };
266
267 config = mkIf cfg.enable {
268 users.groups."${cfg.user}" = { };
269 users.users."${cfg.user}" = {
270 isSystemUser = true;
271 group = cfg.user;
272 };
273
274 systemd = {
275 timers.bitwarden-directory-connector-cli = {
276 description = "Sync timer for Bitwarden Directory Connector";
277 wantedBy = [ "timers.target" ];
278 after = [ "network-online.target" ];
279 wants = [ "network-online.target" ];
280 timerConfig = {
281 OnCalendar = cfg.interval;
282 Unit = "bitwarden-directory-connector-cli.service";
283 Persistent = true;
284 };
285 };
286
287 services.bitwarden-directory-connector-cli = {
288 description = "Main process for Bitwarden Directory Connector";
289 path = [ pkgs.jq ];
290
291 environment = {
292 BITWARDENCLI_CONNECTOR_APPDATA_DIR = "/tmp";
293 BITWARDENCLI_CONNECTOR_PLAINTEXT_SECRETS = "true";
294 };
295
296 preStart = ''
297 set -eo pipefail
298
299 # create the config file
300 ${lib.getExe cfg.package} data-file
301 touch /tmp/data.json.tmp
302 chmod 600 /tmp/data.json{,.tmp}
303
304 ${lib.getExe cfg.package} config server ${cfg.domain}
305
306 # now login to set credentials
307 export BW_CLIENTID="$(< ${escapeShellArg cfg.secrets.bitwarden.client_path_id})"
308 export BW_CLIENTSECRET="$(< ${escapeShellArg cfg.secrets.bitwarden.client_path_secret})"
309 ${lib.getExe cfg.package} login
310
311 jq '.authenticatedAccounts[0] as $account
312 | .[$account].directoryConfigurations.ldap |= $ldap_data
313 | .[$account].directorySettings.organizationId |= $orgID
314 | .[$account].directorySettings.sync |= $sync_data' \
315 --argjson ldap_data ${escapeShellArg cfg.ldap.finalJSON} \
316 --arg orgID "''${BW_CLIENTID//organization.}" \
317 --argjson sync_data ${escapeShellArg cfg.sync.finalJSON} \
318 /tmp/data.json \
319 > /tmp/data.json.tmp
320
321 mv -f /tmp/data.json.tmp /tmp/data.json
322
323 # final config
324 ${lib.getExe cfg.package} config directory 0
325 ${lib.getExe cfg.package} config ldap.password --secretfile ${cfg.secrets.ldap}
326 '';
327
328 serviceConfig = {
329 Type = "oneshot";
330 User = "${cfg.user}";
331 PrivateTmp = true;
332 ExecStart = "${lib.getExe cfg.package} sync";
333 };
334 };
335 };
336 };
337
338 meta.maintainers = with maintainers; [ Silver-Golden ];
339}