saltcorn-samba 0.3.10 → 0.4.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,158 @@ All notable changes to `saltcorn-samba` are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.4.0] – 2026-07-05
8
+
9
+ ### ⚠️ BREAKING CHANGES
10
+
11
+ - **Node.js ≥ 20 wird jetzt zwingend benötigt** (vorher war 16 möglich).
12
+ Grund: die neue Dependency `smb3-client` ist ein Pure-ESM-Paket und wird
13
+ im Plugin über dynamic `import()` aus CommonJS geladen — das ist ab
14
+ Node 20 stabil.
15
+ - **Dependency-Wechsel:** `@marsaud/smb2` wurde vollständig entfernt und
16
+ durch [`smb3-client`](https://www.npmjs.com/package/smb3-client) `^0.2.0`
17
+ ersetzt.
18
+ - **Zwei neue Config-Felder** in Schritt 1 des Konfigurations-Wizards:
19
+ - `SMB-Signing` (Werte: `if-offered` / `required` / `disabled`, Default `if-offered`)
20
+ - `SMB-Verschlüsselung` (Werte: `if-offered` / `required` / `disabled`, Default `if-offered`)
21
+
22
+ Es ist **kein manueller Config-Umbau nötig** — die Defaults sind für
23
+ fast alle Setups sinnvoll. Wer aus Sicherheitsgründen Signing oder
24
+ Encryption erzwingen will, kann jetzt `required` wählen.
25
+
26
+ ### Fixed – **Moderne Samba-Server (`sign_algo_id=0` / AES-CMAC-Pflicht)**
27
+
28
+ Symptom nach dem 0.3.11-Update: nach dem Setzen von
29
+ `NODE_OPTIONS=--openssl-legacy-provider` startete Saltcorn wieder, aber im
30
+ Samba-Log erschien beim ersten Zugriff:
31
+
32
+ ```
33
+ smbd: sign_algo_id=0 in negotiate response, expected AES-CMAC (2) or higher
34
+ ```
35
+
36
+ gefolgt von `STATUS_INVALID_PARAMETER` und einem 401-Fehler im Plugin.
37
+
38
+ **Ursache:** Aktuelle Samba-Versionen (Ubuntu 22.04+ / Debian 12+ /
39
+ RHEL 9+) verlangen im SMB-3.1.1-Handshake das moderne Signing-Verfahren
40
+ **AES-128-CMAC** (`SigningAlgorithmId 2`). `@marsaud/smb2` (letztes
41
+ Release 2020, unmaintained) implementiert diese Cipher-Suite nicht — es
42
+ kann nur die alten HMAC-SHA256- bzw. HMAC-MD5-Signaturen und meldet
43
+ sich mit `sign_algo_id=0`, was ein moderner Samba als Protokoll-Fehler
44
+ verwirft.
45
+
46
+ **Lösung — kompletter Wechsel der SMB-Client-Library:**
47
+
48
+ | Aspekt | Vorher (`@marsaud/smb2`) | Nachher (`smb3-client` 0.2.0) |
49
+ |---|---|---|
50
+ | Protokoll | SMB 2.0 / 2.1 | SMB 2.1 / 3.0 / 3.0.2 / **3.1.1** |
51
+ | Signing | HMAC-SHA256 (nur) | HMAC-SHA256 **+ AES-128-CMAC** |
52
+ | Encryption | – | AES-128-CCM / AES-128-GCM (optional) |
53
+ | Pre-Auth | – | SHA-512 Pre-Auth-Integrity |
54
+ | Auth | NTLM (via `ntlm`, DES-ECB) | **NTLMv2 / SPNEGO** (ohne DES-ECB) |
55
+ | Wartung | Letztes Release Q4 2020 | Aktiv (Mai 2026) |
56
+ | Runtime-Deps | `ntlm`, `iconv-lite` | **keine** |
57
+ | API | Callback | **Promise / async** |
58
+ | Modul-System | CommonJS | Pure ESM (via dynamic import geladen) |
59
+
60
+ ### Removed
61
+
62
+ - Legacy-Crypto-Check-Utility (`checkLegacyCryptoAvailable`,
63
+ `legacyCryptoErrorMessage`) sowie zugehöriger Handshake-Block in der
64
+ `/sambatest`-Route und im Test-Panel — nicht mehr nötig, weil
65
+ `smb3-client` keine Legacy-Cipher (DES-ECB) verwendet.
66
+ - Troubleshooting-Abschnitt zum `--openssl-legacy-provider`-Flag im README
67
+ (durch neuen 0.4.0-Kompatibilitätsabschnitt ersetzt).
68
+
69
+ ### Changed
70
+
71
+ - `smb-client.js` komplett neu geschrieben als dünner Wrapper um
72
+ `smb3-client`. Interne API (`buildClient`, `withClient`, `readdir`,
73
+ `readFile`, `writeFile`, `rename`, `unlink`, `mkdir`, `rmdir`) bleibt
74
+ identisch — kein Anpassungsbedarf in den View- oder Route-Handlern.
75
+ - Pfad-Konvention intern umgestellt: `smb3-client` erwartet den Share als
76
+ erstes Segment jedes Pfads (`share/subdir/datei.pdf`) statt separat im
77
+ Constructor. Der Wrapper prependet den Share-Namen transparent, sodass
78
+ Aufrufer weiterhin nur relative Pfade (`subdir/datei.pdf`) übergeben.
79
+ - Readdir-Ergebnisse werden pro Eintrag mit einem parallelen `stat()`-
80
+ Aufruf (Batch-Größe 16) angereichert, weil `smb3-client`-Dirents nur
81
+ `name` + `isFile()` + `isDirectory()` liefern — der Plugin-Filemanager
82
+ braucht aber weiterhin Größe und mtime.
83
+ - Fehler-Mapping in `/sambatest` erweitert um typische Signing-/
84
+ Encryption-/Pre-Auth-Fehlermeldungen aus `smb3-client` (`bad signature`,
85
+ `preauth integrity`, `encryption`).
86
+
87
+ ### Migration
88
+
89
+ 1. Saltcorn stoppen.
90
+ 2. Plugin auf 0.4.0 aktualisieren (npm bzw. neues ZIP entpacken).
91
+ 3. Falls gesetzt: `NODE_OPTIONS=--openssl-legacy-provider` **entfernen**
92
+ (nicht mehr nötig; siehe README-Abschnitt „Troubleshooting →
93
+ ERR_OSSL_EVP_UNSUPPORTED").
94
+ 4. Sicherstellen, dass **Node.js ≥ 20** installiert ist (`node -v`).
95
+ 5. Saltcorn wieder starten. Die Plugin-Config bleibt gültig — die neuen
96
+ Felder `SMB-Signing` und `SMB-Verschlüsselung` erhalten automatisch
97
+ den Default `if-offered`. Wer maximale Sicherheit möchte, setzt beide
98
+ auf `required` (Voraussetzung: der Samba-Server unterstützt es).
99
+ 6. Im Config-Wizard einmal „→ Verbindung jetzt testen" klicken.
100
+
101
+ ## [0.3.11] – 2026-07-05
102
+
103
+ ### Fixed – **Worker-Crash / 502 durch DES-ECB auf Node 17+ / OpenSSL 3**
104
+
105
+ Symptom nach dem 0.3.10-Update:
106
+
107
+ ```
108
+ node:internal/crypto/cipher:117
109
+ Error: error:0308010C:digital envelope routines::unsupported
110
+ at Cipheriv.createCipherBase ...
111
+ at .../@marsaud/smb2/node_modules/ntlm/lib/smbhash.js:46
112
+ code: 'ERR_OSSL_EVP_UNSUPPORTED'
113
+ worker died
114
+ ```
115
+
116
+ Auf der Config-Seite dann: **„Antwort war kein JSON (HTTP 502)“**.
117
+
118
+ **Ursache:** `@marsaud/smb2` benutzt über das transitive Paket `ntlm` den
119
+ Cipher **DES-ECB** zur Berechnung der LM/NTLM-Hashes. Node.js ab Version 17
120
+ ist gegen OpenSSL 3 gebaut, das DES-ECB standardmäßig blockiert. Der
121
+ `createCipheriv("des-ecb", ...)`-Aufruf wirft dann synchron
122
+ `ERR_OSSL_EVP_UNSUPPORTED`. Weil der Fehler synchron aus tiefen Callback-
123
+ Aufrufen kommt, tötet er den Saltcorn-Worker-Prozess → der Reverse-Proxy
124
+ liefert 502 → die Route liefert kein JSON.
125
+
126
+ **Fixes in diesem Release:**
127
+
128
+ 1. **Präventiver Check in `smb-client.js`:** Beim ersten Aufbau eines
129
+ SMB-Clients wird geprüft, ob `crypto.createCipheriv("des-ecb", ...)`
130
+ funktioniert. Wenn nicht, wird eine saubere, ausführliche deutsche
131
+ Fehlermeldung mit Lieferanleitung geworfen (`E_LEGACY_CRYPTO`) — der
132
+ fehlerhafte NTLM-Code wird gar nicht erst betreten, der Worker überlebt.
133
+ 2. **`/sambatest`-Route:** Der Check läuft zusätzlich explizit **vor**
134
+ dem `withClient`-Aufruf und liefert JSON mit `code: "E_LEGACY_CRYPTO"`,
135
+ `error`, `hint`, `node_version` und `openssl_version` — damit steht der
136
+ Diagnose-Grund direkt in der UI, nicht im Server-Log.
137
+ 3. **Fehler-Mapping erweitert:** Falls der Crash doch mal aus einem
138
+ anderen Pfad kommt (z. B. spätere Reconnect-Versuche), erkennt die
139
+ Catch-Logik jetzt auch `ERR_OSSL_EVP_UNSUPPORTED`,
140
+ `digital envelope routines` und `unsupported` und liefert einen
141
+ verständlichen Hinweis.
142
+ 4. **README:** Neuer Abschnitt „Troubleshooting“ mit Setup-Rezepten für
143
+ `NODE_OPTIONS=--openssl-legacy-provider` (Umgebungsvariable, systemd,
144
+ Docker/Compose, PM2, direkter Aufruf) plus Tabelle mit den häufigsten
145
+ Verbindungsfehlern und Diagnose-Kommandos.
146
+
147
+ ### Notes
148
+ - Das Plugin ändert keinen Node-Startparameter selbst — das können wir aus
149
+ Sicherheits- und Prozessgründen nicht (Node-Flags müssen beim Prozess-
150
+ Start gesetzt werden). Der Fix ist eine saubere Fehlererkennung mit
151
+ Lösungsanleitung.
152
+ - Sobald Saltcorn mit `NODE_OPTIONS=--openssl-legacy-provider` läuft,
153
+ funktionieren Verbindungstest und alle SMB-Operationen ohne weitere
154
+ Anpassung.
155
+ - **Roadmap:** Migration weg von `@marsaud/smb2` (letzter Release 2020) hin
156
+ zu einer aktiv gepflegten SMB-Client-Bibliothek, damit das
157
+ Legacy-Provider-Flag nicht mehr nötig ist.
158
+
7
159
  ## [0.3.10] – 2026-07-05
8
160
 
9
161
  ### Fixed – **DNS-Fehler: `getaddrinfo ENOTFOUND "host:445"`**
package/README.md CHANGED
@@ -12,7 +12,24 @@ Features:
12
12
  - 📄 **Inline-PDF-/Bild-Anzeige** direkt im `SambaFileManager` (Klick auf eine Datei), plus Download- und externe-App-Buttons.
13
13
  - 🚀 **`smb://`-Links** — Öffnet Dateien und Ordner direkt in Nemo, Nautilus, Dolphin (Linux) oder Explorer (Windows).
14
14
  - 🔒 **Sicherheit** — Base-Path als „Chroot", strenge Path-Traversal-Prüfung (`..`, absolute Pfade, UNC, Drive-Letters, NUL-Bytes), CSRF-Schutz auf Schreib-Routen, Filename-Sanitizer, Extension-Blocklist, rollenbasierter Zugriff (getrennte Lese-/Schreib-Rollen).
15
- - 🐳 **Docker-freundlich** — direkt per SMB2-Protokoll, keine System-Binaries nötig.
15
+ - 🔐 **Moderne SMB-Kryptografie** — SMB 3.1.1 mit **AES-128-CMAC-Signing** (Pflicht auf aktuellen Samba-Servern) und optionaler **AES-CCM/GCM-Verschlüsselung**. Signing- und Encryption-Modus per Config-Dropdown wählbar (`if-offered` / `required` / `disabled`). **Keine Legacy-Cipher** (DES-ECB) mehr nötig — läuft ohne `--openssl-legacy-provider` unter Node 20+.
16
+ - 🐳 **Docker-freundlich** — direkt per SMB2/3-Protokoll, keine System-Binaries nötig.
17
+
18
+ > **Voraussetzungen:** **Node.js ≥ 20** (wegen der ESM-basierten [`smb3-client`](https://www.npmjs.com/package/smb3-client) Library, die intern per dynamic `import()` geladen wird). Saltcorn 0.9+ wird unterstützt.
19
+
20
+ ---
21
+
22
+ ## Was ist neu in 0.4.0 (Migration von 0.3.x)
23
+
24
+ Das Plugin wurde intern von der unmaintained `@marsaud/smb2` auf die moderne
25
+ `smb3-client` umgestellt. **Kein Config-Umbau nötig** — die neuen Felder
26
+ `SMB-Signing` und `SMB-Verschlüsselung` haben sinnvolle Defaults (`if-offered`).
27
+ Dadurch:
28
+
29
+ - ✅ funktioniert wieder gegen moderne Samba-Server, die `sign_algo_id != 0`
30
+ (AES-CMAC) verlangen,
31
+ - ✅ **kein** `--openssl-legacy-provider`-Flag mehr nötig,
32
+ - ⚠️ **Breaking:** Node.js ≥ 20 zwingend erforderlich (vorher 16 möglich).
16
33
 
17
34
  ---
18
35
 
@@ -109,6 +126,8 @@ Configure* ausfüllen. Die Konfiguration ist zweistufig:
109
126
  | **Passwort** | *(secret)* | Samba nutzt ein eigenes Passwort (`smbpasswd`), nicht zwingend das Linux-Login. Moderne Server lehnen leere Passwörter ab. |
110
127
  | **Basispfad** | `projekte/2026` | Optional. Relativ, mit Slashes, **ohne** führenden `/`. Beschränkt jeden Zugriff auf dieses Unterverzeichnis der Freigabe. `..` und absolute Pfade werden abgelehnt. |
111
128
  | **TCP-Port** | `445` | Standard SMB2/3 über TCP. **SMBv1 (139) wird nicht unterstützt** – auf dem Server `min protocol = SMB2` setzen. |
129
+ | **SMB-Signing** | `if-offered` | `if-offered` (Standard), `required` oder `disabled`. Nutzt HMAC-SHA256 (SMB 2.x) bzw. AES-128-CMAC (SMB 3.x). Moderne Samba-Server verlangen häufig Signing → `required` oder `if-offered`. |
130
+ | **SMB-Verschlüsselung** | `if-offered` | `if-offered` (Standard), `required` oder `disabled`. Nutzt AES-128/256-CCM/GCM. Shares mit serverseitigem `SMB2_SHAREFLAG_ENCRYPT_DATA` erzwingen Verschlüsselung ohnehin. |
112
131
 
113
132
  > **Tipp:** Bevor Sie speichern, klicken Sie auf **„→ Verbindung jetzt
114
133
  > testen“** – siehe Abschnitt [Verbindung testen](#verbindung-testen).
@@ -331,6 +350,73 @@ Ab dann taucht das Plugin im Plugins-Store jeder Saltcorn-Instanz auf.
331
350
 
332
351
  ---
333
352
 
353
+ ## Troubleshooting
354
+
355
+ ### `ERR_MODULE_NOT_FOUND` / „Cannot find package 'smb3-client'" / Worker startet nicht
356
+
357
+ Das Plugin lädt `smb3-client` (ESM) via dynamic `import()` — das npm-Paket
358
+ muss also im Plugin-Ordner installiert sein.
359
+
360
+ ```bash
361
+ cd /pfad/zu/saltcorn-samba
362
+ npm install
363
+ saltcorn restart # oder systemd/docker-Neustart
364
+ ```
365
+
366
+ Bei Installation über die Saltcorn-Plugin-UI (Source `npm` oder `github`)
367
+ führt Saltcorn `npm install` automatisch aus.
368
+
369
+ ### `STATUS_INVALID_PARAMETER` / „sign_algo_id=0" im Samba-Log
370
+
371
+ Das war der Fehler in **0.3.x mit `@marsaud/smb2`**: moderne Samba-Server
372
+ (Ubuntu 22.04+ / Debian 12+ / RHEL 9+) verlangen **AES-CMAC-Signing**, das
373
+ die alte Library nicht konnte. **In 0.4.0 gelöst** — die neue `smb3-client`-
374
+ Library implementiert AES-CMAC. Falls die Meldung dennoch auftaucht:
375
+
376
+ - In der Plugin-Config **SMB-Signing = `required`** setzen (erzwingt
377
+ AES-CMAC schon beim Handshake).
378
+ - Auf dem Server sicherstellen, dass `server signing = mandatory` gesetzt
379
+ ist, dann in der Client-Config ebenfalls `required` wählen.
380
+
381
+ ### `ERR_OSSL_EVP_UNSUPPORTED` / `--openssl-legacy-provider`
382
+
383
+ **Betrifft 0.4.0 nicht mehr.** Die alte Abhängigkeit `@marsaud/smb2` nutzte
384
+ intern DES-ECB (aus dem transitiven `ntlm`-Paket), das ab OpenSSL 3
385
+ deaktiviert ist. `smb3-client` verwendet ausschließlich moderne Cipher
386
+ (HMAC-SHA256, AES-128-CMAC, AES-CCM/GCM, SHA-512-PreAuth-Integrity).
387
+ **Das `NODE_OPTIONS=--openssl-legacy-provider`-Flag darf und sollte in
388
+ 0.4.0 wieder entfernt werden.**
389
+
390
+ ### Signing- und Verschlüsselungs-Modi
391
+
392
+ Beide Modi haben denselben Wertebereich:
393
+
394
+ | Wert | Verhalten |
395
+ |---|---|
396
+ | `if-offered` **(Standard)** | Wird genutzt, wenn der Server es anbietet. Kompatibel mit alten und neuen Servern. |
397
+ | `required` | Wird zwingend verlangt. Verbindung schlägt fehl, wenn der Server nicht mitmacht. Empfohlen für Produktionsumgebungen. |
398
+ | `disabled` | Wird nicht angefordert. Nur für Legacy-Server oder isolierte LAN-Setups. Nicht mit Servern kombinieren, die Signing/Encryption erzwingen. |
399
+
400
+ Typische Fehlermeldungen und was zu tun ist:
401
+
402
+ | Symptom | Wahrscheinliche Ursache | Lösung |
403
+ |---|---|---|
404
+ | `bad signature` / `SIGNATURE_MISMATCH` | Uhrzeit-Drift zwischen Client und Server, oder falsche Credentials | Zeit synchronisieren (NTP), Passwort prüfen |
405
+ | `preauth integrity` | Man-in-the-Middle oder Netzwerkproblem | Netzwerkpfad prüfen (VPN, Proxy) |
406
+ | Verbindung schlägt bei `required` fehl | Server bietet Modus nicht an | Server konfigurieren oder Modus auf `if-offered` senken |
407
+
408
+ ### Weitere häufige Fehler
409
+
410
+ | Symptom | Ursache | Lösung |
411
+ |---|---|---|
412
+ | `ECONNREFUSED :445` | SMB-Dienst läuft nicht oder Firewall blockt Port 445 | `nc -vz <server> 445` vom Saltcorn-Host testen |
413
+ | `ETIMEDOUT` | Kein Netzwerkpfad (VLAN/Docker-Bridge/VPN) | Vom Saltcorn-Container aus `ping` + `nc -vz` prüfen |
414
+ | `LOGON_FAILURE` / `STATUS_LOGON_FAILURE` | User/Passwort/Domain falsch | Zugangsdaten prüfen. Moderne Samba-Server erlauben keine leeren Passwörter |
415
+ | `BAD_NETWORK_NAME` / `STATUS_BAD_NETWORK_NAME` | Share existiert nicht oder Schreibweise falsch | `smbclient -L //server -U user` zur Kontrolle |
416
+ | Nur SMBv1 verfügbar | Server bietet SMB2/3 nicht an | In `smb.conf`: `min protocol = SMB2` |
417
+
418
+ ---
419
+
334
420
  ## Bekannte Grenzen
335
421
 
336
422
  - Dateien werden komplett gepuffert (`readFile` / `writeFile`). Für
package/index.js CHANGED
@@ -92,13 +92,15 @@ window.sambaTestConn = async function(btn) {
92
92
  if (!form) { out.innerHTML = '<div class="alert alert-danger">Formular nicht gefunden.</div>'; return; }
93
93
  function v(n){ var el = form.querySelector('[name="'+n+'"]'); return el ? el.value : ''; }
94
94
  var payload = {
95
- server: v('server'),
96
- share: v('share'),
97
- domain: v('domain'),
98
- username: v('username'),
99
- password: v('password'),
100
- base_path: v('base_path'),
101
- port: v('port')
95
+ server: v('server'),
96
+ share: v('share'),
97
+ domain: v('domain'),
98
+ username: v('username'),
99
+ password: v('password'),
100
+ base_path: v('base_path'),
101
+ port: v('port'),
102
+ signing_mode: v('signing_mode'),
103
+ encryption_mode: v('encryption_mode')
102
104
  };
103
105
  if (!payload.server || !payload.share) {
104
106
  out.innerHTML = '<div class="alert alert-warning">Bitte mindestens <b>Server</b> und <b>Share</b> ausfüllen.</div>';
@@ -287,6 +289,46 @@ const configuration_workflow = () =>
287
289
  type: "Integer",
288
290
  default: 445,
289
291
  }),
292
+ new Field({
293
+ name: "signing_mode",
294
+ label: "SMB-Signing",
295
+ sublabel:
296
+ "Wie streng jede Nachricht kryptografisch signiert wird (HMAC-SHA256 für SMB 2.x, AES-128-CMAC für SMB 3.x). " +
297
+ "<b>required</b>: Signing wird zwingend verlangt; Verbindung schlägt fehl, wenn der Server nicht signiert. " +
298
+ "<b>if-offered</b> (Standard): Signing wird genutzt, wenn der Server es anbietet, sonst weiter ohne. " +
299
+ "<b>disabled</b>: kein Signing (nur auswählen, wenn der Server Signing verweigert). " +
300
+ "Moderne Samba-Server verlangen häufig Signing → „required“ oder „if-offered“ setzen.",
301
+ type: "String",
302
+ required: true,
303
+ attributes: {
304
+ options: [
305
+ { value: "if-offered", label: "if-offered (Standard)" },
306
+ { value: "required", label: "required (strikt)" },
307
+ { value: "disabled", label: "disabled (aus)" },
308
+ ],
309
+ },
310
+ default: "if-offered",
311
+ }),
312
+ new Field({
313
+ name: "encryption_mode",
314
+ label: "SMB-Verschlüsselung",
315
+ sublabel:
316
+ "Verschlüsselung der Nutzdaten auf dem Draht (AES-128/256-CCM/GCM). " +
317
+ "<b>required</b>: Verbindung nur mit Verschlüsselung; scheitert, wenn der Server keine anbietet. " +
318
+ "<b>if-offered</b> (Standard): Verschlüsselung wird genutzt, wenn der Server sie anbietet. " +
319
+ "<b>disabled</b>: keine Verschlüsselung anfordern (nur für Legacy-Server oder LAN-Only-Setups). " +
320
+ "Shares, die serverseitig <code>SMB2_SHAREFLAG_ENCRYPT_DATA</code> tragen, erzwingen Verschlüsselung ohnehin.",
321
+ type: "String",
322
+ required: true,
323
+ attributes: {
324
+ options: [
325
+ { value: "if-offered", label: "if-offered (Standard)" },
326
+ { value: "required", label: "required (strikt)" },
327
+ { value: "disabled", label: "disabled (aus)" },
328
+ ],
329
+ },
330
+ default: "if-offered",
331
+ }),
290
332
  new Field({
291
333
  name: "_test_html",
292
334
  label: " ",
@@ -742,6 +784,8 @@ code{background:#f4f4f4;padding:2px 6px;border-radius:3px;word-break:break-all}<
742
784
  password: String(body.password || ""),
743
785
  base_path: String(body.base_path || "").trim(),
744
786
  port: Number(body.port) || 445,
787
+ signing_mode: String(body.signing_mode || "").trim() || undefined,
788
+ encryption_mode: String(body.encryption_mode || "").trim() || undefined,
745
789
  };
746
790
 
747
791
  if (!testCfg.server) return jsonError(res, 400, "Please enter a Server (hostname or IP).");
@@ -798,6 +842,14 @@ code{background:#f4f4f4;padding:2px 6px;border-radius:3px;word-break:break-all}<
798
842
  hint = "The server may be offering only SMBv1 which this plugin does not support. Enable SMB2 / SMB3 on the Samba server (min protocol = SMB2 in smb.conf).";
799
843
  else if (m.includes("traversal") || m.includes("path"))
800
844
  hint = "The Base path could not be validated. It must be a relative sub-directory (e.g. 'projects/2026'), never start with / or \\, and must not contain '..'.";
845
+ else if (m.includes("bad signature") || m.includes("sign_algo") || m.includes("signing"))
846
+ hint = "Signaturprüfung fehlgeschlagen. Setze in der Plugin-Config unter „SMB-Signing“ auf „if-offered“ oder prüfe auf dem Server, ob AES-CMAC unterstützt wird.";
847
+ else if (m.includes("pre-auth") || m.includes("preauth"))
848
+ hint = "Pre-Auth-Integrity fehlgeschlagen. Server und Client müssen SMB 3.1.1 sprechen. Auf Samba: 'server min protocol = SMB3_11' prüfen.";
849
+ else if (m.includes("encryption") || m.includes("encrypted"))
850
+ hint = "Verschlüsselungs-Verhandlung fehlgeschlagen. In der Plugin-Config „SMB-Verschlüsselung“ auf „if-offered“ setzen oder den Server so konfigurieren, dass er AES-GCM/CCM anbietet.";
851
+ else if (code === "ERR_OSSL_EVP_UNSUPPORTED" || m.includes("digital envelope routines"))
852
+ hint = "Node blockiert Legacy-Cipher. Ab v0.4.0 nutzt das Plugin smb3-client statt @marsaud/smb2 — diese Meldung sollte eigentlich nicht mehr auftreten. Bitte README-Abschnitt zu SMB3-Verbindung prüfen.";
801
853
 
802
854
  return res.status(200).json({
803
855
  ok: false,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "saltcorn-samba",
3
- "version": "0.3.10",
4
- "description": "Saltcorn plugin: browse, upload, rename and delete files on a Samba/CIFS share. File-manager view, directory tree, inline PDF viewer, external-app open (smb://).",
3
+ "version": "0.4.0",
4
+ "description": "Saltcorn plugin: browse, upload, rename and delete files on a Samba/CIFS share via SMB 3.1.1 (AES-CMAC signing, optional encryption). File-manager view, directory tree, inline PDF viewer, external-app open (smb://).",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "test": "node test/sanitize.test.js",
@@ -31,10 +31,10 @@
31
31
  "url": "https://github.com/pv-host/saltcorn-samba/issues"
32
32
  },
33
33
  "engines": {
34
- "node": ">=18"
34
+ "node": ">=20"
35
35
  },
36
36
  "dependencies": {
37
- "@marsaud/smb2": "^0.18.0"
37
+ "smb3-client": "^0.2.0"
38
38
  },
39
39
  "files": [
40
40
  "index.js",
package/smb-client.js CHANGED
@@ -1,25 +1,53 @@
1
1
  /**
2
- * SMB client wrapper provides a small, connection-pooled interface
3
- * around @marsaud/smb2 with security-conscious path handling.
2
+ * SMB client wrapper provides a connection-scoped, `fs`-like interface
3
+ * around the `smb3-client` npm package (SMB 3.1.1 with AES-CMAC signing
4
+ * and optional AES-GCM encryption).
4
5
  *
5
- * All paths that come from the browser MUST be validated with
6
- * `sanitizeRelativePath` before being combined with the base_path.
6
+ * Design notes for v0.4.0:
7
+ *
8
+ * 1. Prior versions used `@marsaud/smb2` (SMB 2.0.2 / 2.1 only, tied to the
9
+ * unmaintained `ntlm` package that requires DES-ECB and dies on Node 17+
10
+ * with OpenSSL 3, and unable to satisfy `server signing = mandatory` on
11
+ * modern Samba servers because it lacks AES-CMAC signing).
12
+ *
13
+ * `smb3-client` speaks SMB 2.1 / 3.0 / 3.0.2 / 3.1.1, signs with either
14
+ * HMAC-SHA256 or AES-128-CMAC, does SHA-512 pre-auth integrity, has zero
15
+ * runtime dependencies, and no DES anywhere.
16
+ *
17
+ * 2. `smb3-client` is pure ESM. This plugin is CommonJS. We therefore load
18
+ * the module via a cached dynamic `import()` inside an async helper.
19
+ *
20
+ * 3. `smb3-client`'s path convention is "<share>/<sub>/<file>" — the share
21
+ * name is the FIRST segment of every path passed to `readFile`/`stat`/…
22
+ * We keep the plugin's external contract (callers still pass paths that
23
+ * are relative to the share root, e.g. "reports/2026/q1.xlsx"), and the
24
+ * wrapper prepends the share name and optional base_path.
25
+ *
26
+ * 4. All user-supplied path components MUST go through `sanitizeRelativePath`
27
+ * and `sanitizeFilename` before being handed to this wrapper. Those
28
+ * sanitizers are unchanged from previous versions (tests still cover them).
7
29
  */
8
30
 
9
31
  const path = require("path");
10
32
 
11
- // @marsaud/smb2 is loaded lazily so the pure sanitizer helpers exported by
12
- // this module can be required (e.g. from unit tests) without needing the
13
- // native SMB dependency installed.
14
- let _SMB2 = null;
15
- function getSMB2() {
16
- if (_SMB2) return _SMB2;
17
- _SMB2 = require("@marsaud/smb2");
18
- return _SMB2;
33
+ // ---------------------------------------------------------------------------
34
+ // Dynamic ESM import cache for smb3-client
35
+ // ---------------------------------------------------------------------------
36
+ //
37
+ // The `smb3-client` package is pure ESM (`"type": "module"`), so we cannot
38
+ // use CommonJS `require()`. Node supports `import()` in CommonJS as a dynamic
39
+ // expression, which returns a Promise. We cache the resolved module.
40
+ let _smb3Module = null;
41
+ async function getSmb3Client() {
42
+ if (_smb3Module) return _smb3Module;
43
+ // Use string concatenation so bundlers do not try to statically resolve
44
+ // this at build time.
45
+ _smb3Module = await import("smb3-client");
46
+ return _smb3Module;
19
47
  }
20
48
 
21
49
  // ---------------------------------------------------------------------------
22
- // Path helpers
50
+ // Path helpers (unchanged from previous versions — covered by unit tests)
23
51
  // ---------------------------------------------------------------------------
24
52
 
25
53
  /**
@@ -33,7 +61,6 @@ function sanitizeFilename(name) {
33
61
  if (typeof name !== "string") throw new Error("Filename must be a string");
34
62
  const trimmed = name.trim();
35
63
  if (!trimmed) throw new Error("Filename must not be empty");
36
- // reject leading/trailing whitespace on non-empty names (Windows problem)
37
64
  if (name !== trimmed)
38
65
  throw new Error("Filename must not start or end with whitespace");
39
66
  if (trimmed.length > 255) throw new Error("Filename too long");
@@ -41,12 +68,10 @@ function sanitizeFilename(name) {
41
68
  throw new Error("Filename must not be '.' or '..'");
42
69
  if (/[\\/]/.test(trimmed)) throw new Error("Filename must not contain slashes");
43
70
  if (/[\x00-\x1f]/.test(trimmed)) throw new Error("Filename must not contain control characters");
44
- // Reject characters SMB / Windows disallow in filenames
45
71
  if (/[<>:"|?*]/.test(trimmed))
46
72
  throw new Error('Filename must not contain any of: < > : " | ? *');
47
73
  if (trimmed.endsWith(".") || trimmed.endsWith(" "))
48
74
  throw new Error("Filename must not end with a dot or space");
49
- // Windows reserved device names (case-insensitive, with or without extension)
50
75
  const base = trimmed.split(".")[0].toUpperCase();
51
76
  const RESERVED = new Set([
52
77
  "CON", "PRN", "AUX", "NUL",
@@ -67,15 +92,11 @@ function sanitizeRelativePath(rel) {
67
92
  if (typeof rel !== "string") throw new Error("Path must be a string");
68
93
  if (rel.length > 4096) throw new Error("Path too long");
69
94
  if (rel.includes("\0")) throw new Error("Illegal NUL byte in path");
70
- // Convert backslashes to forward slashes first (do NOT collapse yet)
71
95
  let p = rel.replace(/\\/g, "/");
72
- // Reject Windows drive letters and UNC paths BEFORE collapsing slashes
73
96
  if (/^[a-zA-Z]:/.test(p)) throw new Error("Drive letters not allowed");
74
97
  if (p.startsWith("//")) throw new Error("UNC paths not allowed");
75
- // Now collapse multiple slashes and strip leading slash
76
98
  p = p.replace(/\/+/g, "/");
77
99
  if (p.startsWith("/")) p = p.slice(1);
78
- // Reject explicit traversal segments
79
100
  const parts = p.split("/").filter((s) => s !== "" && s !== ".");
80
101
  for (const seg of parts) {
81
102
  if (seg === "..") throw new Error("Path traversal not allowed");
@@ -83,167 +104,264 @@ function sanitizeRelativePath(rel) {
83
104
  return parts.join("/");
84
105
  }
85
106
 
86
- /** Convert a POSIX-style relative path into the SMB backslash form. */
107
+ /**
108
+ * Convert a POSIX-style relative path into the SMB backslash form.
109
+ * Kept for backwards compatibility — smb3-client itself uses forward
110
+ * slashes, but external callers (URL builders, logging) may still use
111
+ * this helper.
112
+ */
87
113
  function toSmbPath(rel) {
88
114
  return rel.replace(/\//g, "\\");
89
115
  }
90
116
 
117
+ // ---------------------------------------------------------------------------
118
+ // Host / port helpers
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /**
122
+ * Split a possibly-composite server field into { host, port }.
123
+ * Tolerant to `host:445` typed into the server field, `[::1]:445` IPv6, and
124
+ * plain hostnames or bare IPs. Never returns a host string that contains a
125
+ * colon-port suffix (that would break DNS lookup).
126
+ */
127
+ function parseHostPort(server, explicitPort) {
128
+ let host = String(server || "").trim();
129
+ let port;
130
+
131
+ const v6 = host.match(/^\[([^\]]+)\](?::(\d+))?$/);
132
+ if (v6) {
133
+ host = v6[1];
134
+ if (v6[2]) port = Number(v6[2]);
135
+ } else {
136
+ const colonCount = (host.match(/:/g) || []).length;
137
+ if (colonCount === 1) {
138
+ const [h, p] = host.split(":");
139
+ if (h && /^\d+$/.test(p)) {
140
+ host = h;
141
+ port = Number(p);
142
+ }
143
+ }
144
+ }
145
+
146
+ return {
147
+ host,
148
+ port: Number(explicitPort) || port || 445,
149
+ };
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Config validation
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /** Normalise the security-mode fields to smb3-client's vocabulary. */
157
+ function normSecurityMode(v, fallback) {
158
+ const s = String(v || "").toLowerCase().trim();
159
+ if (s === "disabled" || s === "if-offered" || s === "required") return s;
160
+ return fallback;
161
+ }
162
+
91
163
  // ---------------------------------------------------------------------------
92
164
  // SMB client factory
93
165
  // ---------------------------------------------------------------------------
94
166
 
95
167
  /**
96
- * Build a fresh SMB2 client from the plugin configuration.
168
+ * Build and connect a fresh smb3-client `Client` from the plugin
169
+ * configuration. Returns a small wrapper object with `fs`-like methods that
170
+ * accept paths RELATIVE TO THE SHARE ROOT (with `base_path` automatically
171
+ * prepended). Always call `disconnect()` when done — or use `withClient()`.
97
172
  *
98
173
  * config = {
99
- * server: "192.168.1.10", // host or IP of the samba server
100
- * share: "documents", // share name (no slashes)
101
- * domain: "WORKGROUP", // optional
174
+ * server: "192.168.1.10", // host or IP
175
+ * share: "documents", // share name (no slashes)
176
+ * domain: "WORKGROUP", // optional
102
177
  * username: "reader",
103
178
  * password: "secret",
104
- * base_path:"", // optional subdirectory to lock into
105
- * port: 445, // optional
179
+ * base_path:"", // optional subdirectory lock
180
+ * port: 445, // optional
181
+ * signing_mode: "required", // "disabled" | "if-offered" | "required"
182
+ * encryption_mode: "if-offered", // ditto
106
183
  * }
107
- *
108
- * The returned object exposes: readdir(rel), stat(rel), readFile(rel),
109
- * createReadStream(rel), disconnect(), plus helpers `resolve(rel)` and
110
- * `basePath`.
111
184
  */
112
- function buildClient(config) {
185
+ async function buildClient(config) {
113
186
  if (!config) throw new Error("Samba plugin is not configured");
114
- const { server, share, domain, username, password, port } = config;
187
+ const { server, share, domain, username, password } = config;
115
188
  if (!server) throw new Error("Samba: server missing");
116
189
  if (!share) throw new Error("Samba: share missing");
117
- if (/[\\/]/.test(share)) throw new Error("Samba: share must not contain slashes");
118
-
119
- // Host und Port sauber trennen. Der Server-String darf NICHT im share-UNC-
120
- // Pfad landen, sonst versucht Node's DNS "host:port" als Hostnamen aufzulösen
121
- // (getaddrinfo ENOTFOUND "1.2.3.4:445"). @marsaud/smb2 nimmt den Port über
122
- // die separate `port`-Option entgegen; der share-String enthält nur den Host.
123
- //
124
- // Zusätzlich tolerant sein, falls jemand versehentlich "host:445" ins
125
- // Server-Feld getippt hat — dann Port dort rausziehen.
126
- let hostOnly = String(server).trim();
127
- let portFromServer;
128
- // IPv6-Adressen in eckigen Klammern zulassen: [::1]:445
129
- const v6 = hostOnly.match(/^\[([^\]]+)\](?::(\d+))?$/);
130
- if (v6) {
131
- hostOnly = v6[1];
132
- if (v6[2]) portFromServer = Number(v6[2]);
133
- } else {
134
- // Nur splitten, wenn genau EIN Doppelpunkt — sonst IPv6 ohne Klammern
135
- const colonCount = (hostOnly.match(/:/g) || []).length;
136
- if (colonCount === 1) {
137
- const [h, p] = hostOnly.split(":");
138
- if (h && /^\d+$/.test(p)) {
139
- hostOnly = h;
140
- portFromServer = Number(p);
141
- }
142
- }
143
- }
190
+ if (/[\\/]/.test(share))
191
+ throw new Error("Samba: share must not contain slashes");
192
+
193
+ const { host, port } = parseHostPort(server, config.port);
194
+ const signing = normSecurityMode(config.signing_mode, "if-offered");
195
+ const encryption = normSecurityMode(config.encryption_mode, "if-offered");
196
+
197
+ const basePath = sanitizeRelativePath(config.base_path || "");
198
+ const shareName = String(share).trim();
144
199
 
145
- const effectivePort = Number(port) || portFromServer || 445;
146
- const shareStr = `\\\\${hostOnly}\\${share}`;
147
- const SMB2 = getSMB2();
148
- const smb = new SMB2({
149
- share: shareStr,
150
- domain: domain || "WORKGROUP",
151
- username: username || "guest",
200
+ const { Client } = await getSmb3Client();
201
+ const client = new Client({
202
+ host,
203
+ port,
204
+ domain: domain || "",
205
+ username: username || "",
152
206
  password: password || "",
153
- port: effectivePort,
154
- autoCloseTimeout: 10000,
207
+ connectTimeout: 10000,
208
+ requestTimeout: 30000,
209
+ signing,
210
+ encryption,
155
211
  });
156
212
 
157
- const basePath = sanitizeRelativePath(config.base_path || "");
213
+ await client.connect();
158
214
 
159
- /** Combine base + user-supplied relative into a validated SMB path. */
160
- function resolve(rel) {
215
+ /**
216
+ * Combine share + basePath + user-supplied relative into the full
217
+ * smb3-client-style path ("share/sub/dir/file.ext"), always sanitising
218
+ * the user input first.
219
+ */
220
+ function resolvePath(rel) {
161
221
  const safe = sanitizeRelativePath(rel);
162
- const combined = [basePath, safe].filter(Boolean).join("/");
163
- return toSmbPath(combined);
222
+ return [shareName, basePath, safe].filter(Boolean).join("/");
223
+ }
224
+
225
+ /**
226
+ * Convert a smb3-client Dirent + best-effort stat into the shape the
227
+ * rest of the plugin expects (`name`, `isDirectory` as function OR
228
+ * boolean, `size`, `mtime`, `birthtime`).
229
+ *
230
+ * We fetch stat data in parallel because smb3-client's readdir Dirent
231
+ * only carries name/isFile/isDirectory. For very large directories this
232
+ * would fan out into many stat calls — that's an acceptable trade-off
233
+ * for now; a future release can optimise by using SMB2_QUERY_DIRECTORY's
234
+ * FileBothDirectoryInformation output directly.
235
+ */
236
+ async function enrichEntry(dirent, parentFullPath) {
237
+ const isDir = !!dirent.isDirectory();
238
+ const fullPath = parentFullPath
239
+ ? parentFullPath + "/" + dirent.name
240
+ : dirent.name;
241
+ let size = 0;
242
+ let mtime;
243
+ let birthtime;
244
+ try {
245
+ const st = await client.stat(fullPath);
246
+ size = Number(st.size || 0);
247
+ mtime = st.mtime;
248
+ birthtime = st.ctime;
249
+ } catch (_) {
250
+ // Non-fatal; return whatever we already have.
251
+ }
252
+ return {
253
+ name: dirent.name,
254
+ isDirectory: isDir,
255
+ size,
256
+ mtime,
257
+ birthtime,
258
+ };
164
259
  }
165
260
 
166
261
  return {
262
+ /** Share name, exposed for URL building / logging. */
263
+ shareName,
264
+ /** Sanitised base path (may be ""). */
167
265
  basePath,
168
- resolve,
169
- readdir(rel) {
170
- return new Promise((res, rej) => {
171
- smb.readdir(resolve(rel), { stats: true }, (err, files) =>
172
- err ? rej(err) : res(files)
266
+ /** Underlying smb3-client Client — do not use unless you know why. */
267
+ _raw: client,
268
+
269
+ /**
270
+ * List a directory (share root by default). Returns an array of objects
271
+ * shaped like the legacy `@marsaud/smb2` output so index.js does not need
272
+ * to change: `{ name, isDirectory, size, mtime, birthtime }`.
273
+ */
274
+ async readdir(rel) {
275
+ const full = resolvePath(rel);
276
+ const dirents = await client.readdir(full, { withFileTypes: true });
277
+ // Parallel enrichment. Bounded to a reasonable concurrency to avoid
278
+ // saturating the SMB session on huge directories.
279
+ const CHUNK = 16;
280
+ const result = [];
281
+ for (let i = 0; i < dirents.length; i += CHUNK) {
282
+ const slice = dirents.slice(i, i + CHUNK);
283
+ const enriched = await Promise.all(
284
+ slice.map((d) => enrichEntry(d, full))
173
285
  );
174
- });
286
+ result.push(...enriched);
287
+ }
288
+ return result;
175
289
  },
176
- stat(rel) {
177
- return new Promise((res, rej) => {
178
- // marsaud-smb2 does not export a proper stat; emulate via readdir of parent
179
- const target = resolve(rel);
180
- const parent = target.includes("\\")
181
- ? target.slice(0, target.lastIndexOf("\\"))
182
- : "";
183
- const name = target.includes("\\")
184
- ? target.slice(target.lastIndexOf("\\") + 1)
185
- : target;
186
- smb.readdir(parent, { stats: true }, (err, files) => {
187
- if (err) return rej(err);
188
- const match = files.find((f) => f.name === name);
189
- if (!match) return rej(new Error("Not found"));
190
- res(match);
191
- });
192
- });
290
+
291
+ /** Stat a single file/directory (name-relative-to-share/basePath). */
292
+ async stat(rel) {
293
+ const full = resolvePath(rel);
294
+ const st = await client.stat(full);
295
+ // Return a shape compatible with the old readdir-emulated stat.
296
+ return {
297
+ name: full.split("/").pop(),
298
+ size: Number(st.size || 0),
299
+ mtime: st.mtime,
300
+ birthtime: st.ctime,
301
+ isDirectory: !!st.isDirectory,
302
+ isFile: !!st.isFile,
303
+ };
193
304
  },
194
- readFile(rel) {
195
- return new Promise((res, rej) => {
196
- smb.readFile(resolve(rel), (err, data) =>
197
- err ? rej(err) : res(data)
198
- );
199
- });
305
+
306
+ /** Read the full content of a file into a Buffer. */
307
+ async readFile(rel) {
308
+ return await client.readFile(resolvePath(rel));
200
309
  },
201
- writeFile(rel, data) {
202
- return new Promise((res, rej) => {
203
- smb.writeFile(resolve(rel), data, (err) =>
204
- err ? rej(err) : res()
205
- );
206
- });
310
+
311
+ /** Create or overwrite a file with the given Buffer / string. */
312
+ async writeFile(rel, data) {
313
+ return await client.writeFile(resolvePath(rel), data);
207
314
  },
208
- exists(rel) {
209
- return new Promise((res) => {
210
- smb.exists(resolve(rel), (err, ok) => res(err ? false : !!ok));
211
- });
315
+
316
+ /** Check whether a path exists (never throws). */
317
+ async exists(rel) {
318
+ try {
319
+ await client.stat(resolvePath(rel));
320
+ return true;
321
+ } catch (_) {
322
+ return false;
323
+ }
212
324
  },
213
- unlink(rel) {
214
- return new Promise((res, rej) => {
215
- smb.unlink(resolve(rel), (err) => (err ? rej(err) : res()));
216
- });
325
+
326
+ /** Delete a single file. */
327
+ async unlink(rel) {
328
+ return await client.rm(resolvePath(rel));
217
329
  },
218
- rmdir(rel) {
219
- return new Promise((res, rej) => {
220
- smb.rmdir(resolve(rel), (err) => (err ? rej(err) : res()));
221
- });
330
+
331
+ /** Remove an empty directory. */
332
+ async rmdir(rel) {
333
+ return await client.rmdir(resolvePath(rel));
222
334
  },
223
- mkdir(rel) {
224
- return new Promise((res, rej) => {
225
- smb.mkdir(resolve(rel), (err) => (err ? rej(err) : res()));
226
- });
335
+
336
+ /** Create a directory (non-recursive). */
337
+ async mkdir(rel) {
338
+ return await client.mkdir(resolvePath(rel));
227
339
  },
228
- rename(oldRel, newRel) {
229
- return new Promise((res, rej) => {
230
- smb.rename(resolve(oldRel), resolve(newRel), (err) =>
231
- err ? rej(err) : res()
232
- );
233
- });
340
+
341
+ /** Rename (or move within the same share). */
342
+ async rename(oldRel, newRel) {
343
+ return await client.rename(resolvePath(oldRel), resolvePath(newRel));
234
344
  },
345
+
346
+ /**
347
+ * Streaming read — returns a Node Readable. Note: unlike the callback-
348
+ * based old API this is synchronous (smb3-client's own signature).
349
+ */
235
350
  createReadStream(rel) {
236
- return new Promise((res, rej) => {
237
- smb.createReadStream(resolve(rel), (err, stream) =>
238
- err ? rej(err) : res(stream)
239
- );
240
- });
351
+ return client.createReadStream(resolvePath(rel));
352
+ },
353
+
354
+ /** Streaming write. */
355
+ createWriteStream(rel) {
356
+ return client.createWriteStream(resolvePath(rel));
241
357
  },
242
- disconnect() {
358
+
359
+ /** Tear down the SMB session and TCP socket. Always await this. */
360
+ async disconnect() {
243
361
  try {
244
- smb.disconnect();
362
+ await client.close();
245
363
  } catch (_) {
246
- // ignore
364
+ // ignore — best-effort teardown
247
365
  }
248
366
  },
249
367
  };
@@ -252,14 +370,15 @@ function buildClient(config) {
252
370
  /**
253
371
  * Execute a callback with a fresh SMB client and always disconnect afterwards.
254
372
  * Prefer this helper over building a long-lived client because SMB sessions
255
- * can time out and marsaud-smb2 does not handle reconnects well.
373
+ * time out on idle and smb3-client does not currently expose a reconnect
374
+ * primitive.
256
375
  */
257
376
  async function withClient(config, fn) {
258
- const client = buildClient(config);
377
+ const client = await buildClient(config);
259
378
  try {
260
379
  return await fn(client);
261
380
  } finally {
262
- client.disconnect();
381
+ await client.disconnect();
263
382
  }
264
383
  }
265
384