@openinc/smb-client
v1.1.5
Published
SMB1/2/3 client for Node.js, backed by native libsmbclient (Samba) via koffi FFI. Usable as a library or as a standalone HTTP container.
Readme
@openinc/smb-client
An SMB client for Node.js backed by Samba's native libsmbclient, exposed through koffi (FFI). Because it wraps libsmbclient, it speaks every SMB dialect the installed Samba supports — SMB1/CIFS, SMB2, and SMB3 including SMB3.1.1 encryption — instead of reimplementing the protocol in JavaScript.
Use it two ways:
- As a library in a Node.js application:
const smb = new SMB({ ... }). - As a standalone Docker container that exposes a share over a small HTTP API, so other containers can read and write files without speaking SMB themselves.
Why libsmbclient
Pure-JavaScript SMB packages (@marsaud/smb2 and friends) are stuck at SMB2 and do not implement
SMB3 encryption. Rather than ship an incomplete, security-sensitive protocol reimplementation, this
package binds the battle-tested libsmbclient. The trade-off is a native dependency: the
libsmbclient.so runtime library must be present in the environment (a package install on normal
images, a multi-stage copy on hardened images — see below).
Requirements
- Node.js ≥ 18
libsmbclientshared library available at runtime- Alpine:
apk add libsmbclient - Debian/Ubuntu:
apt install libsmbclient
- Alpine:
- Linux on x64 or arm64 (matches the koffi and libsmbclient distribution model)
Library usage
import { SMB } from "@openinc/smb-client";
const smb = new SMB({
host: "fileserver.example.com",
share: "data",
username: "svc-reports",
password: process.env.SMB_PASS,
domain: "CORP", // optional workgroup / AD domain
maxProtocol: "SMB3", // "NT1" (SMB1) | "SMB2" | "SMB3"
encrypt: true, // require SMB3 encryption
});
// Whole-file helpers
await smb.writeFile("reports/2026-07.csv", Buffer.from("a;b;c\n"));
const buf = await smb.readFile("reports/2026-07.csv");
// Metadata
const st = await smb.stat("reports/2026-07.csv"); // { size, mtime, isDirectory }
if (await smb.exists("reports/2026-07.csv")) { /* ... */ }
const entries = await smb.readdir("reports"); // [{ name, type, size, mtime }, ...]
// Mutations
await smb.mkdir("reports/archive"); // recursive
await smb.rename("reports/2026-07.csv", "reports/archive/2026-07.csv");
await smb.unlink("reports/archive/2026-07.csv");
await smb.rmdir("reports/archive");
// Streaming (large files stay off the event loop; I/O runs on the libuv threadpool)
import { pipeline } from "node:stream/promises";
await pipeline(fs.createReadStream("big.bin"), smb.createWriteStream("uploads/big.bin"));
await pipeline(smb.createReadStream("uploads/big.bin"), fs.createWriteStream("copy.bin"));Constructor options
| Option | Default | Description |
|---|---|---|
| host | — (required) | SMB server hostname or IP |
| share | — (required) | Share name |
| username | "" | User for authentication |
| password | "" | Password |
| domain | "" | Workgroup / AD domain |
| port | 445 | SMB port |
| minProtocol | "SMB2" | Lowest allowed dialect ("NT1" = SMB1/CIFS) |
| maxProtocol | "SMB3" | Highest allowed dialect |
| encrypt | false | Require SMB3 encryption |
| useKerberos | false | Authenticate with Kerberos (with NTLM fallback) |
| chunkSize | 131072 | Read/write chunk size in bytes |
| debug | 0 | libsmbclient debug verbosity (0–10) |
Errors
Every method rejects with an SmbError exposing code (Node-style, e.g. ENOENT, EACCES,
ECONNREFUSED), errno, and path. Metadata operations report an accurate code; for bulk
read/write a mid-transfer failure falls back to a generic EIO (see Design notes).
Method reference
| Method | Returns | Notes |
|---|---|---|
| readFile(path) | Buffer | Reads the whole file |
| writeFile(path, data, { mode }) | void | Truncates existing content |
| createReadStream(path) | Readable | |
| createWriteStream(path, { mode }) | Writable | Creates/truncates the file |
| stat(path) | { size, mtime, isDirectory } | Throws if missing |
| exists(path) | boolean | |
| readdir(path, { stat }) | [{ name, type, size, mtime }] | stat: false skips per-entry stat |
| mkdir(path) | void | Recursive |
| rmdir(path) | void | Directory must be empty |
| unlink(path) | void | |
| rename(from, to) | void | Same share |
| disconnect() | void | No-op (shared context); see SMB.shutdown() |
| SMB.shutdown() | Promise | Frees the process-wide libsmbclient context |
HTTP gateway (standalone container)
The container in this directory runs a zero-dependency node:http server that fronts a single share.
| Method & route | Action |
|---|---|
| GET /healthz | Liveness probe |
| GET /list/<path> | JSON directory listing |
| HEAD /files/<path> | Stat (Content-Length, Last-Modified) |
| GET /files/<path> | Download |
| PUT /files/<path> | Upload (request body → file) |
| DELETE /files/<path> | Delete |
Configuration is via environment variables:
| Variable | Default | Description |
|---|---|---|
| SMB_HOST | — (required) | SMB server host/IP |
| SMB_SHARE | — (required) | Share name |
| SMB_USER / SMB_PASS | "" | Credentials |
| SMB_DOMAIN | "" | Workgroup / AD domain |
| SMB_PORT | 445 | SMB port |
| SMB_MIN_PROTOCOL / SMB_MAX_PROTOCOL | SMB2 / SMB3 | Dialect range |
| SMB_ENCRYPT | — | 1/true to require SMB3 encryption |
| PORT | 8080 | HTTP listen port |
| HOST | 0.0.0.0 | HTTP bind address |
| API_TOKEN | — | If set, require Authorization: Bearer <token> |
| ACCESS_LOG | 1 | Log one line per request (method, path, status, bytes, duration); set 0 to disable |
| STARTUP_CHECK | 1 | Probe the share once at boot and log whether it is reachable; set 0 to disable |
| STARTUP_CHECK_FAIL_FAST | — | 1/true to exit the container if the share is unreachable at boot (otherwise it warns and keeps serving) |
| ROUTE_DESTINATION | — | dhi-bootstrap: subnet to route via ROUTE_GATEWAY (e.g. a VPN-only SMB subnet). Route setup is skipped if unset |
| ROUTE_GATEWAY | — | dhi-bootstrap: gateway IP for ROUTE_DESTINATION (e.g. the vpn-client's IP) |
| DROP_TO_USER | node | dhi-bootstrap: user to drop privileges to before starting the server |
On startup the gateway logs a one-line diagnosis, e.g.:
[smb-client] Share smb://fileserver/data reachable (7 entries at root, 41ms).
[smb-client] Share smb://fileserver/data NOT reachable [ECONNREFUSED]: smbc_opendir failed: Connection refused (ECONNREFUSED)Run it
docker build -t smb-client-gateway .
docker run --rm -p 8080:8080 \
-e SMB_HOST=fileserver -e SMB_SHARE=data \
-e SMB_USER=svc -e SMB_PASS=secret \
smb-client-gateway
curl -T ./report.csv http://localhost:8080/files/reports/report.csv # upload
curl http://localhost:8080/files/reports/report.csv # download
curl http://localhost:8080/list/reports # listing
curl -X DELETE http://localhost:8080/files/reports/report.csv # deletedocker-compose
Minimal service definition to run the gateway in front of an existing SMB server and let other containers reach it over HTTP:
services:
smb-gateway:
image: openinc/smb-client-gateway:latest # or: build: ./smb-client
restart: unless-stopped
environment:
SMB_HOST: fileserver.example.com # required — SMB server host/IP
SMB_SHARE: data # required — share name
SMB_USER: svc-reports
SMB_PASS: ${SMB_PASS} # from .env / your secret store, not hard-coded
SMB_DOMAIN: CORP # optional workgroup / AD domain
SMB_MAX_PROTOCOL: SMB3
SMB_ENCRYPT: "1" # require SMB3 encryption
API_TOKEN: ${GATEWAY_TOKEN} # optional: require Bearer auth on the HTTP API
PORT: "8080"
ports:
- "8080:8080" # drop this if only other compose services need it
# Any other service talks to the share over HTTP via the compose network:
my-app:
image: my-app
depends_on: [smb-gateway]
environment:
SMB_GATEWAY_URL: http://smb-gateway:8080What each part is for:
image:/build:— use the published image, orbuild: ./smb-clientto build locally.environment:— theSMB_*variables configure the target share (see the table above);PORT/HOST/API_TOKENconfigure the HTTP side. KeepSMB_PASSandAPI_TOKENin a.envfile or Docker/Swarm secrets rather than in the compose file.ports:— only needed to reach the gateway from the host. Other services in the same compose project reach it ashttp://smb-gateway:8080without any published port.depends_on:— start ordering; the gateway itself connects to SMB lazily on the first request, so it starts fine even if the SMB server is not up yet.
Note: the server process runs as the unprivileged
nodeuser. Unless you need a VPN route (below), the SMB client needs no extra Linux capabilities — unlike an SMB server (port 445).
A full local stack (gateway + a throwaway Samba server for testing) is in
docker-compose.example.yml.
Reaching a share that is only accessible over a VPN
When the SMB share sits behind a VPN reached by a vpn-client container, do not join that
container's network stack with network_mode: "service:vpn-client" — Docker then refuses to publish
the HTTP port:
Error response from daemon: conflicting options: port publishing and the container type network modeInstead, keep the gateway on its own network and add a route to the SMB subnet via the
vpn-client, exactly as the Parse server does. The image bundles
dhi-bootstrap: on startup it runs as root, adds the
route (ip route replace <ROUTE_DESTINATION> via <ROUTE_GATEWAY>), then drops to node before
starting the server. Port publishing keeps working because the container is not sharing the
vpn-client's netns.
services:
smb-gateway:
image: openinc/smb-client-gateway:latest
cap_add: [NET_ADMIN] # required for `ip route`
environment:
ROUTE_DESTINATION: 10.8.0.0/16 # SMB subnet (same as the Parse server uses)
ROUTE_GATEWAY: 172.28.0.2 # the vpn-client's IP on the shared network
DROP_TO_USER: node
SMB_HOST: 10.8.0.10
SMB_SHARE: data
SMB_USER: svc
SMB_PASS: ${SMB_PASS}
ports:
- "8080:8080" # works — no network_mode conflict
networks: [vpn-network]Give the vpn-client a static IP (ipv4_address) on the shared network and use it as
ROUTE_GATEWAY. ROUTE_DESTINATION is the same subnet your Parse server already routes. When both
are unset the route step is skipped, so the same image runs unchanged without a VPN. A full example
(vpn-client + gateway) is in docker-compose.vpn.example.yml.
Docker Hardened Image
Hardened base images (dhi.io) have no package manager, so libsmbclient and its dependency chain
must be copied in from a provider stage — the same pattern
dhi-bootstrap uses for iproute2. libsmbclient
additionally dlopens modules from /usr/lib/samba at runtime, so that directory is copied
wholesale in addition to the ldd closure.
# Provider stage: resolve libsmbclient + its full dependency closure
FROM alpine:3.23 AS smb-libs
RUN apk add --no-cache libsmbclient
RUN mkdir -p /libs && \
cp -aL /usr/lib/libsmbclient.so* /libs/ && \
for so in $(ldd /usr/lib/libsmbclient.so.0 | awk '/=>/ {print $3}' | grep '^/'); do \
cp -aL "$so" /libs/; \
done
# Build stage: compile the TypeScript sources to dist/
FROM node:26-alpine3.24 AS build
RUN npm install -g [email protected]
WORKDIR /usr/src/app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY tsconfig.json ./
COPY src ./src
RUN pnpm build
# Your hardened application image
FROM dhi.io/node:26-alpine3.24 AS app
WORKDIR /usr/src/app
# libsmbclient + dependency closure, plus the runtime-loaded samba modules
COPY --from=smb-libs /libs/ /usr/lib/
COPY --from=smb-libs /usr/lib/samba/ /usr/lib/samba/
COPY --chown=node:node package.json ./
RUN npm install --omit=dev
COPY --chown=node:node --from=build /usr/src/app/dist ./dist
USER node
CMD ["node", "dist/server.js"]Verify the library chain in the provider stage with
ldd /usr/lib/libsmbclient.so.0. If adlopened module is missing at runtime you will see an SMB auth or negotiation failure — copying all of/usr/lib/samba(as above) covers the modulesldddoes not report.
To embed the client as a library in a hardened image instead of the gateway, copy the same libs
and import { SMB } from "@openinc/smb-client" from your own entrypoint.
Design notes
- One process-wide libsmbclient context. Credentials are carried per-operation inside the
smb://URL, so multipleSMBinstances with different credentials coexist. Protocol and encryption options, however, are set on the shared context: the first instance to initialize wins. Use separate processes if you need conflicting protocol settings. - Non-blocking I/O. libsmbclient is synchronous; koffi runs the calls on the libuv threadpool
(
UV_THREADPOOL_SIZEtunes concurrency, default 4). The event loop is never blocked by transfers. - errno handling. Failure is always detected via return values (reliable across threads).
libsmbclient leaves a stale errno even after success, and errno set on a worker thread is not
visible on the main thread — so on failure the call is re-run synchronously on the main thread to
recover a trustworthy errno (and to absorb transient async failures). Bulk read/write skip this
and report a generic
EIOon the rare mid-transfer error. - Platform scope. Linux x64/arm64 containers.
struct statandsmbc_direntare decoded at fixed LP64 field offsets that are identical across musl and glibc on those architectures.
License
MIT © open.INC GmbH
