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