Proxies: Network Programming in Gerbil
In this tutorial we illustrate network programming facilities in Gerbil, by writing three network proxies.
The first program is a transparent TCP proxy, written using low level socket programming with the :std/os/socket package. This package utilizes raw devices and opens sockets through FFI, thus providing access to the full POSIX socket programming API with a nonblocking interface.
The second program is another version of the TCP proxy, but this time written utilizing the Standard IO API, which results in half the code.
The third program is the final installment of the TCP proxy, but this time
rewriting the second version to take advantage of the all powerful using
macro from the :std/contract module.
Preliminaries
The source code for the tutorial is available at src/tutorial/proxy. You can build the programs using the build script:
$ cd gerbil/src/tutorial/proxy
$ gerbil build
...
A Transparent TCP Proxy
The first variant of the transparent proxy listens to a local port and proxies all incoming connections to a specified remote server. It is implemented using low level raw socket devices.
The main function
First the main function of the proxy, which is common for all three variants.
It simply parses the arguments to the program using the getopt library,
and dispatches to run
which is the server main loop:
(def (main . args)
(call-with-getopt proxy-main args
program: "tcp-proxy"
help: "A transparent TCP proxy"
(argument 'local help: "local address to bind")
(argument 'remote help: "remote address to proxy to")))
(def (proxy-main opt)
(start-logger!)
(run (hash-get opt 'local) (hash-get opt 'remote)))
The server main loop
The main loop of the server creates a listening socket and accepts incoming connections. For each connection, it logs it and spawns a thread to proxy it:
(def (run local remote)
(let* ((laddr (socket-address local))
(raddr (socket-address remote))
(caddr (make-socket-address (socket-address-family laddr)))
(sock (server-socket (socket-address-family laddr) SOCK_STREAM)))
(socket-setsockopt sock SOL_SOCKET SO_REUSEADDR 1)
(socket-bind sock laddr)
(socket-listen sock 10)
(while #t
(wait (fd-io-in sock))
(try
(let (cli (socket-accept sock caddr))
(when cli
(debugf "Accepted connection from ~a" (socket-address->string caddr))
(spawn proxy cli raddr)))
(catch (e)
(errorf "Error accepting connection ~a" e))))))
Connection proxying
The procedure proxy
takes a client socket and proxies it to the remote address.
First it opens and connects a socket to the remote server, and then spawns two
threads piping data between the two ends. The programming should look familiar to
anyone with experience with network programming with the socket API in nonblocking
mode.
(def (proxy clisock raddr)
(try
(let* ((srvsock (socket (socket-address-family raddr) SOCK_STREAM))
(rcon (socket-connect srvsock raddr)))
(unless rcon
(wait (fd-io-out srvsock)))
(let (r (or rcon (socket-getsockopt srvsock SOL_SOCKET SO_ERROR)))
(unless (fxzero? r)
(error (format "Connection error: ~a" (strerror r))))
(spawn proxy-io clisock srvsock)
(spawn proxy-io srvsock clisock)))
(catch (e)
(errorf "Error creating proxy ~a" e))))
(def (proxy-io! isock osock)
(def buf (make-u8vector 4096))
(try
(let lp ()
(let (rd (socket-recv isock buf))
(cond
((not rd)
(wait (fd-io-in isock))
(lp))
((fxzero? rd)
(close-input-port isock)
(socket-shutdown osock SHUT_WR))
(else
(let (end rd)
(let lp2 ((start 0))
(if (fx< start end)
(let (wr (try (socket-send osock buf start end)
(catch (e)
(socket-shutdown isock SHUT_RD)
(raise e))))
(cond
((not wr)
(wait (fd-io-out osock))
(lp2 start))
(else
(lp2 (fx+ start wr)))))
(lp))))))))
(catch (e)
(errorf "Error proxying connection ~a" e)
(close-input-port isock)
(close-output-port osock))))
Using the proxy
Here we'll run the proxy locally bound at port 9999, and will proxy to Google's http servers.
So we can run our proxy like this:
$ gerbil env tcp-proxy1 :9999 www.google.com:80
And in another shell we can proxy a connection through telnet:
$ telnet localhost 9999
Connected to localhost.
Escape character is '^]'.
GET / HTTP/1.0
HTTP/1.0 200 OK
Date: Tue, 03 Oct 2023 10:27:05 GMT
Expires: -1
Cache-Control: private, max-age=0
Content-Type: text/html; charset=ISO-8859-1
Content-Security-Policy-Report-Only: object-src 'none';base-uri 'self';script-src 'nonce-15SbgtoA8U4oGsuAcsan8g' 'strict-dynamic' 'report-sample' 'unsafe-eval' 'unsafe-inline' https: http:;report-uri https://csp.withgoogle.com/csp/gws/other-hp
Server: gws
X-XSS-Protection: 0
X-Frame-Options: SAMEORIGIN
Set-Cookie: AEC=Ackid1TXyczmwCE9JEtw2DH3RhK1oKJI8jtL4ukVxGNcVEoJ1PniUA2M-XE; expires=Sun, 31-Mar-2024 10:27:05 GMT; path=/; domain=.google.com; Secure; HttpOnly; SameSite=lax
Accept-Ranges: none
Vary: Accept-Encoding
<!doctype html>
...
Transparent TCP Proxy with stdio
This is the second take on the transparent proxy, this time written using the Standard IO API.
You'll notice that it is half the code and you don't have to do any
nonblocking IO gymnastics; the stdio API takes care of all these
details for you.
The main
function is exactly the same.
The server main loop
Things are simpler, as we use tcp-listen
and the ServerSocket
interface:
(def (run local remote)
(let* ((laddr (resolve-address local))
(raddr (resolve-address remote))
(sock (tcp-listen laddr)))
(while #t
(try
(let (cli (ServerSocket-accept sock))
(when cli
(debugf "Accepted connection from ~a" (StreamSocket-peer-address cli))
(spawn proxy cli raddr)))
(catch (e)
(errorf "Error accepting connection: ~a" e))))))
Connection Proxying
And here is where stdio
shines, compared to low-level socket programming.
We use the Reader
/Writer
interfaces and the io-copy!
stdio
utility function:
(def (proxy client raddr)
(try
(let (remote (tcp-connect raddr))
(spawn proxy-io! (StreamSocket-reader client) (StreamSocket-writer remote))
(spawn proxy-io! (StreamSocket-reader remote) (StreamSocket-writer client)))
(catch (e)
(errorf "Error proxying connection: ~a" e)
(StreamSocket-close client))))
(def (proxy-io! reader writer)
(io-copy! reader writer)
(Writer-close writer)
(Reader-close reader))
Using the proxy
Just like the previous instance, here is a small demonstration:
$ gerbil env tcp-proxy2 :9999 www.google.com:80
And in another shell we can proxy a connection through telnet:
$ telnet localhost 9999
Connected to localhost.
Escape character is '^]'.
GET / HTTP/1.0
HTTP/1.0 200 OK
...
Transparent TCP Proxy with stdio and the using macro
This is the third and final installment on the transparent proxy, this time written
using stdio and the all powerful using
macro from :std/contract.
This allows you to attach annotations and contracts on variables and use the dotted notation for making interface calls within the body, thus eliminating the boilerplate from using stdio. You'll notice that the code is half as wide, and I can tell you that I did not have to twist my finger and my wrists did not have to suffer.
The server main loop
Here, we notice the using
declarations and the dots:
(def (run local remote)
(let ((laddr (resolve-address local))
(raddr (resolve-address remote)))
(using (sock (tcp-listen laddr) : ServerSocket)
(while #t
(try
(using (cli (sock.accept) : StreamSocket)
(debugf "Accepted connection from ~a" (cli.peer-address))
(spawn proxy cli raddr))
(catch (e)
(errorf "Error accepting connection: ~a" e)))))))
Connection Proxying
No more ComicallyLongName-to-do-an-interface-call
here:
(def (proxy client raddr)
(using (client :- StreamSocket)
(try
(using (remote (tcp-connect raddr) : StreamSocket)
(spawn proxy-io! (client.reader) (remote.writer))
(spawn proxy-io! (remote.reader) (client.writer)))
(catch (e)
(errorf "Error proxying connection: ~a" e)
(client.close)))))
Using the proxy
Just like the previous instance, here is a small demonstration:
$ gerbil env tcp-proxy3 :9999 www.google.com:80
And in another shell we can proxy a connection through telnet:
$ telnet localhost 9999
Connected to localhost.
Escape character is '^]'.
GET / HTTP/1.0
HTTP/1.0 200 OK
...