A small, self-contained IRC server written with the Python standard
library. No framework, no runtime dependencies, no virtualenv needed to
run it — just asyncio, ssl, sqlite3 and hashlib.
It speaks enough of RFC 1459/2812 that real clients (irssi, HexChat, WeeChat, Halloy) connect and behave normally, and enough IRCv3 that modern clients get message tags, SASL, and the capabilities they expect.
Designed to be deployed two ways: on a private mesh (Tailscale, WireGuard) with nothing exposed to the internet, or on a public address with the hardening that requires. The defaults assume the first.
pip install .
meshircdctl genconfig > ircd.toml
meshircd --config ircd.toml- Features
- Quick start
- Deployment
- Configuration
- Accounts and authentication
- Operators
- Persistence
- Privacy and cloaking
- Operations
- Testing
- Architecture
- Limitations
- Security
- Contributing
Protocol
NICK USER PASS QUIT PING PONG JOIN PART TOPIC NAMES
LIST INVITE KICK PRIVMSG NOTICE TAGMSG AWAY MODE WHO
WHOIS ISON USERHOST MOTD LUSERS VERSION TIME ADMIN
INFO STATS HELP CAP AUTHENTICATE
Operator: OPER KILL KLINE UNKLINE DLINE UNDLINE REHASH
WALLOPS SAMODE CONNECTINFO
Channels
- Modes
+bban,+eban exception,+Iinvite exception (list modes);+kkey;+llimit;+iinvite-only,+mmoderated,+nno external messages,+pprivate,+ssecret,+ttopic lock - Membership prefixes
+o(op,@) and+v(voice,+) - RFC 1459 casemapping —
Foo[andfoo{are correctly the same name 005 ISUPPORTgenerated from the same constants the server enforces, so the advertisement cannot drift from the behaviour
IRCv3
multi-prefix · userhost-in-names · server-time · message-tags ·
account-tag · account-notify · away-notify · extended-join ·
chghost · echo-message · invite-notify · cap-notify · sasl
Only capabilities that are actually implemented are advertised.
Authentication
- SASL
PLAIN(password) andEXTERNAL(TLS client certificate) - Offered only over TLS, and independently refused over plaintext even if a client ignores the capability list and tries anyway
- Passwords stored as salted
scrypthashes, never plaintext - Accounts are independent of nicknames: logging in as
aliceneither requires nor reserves the nickalice - Optional server-wide connection password
Hardening
- Host cloaking on by default — real IP addresses are never broadcast
- TLS 1.2 floor, compression off, weak ciphers excluded, certificate reload without a restart
- Per-client token-bucket flood control, with a startup check that the burst is large enough for a legitimate reconnect
- Total and per-IP connection limits; a registration deadline that reaps connections which never identify themselves
- Bounded send queue — a client that stops reading is disconnected rather than allowed to consume memory without limit
- 512-byte line limit enforced without breaking legitimate pipelining; outbound lines truncated on UTF-8 boundaries
- K-lines (user@host) and D-lines (address, enforced before registration)
- Credentials redacted from logs; account lookups are timing-safe
- Password hashing and database writes run off the event loop
- PROXY protocol v1/v2 for deployment behind a terminator
Requires Python 3.11+ (for tomllib). Nothing else.
git clone https://github.com/JamesLunaa/meshircd.git
cd meshircd
pip install .
meshircdctl genconfig > ircd.toml
$EDITOR ircd.toml # set server.name and cloak.secret
meshircd --check-config -c ircd.toml
meshircd -c ircd.tomlThe default configuration listens on 127.0.0.1:6667 only. A fresh install is not reachable from anywhere else until you say so.
Connect:
irssi -c localhost -p 6667 -n yournickOr drive the protocol directly, which is often the fastest way to see what is really happening:
nc localhost 6667
NICK alice
USER alice 0 * :Alice Example
JOIN #test
PRIVMSG #test :helloRunning from a checkout without installing:
PYTHONPATH=src python3 -m meshircd --config ircd.tomlFor a server anyone with a plain IRC client can reach, with no VPN or extra software on their side. Bind to the public interface with TLS:
[[listen]]
host = "0.0.0.0"
port = 6697
tls = true→ deploy/public-vps.md covers choosing a
host, DNS, Let's Encrypt via DNS-01 (no port 80 needed), certificate
permissions under DynamicUser, systemd, firewalls at both the OS and
provider layer, renewal, and hardening a server that's reachable by
anyone.
The recommended deployment. Bind to the overlay interface by name:
[[listen]]
host = "interface:tailscale0" # or "interface:wg0"
port = 6667Not by address — overlay addresses are assigned by the overlay and change. If the interface has no address the server refuses to start, rather than quietly falling back to a wider bind.
On Tailscale you also get real Let's Encrypt certificates for your
*.ts.net name, so TLS works with no self-signed warnings at all.
→ deploy/mesh-networking.md covers Tailscale ACLs, certificate renewal without dropping clients, WireGuard setup, firewall rules, systemd ordering, and running mesh-private and internet-public listeners side by side.
sudo cp deploy/meshircd.service /etc/systemd/system/
sudo mkdir -p /etc/meshircd && sudo cp ircd.toml /etc/meshircd/
sudo systemctl daemon-reload && sudo systemctl enable --now meshircdThe unit runs under DynamicUser with ProtectSystem=strict,
NoNewPrivileges, an empty capability set, and a syscall filter.
systemctl reload sends SIGHUP, which reloads the TLS certificate and
MOTD in place. deploy/meshircd-reload.path does that automatically
when an ACME client renews.
export IRCD_CLOAK_SECRET=$(python3 -c "import secrets; print(secrets.token_hex(32))")
docker compose up -dNon-root, read-only root filesystem, all capabilities dropped, with a healthcheck that validates the config and resolves every listener.
A TOML file, found at --config, then $IRCD_CONFIG, then ./ircd.toml,
then /etc/meshircd/ircd.toml. Environment variables override the file.
meshircdctl genconfig # a fully commented starter config
meshircd --check-config # validate without binding anythingConfiguration errors are fatal at startup and name the setting involved. There is no partial start: a server running with a config you thought said something else is worse than one that did not come up.
| Section | Purpose |
|---|---|
[server] |
name, network, MOTD, admin contact, optional server password |
[[listen]] |
one block per listener: host, port, tls, proxy_protocol |
[tls] |
certificate, key, minimum version, ciphers, CertFP CA |
[limits] |
every resource bound (see below) |
[cloak] |
host cloaking on/off, secret, suffix |
[logging] |
level, text or json, raw-line logging |
[[oper]] |
one block per operator |
[storage] |
database path, trusted proxy CIDRs |
Key limits and their defaults:
flood_burst must be large enough to absorb a client reconnecting and
rejoining every channel it was in. The server refuses to start if it is
not, because the resulting failure — ordinary clients disconnected for
flooding the instant they reconnect — is very hard to diagnose from the
client side.
The 512-byte line limit is fixed. It is the protocol's, not ours.
Environment overrides: IRCD_CONFIG, IRCD_SERVER_NAME, IRCD_NETWORK,
IRCD_MOTD_FILE, IRCD_TLS_CERT, IRCD_TLS_KEY, IRCD_STORAGE_PATH,
IRCD_CLOAK_SECRET, IRCD_CLOAK_ENABLED, IRCD_LOG_LEVEL,
IRCD_LOG_FORMAT, IRCD_HOST, IRCD_PORT, IRCD_TLS_PORT, and
IRCD_MAX_* / IRCD_FLOOD_* / IRCD_PING_* for the limits.
Accounts are created out of band. SASL authenticates against existing accounts; there is no wire command that creates one.
meshircdctl create alice # prompts for a password
meshircdctl list
meshircdctl passwd alice
meshircdctl lock alice # keep the account, refuse logins
meshircdctl unlock alice
meshircdctl delete alice--stdin reads the password from standard input for scripted
provisioning. Avoid it on a shared shell.
/network add -sasl_mechanism PLAIN -sasl_username alice -sasl_password '<pw>' MyNet
/connect -tls -network MyNet irc.example.org 6697
Authenticate with a TLS client certificate instead of a password.
openssl req -x509 -newkey rsa:2048 -keyout alice.key -out alice.crt \
-days 3650 -nodes -subj "/CN=alice"
meshircdctl certfp add alice alice.crt # a PEM path or a hex digest[tls]
request_client_cert = true
client_ca = "/etc/meshircd/tls/client-ca.pem"This requires
client_ca, and the server refuses to start without it. Python'ssslmodule exposes no certificate-verification callback, so the only way to request a client certificate isCERT_OPTIONAL— which aborts the handshake for any certificate that does not chain to a trusted CA. Enabling it without a matching CA bundle would stop anyone who happens to hold an unrelated client certificate from connecting at all. Pointclient_caat the CA that signs your users' certificates, or concatenate the self-signed client certificates you accept into one PEM file. Clients presenting a certificate outside that bundle will be refused at the TLS layer.This is why
request_client_certis off by default.
meshircdctl oper-hash admin # prints a ready-to-paste [[oper]] block[[oper]]
name = "admin"
salt = "..."
password_hash = "..."
host_mask = "*!*@100.64.0.5" # tighten this
tls_only = true
fingerprint = "" # optionally require a specific CertFPA wrong name and a wrong password are indistinguishable — both pay the same hashing cost and return the same numeric. Every operator action is logged and announced to other operators; an action one person can take silently is one nobody can audit.
/OPER admin <password>
/KILL baduser :reason
/KLINE 24h *@198.51.100.0/24 :spam
/DLINE 1w 203.0.113.7 :scanning
/STATS k
/UNKLINE *@198.51.100.0/24
/REHASH
Durations: 30m, 2h, 7d, 1w, or a bare number meaning minutes.
Omit for permanent.
Two guard rails: a mask matching every connection (*!*@*) is refused,
and live operators are exempt from retroactive ban enforcement — so
banning the network you are connected from does not disconnect you with
the same command and lock you out of undoing it.
State an operator deliberately set survives a restart, in SQLite:
- Accounts, password hashes, CertFP fingerprints, lock status
- Channel topics, modes, keys, limits, and ban/exception/invite lists
- Channel operator and voice grants, recorded against accounts — a nick is transient, an account is not, so op survives both a reconnect and a restart
- K-lines and D-lines, with expiry honoured on load
What is not persisted: who is connected, which nick is in use, and channel membership. Those describe live connections, which do not survive a restart no matter what is written down.
A channel is only stored once somebody configures it — a topic, a mode, a ban. Throwaway channels stay purely in memory, so the table cannot grow without bound from unauthenticated users creating channels. Channels untouched for 90 days are pruned.
A channel with recorded access does not auto-op whoever joins it while empty, which would otherwise let anyone seize a configured channel by waiting for it to empty out.
Without cloaking, every JOIN, PART, QUIT and PRIVMSG broadcasts
the sender's real IP address to everyone in the channel. Cloaking is on
by default:
203.0.113.45 -> 7f3a91c2.b41e07.9d22ac.ip
HMAC-SHA256 with a secret key, so the mapping is stable (bans keep
working), irreversible (the IPv4 space is small enough to enumerate
against a plain hash in seconds), and structure-preserving — the
segments are hashes of progressively broader prefixes, so
*.9d22ac.ip bans the whole /16 without revealing which /16 it is.
Set
cloak.secretand never change it. If it is absent the server generates one per process and warns loudly. Every user's visible host would then change on restart, and every cloak-based ban would silently stop matching.
python3 -c "import secrets; print(secrets.token_hex(32))"Operators see real addresses via WHOIS (numeric 338) and
CONNECTINFO. Ordinary users never do.
Signals. SIGTERM/SIGINT shut down gracefully: listeners close,
every client gets a real ERROR line explaining why rather than a silent
reset, and channel state is flushed. SIGHUP reloads the MOTD and the
TLS certificate in place — no restart, no disconnections. A failed
certificate reload is logged and the old certificate keeps serving, so a
half-written file during renewal cannot take the server down.
Logging. text for a terminal or journald, json for an
aggregator. Credentials are redacted from every raw protocol line before
it is logged — AUTHENTICATE carries the SASL password base64-encoded,
which is encoding, not encryption. log_raw_lines is off by default: at
production volume it is both a disk-filling liability and a record of
who said what to whom.
Health. meshircd --check-config validates the configuration and
resolves every listener without binding, which makes it a real readiness
check rather than a liveness placeholder.
Visibility. /STATS u (uptime, peak connections) is public;
/STATS k, /STATS d, /STATS o, /STATS c, /STATS m are operator
only. /CONNECTINFO shows addresses, accounts, TLS details, idle times
and byte counts.
python3 -m unittest discover -s tests -p "test_*.py"130 tests, no test framework required — the suite uses stdlib unittest
(including IsolatedAsyncioTestCase), held to the same standard-library
rule as the server. It is also collectable by pytest if you have it.
Each test starts a real server in-process on throwaway ports with a temporary certificate and database, so nothing touches a server you are already running. Coverage includes the wire format, validation and CRLF-injection resistance, cloaking properties, channel modes and visibility, every IRCv3 capability, SASL PLAIN and EXTERNAL, the operator layer, flood and connection limits, PROXY protocol, and restart persistence.
src/meshircd/
├── server.py registries, connection lifecycle, dispatch
├── client.py one connection: identity, capabilities, output
├── channel.py channel state: members, modes, lists, access
├── protocol.py wire format: parsing, tags, casefolding
├── connection.py framing, idle detection, PROXY protocol
├── commands/ one module per command group
│ ├── registration.py NICK USER PASS CAP AUTHENTICATE QUIT PING PONG
│ ├── channels.py JOIN PART TOPIC NAMES LIST INVITE KICK
│ ├── messaging.py PRIVMSG NOTICE TAGMSG AWAY
│ ├── modes.py MODE, user and channel
│ ├── queries.py WHO WHOIS ISON MOTD LUSERS STATS HELP ...
│ └── operator.py OPER KILL KLINE DLINE REHASH WALLOPS ...
├── accounts.py password hashing and the authentication decision
├── storage.py SQLite schema and access
├── cloak.py host cloaking
├── caps.py IRCv3 capability registry
├── config.py TOML + environment, with validation
├── tlsctx.py TLS context and certificate reloading
├── validation.py identifier validation (allow-list)
├── netiface.py interface-name to address resolution
├── numerics.py named numeric reply codes
└── cli.py meshircd and meshircdctl
One asyncio task per connection, single-threaded, no locks: the event
loop is the mutual exclusion, so the shared registries cannot be observed
half-updated. The corollary is that nothing blocking may run on the loop.
The two things that genuinely block — scrypt hashing (~30 ms) and
SQLite writes — are pushed to threads, and there is a test asserting that
a login does not stall other clients.
Commands are registered with a decorator carrying their constraints (minimum parameters, registration required, operator required, flood cost) so those checks cannot be forgotten on a new command — the kind of omission that turns into an authentication bypass.
Known and deliberate:
- No server-to-server linking. Single node. S2S is the hardest part of IRC and is out of scope.
- No services. No NickServ or ChanServ, no nick registration
enforcement, no channel registration commands. Op grants persist
against accounts, but there is no ACL system beyond
+oand+v. - No
WHOWAS, noSILENCE, noMONITOR, no+q/+a(owner and admin prefixes), no channel forwarding. - CertFP needs a CA bundle — see above for why the stdlib leaves no alternative.
- Slow clients are dropped, not queued. Exceeding the send-queue bound disconnects the client, which is what real servers do, but a long network stall on a busy channel can cost a client its connection.
- No brute-force lockout beyond the general flood limiter and the per-connection SASL failure cap. Accounts can be locked manually.
- No metrics endpoint. Operational visibility is logs and
/STATS. - Linux-only interface binding.
interface:specs use SIOCGIFADDR and/proc/net/if_inet6. Bind by address on other platforms.
Never commit meshircd.db, server.key, server.crt, or any file
containing cloak.secret or an oper hash. The shipped .gitignore
covers the defaults.
The database is created 0600. Passwords are salted and hashed with
scrypt (N=2¹⁴). Account lookups pay the same cost whether or not the
account exists, so timing does not reveal which names are valid — there
is a test for that. Credential lines are redacted before logging.
Defaults are chosen to be safe rather than convenient: loopback-only listeners, cloaking on, no operators, no server password, SASL over TLS only. A fresh install is useless to the internet until someone decides otherwise in writing.
This server has not been through an external audit or a public deployment at scale. If you need a battle-tested network daemon, use Ergo, Solanum, or InspIRCd. If you want something small enough to read end to end and run on a tailnet for your friends, that is what this is for.
Please report security issues by describing the class of problem rather than posting a working exploit.
Two rules shape everything:
- Standard library only. This applies to the test suite too. If a dependency seems necessary, open an issue explaining what problem it solves and what implementing it by hand would involve.
- Don't hide the networking. This code exists to be read. Prefer an explicit implementation over a clever one, and explain protocol decisions in comments where the reasoning is not obvious from the code.
Run the suite before opening a pull request, and add a case for anything new:
python3 -m unittest discover -s tests -p "test_*.py"MIT.
