├── screen.png ├── motd ├── COPYING ├── README.md └── ircd.sh /screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgl/bash-ircd/HEAD/screen.png -------------------------------------------------------------------------------- /motd: -------------------------------------------------------------------------------- 1 | Welcome to a pure Bash IRCd! 2 | 3 | For more details see https://dgl.cx/bash-ircd 4 | 5 | Be excellent to each other 😇 6 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright 2025 David Leadbeater 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted. 5 | 6 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 7 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 8 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 9 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 10 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 11 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 12 | PERFORMANCE OF THIS SOFTWARE. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A bash IRCd 🐣 💬 2 | 3 | [This](./ircd.sh) is an IRC server written in bash. It is nearly "pure" bash, 4 | because it does not use any external commands. But it does cheat slightly by 5 | using some loadable builtins. 6 | 7 | 8 | 9 | Credit to [YSAP](https://www.youtube.com/@yousuckatprogramming) for 10 | [bash-web-server](https://github.com/bahamas10/bash-web-server). Inspiration 11 | from [example.fi's IRCd service](https://example.fi/blog/ircd.html), which 12 | unfortunately hasn't published the code behind it. 13 | 14 | ## Running 15 | 16 | ``` 17 | ./ircd.sh 18 | ``` 19 | 20 | Connect to localhost:6667 with an IRC client. If using Irssi you need to use 21 | `/connect -nocap localhost`. 22 | 23 | ## Try it 24 | 25 | Try it now by connecting to [irc.st:6697](irc://irc.st/#bash) channel #bash (the TLS is done outside of bash by a proxy). 26 | 27 |
28 | Example of raw IRC session... 29 | 30 | ```cli 31 | $ telnet irc.st 6667 32 | Connected to irc.st. 33 | Escape character is '^]'. 34 | NICK test 35 | USER test test test test 36 | :irc.st 001 test :Welcome to IRC, test! 37 | :irc.st 002 test :Your host is irc.st on bash-ircd v0.0.1, bash 5.3.3(1)-release 38 | :irc.st 004 test irc.st 0.0.1 i o o 39 | :irc.st 375 test :- irc.st Message of the Day 40 | :irc.st 372 test :- Welcome to a pure Bash IRCd! 41 | :irc.st 372 test :- 42 | :irc.st 372 test :- For more details see https://dgl.cx/bash-ircd 43 | :irc.st 372 test :- 44 | :irc.st 372 test :- Be excellent to each other 😇 45 | :irc.st 376 test :End of /MOTD command. 46 | JOIN #bash 47 | :test!user@host JOIN #bash 48 | :irc.st 353 test = #bash :dgl dg test 49 | :irc.st 366 test #bash :End of /NAMES list 50 | :irc.st 329 test #bash 1761878952 51 | :irc.st 332 test #bash :bash? 52 | :irc.st 333 test #bash dg 0 53 | ``` 54 | 55 |
56 | 57 | ## Architecture 🏗️ 58 | 59 | The `accept` loadable bash builtin makes it possible to listen on a socket. See 60 | the very end of the script for that. 61 | 62 | Then "process-client" runs as another process with stdin/stdout set to the 63 | client (this means `echo` to send to the client just works). Nicknames are kept 64 | by a `user-${name}` FIFO on the filesystem. Once the client has registered another 65 | process ("watcher") that reads from that FIFO and writes to the client is 66 | started, this means other clients can directly write to the FIFO, which in turn 67 | directly writes to the user's connection. 68 | 69 | Channels are plain text files with a nickname per line and are simply expanded 70 | by process-client running as the sending user when needing to send to them. 71 | 72 | Unlike the original IRC server software, which used non-blocking I/O so it 73 | could efficiently support many clients in a single process, this architecture 74 | results in at least 2 processes per connection, so is hardly scalable. However, 75 | because it uses FIFOs parts of it can be upgraded without disconnecting other 76 | clients. If you ^C ircd.sh (and keep your shell/session running) clients will 77 | stay connected, but new clients cannot connect, until you run `ircd.sh` again. 78 | 79 | As each user is a FIFO, you can use this to send messages to users from outside 80 | the IRCd on the server machine: 81 | 82 | ```bash 83 | msg-user() { 84 | local to="$1" 85 | local msg="$2" 86 | echo ':'"${USER}"'!user@host PRIVMSG '"${to} :${msg}" >> "user-${to}" 87 | } 88 | ``` 89 | 90 | ## Bugs 🐛 91 | 92 | This is full of them. Not recommended for production use, in particular the use 93 | of FIFOs means there are various cases where a misbehaving client can block 94 | sending to them, which could slowly block all messages being sent to a channel. 95 | I don't think that is fixable in pure Bash. 96 | 97 | ## Security 🔐 🚨 98 | 99 | I suspect this has some hilarious security holes. You could put stunnel in 100 | front of it to make it do TLS, but that's like putting lipstick on a pig. 101 | 102 | ## Contributing 🧑‍💻 103 | 104 | PRs welcome. This so far has been written without any AI, please disclose any 105 | usage. 106 | -------------------------------------------------------------------------------- /ircd.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # © David Leadbeater ; NO WARRANTY 3 | # SPDX-License-Identifier: 0BSD 4 | # 5 | # "Pure"(*) bash IRCd -- https://dgl.cx/bash-ircd 6 | # *: Uses various loadable bash builtins 7 | readonly VERSION="0.0.3" 8 | # 9 | # Credit to https://github.com/bahamas10/bash-web-server for ideas, 10 | # see also https://youtu.be/L967hYylZuc 11 | 12 | set -euoo pipefail noclobber 13 | shopt -s extglob nocasematch 14 | 15 | PORT="${PORT:-6667}" 16 | ADDRESS="${ADDRESS:-127.0.0.1}" 17 | SERVER="${SERVER:-irc.example.com}" 18 | 19 | # Ensure we stay pure: 20 | readonly PATH="" 21 | 22 | need_loadables() { 23 | echo "To use this script needs a version of bash with loadables." >&2 24 | echo "On some systems you may need to install a package like bash-builtins." >&2 25 | exit 127 26 | } 27 | 28 | # The loadable bash builtins needed: 29 | enable accept || need_loadables 30 | enable mkfifo || need_loadables 31 | enable rm || need_loadables 32 | 33 | nick= 34 | user= 35 | state=new 36 | declare -a channels=() 37 | 38 | process-client() { 39 | local line last=$SECONDS 40 | 41 | # stdin is stdout too, because it's a socket 42 | exec >&0 43 | 44 | while true; do 45 | # Not pretty, need to tell the difference between exit 1 from read (EOF) 46 | # and signalled due to timeout. 47 | local rc=0 48 | while IFS=$'\n' read -t 90 -r line; rc=$?; do 49 | [[ $rc -gt 0 ]] && break 50 | last=$SECONDS 51 | line="${line//$'\r'}" 52 | # Enforce IRC line length limit 53 | line="${line:0:512}" 54 | # Clients can't send prefixes, strip them 55 | line="${line#:+([^ ]) }" 56 | commands-$state "${line%% *}" "${line##+([^ ])*( )}" 57 | done 58 | [[ $rc -eq 1 ]] && break # EOF 59 | if [[ $rc -ge 128 ]]; then 60 | local active=$((SECONDS-last)) 61 | if [ $active -ge 180 ]; then 62 | break # Ping timeout 63 | elif [ $active -ge 90 ]; then 64 | echo "PING :${SERVER}"$'\r' 65 | fi 66 | fi 67 | done 68 | } 69 | 70 | # Commands for unregistered connections 71 | commands-new() { 72 | local command="$1" 73 | local args="$2" 74 | case $command in 75 | QUIT) exit 0;; 76 | NICK) 77 | [[ -n $nick ]] && return 78 | arg="${args#@(:)}" 79 | # Stricter than RFC, but avoids issues with shell metacharacters. 80 | local arg_validated="${args/@([^a-z0-9_])}" 81 | if [[ $arg != "$arg_validated" ]]; then 82 | reply-numeric "432" "$arg :Erroneous Nickname" 83 | return 84 | fi 85 | nick=$(lower "$arg") 86 | if [[ -e "user-$nick" ]]; then 87 | reply-numeric "433" "$arg :Nickname already in use" 88 | nick= 89 | else 90 | mkfifo "user-$nick" 91 | trap 'rm -f "user-$nick"' EXIT 92 | fi 93 | maybe-connect 94 | ;; 95 | USER) 96 | user=1 97 | maybe-connect 98 | ;; 99 | *) reply-numeric 421 "${command} :Unknown command";; 100 | esac 101 | } 102 | 103 | WPID= 104 | 105 | maybe-connect() { 106 | [[ -z $user ]] && return 107 | [[ -z $nick ]] && return 108 | state=on 109 | reply-numeric "001" ":Welcome to IRC, ${nick}!" 110 | reply-numeric "002" ":Your host is ${SERVER} on bash-ircd ${VERSION}, bash ${BASH_VERSION}" 111 | reply-numeric "004" "${SERVER} ${VERSION} i o o" 112 | commands-${state} MOTD 113 | watcher& 114 | WPID=$! 115 | trap 'send-quit; kill $WPID; rm -f "user-$nick"' EXIT 116 | } 117 | 118 | watcher() { 119 | while true; do 120 | exec <"user-$nick" 121 | while IFS=$'\n' read -r line; do 122 | echo "$line" 123 | done 124 | done 125 | } 126 | 127 | # Commands for registered connections, $nick is valid. 128 | commands-on() { 129 | local command="$1" 130 | local args="${2:-}" 131 | case $command in 132 | PONG) ;; 133 | PING) 134 | echo ":${SERVER} PONG ${SERVER} $args"$'\r' 135 | ;; 136 | QUIT) 137 | send-quit "${args#:}" 138 | exit 0 139 | ;; 140 | MOTD) 141 | reply-numeric "375" ":- ${SERVER} Message of the Day" 142 | if [ -f "motd" ]; then 143 | while IFS= read -r line; do 144 | reply-numeric 372 ":- $line" 145 | done "meta-$chan"; then 175 | # Empty topic and set by 176 | { 177 | echo "" 178 | echo "" 179 | echo "0" 180 | } >> "meta-$chan" 181 | fi 182 | fi 183 | echo "$nick" >> "channel-$chan" 184 | local users="" 185 | for n in $(<"channel-$chan"); do 186 | send-to-user "$n" "JOIN $chan" 187 | users="${users} $n" 188 | done 189 | reply-numeric 353 "= $chan :${users# }" 190 | reply-numeric 366 "$chan :End of /NAMES list" 191 | local ts topic setby when 192 | exec 3<"meta-$chan" 193 | read -r ts <&3 && reply-numeric 329 "$chan $ts" 194 | send-topic "$chan" 3 195 | exec 3>&- 196 | done 197 | ;; 198 | PRIVMSG|NOTICE) 199 | if [[ ${args/ } = "$args" ]]; then 200 | reply-numeric 412 ":No text to send" 201 | return 202 | fi 203 | local to msg 204 | to="$(lower "${args%% *}")" 205 | msg="${args##+([^ ]) *(:)}" 206 | if [[ ${to:0:1} = "#" ]]; then 207 | local to_validated="${to/@([^#a-z0-9_-])}" 208 | if [[ $to != "$to_validated" ]] || [ ! -f "channel-$to" ]; then 209 | reply-numeric 401 "$to :No such nick/channel" 210 | return 211 | fi 212 | for n in $(<"channel-$to"); do 213 | if [[ $n != "$nick" ]]; then 214 | send-to-user "$n" "$command $to :$msg" 215 | fi 216 | done 217 | else 218 | local to_validated="${to/@([^a-z0-9_-])}" 219 | if [[ $to != "$to_validated" ]] || [ ! -p "user-$to" ]; then 220 | reply-numeric 401 "$to :No such nick/channel" 221 | return 222 | fi 223 | send-to-user "$to" "$command $to :${msg}" 224 | fi 225 | ;; 226 | TOPIC) 227 | local chan topic 228 | chan="$(lower "${args%% *}")" 229 | topic="${args##+([^ ])*( )}" 230 | if [[ ${chan:0:1} != "#" ]]; then 231 | reply-numeric 403 ":No such channel" 232 | return 233 | fi 234 | local chan_valid="${chan/@([^#a-z0-9_-])}" 235 | local file="meta-$chan" 236 | if [[ $chan != "$chan_valid" ]] || [ ! -f "$file" ]; then 237 | reply-numeric 403 ":No such channel" 238 | return 239 | fi 240 | local ts 241 | exec 3<"$file" 242 | read -r ts <&3 243 | if [[ $topic = "" ]]; then 244 | send-topic "$chan" 3 245 | return 246 | fi 247 | exec 3>&- 248 | topic="${topic#:}" 249 | # noclobber needed, acts as lockfile 250 | local write=1 251 | echo "$ts" > ".$file" || write=0 252 | if [[ $write = 1 ]]; then 253 | { 254 | echo "$topic" 255 | echo "$nick" 256 | echo "$EPOCHSECONDS" 257 | } >> ".$file" 258 | echo "$(<".$file")" >| "$file" 259 | rm ".$file" 260 | for n in $(<"channel-$chan"); do 261 | send-to-user "$n" "TOPIC $chan :$topic" 262 | done 263 | fi 264 | ;; 265 | WHO) ;; # Ignore for now, avoids floods of errors in clients 266 | MODE) ;; 267 | *) reply-numeric 421 "${command} :Unknown command";; 268 | esac 269 | } 270 | 271 | lower() { 272 | # Usage: lower "string" 273 | printf '%s\n' "${1,,}" 274 | } 275 | 276 | reply-numeric() { 277 | local numeric=$1 278 | local msg=$2 279 | echo ":${SERVER} $numeric ${nick:-*} $msg"$'\r' 280 | } 281 | 282 | send-to-user() { 283 | local user="$1" 284 | local line=":$nick!user@host $2"$'\r' 285 | if [[ $user = "$nick" ]]; then 286 | echo "$line" 287 | elif [ -p "user-$user" ]; then 288 | echo "$line" >> "user-$user" 289 | fi 290 | } 291 | 292 | send-topic() { 293 | local chan="${1?}" 294 | local fd="${2?}" 295 | if IFS= read -r topic <&${fd} && [[ -n "$topic" ]]; then 296 | reply-numeric 332 "$chan :$topic" 297 | read -r setby <&${fd} || : 298 | read -r when <&${fd} || : 299 | reply-numeric 333 "$chan $setby $when" 300 | fi 301 | } 302 | 303 | send-quit() { 304 | local msg="${1:-}" 305 | local -A tosend 306 | for channel in "${channels[@]}"; do 307 | # noclobber needed, acts as lockfile 308 | local write=1 309 | local file="channel-$channel" 310 | echo -n > ".$file" || write=0 311 | for n in $(<"$file"); do 312 | # filter out other users who have gone away 313 | [[ -p "user-$n" ]] || continue 314 | tosend["$n"]=1 315 | # filter out this user 316 | [[ $n = "$nick" ]] && continue 317 | [[ $write = 1 ]] && echo "$n" >> ".$file" 318 | done 319 | if [[ $write = 1 ]]; then 320 | echo "$(<".$file")" >| "$file" 321 | rm ".$file" 322 | fi 323 | done 324 | for n in "${!tosend[@]}"; do 325 | send-to-user "$n" "QUIT :$msg" 326 | done 327 | channels=() 328 | } 329 | 330 | while true; do 331 | accept -b "$ADDRESS" -v fd -r ip "$PORT" 332 | process-client <&"${fd:?}" & 333 | # close in parent 334 | exec {fd}>&- 335 | done 336 | --------------------------------------------------------------------------------