IRC is a great chat protocol, but it has some annoying limitations when compared to other modern chat protocols. First and foremost, it lacks the ability to see messages that are sent on servers when you are not connected to a server via a client. Messages are sent to your client when you are connected, but there is no obvious way to log messages that are sent while you are offline. This means that when you log into a server you might come into the middle of an existing conversation with no context, or if you send someone a direct message and then log off, they have no way to send you a response until you log back on to the server again. Luckily there is an excellent, if not entirely straightforward or ergonomic, solution to this. The IRC bouncer.
In simple terms, an IRC bouncer is an application that is able to connect to one or more IRC servers, such as Libera Chat, and sending and receiving messages via the IRC protocol in a similar way to a regular IRC client. There are some key distinctions though. An IRC bouncer is designed to be connected to an IRC server continuously and is able to log messages that are sent when we aren’t connected with a client. Another distinction is that when we connect with an IRC client, our username, or nickname, is registered with the server. This means that our nickname will be unavailable if we try to connect again with another client. The workaround to this is that clients provide the functionality to define aliases or backup user names that look very similar to our normal user name, but are slightly different. For example, if my normal user name is Naokotani
, I could log in as Naokotani~
from a second client and people will most likely be able to deduce who I am. Still, people don’t know for sure that it is you, and it is a bit jarring to many of us that are accustomed other modern chat software where logging in with the same username from different clients is common place.
IRC bouncers pose a solution to this problem. Instead of logging into a server separately from different clients, the IRC bouncer logs into the server once with our nickname and stays logged in. The bouncer then acts as a proxy that we can connect to multiple times from different clients and, as far as the server is concerned, we have only logged in once.
IRC bouncers also solve the problem of missed messages. They are able to log messages that you miss while you are not connected with a client because they are connected all the time. The bouncer can log messages and use a “playback” to show you the messages that were sent while you were not connected. This is similar to other chat software that will use a horizontal rule to separate the messages that you have “seen” from those that were sent while you were away.
As is customary with open protocols like IRC, there are many ways to implement this functionality. In my case I opted to host a ZNC bouncer on a VPS, in my case I used Digital Ocean, but any provider should suffice, run it in Docker, reverse proxy it with Nginx and then connect to it from Doom Emacs using the Emacs Circe client, the default IRC client for Doom Emacs.
These selections are somewhat arbitrary, but determined by infrastructure that I had set up previously for other projects, and there are many viable alternatives to these. Soju is a popular alternative to ZNC that you might want to check out. I am using Nginx as the reverse proxy for my setup and, while it is very capable of performing this task well, Nginx can be a little bit arcane and hard to setup. Caddy is another popular choice for a reverse proxy. It is generally easier to setup and has built-in automatic SSL. If you don’t like Docker for whatever reason, you can always use Podman, or of course you can just run the bouncer directly on your host machine. As for clients, you could also use ERC, a CLI app like Weechat, or a GUI client like Polari. I could digress and talk about all of these, but there are enough moving parts here already that it’s best we get to it.
The first step is to get ZNC itself running on your machine. You can do this in two simple commands. First run:
docker run -it -v znc-cfg:/znc-data znc --makeconf
This will pull the official ZNC image if it’s not on your machine, create a persistent volume for the ZNC configuration data called znc-cfg
, run it with an interactive terminal, the -it
part, and then run ZNC with the --makeconf
flag which, as the name suggestions, will setup an initial configuration file.
When you do this you will be prompted to make some initial configuration entries. You should get prompts that look like this.
[ ** ] -- Global settings -- [ ** ] [ ?? ] Listen on port (1025 to 65534): 12345 [ ?? ] Listen using SSL (yes/no) [no]: [ ?? ] Listen using both IPv4 and IPv6 (yes/no) [yes]: [ .. ] Verifying the listener... [ ** ] Unable to locate pem file: [/znc-data/znc.pem], creating it [ .. ] Writing Pem file [/znc-data/znc.pem]... [ ** ] Enabled global modules [corecaps, saslplainauth, webadmin] [ ** ] [ ** ] -- Admin user settings -- [ ** ] [ ?? ] Username (alphanumeric): User [ ?? ] Enter password: [ ?? ] Confirm password: [ ?? ] Nick [User]: [ ?? ] Alternate nick [User_]: [ ?? ] Ident [User]: [ ?? ] Real name (optional): [ ?? ] Bind host (optional): [ ** ] Enabled user modules [chansaver, controlpanel] [ ** ] [ ?? ] Set up a network? (yes/no) [yes]: [ ** ] [ ** ] -- Network settings -- [ ** ] [ ?? ] Name [libera]: [ ?? ] Server host [irc.libera.chat]: [ ?? ] Server uses SSL? (yes/no) [yes]: yes [ ?? ] Server port (1 to 65535) [6697]: [ ?? ] Server password (probably empty): [ ?? ] Initial channels: [ ** ] Enabled network modules [simple_away]
First, it’s important that Listen on port
is set to the same port that you exposed in your Docker command. The Docker command only binds the container’s port to your hosts port, but the Listen on port
is what actually tells ZNC to listen on that port inside the container. Additionally, we want to set Listen using SSL
to no
because ZNC itself is not going to be handling SSL in our setup, Nginx will do that. ZNC should only be exposed internally on the server, so encryption is not necessary. Finally we need to create an admin user that we will use later to connect to the ZNC web front end. You can set the username and password to whatever you wish, but keep in mind that this will be publicly accessible on the web, so probably avoid something like user: admin pass: so-secret
. It is convenient to setup the network settings now because it will be mostly applying default settings, but you can also set it up in the web front end later if you wish.
Once this is setup, you can run this command:
docker run -d \ --restart unless-stopped \ --name znc-server \ -p 12345:12345 \ -v znc-cfg:/znc-data \ znc
This command will run ZNC in a Docker container. --restart unless-stopped
will make it so the container will automatically restart if the process ends, for example if your server restarts. --name znc-server
will name the container. If you don’t do this it will still work, but Docker will automatically assign a silly name like wonky_wombat
or something. Naming the container makes things easier if you want to make changes later. -p 12345:12345
will expose the port 12345 inside the container to the host outside the container. This makes ZNC accessible from outside the container. The ports don’t need to be the same, and you can choose whichever port you would like in the available range of non-privileged ports: 1024-49151
. The main thing is if you have another process running on a port you cannot reuse it, so you have to make sure the port is available. If there is a conflict, you can run lsof -i :12345
to check what is running on port 12345 on your host, or more simply just choose a different port. Finally, -v znc-cfg:/znc-data
means the container will use the volume we put our configuration file in earlier. If we don’t do this, we will lose our configuration data when the container restarts.
Now that we have ZNC up and running, we need some way to access our ZNC process from outside our server. You can expose ZNC directly to the public internet, but it is common practice to route public traffic through a reverse proxy like Nginx or Caddy and have that handle directing the TCP and HTTP requests to your ZNC server. This also means that Nginx or another reverse proxy will handle SSL instead of ZNC, which is why we said no to SSL encryption in the initial ZNC configuration.
Our first step will be to ensure that we have a FQDN (like irc.mysever.com) pointing to our server. It is possible, but cumbersome to enable SSL without a FQDN, and in most cases not worth the effort. The ZNC wiki gives two options for this. You can either use a sub domain, for example irc.myerver.com
, or you can use a route on an existing domain, such as myserver.com/irc
. We are going to use the sub domain method in our setup. Once the domain is pointed to our server’s IP and the ZNC server is running, we can make a location block like this in our nginx.conf file:
server { server_name irc.myserver.com; location / { proxy_pass http://[::1]:12345/; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }
This means that any http requests that Nginx gets to irc.myserver.com
will be directed to port 12345 on our host machine where ZNC is listening because we added the Docker -p 12345:12345
flag and we input for Listen on port
when you ran znc --makeconf
earlier. It will also forward the headers from the original request on to port 12345. Once we have reloaded Nginx and this takes effect, requests should already be routed correctly, but we still need to set up SSL before we can connect the ZNC via HTTP.
While Nginx does not have built-in SSL like Caddy, it is fairly easy to set up. Certbot will setup certificates for us and update our nginx.conf
file to point to the correct certificates.
At this point, you should be able to access the ZNC web front end, and you can input the username and password you input earlier to access the configuration for your ZNC server, but there is something else we need to do with Nginx. The location block we setup earlier listens specifically for HTTP and HTTPS requests on ports 80 and 443 respectively, which is exactly what the ZNC web front end uses, but the IRC protocol itself uses TCP protocol. Nginx needs a way to appropriately proxy TCP requests to ZNC. Furthermore, since port 443 is the standard port for HTTPS requests, we need to configure Nginx to listen for TCP requests on a different port. To do this, we need to create a stream block (you can find the documentation for this here) similar to this:
stream { upstream znc { server [::1]:12345; } server { listen 54321 ssl; proxy_pass znc; ssl_certificate /etc/letsencrypt/live/irc.domain.dev/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/irc.domain.dev/privkey.pem; } }
We need to create a stream
block that defines a server
and upstream
block. The server
defines the port Nginx will listen on with the listen
directive, which we set to port 54321
in this example. The listen
directive also specifies that we are listening for SSL connections. We can use the same SSL certs that we created for the location block for the web front end earlier by simply copying the directives from our HTTP location block. The proxy_pass
directive passes that request on to the named upstream znc
block defined above, which defines the internal port that Nginx should pass the TCP stream on to, 12345. The port defined here must match the port that we bound the Docker container to, which in our case was 12345. You can double check this by running docker ps
and then under PORTS
you should see something like 0.0.0.0:12345->12345/tcp, [::]:12345->12345/tcp
, which tells us that both IPv4 and IPv6 port 12345 inside the container is bound to the same port on the host. I used different ports, 54321
and 12345
here to make the distinction between the two, but they can be the same number. Another way to think of this, is that it’s very similar to Docker -p 12345:12345
; its binding TCP requests outside our host to the port inside our host.
You might need to create a firewall rule to ensure that your firewall is not blocking port 54321
, or which ever port is defined by your listen
directive. For example if you are using ufw
you can use the command ufw allow 54321/tcp
with root privileges which will allow Nginx to receive TCP requests on port 54321
.
At this point we should be done with the work on the server itself and it’s time to do some work on configuring ZNC to talk to an IRC server like Libera.
Now that everything is set up on the server, we should be able to navigate to https://irc.myserver.com
on the public internet to access our ZNC configuration to do the final bits of work needed to connect to the Libera server.
First and foremost, navigate to the Global Settings
tab and scroll down to skin
select Forest
. This is by far and away the most important step so don’t skip it!
Next, we need to make sure that ZNC is correctly configured to connect to Libera. Select the Your Settings
tab and scroll down to the Networks
section. If we configured it during the znc --makeconf
step, we should see Libera there already. We can click Edit
to get further settings. This is where we will input all of the important information we need to log into Libera. Ensure that SSL
is ticked and that the port is set correctly to 6697
, the default port for libera.chat.irc SASL connections. Finally, scroll down to the Modules
section and tick the sasl
module. Once everything is set, we can select save and return
near the bottom of the page.
Once we have saved these settings we should see Network Modules (libera)
section with a SASL
hyperlink. We need to click that and input our Libera Nickname and password. This allows ZNC to connect to Libera via SASL. At this point ZNC should be able to connect to Libera and all that is left is to connect to your ZNC server via Doom Emacs, or another client of your choosing.
On Doom Emacs, to add custom configuration we can use M x doom/open-private-config
to open up our personal configuration file. In order to connect to our server, we need to add something like the following:
(after! circe (set-irc-server! "znc/libera" (let* ((auth (car (auth-source-search :host "irc.myserver.com" :port 54321 :user "StampChad" :require '(:user :secret)))) (secret (plist-get auth :secret))) `(:host "irc.myserver.com" :port 54321 :tls t :nick "StampChad" :pass ,(if (functionp secret) (funcall secret) secret) :channels ("#stamp-collecting")))))
This will connect to our ZNC server at irc.myserver.com
port 54321. You might notice that instead of our plain text password, we are getting our password from our authinfo.gpg
file. The process of using an authinfo.gpg file is explained in this excellent blog post. Simply put, you need to ensure you have a gpg key generated by running gpg --full-generate-key
and following the prompts and then simply create a file called ~/.authinfo.gpg
and input your information in the following format:
machine irc.myserver.com login StampChad password StampChad/libera:s0-s3cr3t port 54321
In the above example, the let*
binds the return value of auth-source-serach
to the secret
variable. Instead of passing our plain text password to :pass
, we wrap our Circe config in the let*
and then we pass it (if (functionp secret) (funcall secret) secret)
. This checks whether auth-source-search
returned a function or a string. If it returned a function, (funcall secret)
calls secret
as a function and returns our password, otherwise it just returns the value of secret
, which should be a our password as a string.
And that’s it! We should now be able to run M-x =irc
, the Doom Emacs command to run Circe, and we are ready to chat. ZNC will stay connected and update us about chat that we have missed in the channels we are connected to. We can also connect with as many clients we want. There are a lot of ZNC modules to explore and this is really just scratching the service, but getting a basic set up going is a big first step.
Messing around with computers and coding since I was 8. Now getting paid to do what I love.