Tunnel a TCP port across two hosts that share a filesystem but have no direct network path.
Some machines can't talk over the network — different VLANs, an air gap, a one-way
DMZ — yet they happen to mount the same shared filesystem (NFS, SMB, a synced
folder, a cloud drive). fswire turns that shared directory into a wire: it
carries a real TCP connection between the two hosts, byte for byte.
- Zero dependencies. Pure Node.js (
net,fs,path). No build step.npx fswireand go. - NFS-safe. Doesn't rely on append/tail (which NFS close-to-open consistency makes
unreliable). Each direction is a stream of discrete, monotonically-numbered message
files published with
write-tmp → fsync → rename(atomic within a directory) and consumed strictly in order. - Transparent. It moves raw bytes, so anything TCP works through it — HTTP, WebSocket, SSH, databases, RPC. The application needs no changes; just point it at the local port.
client host (no network to target) shared FS server host
┌──────────────────────────────┐ ┌──────────────────┐ ┌────────────────────────┐
│ your app ──▶ 127.0.0.1:PORT │ │ <SHARED>/fswire/ │ │ fswire server │
│ fswire client ──────────────┼───▶│ <channel>/ │◀─┼── dials TARGET_HOST:PORT│
│ --listen-port PORT │ │ conns/<id>/ │ │ │
└──────────────────────────────┘ └──────────────────┘ │ real TCP service (:PORT)│
└────────────────────────┘
No install needed:
# on the host that CAN reach the real service (e.g. a web server on :3000)
npx fswire server --shared-dir /mnt/share --target-port 3000
# on the host that shares the filesystem but can't reach it directly
npx fswire client --shared-dir /mnt/share --listen-port 3000
# now, on the client host:
curl http://127.0.0.1:3000 # tunnels to the server host's :3000Or install globally: npm i -g fswire, then fswire server … / fswire client ….
--shared-diris each host's own mount point of the same shared filesystem. The two paths may differ between hosts (e.g./mnt/sharevs/data/share); each side just points at its own view.
- server runs where the real TCP service lives. It dials
--target-port. - client runs where your application is. It listens on
--listen-port.
Mnemonic: the client is where you connect from; the server is where the service is.
Every option is a CLI flag or an environment variable (flag wins).
| Flag | Env | Default | Side | Meaning |
|---|---|---|---|---|
--shared-dir |
FSWIRE_SHARED_DIR |
(required) | both | local path to the shared FS root |
--channel |
FSWIRE_CHANNEL |
default |
both | namespace; client & server must match |
--poll-ms |
FSWIRE_POLL_MS |
15 |
both | recv directory poll interval (ms) |
--hb-ms |
FSWIRE_HB_MS |
1000 |
both | heartbeat write interval (ms) |
--peer-timeout |
FSWIRE_PEER_TIMEOUT |
15000 |
both | tear down if peer heartbeat stale (ms) |
--max-backlog |
FSWIRE_MAX_BACKLOG |
256 |
both | pause socket above this many pending files |
--verbose |
— | off | both | log to stderr |
--target-host |
FSWIRE_TARGET_HOST |
127.0.0.1 |
server | host of the real service to dial |
--target-port |
FSWIRE_TARGET_PORT |
(required) | server | port of the real service to dial |
--scan-ms |
FSWIRE_SCAN_MS |
50 |
server | new-connection scan interval (ms) |
--listen-host |
FSWIRE_LISTEN_HOST |
127.0.0.1 |
client | local bind host |
--listen-port |
FSWIRE_LISTEN_PORT |
(required) | client | local TCP port your app connects to |
Run multiple independent tunnels over one shared dir by giving each a distinct
--channel.
import { runServer, runClient } from 'fswire'
// both return a promise that stays pending while the tunnel runs
runServer({ sharedDir: '/mnt/share', targetPort: 3000, verbose: true })
runClient({ sharedDir: '/mnt/share', listenPort: 3000, verbose: true })Per connection, under <shared>/fswire/<channel>/conns/<connId>/:
meta.json # client announces the connection (written last)
c2s/<seq>.msg # client → server bytes (≤64 KiB each, tmp+rename)
s2c/<seq>.msg # server → client bytes
c2s.eof / s2c.eof # half-close: that writer is done sending
close # either side requests teardown
client.hb / server.hb # liveness heartbeats (epoch ms)
Readers poll their recv subdir, read complete files in seq order (a gap blocks
until the missing file appears — never reorders), then delete them. A stale heartbeat
(--peer-timeout) or a close marker tears the connection down and closes the paired
TCP socket. Backpressure: the local socket is paused when unconsumed outbound files
exceed --max-backlog.
- Latency is bounded by
--poll-ms(default 15 ms per hop). Great for APIs, control channels, and bulk transfer; interactive typing over the tunnel feels slightly laggier than a direct link. Lower--poll-msto trade CPU for latency. - Security. The tunnel carries the same bytes as a direct TCP link — including any credentials your protocol sends. Anyone who can read the shared directory can read the traffic. Protect it with filesystem permissions, and prefer protocols with their own encryption for sensitive data.
- Cleanup. Connection dirs are reaped on teardown and on server startup. Leftover dirs from a hard-killed host are harmless and get cleaned when their heartbeat goes stale or on the next server start.
npm test # byte-exact round-trips (incl. 2 MiB chunked) + concurrent isolationMIT