TCP connections in ZSH without extra tools

I switched from Bash to ZSH as my default shell after discovering the amazing oh-my-zsh project. It wasn’t a particularly well researched or thought through decision. oh-my-zsh just made ZSH look cool (plugins!) and pretty (themes!), which was enough.

So, it’s kind of funny that ever since writting “One Line Reverse Shell in Bash”, I felt a bit disappointed by ZSH. I mean, why does my “cool” shell has no pseudo-devices for TCP connections? They seem fun:

$ (printf >&4 "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"; \
   head <&4 --lines 20) 4<>/dev/tcp/example.com/80
HTTP/1.1 200 OK
Accept-Ranges: bytes
Age: 404230
Cache-Control: max-age=604800
Content-Type: text/html; charset=UTF-8
Date: Tue, 09 Jan 2024 20:44:33 GMT
Etag: "3147526947"
Expires: Tue, 16 Jan 2024 20:44:33 GMT
Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
Server: ECS (sed/58AA)
Vary: Accept-Encoding
X-Cache: HIT
Content-Length: 1256

<!doctype html>
<html>
<head>
    <title>Example Domain</title>

    <meta charset="utf-8" />

Here, as far as I understand, a sub-shell gets a read-write1 file descriptor 4 pointing at example.com:80. We send an HTTP GET request by writing to 4 with printf. Then we read the first 20 lines of the response (including headers) from 4 with head. Bash automatically resolves the domain name into an IP2 address, opens a TCP socket to port 80, and then closes it after we are done. Amazing!

Well, recently I learned that ZSH has ✨modules✨. Its documentation page describes them as “optional parts” that “may be linked in to the shell at build time, or can be dynamically linked while the shell is running”. Modules are loaded (“linked”, I guess?) with zmodload command.

There’s one module that immediately caught my attention — zsh/net/tcp. When loaded, it adds a single new command ztcp and it’s exactly what I wanted!

Here’s how to make the same GET request to example.com:80 but with ztcp:

% zmodload zsh/net/tcp

# open the socket
% ztcp -d 4 example.com 80

% printf >&4 "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"

% head <&4 --lines 20
HTTP/1.1 200 OK
Accept-Ranges: bytes
Age: 404076
Cache-Control: max-age=604800
Content-Type: text/html; charset=UTF-8
Date: Tue, 09 Jan 2024 20:41:59 GMT
Etag: "3147526947"
Expires: Tue, 16 Jan 2024 20:41:59 GMT
Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
Server: ECS (sed/58AA)
Vary: Accept-Encoding
X-Cache: HIT
Content-Length: 1256

<!doctype html>
<html>
<head>
    <title>Example Domain</title>

    <meta charset="utf-8" />

# close the socket
% ztcp -c 4

Admittedly, not as succint as pseudo-devices in Bash. Although, I like that it’s less “magical” and seems more “googleable”.

Additionally, ztcp, unlike devices in Bash (as far as I know), can also be used to listen for incoming connections. Here’s a little experiment.

Start accepting connections on port 8181 in ZSH:

% ztcp -v -l 8181
8181 listener is on fd 11

% ztcp -v -a 11 # blocks

The last command blocks waiting for a client. In a different terminal start a Bash session and send the request:

$ (echo >&4 "Hello, ZSH?"; \
   head <&4 --lines 1) 4<>/dev/tcp/localhost/8181 # blocks

Now, head in Bash blocks waiting for the response, but over on the ZSH side we accepted a connection and can read from it:

% ztcp -v -a 11
39980 is on fd 3

% head <&3 --lines 1
Hello, ZSH?

Send the response:

% echo >&3 "Hello, Bash!"

This unblocks head, which prints the greeting and exits:

$ (echo >&4 "hello, ZSH?"; \
   head <&4 --lines 1) 4<>/dev/tcp/localhost/8181
Hello, Bash!

Finally, let’s not forget to close the sockets opened with ztcp:

% ztcp -c 3
% ztcp -c 11

Just two shells communicating over a TCP connection with no extra tools required. How cool is that?

So, am I going to uninstall curl or wget any time soon? Of course, the answer is no. But, it is nice to know that ztcp is there for me when I need something simple yet not constrained by protocols like those tools are. Maybe I can replace netcat3 with it?


  1. I think, because this is not a regular file, you can’t really open it only for reading or writing, so 4>/dev/tcp/... and 4</dev/tcp/... variants also work ↩︎

  2. According to strace, it even tries IPv6 first, before falling back to IPv4 ↩︎

  3. Or is it ncat? Or nc? … netcat-traditional↩︎