# bsdirc 0.9

A small irssi-style IRC client for OpenBSD. It is written in C and uses only the base system: curses, libtls, pledge(2) and unveil(2).

New here? Start with QUICKSTART.md, a short guide from install to sitting in your channels. This file is the full manual.

## Building and running

    $ make
    $ doas make install            # installs to /usr/local/bin
    $ bsdirc                       # reads ~/.bsdirc/config
    $ bsdirc -c other.conf

bsdirc needs a UTF-8 locale for å, ä, ö and emoji. If none is set, as when it is started from cron, it picks `en_US.UTF-8` by itself. Inside tmux it also uses 256 colours even when `TERM` says `screen`.

## Configuration file

The file is `~/.bsdirc/config`. You can write it by hand, but the easiest way is to let `/server` and `/autojoin` manage it. bsdirc rewrites the file atomically (temporary file, fsync, rename) with mode 0600, and warns if the file contains passwords but is readable by others.

    nick _empty
    colors yes                # no = monochrome
    theme default             # or irssi (blue) or bw (black on grey)
    log yes                   # no = no logging
    bind 2001:db8::42         # connect from this IP (all networks)
    autoaway 10               # minutes after tmux detach, 0 = off
    awaymsg Not at the keyboard
    realname Johan Tärnklint
    user johan                # user name at login; default: your login name

    [libera]
    host irc.libera.chat
    port 6697
    tls yes
    sasl yes
    account _empty
    password secret           # leave out to be asked at startup
    autoconnect yes
    join #runbsd
    join #openbsd
    join #secret key

    [efnet]
    host irc.efnet.org
    port 6667
    tls no
    autoconnect no

Keys per network: `host`, `port`, `tls`, `insecure`, `sasl`, `qauth`, `qhide`, `account`, `password`, `nick`, `user`, `realname`, `bind`, `autoconnect` and `join` (one per channel, with an optional channel key). If `tls` is missing, the same rule as for `/server add` applies: TLS only if the port is 6697.

If a network has `autoconnect yes` and `sasl yes` (or `qauth yes`) but no password, bsdirc asks for it at startup with readpassphrase(3). A password entered that way is never written to the file.

## Commands

**Networks**

    /server                                  list networks and their state
    /server add NAME HOST [PORT] [-tls|-notls] [-insecure] [-sasl|-qauth] [-auto] [-bind IP]
                                             default: no TLS; port 6697 or -tls means TLS
    /server set NAME KEY VALUE               "-" as the value clears it
    /server del NAME
    /connect [NAME]   /disconnect [NAME] [msg]   /reconnect [NAME]

**Changing a network**

`/server set NAME KEY VALUE` changes one setting and saves the config file at once. Settings that affect the connection (`host`, `port`, `tls`, `insecure`, `bind`) take effect at the next connect, so run `/reconnect NAME` afterwards.

| Key | Value | Example |
|---|---|---|
| `host` | server name or address | `/server set libera host irc.eu.libera.chat` |
| `port` | port number | `/server set quakenet port 6697` |
| `tls` | `yes` / `no` | `/server set quakenet tls yes` |
| `insecure` | `yes` / `no`, skip certificate checks | `/server set mynet insecure yes` |
| `sasl` | `yes` / `no` | `/server set libera sasl yes` |
| `qauth` | `yes` / `no`: log in to Q on QuakeNet | `/server set quakenet qauth yes` |
| `qhide` | `yes` / `no`: set +x after logging in to Q (default yes) | `/server set quakenet qhide no` |
| `account` | SASL or Q account (default: nick) | `/server set libera account _empty` |
| `password` | SASL or Q password | `/server set libera password secret` |
| `nick`, `user`, `realname` | text | `/server set libera nick _empty` |
| | `user` is the user name sent at login (the part before @ in your host). Default: your login name on the machine, lowercased, letters and digits only, as some servers (QuakeNet) refuse anything else | `/server set quakenet user johan` |
| `bind` | local IP to connect from | `/server set libera bind 2001:db8::42` |
| `autoconnect` | `yes` / `no` | `/server set libera autoconnect yes` |

`-` as the value clears a setting, for example `/server set libera bind -`.

Switching a network to TLS:

    /server set quakenet tls yes
    /server set quakenet port 6697
    /reconnect quakenet

`tls yes` does not change the port by itself, so set the TLS port as well. Run `/server` to check: the line should show `:6697 tls`.

**Autojoin** (for the network of the current window)

    /autojoin                        list
    /autojoin add [#channel [key]]   no argument: the current channel
    /autojoin del [#channel]
    /autojoin save                   save all channels you are in right now

**Ignore**

    /ignore                  list
    /ignore troll            ignore the nick and its host (*!*@host)
    /ignore *!*@bad.example  ignore a mask (* and ? work)
    /unignore troll          remove (also takes a mask or the number from /ignore)

In big channels you can hide joins, parts, quits and nick changes:

    /ignore #linux                 hide all four in #linux
    /ignore #linux joins parts     hide only joins and parts
    /unignore #linux               show them again

The nick list is still kept up to date, your own joins and parts are always shown, and kicks are never hidden. The channel may contain `*` and `?`, and the setting is saved as `ignore #linux joins parts`.

`/ignore nick` looks up the person's host in the nick lists (or asks the server with `USERHOST`) and ignores both, so changing nick does not get around it. Messages, notices, actions, CTCP (which is then not answered) and invites from an ignored person are dropped, and their joins, parts, quits and nick changes are not shown. They stay in the nick list. The list applies to all networks and is saved in the config file as `ignore troll *!*@host`.

**Channel operator**

These act on the channel of the current window. To use them from another window, give the channel first: `/op #runbsd alice`.

    /op alice bob            give ops (also /deop, /voice, /devoice)
    /kick troll [reason]     kick
    /ban troll               ban *!*@host (nick!*@* if the host is unknown)
    /ban *!*@bad.example     ban a mask
    /unban *!*@bad.example   remove a ban
    /kb troll [reason]       ban *!*@host, then kick
    /invite carl [#channel]  invite
    /mode                    show the channel's modes
    /mode +m                 set channel modes (outside a channel: your user modes, /mode +i)

`/op` with many nicks is split into lines of as many modes as the server allows (`MODES` in 005, 3 if not given).

**Other**

`/whois [nick]` (no nick: yourself), `/ctcp nick version|ping|time` (PING shows the round-trip time), `/away [msg]`, `/autoaway [minutes]`, `/join`, `/part`, `/msg`, `/query`, `/me`, `/nick`, `/topic`, `/names`, `/win N`, `/windows`, `/close`, `/clear`, `/nicklist`, `/serverlist`, `/set theme default|irssi|bw`, `/raw`, `/save`, `/quit` and `/help`. Unknown commands are sent to the server as is, so `/list`, `/who` and others work too.

## Keys

| Key | Action |
|---|---|
| Esc | works as Alt, just like in irssi: press Esc and then the key, even with a pause in between |
| Tab / Shift-Tab | complete a nick (most recent speaker first, `nick: ` at the start of the line), `#channel` or `/command`; press again to cycle |
| Alt-1..0 or Esc 1..0 | windows 1–10 |
| Alt-q..o or Esc q..o | windows 11–19 (q w e r t y u i o) |
| ^N/^P | next or previous window |
| Alt-a | jump to activity (highlights first) |
| Alt-n | show or hide the nick list |
| Alt-s | show or hide the window list |
| Alt-l | bare view: the window as plain text, for clicking and copying links |
| Up/Down | browse what you have typed |
| PgUp/PgDn | scroll |
| ^A ^E ^U ^W ^K | edit the line |
| ^L | redraw the screen |

## Logging in to Q on QuakeNet

QuakeNet has no SASL; you log in to its service bot Q instead. With `qauth yes`, bsdirc does it right after connecting, using Q's CHALLENGEAUTH (HMAC-SHA-256), so your password is never sent, not even to Q:

    [quakenet]
    host se.quakenet.org
    port 6667
    qauth yes
    account myaccount
    password secret
    join #shells.se

1. bsdirc asks Q for a challenge (`PRIVMSG Q@CServe.quakenet.org :CHALLENGE`).
2. It answers with `CHALLENGEAUTH`, a hash of your account, password and the challenge.
3. When Q says you are logged in, bsdirc sets `+x` to hide your host, and only then joins your channels, so your real host is never shown in them. With `qhide no` it logs in but does not set `+x`.

If the login fails, or Q does not answer within 15 seconds, bsdirc says so and joins anyway. Only notices from `Q!TheQBot@CServe.quakenet.org` count, so nobody else can pretend to be Q. Q only uses the first 10 characters of a password, as on QuakeNet's own site.

## Links and copying

bsdirc wraps long lines itself and indents them under the text, and the nick list's border is on the same rows. A long link is therefore split over several rows: the terminal does not see it as one link, and copying it brings the indent and the border along. Two ways around it, as in WeeChat:

    /url          the last 5 links in this window, numbered, newest first
    /url 2        show link 2 alone, whole and clickable
    /url clear    forget this window's links
    /bare, Alt-l  show the window as plain text, without panels or indent

Both leave the drawn screen for a moment and print plain text, which the terminal wraps by itself, so links are whole: clickable, and copied without anything extra. Any key goes back. bsdirc keeps running meanwhile, so the connection does not time out. Links are taken from messages and actions (http:// and https://), and each channel and query has its own list: `/url` in #runbsd only shows links posted in #runbsd on that network. Up to 200 links are kept in all (only in memory), so `/url 12` also works.

## Pushover notifications

bsdirc can send a notice to your phone with [Pushover](https://pushover.net) when someone mentions your nick in a channel or writes to you in private. Create an application on pushover.net to get an API token, and copy your user key from the front page. Then:

    /pushover token APP_TOKEN
    /pushover user USER_KEY
    /pushover test

The keys are saved in the config file (keep it `chmod 600`):

    pushover_token APP_TOKEN
    pushover_user USER_KEY
    pushover away

| Mode | Sends |
|---|---|
| `away` (default) | only while you are away: `/away`, or auto-away after tmux has been detached for a while |
| `always` | every mention and private message |
| `off` | nothing |

Change it with `/pushover away`, `/pushover always` or `/pushover off`; `/pushover` alone shows the state. At most one notice a minute is sent per channel or query, so a lively conversation does not flood the phone. Notices from services (NickServ and the like) and ignored people are never sent. Colour codes are removed from the text.

The request goes over TLS to api.pushover.net from the same poll loop as the IRC connections, with the certificate checked against the system CA file. No program is started and pledge needs no new promises. If it fails (wrong key, no network, no answer in 20 seconds), a line about it appears in the first status window.

## Connecting from a specific IP

`bind` chooses the local address bsdirc connects from, for example your own IPv6 address or vhost. It can be set globally at the top of the config file or per network, and the per-network value wins:

    /server set libera bind 2001:db8::42
    /server add efnet irc.efnet.org -bind 192.0.2.10

- **The address must be numeric** and exist on the machine.
- **Only server addresses of the same family are used.** With an IPv6 bind, bsdirc only connects to the server's IPv6 addresses, and vice versa.
- **On connect** it shows `Connected to irc.libera.chat [2001:5a8:…] from 2001:db8::42`. The topic bar in the network's status window shows both addresses while you are connected.

## Away and tmux

`/away text` marks you as away on all networks, and `/away` without text removes it. `[away]` is shown in the status bar.

If you run bsdirc in tmux, away is handled automatically without any configuration:

- **When you leave tmux** (detach) bsdirc switches to window 1, like irssi, so a highlight in the window you were reading is not missed: it shows up red in the window list and the Act list when you come back. After `autoaway` minutes (10 by default) you are marked away, with `awaymsg` as the message.
- **When you come back** (attach) away is removed within a few seconds.
- **If you set away yourself with `/away`**, bsdirc leaves it alone until you remove it.
- **If bsdirc reconnects** while you are away, away is set again on the server.

How it works: tmux sets the execute bit on its socket (`srwx…`) while a client is attached and clears it when all clients have detached. bsdirc reads the socket path from `$TMUX` and calls `stat()` on it every five seconds. The socket is added to unveil with read permission only. This covers the whole tmux server, so if another session on the same server is attached, you count as attached.

**Troubleshooting:** `/autoaway` shows the socket, whether a client is attached, when away will be set and any errors. `/autoaway 15` changes the number of minutes, and `/autoaway 0` turns it off. At startup bsdirc reports in the first network's status window whether tmux was found, and every attach and detach is shown there.

**Optional, for an immediate reaction instead of within five seconds:** add hooks to `~/.tmux.conf`. bsdirc understands both methods at the same time.

    set-hook -g client-detached 'run-shell "pkill -USR1 -x bsdirc || true"'
    set-hook -g client-attached 'run-shell "pkill -USR2 -x bsdirc || true"'

## Colours

bsdirc has a calm, irssi-style colour theme:

- **Nicks** all have the terminal's normal text colour, yours too, and so do the `@` and `+` prefixes, both in messages and in the nick list.
- **When someone mentions you**, the time stamp of that line becomes a red block in the margin, so it stands out when you scroll. The rest of the line keeps its normal colours. Only whole words count, so `johansson` does not trigger `johan`. The window turns red in the Act list, and the terminal beeps.
- **Events:** `-!-` is blue, and red for errors and warnings. Joins are cyan.
- **The status bar** and the topic bar come in three themes: `default`, muted yellow (khaki, colour 186 on 256-colour terminals) with black text; `irssi`, blue with white text and cyan brackets; and `bw`, black on light grey (colour 250) with no colour at all, where a window you were mentioned in is shown bold and underlined instead of red. Switch with `/set theme irssi`, `/set theme bw` or `/set theme default`; the choice is saved in the config file as `theme irssi`. When you are mentioned in another window, its number in the Act list turns red.
- **mIRC colours**, bold, underline and reverse text from others are shown.

Colours are stored per character next to the text, so text from others cannot fake the client's own markings. Turn colours off with `colors no` at the top of the config file, or set the `NO_COLOR` environment variable.

## Features

- **Several networks at once.** Each network has a status window followed by its channels and queries, so window 1 is your first network. bsdirc's own messages (startup, warnings, auto-away) go to that first status window. A separate bsdirc window only exists while there are no networks, and shows how to add one.
- **Automatic reconnect.** The delay grows from 5 to 300 seconds. Channels you were in are joined again. bsdirc pings the server after 120 seconds of silence and gives up after 300 seconds. `/disconnect` stops reconnecting.
- **Window list** on the left, with one group per network and its channels and queries indented below. Every line shows the window number, which is the key you press after Alt or Esc. Windows are numbered in the same order, so Alt-1, Alt-2 and so on follow the list from the top. Networks are white when connected and grey when not. Channels and queries are grey while nothing new has happened, turn bright white and bold when someone writes there, and red when you were mentioned or got a private message; joins, parts and quits do not count. The current window is highlighted. Show or hide the list with Alt-s or `/serverlist`.
- **Nick list.** It is shown on the right when the chat area stays at least 40 columns wide. It follows JOIN, PART, KICK, QUIT, NICK and MODE. PREFIX and CHANMODES are read from 005, so `~&@%+` work. `multi-prefix` is requested when the server supports it.
- **CAP negotiation.** `CAP LS 302`, then `multi-prefix`, `userhost-in-names` and `sasl`.
- **Read marker.** When you leave a channel or query, a bookmark is set. If new lines have arrived when you come back, a red line, `──── 5 new ────`, shows exactly where you stopped reading. The line disappears when you write something in the window, and moves the next time you leave it. It is never written to the log.
- **Channel modes in the status bar**, like irssi: `[2:libera/#runbsd(+ntl 50)]`. bsdirc asks for the modes quietly when you join and follows every MODE change after that. The channel key is never shown.
- **Pushover notifications** to your phone when you are mentioned or get a private message, by default only while you are away. See [Pushover notifications](#pushover-notifications).
- **Topic bar** at the top with the channel's topic. In status and query windows it shows what the window is.
- **Word wrap with indent.** Long lines break at spaces, and the continuation lines line up under the text, not under the time stamp.
- **Input history** with Up and Down. What you were typing is kept while you browse. Lines containing `password` are never stored.
- **WHOIS and other replies** are shown in the window you are in, formatted like in irssi. Errors about a channel ("Cannot send to channel", "You're not channel operator") go to the channel's window.
- **Send queue against flooding.** At most 5 lines go out at once, then one every two seconds, so you avoid "Excess Flood". The queue length is shown in the status bar (`[Queue: 3]`). PING, PONG and QUIT always go out directly. Writing never blocks: what the server does not take at once waits in a small buffer and goes out when the connection allows, so a slow or stalled server cannot freeze the client. If the server takes nothing for 5 minutes, or 64 KB piles up, bsdirc disconnects and reconnects.
- **Paste protection.** If you paste 3 lines or more, bsdirc asks first: Ctrl-K sends and Ctrl-C cancels.
- **Logging** to `~/.bsdirc/logs/<network>/<channel>.log`, with date and time on every line. Directories get mode 0700 and files 0600. Turn it off with `log no` at the top of the config file. When a channel or query window opens, for example after a restart, its last 50 log lines are shown first in grey, followed by `─── backlog until 2026-11-05 10:09 ───`, so you can see what was said. They count neither as new messages nor as highlights. Change the number with `/set backlog 100`, or turn it off with `/set backlog 0`. Query windows that are open when you quit come back at the next start, each with its backlog; they are saved as `query nick` lines under the network in the config file. A query you close with `/close` (or `/wc`) does not come back.
- **Autojoin in one go.** Channels without a key are sent as a single `JOIN #a,#b,#c`.
- **Keep your nick** (like irssi's keepnick.pl). If your nick is taken when you connect and the server gives you a fallback (`jt_`), bsdirc wins the configured nick back as soon as it is free. It uses `MONITOR` when the server supports it, so the server tells it the moment the nick is free, and falls back to `ISON` every 30 seconds on older servers. It also reacts at once when the holder quits or changes nick in a channel you share. Nothing happens when you already have your nick. If you pick another nick yourself with `/nick`, the chase stops; `/nick` back to the configured nick, or a reconnect, starts it again. While it is chasing, the status bar shows `[jt_ -> jt]`.
- **Fixed window numbers for autojoin.** The windows for autojoin channels are created at startup in the order of the config file, so a channel always gets the same number, whatever order the server answers in. A channel you could not join (banned, wrong key) keeps its window, shown grey with the error, instead of the windows after it moving up.
- **DNS without blocking the user interface.** On OpenBSD the lookup is asynchronous, using `getaddrinfo_async` and `asr_run(3)` in the same poll loop, so the client stays responsive while it connects. On other systems the lookup still blocks.

## Security

- **During startup:** `pledge("stdio rpath wpath cpath inet dns tty unveil")`.
- **Once terminfo, CA certificates, time zone and config are loaded:**
  - `unveil` allows only `~/.bsdirc` (rwc), `/etc/resolv.conf` and `/etc/hosts` (r), and the tmux socket (r) when running inside tmux.
  - `pledge` shrinks to `stdio rpath wpath cpath inet dns tty`, that is without `unveil`. `rpath` is needed because rename(2) requires it, but unveil leaves only the paths above visible.
- **Incoming text** is stripped of control characters, so nobody can send terminal escape sequences. Bidirectional overrides (U+202A–202E, U+2066–2069) are removed too, so text cannot be made to look reversed or spoofed.
- **One line in, one line out:** a CR inside a line from the server becomes a space, every line sent is cut at CR and LF, and the tail of an over-long line is thrown away instead of being read as a new line. Nothing from others can inject an extra command.
- **CTCP flood protection:** at most 3 CTCP replies per 10 seconds, and none while the send queue is backed up.
- **Secrets to services:** `IDENTIFY`, `AUTH`, `LOGIN`, `REGISTER`, `GHOST`, `SET PASSWORD` and similar to NickServ, Q and other nicks are shown and logged as `IDENTIFY ****`, and kept out of the input history, as are `/pushover token` lines.
- **SASL** is answered only while logging in and only when bsdirc asked for it. Using SASL without TLS gives a warning, since the password then travels unencrypted.
- **Pushover** sends at most 10 notices per 10 minutes, so a flood of private messages cannot use up your quota.
- **Passwords:** the input line is cleared after every line, since it may contain `/server set ... password`.
- **No core dumps** (`RLIMIT_CORE` 0), since a dump would contain SASL passwords.
- **CA certificates** are loaded into memory at startup with `tls_load_file`. Otherwise libtls reads them only at the handshake, when unveil has already closed the file system.
- **Network connections:** besides the IRC servers in the config file, bsdirc only connects to api.pushover.net, and only when Pushover keys are set. The certificate is always verified and the token is never shown on screen.
- **SIGPIPE is ignored**, so a connection closed by the server leads to a reconnect instead of killing the process.

## Known limitations

- Comments in the config file are lost when bsdirc rewrites it.
- No custom highlight words or lag meter yet.

## License

ISC. See the header of `bsdirc.c`.
