ldap-rest 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,6 +53,28 @@ LDAP-Rest uses syslog-style log levels:
53
53
 
54
54
  Use `--log-level notice` for production to see web access logs without general info messages.
55
55
 
56
+ ## Deployment
57
+
58
+ ### Docker
59
+
60
+ Multi-arch (amd64/arm64) images are published on each release:
61
+
62
+ ```shell
63
+ docker run -p 8081:8081 ghcr.io/linagora/ldap-rest:latest
64
+ # also available on Docker Hub as yadd/ldap-rest
65
+ ```
66
+
67
+ ### Helm
68
+
69
+ A Helm chart is published as an OCI artifact on each release:
70
+
71
+ ```shell
72
+ helm install ldap-rest oci://ghcr.io/linagora/charts/ldap-rest
73
+ ```
74
+
75
+ See the **[Helm chart README](./helm/ldap-rest/README.md)** for configuration
76
+ (image, `env`/`secrets`, ingress, probes, …).
77
+
56
78
  ## Configuration
57
79
 
58
80
  See **[Configuration Guide](./docs/usage/configuration.md)** for all CLI options and environment variables.
@@ -67,6 +89,7 @@ Installation, configuration and plugin usage.
67
89
 
68
90
  - **[Getting Started](./docs/usage/README.md)** - Quick start guide
69
91
  - **[Configuration](./docs/usage/configuration.md)** - CLI options and environment variables
92
+ - **[Helm chart](./helm/ldap-rest/README.md)** - Kubernetes deployment
70
93
  - **[Plugins](./docs/usage/plugins/README.md)** - Plugin documentation
71
94
  - **[Authentication](./docs/usage/plugins/auth/README.md)** - Token, TOTP, HMAC, LemonLDAP::NG, OpenID Connect
72
95
 
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Sync utility to verify and fix consistency between LDAP and James
4
- * Ensures James quotas match LDAP mailQuota values
4
+ * Ensures James quotas match LDAP mailQuota values and that James aliases
5
+ * match the LDAP alias attribute (catch-up in case the event-based sync failed)
5
6
  * @author Generated with Claude Code
6
7
  */
7
8
 
@@ -12,18 +13,27 @@ import { DM } from '../dist/bin/index.js';
12
13
  const args = process.argv.slice(2);
13
14
  const quiet = args.includes('--quiet') || args.includes('-q');
14
15
  const dryRun = args.includes('--dry-run') || args.includes('-n');
16
+ const noAliasDelete = args.includes('--no-alias-delete');
15
17
 
16
18
  if (args.includes('--help') || args.includes('-h')) {
17
19
  console.log(`
18
20
  Usage: sync-james [options]
19
21
 
20
- Synchronizes James quotas with LDAP mailQuota values.
21
- LDAP is considered the source of truth.
22
+ Synchronizes James with LDAP. LDAP is considered the source of truth.
23
+
24
+ For every user it reconciles:
25
+ - the mailbox quota (James quota <- LDAP mailQuota)
26
+ - the mail aliases (James aliases <- LDAP alias attribute)
27
+
28
+ The alias reconciliation is a catch-up mechanism: it repairs aliases that
29
+ were not propagated to James (e.g. when the event-based sync failed).
22
30
 
23
31
  Options:
24
- --quiet, -q Only show summary and errors
25
- --dry-run, -n Show what would be changed without making changes
26
- --help, -h Show this help message
32
+ --quiet, -q Only show summary and errors
33
+ --dry-run, -n Show what would be changed without making changes
34
+ --no-alias-delete Do not remove James aliases that are absent from LDAP
35
+ (only add missing ones)
36
+ --help, -h Show this help message
27
37
 
28
38
  Environment variables:
29
39
  DM_JAMES_WEBADMIN_URL James WebAdmin URL (required)
@@ -31,16 +41,45 @@ Environment variables:
31
41
  DM_LDAP_BASE LDAP search base
32
42
  DM_MAIL_ATTRIBUTE Mail attribute name (default: mail)
33
43
  DM_QUOTA_ATTRIBUTE Quota attribute name (default: mailQuota)
44
+ DM_ALIAS_ATTRIBUTE Alias attribute name (default: mailAlternateAddress)
34
45
  `);
35
46
  process.exit(0);
36
47
  }
37
48
 
38
- async function syncJamesQuotas() {
49
+ /**
50
+ * Normalize an email alias - handle AD format (smtp:alias@domain.com).
51
+ * Mirrors the behaviour of TwakePlugin.normalizeAlias in the main codebase.
52
+ * @param {string} alias
53
+ * @returns {string}
54
+ */
55
+ function normalizeAlias(alias) {
56
+ if (alias.toLowerCase().startsWith('smtp:')) {
57
+ return alias.substring(5);
58
+ }
59
+ return alias;
60
+ }
61
+
62
+ /**
63
+ * Extract normalized aliases from an LDAP attribute value.
64
+ * @param {string|string[]|Buffer|Buffer[]|undefined} value
65
+ * @returns {string[]}
66
+ */
67
+ function getAliases(value) {
68
+ if (!value) return [];
69
+ const aliases = Array.isArray(value) ? value : [value];
70
+ return aliases
71
+ .map(a => (Buffer.isBuffer(a) ? a.toString('utf-8') : String(a)))
72
+ .map(a => normalizeAlias(a))
73
+ .filter(a => a.length > 0);
74
+ }
75
+
76
+ async function syncJames() {
39
77
  const dm = new DM();
40
78
  await dm.ready;
41
79
 
42
80
  const mailAttr = dm.config.mail_attribute || 'mail';
43
81
  const quotaAttr = dm.config.quota_attribute || 'mailQuota';
82
+ const aliasAttr = dm.config.alias_attribute || 'mailAlternateAddress';
44
83
  const jamesUrl = dm.config.james_webadmin_url;
45
84
  const jamesToken = dm.config.james_webadmin_token;
46
85
 
@@ -49,8 +88,13 @@ async function syncJamesQuotas() {
49
88
  process.exit(1);
50
89
  }
51
90
 
91
+ const headers = {};
92
+ if (jamesToken) {
93
+ headers.Authorization = `Bearer ${jamesToken}`;
94
+ }
95
+
52
96
  if (!quiet) {
53
- dm.logger.info('Starting LDAP to James quota synchronization...');
97
+ dm.logger.info('Starting LDAP to James synchronization...');
54
98
  dm.logger.info(`LDAP base: ${dm.config.ldap_base}`);
55
99
  dm.logger.info(`James URL: ${jamesUrl}`);
56
100
  if (dryRun) {
@@ -59,18 +103,196 @@ async function syncJamesQuotas() {
59
103
  dm.logger.info('');
60
104
  }
61
105
 
106
+ const stats = {
107
+ checked: 0,
108
+ quotaSynced: 0,
109
+ aliasesAdded: 0,
110
+ aliasesDeleted: 0,
111
+ errors: 0,
112
+ };
113
+
114
+ /**
115
+ * Reconcile the mailbox quota for a single user.
116
+ */
117
+ async function syncQuota(dn, mail, ldapQuota) {
118
+ if (isNaN(ldapQuota)) return;
119
+
120
+ const url = `${jamesUrl}/quota/users/${mail}/size`;
121
+ const getRes = await fetch(url, { method: 'GET', headers });
122
+
123
+ if (!getRes.ok) {
124
+ if (getRes.status === 404) {
125
+ dm.logger.warn(`User ${mail} not found in James, skipping quota (DN: ${dn})`);
126
+ } else {
127
+ dm.logger.error(
128
+ `Error getting quota for ${mail}: ${getRes.status} ${getRes.statusText}`
129
+ );
130
+ stats.errors++;
131
+ }
132
+ return;
133
+ }
134
+
135
+ const jamesQuota = Number(await getRes.text());
136
+
137
+ if (jamesQuota === ldapQuota) {
138
+ if (!quiet) {
139
+ dm.logger.info(`${mail}: quota OK (${ldapQuota})`);
140
+ }
141
+ return;
142
+ }
143
+
144
+ dm.logger.warn(
145
+ `${mail}: quota mismatch - LDAP: ${ldapQuota}, James: ${jamesQuota}`
146
+ );
147
+ if (dryRun) {
148
+ dm.logger.info(` Would update James quota to ${ldapQuota}`);
149
+ stats.quotaSynced++;
150
+ return;
151
+ }
152
+
153
+ if (!quiet) {
154
+ dm.logger.info(` Updating James quota to ${ldapQuota}...`);
155
+ }
156
+ const putRes = await fetch(url, {
157
+ method: 'PUT',
158
+ headers,
159
+ body: ldapQuota.toString(),
160
+ });
161
+ if (putRes.ok) {
162
+ if (!quiet) {
163
+ dm.logger.info(` Updated successfully`);
164
+ }
165
+ stats.quotaSynced++;
166
+ } else {
167
+ dm.logger.error(` Failed to update quota: ${putRes.status} ${putRes.statusText}`);
168
+ stats.errors++;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Fetch the aliases currently declared in James for a destination mail.
174
+ * @returns {Promise<string[]|null>} list of source addresses, or null on error
175
+ */
176
+ async function fetchJamesAliases(mail) {
177
+ const res = await fetch(`${jamesUrl}/address/aliases/${mail}`, {
178
+ method: 'GET',
179
+ headers,
180
+ });
181
+
182
+ // James returns 404 when the destination has no alias at all
183
+ if (res.status === 404) {
184
+ return [];
185
+ }
186
+ if (!res.ok) {
187
+ dm.logger.error(
188
+ `Error getting aliases for ${mail}: ${res.status} ${res.statusText}`
189
+ );
190
+ return null;
191
+ }
192
+
193
+ const body = await res.json();
194
+ if (!Array.isArray(body)) {
195
+ return [];
196
+ }
197
+ // James returns [{ source: 'alias@domain' }, ...]
198
+ return body
199
+ .map(e => (e && typeof e.source === 'string' ? e.source : null))
200
+ .filter(Boolean);
201
+ }
202
+
203
+ /**
204
+ * Reconcile the aliases for a single user (catch-up).
205
+ * LDAP is the source of truth: missing aliases are added, extra aliases in
206
+ * James are removed (unless --no-alias-delete is set).
207
+ */
208
+ async function syncAliases(dn, mail, ldapAliasesRaw) {
209
+ const ldapAliases = getAliases(ldapAliasesRaw);
210
+
211
+ const jamesAliases = await fetchJamesAliases(mail);
212
+ if (jamesAliases === null) {
213
+ stats.errors++;
214
+ return;
215
+ }
216
+
217
+ // Case-insensitive comparison to avoid spurious add/delete churn
218
+ const ldapSet = new Set(ldapAliases.map(a => a.toLowerCase()));
219
+ const jamesSet = new Set(jamesAliases.map(a => a.toLowerCase()));
220
+
221
+ const toAdd = ldapAliases.filter(a => !jamesSet.has(a.toLowerCase()));
222
+ const toDelete = noAliasDelete
223
+ ? []
224
+ : jamesAliases.filter(a => !ldapSet.has(a.toLowerCase()));
225
+
226
+ if (toAdd.length === 0 && toDelete.length === 0) {
227
+ if (!quiet && (ldapAliases.length > 0 || jamesAliases.length > 0)) {
228
+ dm.logger.info(`${mail}: aliases OK (${ldapAliases.length})`);
229
+ }
230
+ return;
231
+ }
232
+
233
+ for (const alias of toAdd) {
234
+ dm.logger.warn(`${mail}: missing alias in James - ${alias}`);
235
+ if (dryRun) {
236
+ dm.logger.info(` Would add alias ${alias}`);
237
+ stats.aliasesAdded++;
238
+ continue;
239
+ }
240
+ const res = await fetch(
241
+ `${jamesUrl}/address/aliases/${mail}/sources/${alias}`,
242
+ { method: 'PUT', headers }
243
+ );
244
+ // 409 = alias already exists, treat as success (mirrors plugin behaviour)
245
+ if (res.ok || res.status === 409) {
246
+ if (!quiet) {
247
+ dm.logger.info(` Added alias ${alias}`);
248
+ }
249
+ stats.aliasesAdded++;
250
+ } else {
251
+ dm.logger.error(
252
+ ` Failed to add alias ${alias}: ${res.status} ${res.statusText}`
253
+ );
254
+ stats.errors++;
255
+ }
256
+ }
257
+
258
+ for (const alias of toDelete) {
259
+ dm.logger.warn(`${mail}: stale alias in James - ${alias}`);
260
+ if (dryRun) {
261
+ dm.logger.info(` Would delete alias ${alias}`);
262
+ stats.aliasesDeleted++;
263
+ continue;
264
+ }
265
+ const res = await fetch(
266
+ `${jamesUrl}/address/aliases/${mail}/sources/${alias}`,
267
+ { method: 'DELETE', headers }
268
+ );
269
+ if (res.ok) {
270
+ if (!quiet) {
271
+ dm.logger.info(` Deleted alias ${alias}`);
272
+ }
273
+ stats.aliasesDeleted++;
274
+ } else {
275
+ dm.logger.error(
276
+ ` Failed to delete alias ${alias}: ${res.status} ${res.statusText}`
277
+ );
278
+ stats.errors++;
279
+ }
280
+ }
281
+ }
282
+
62
283
  try {
63
- // Search for all users with mail and quota attributes (paginated for large directories)
284
+ // Search for every user that has a mail (paginated for large directories).
285
+ // We enumerate all mailboxes, not only those carrying a quota/alias, so
286
+ // that a user whose alias attribute was cleared still gets its stale James
287
+ // aliases removed (LDAP is the source of truth). Destinations whose mail is
288
+ // absent from LDAP are intentionally left alone: James refuses delivery for
289
+ // an alias pointing to a non-existent mailbox, so they are harmless.
64
290
  const resultGenerator = await dm.ldap.search({
65
291
  paged: true,
66
- filter: `(&(${mailAttr}=*)(${quotaAttr}=*))`,
67
- attributes: [mailAttr, quotaAttr, 'dn'],
292
+ filter: `(${mailAttr}=*)`,
293
+ attributes: [mailAttr, quotaAttr, aliasAttr, 'dn'],
68
294
  });
69
295
 
70
- let checked = 0;
71
- let synced = 0;
72
- let errors = 0;
73
-
74
296
  // Process results page by page
75
297
  for await (const result of resultGenerator) {
76
298
  if (!result.searchEntries || result.searchEntries.length === 0) {
@@ -88,92 +310,46 @@ async function syncJamesQuotas() {
88
310
  const mail = Array.isArray(entry[mailAttr])
89
311
  ? entry[mailAttr][0]
90
312
  : entry[mailAttr];
91
- const ldapQuota = Array.isArray(entry[quotaAttr])
92
- ? Number(entry[quotaAttr][0])
93
- : Number(entry[quotaAttr]);
94
313
 
95
- if (!mail || isNaN(ldapQuota)) {
96
- dm.logger.warn(`Skipping ${dn}: invalid mail or quota`);
314
+ if (!mail) {
315
+ dm.logger.warn(`Skipping ${dn}: invalid mail`);
97
316
  continue;
98
317
  }
99
318
 
100
- checked++;
319
+ stats.checked++;
101
320
 
102
321
  try {
103
- // Get James quota
104
- const getUrl = `${jamesUrl}/quota/users/${mail}/size`;
105
- const headers = {};
106
- if (jamesToken) {
107
- headers.Authorization = `Bearer ${jamesToken}`;
108
- }
109
-
110
- const getRes = await fetch(getUrl, { method: 'GET', headers });
111
-
112
- if (!getRes.ok) {
113
- if (getRes.status === 404) {
114
- dm.logger.warn(
115
- `User ${mail} not found in James, skipping (DN: ${dn})`
116
- );
117
- } else {
118
- dm.logger.error(
119
- `Error getting quota for ${mail}: ${getRes.status} ${getRes.statusText}`
120
- );
121
- errors++;
122
- }
123
- continue;
322
+ // Reconcile quota only when the user has a quota attribute
323
+ if (entry[quotaAttr] !== undefined) {
324
+ const ldapQuota = Array.isArray(entry[quotaAttr])
325
+ ? Number(entry[quotaAttr][0])
326
+ : Number(entry[quotaAttr]);
327
+ await syncQuota(dn, mail, ldapQuota);
124
328
  }
125
329
 
126
- const jamesQuota = Number(await getRes.text());
127
-
128
- if (jamesQuota === ldapQuota) {
129
- if (!quiet) {
130
- dm.logger.info(`${mail}: quota OK (${ldapQuota})`);
131
- }
132
- } else {
133
- dm.logger.warn(
134
- `${mail}: quota mismatch - LDAP: ${ldapQuota}, James: ${jamesQuota}`
135
- );
136
- if (dryRun) {
137
- dm.logger.info(` Would update James quota to ${ldapQuota}`);
138
- synced++;
139
- } else {
140
- if (!quiet) {
141
- dm.logger.info(` Updating James quota to ${ldapQuota}...`);
142
- }
143
-
144
- // Update James quota
145
- const putUrl = `${jamesUrl}/quota/users/${mail}/size`;
146
- const putRes = await fetch(putUrl, {
147
- method: 'PUT',
148
- headers,
149
- body: ldapQuota.toString(),
150
- });
151
-
152
- if (putRes.ok) {
153
- if (!quiet) {
154
- dm.logger.info(` Updated successfully`);
155
- }
156
- synced++;
157
- } else {
158
- dm.logger.error(
159
- ` Failed to update: ${putRes.status} ${putRes.statusText}`
160
- );
161
- errors++;
162
- }
163
- }
164
- }
330
+ // Reconcile aliases (catch-up). Always run so that stale aliases
331
+ // are removed even when the LDAP attribute has been cleared.
332
+ await syncAliases(dn, mail, entry[aliasAttr]);
165
333
  } catch (err) {
166
334
  dm.logger.error(`Error processing ${mail}: ${err.message}`);
167
- errors++;
335
+ stats.errors++;
168
336
  }
169
337
  }
170
338
  }
171
339
 
172
340
  dm.logger.info('\n' + '='.repeat(60));
173
341
  dm.logger.info('Synchronization summary:');
174
- dm.logger.info(` Users checked: ${checked}`);
175
- dm.logger.info(` Quotas ${dryRun ? 'needing sync' : 'synced'}: ${synced}`);
176
- dm.logger.info(` Errors: ${errors}`);
342
+ dm.logger.info(` Users checked: ${stats.checked}`);
343
+ dm.logger.info(
344
+ ` Quotas ${dryRun ? 'needing sync' : 'synced'}: ${stats.quotaSynced}`
345
+ );
346
+ dm.logger.info(
347
+ ` Aliases ${dryRun ? 'to add' : 'added'}: ${stats.aliasesAdded}`
348
+ );
349
+ dm.logger.info(
350
+ ` Aliases ${dryRun ? 'to delete' : 'deleted'}: ${stats.aliasesDeleted}`
351
+ );
352
+ dm.logger.info(` Errors: ${stats.errors}`);
177
353
  dm.logger.info('='.repeat(60));
178
354
  } catch (err) {
179
355
  dm.logger.error('Error during synchronization:', err);
@@ -184,7 +360,7 @@ async function syncJamesQuotas() {
184
360
  }
185
361
 
186
362
  // Run the sync
187
- syncJamesQuotas().catch(err => {
363
+ syncJames().catch(err => {
188
364
  console.error('Fatal error:', err);
189
365
  process.exit(1);
190
366
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"
@@ -549,7 +549,7 @@
549
549
  "require": "./static/schemas/twake/users.json"
550
550
  }
551
551
  },
552
- "main": "dist/bin/index.mjs",
552
+ "main": "dist/bin/index.js",
553
553
  "types": "./dist/src/bin/index.d.ts",
554
554
  "bin": {
555
555
  "cleanup-external-users": "bin/cleanup-external-users.mjs",