← Notes

Sockets hiding in the shell

Opening TCP connections with Bash redirections and Zsh's networking module when curl and netcat are not available.

I like discovering that a familiar tool contains a lower-level machine than the one I normally use. Shell redirection is usually about files, but in Bash it can also open a network socket. Zsh takes a different route and exposes TCP through a loadable module.

Neither replaces curl or netcat for routine work. They are useful for diagnostics, minimal systems, and understanding that a socket is ultimately something a process can read from and write to like any other file descriptor.

Bash: special redirection paths

Bash recognises /dev/tcp/host/port and /dev/udp/host/port specially when they appear in a redirection. They do not need to exist in /dev; Bash interprets the path and attempts the connection itself.

To check whether a TCP service accepts a connection:

if exec 3>/dev/tcp/127.0.0.1/8080; then
    printf '%s\n' "service is accepting connections"
    exec 3>&-
else
    printf '%s\n' "connection failed" >&2
fi

File descriptor 3 is arbitrary. Using a descriptor other than standard input, output, and error lets the script keep the socket open while doing other work. Closing it explicitly avoids leaking the descriptor into later commands.

Open the connection for both reading and writing to speak a small amount of HTTP:

exec 3<>/dev/tcp/example.com/80 || exit 1

printf 'GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' >&3
cat <&3

exec 3>&-

The carriage-return/line-feed pairs are part of HTTP’s wire format. Connection: close gives this deliberately small client a simple way to know when the response has ended.

This is plain TCP. It does not perform TLS, follow redirects, decode compression, enforce timeouts, or validate an HTTP response. Sending an authorization header this way over an unencrypted connection would expose it on the network.

The behaviour is documented in the Bash redirection manual. A particular Bash build can omit network-redirection support, so failure does not necessarily mean the remote service is down.

Zsh: ztcp

Zsh provides TCP through its zsh/net/tcp module:

zmodload zsh/net/tcp

Open an outbound connection and capture the returned file descriptor:

ztcp example.com 80 || exit 1
socket=$REPLY

print -n -u $socket $'GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n'
cat <&$socket

ztcp -c $socket

Unlike Bash’s special path, ztcp also maintains a session table and can listen for incoming TCP connections. In one shell:

zmodload zsh/net/tcp
ztcp -l 7128
listener=$REPLY
ztcp -a $listener
peer=$REPLY

In another:

zmodload zsh/net/tcp
ztcp 127.0.0.1 7128
peer=$REPLY
print -r -u $peer -- 'hello from zsh'

The listening shell can read the message with:

IFS= read -r line <&$peer
print -r -- "$line"

The full behaviour is in the Zsh TCP module documentation. Zsh also ships a higher-level TCP function system with helpers for opening, reading, sending, and applying timeouts.

Why keep this trick around?

These facilities are not an argument for rebuilding network clients in shell. They are a useful emergency probe and a compact demonstration of Unix I/O:

  • Test whether a host and port are reachable when diagnostic utilities are missing.
  • Send a minimal protocol message to a local development service.
  • Build a health check without adding another executable to a constrained image.
  • Explore a text protocol one byte stream at a time.

Most of the time I will still reach for curl. Knowing what is hiding under the convenient tool makes the convenient tool more intelligible.