Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
- **Access control** — define hostmask-based roles with `.with_role("admin", ["*!*@trusted.host"])` and gate commands with `#[command("op", role = "admin")]`; unauthorized senders are silently ignored.
- **Message accessors** — `ctx.nick()`, `ctx.is_from_self()`, `ctx.mentions_me()` to inspect who sent a message and what it says.
- **Keepalive & auto-reconnect** — periodic `PING`/`PONG` monitoring; reconnects and re-joins on drop. If the configured nick is already in use, the bot automatically retries with a suffixed alternative (`bot`, `bot_`, …).
- **Authentication** — SASL `PLAIN` and `EXTERNAL` (CertFP) during registration, a `PASS` server password, and IRCv3 capability negotiation. A rejected login fails the connection instead of continuing unauthenticated.
- **TLS** (optional) — `Server::tls("irc.libera.chat:6697")` behind the `tls` feature, with certificate verification against the platform root store, private-CA and self-signed support, and client certificates for CertFP.
- **Hot reload** (Unix) — `SIGHUP` execs the new binary with the live TCP socket inherited; no reconnect, no missed messages.
- **Flood protection** — token-bucket rate limiter (default: burst 4, 1 msg / 500 ms).
Expand All @@ -80,6 +81,49 @@ tokio = { version = "1", features = ["full"] }

See the [`basic_bot` example](ircbot/examples/basic_bot.rs) and the [docs](https://docs.rs/ircbot) for the complete API, hot-reload guide, testing helpers, and lower-level `State` / `internal` APIs.

## Authentication

Most networks want a bot to identify itself. The credentials live on `Server`,
because the handshake needs them before the bot exists:

```rust,ignore
use ircbot::Server;

MyBot::new(
"mybot",
Server::tls("irc.libera.chat:6697").with_sasl_plain("mybot", &password),
["rust"],
)
```

The bot authenticates during registration, before it joins a channel. It is
therefore already identified when it arrives, which is what `+r` channels
require and what earns the account its cloak.

Three methods are available:

- `with_sasl_plain(account, password)` — an account name and a password. The
password travels in a reversible encoding, so use it only over TLS.
- `with_sasl_external()` — the server reads the account from the TLS client
certificate (CertFP), and no password is sent. Set the certificate with
`Server::tls(..).with_client_cert_pem(..)`, and register its fingerprint with
the network first.
- `with_password(password)` — a server password (`PASS`). This authenticates to
the server itself, not to its services.

**A failed SASL exchange fails the connection.** If the network offers no SASL,
or rejects the credentials, `connect` returns an error. The bot does not
continue unauthenticated: a bot that loses its identity without saying so is the
fault SASL exists to prevent.

`with_capabilities` asks for further IRCv3 capabilities, for example
`server-time` or `multi-prefix`. The framework requests the ones the server
advertises and skips the rest. It does not interpret them itself — what they
change reaches handlers on the raw message.

A bot with no credentials sends the same bare `NICK`/`USER` handshake as before,
and no `CAP` line at all.

## TLS

TLS is behind the optional `tls` feature, which pulls in
Expand Down Expand Up @@ -114,8 +158,9 @@ Server::tls("irc.internal.example:6697")
.with_extra_root_pem(std::fs::read("ca.pem")?)
```

`with_client_cert_pem` presents a client certificate for CertFP / SASL EXTERNAL,
and `with_sni` overrides the verified hostname when connecting by IP.
`with_client_cert_pem` presents a client certificate for CertFP, which
`with_sasl_external` then authenticates with. `with_sni` overrides the verified
hostname when connecting by IP.
`danger_accept_invalid_certs` disables verification entirely — it is meant for a
development server on `localhost`, leaves the connection unauthenticated, and
logs a warning on every connect.
Expand Down
16 changes: 11 additions & 5 deletions ircbot-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,19 +448,25 @@ pub fn bot(attr: TokenStream, item: TokenStream) -> TokenStream {
///
/// On Unix, if this process was started by `exec_reload` the live
/// TCP connection is inherited from the parent binary and no new
/// connection is made. The `nick`, `server`, and `channels`
/// arguments are used only when no inherited connection is present.
/// A TLS connection is never inherited, so a reloaded TLS bot always
/// reconnects using the `server` given here.
/// connection is made. The `nick` and `channels` arguments are then
/// taken from the inherited session rather than from here, but
/// `server` is still kept: a later reconnect re-runs the handshake
/// and needs its transport and credentials. A TLS connection is
/// never inherited, so a reloaded TLS bot always reconnects using
/// the `server` given here.
pub async fn new(
nick: impl Into<String>,
server: impl Into<ircbot::Server>,
channels: impl IntoIterator<Item = impl Into<String>>,
) -> std::result::Result<Self, Box<dyn std::error::Error + Send + Sync>> {
// On Unix, check for an inherited fd from a hot-reload exec.
#[cfg(unix)]
if let Some(state) = ircbot::State::try_inherit_from_env()? {
if let Some(mut state) = ircbot::State::try_inherit_from_env()? {
eprintln!("[ircbot] hot-reload: resumed on inherited connection");
// The inherited socket is already registered, but a later
// reconnect is not: it re-runs the handshake and needs the
// credentials, which only the caller has.
state.server = server.into();
return Ok(#struct_name { __state: Some(state) #state_field_init });
}

Expand Down
3 changes: 3 additions & 0 deletions ircbot/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ tokio-rustls = { version = "0.26", default-features = false, features = [
rustls-native-certs = { version = "0.8", optional = true }

[dev-dependencies]
# `test-util` lets the SASL tests run the registration timeout on a paused
# clock instead of waiting REGISTRATION_TIMEOUT in real time.
tokio = { version = "1", features = ["full", "test-util"] }
testcontainers = "0.28"
irc = { version = "1", default-features = false }
trybuild = "1.0"
Expand Down
49 changes: 47 additions & 2 deletions ircbot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
- **Access control** — define hostmask-based roles with `.with_role("admin", ["*!*@trusted.host"])` and gate commands with `#[command("op", role = "admin")]`; unauthorized senders are silently ignored.
- **Message accessors** — `ctx.nick()`, `ctx.is_from_self()`, `ctx.mentions_me()` to inspect who sent a message and what it says.
- **Keepalive & auto-reconnect** — periodic `PING`/`PONG` monitoring; reconnects and re-joins on drop. If the configured nick is already in use, the bot automatically retries with a suffixed alternative (`bot`, `bot_`, …).
- **Authentication** — SASL `PLAIN` and `EXTERNAL` (CertFP) during registration, a `PASS` server password, and IRCv3 capability negotiation. A rejected login fails the connection instead of continuing unauthenticated.
- **TLS** (optional) — `Server::tls("irc.libera.chat:6697")` behind the `tls` feature, with certificate verification against the platform root store, private-CA and self-signed support, and client certificates for CertFP.
- **Hot reload** (Unix) — `SIGHUP` execs the new binary with the live TCP socket inherited; no reconnect, no missed messages.
- **Flood protection** — token-bucket rate limiter (default: burst 4, 1 msg / 500 ms).
Expand All @@ -80,6 +81,49 @@ tokio = { version = "1", features = ["full"] }

See the [`basic_bot` example](ircbot/examples/basic_bot.rs) and the [docs](https://docs.rs/ircbot) for the complete API, hot-reload guide, testing helpers, and lower-level `State` / `internal` APIs.

## Authentication

Most networks want a bot to identify itself. The credentials live on `Server`,
because the handshake needs them before the bot exists:

```rust,ignore
use ircbot::Server;

MyBot::new(
"mybot",
Server::tls("irc.libera.chat:6697").with_sasl_plain("mybot", &password),
["rust"],
)
```

The bot authenticates during registration, before it joins a channel. It is
therefore already identified when it arrives, which is what `+r` channels
require and what earns the account its cloak.

Three methods are available:

- `with_sasl_plain(account, password)` — an account name and a password. The
password travels in a reversible encoding, so use it only over TLS.
- `with_sasl_external()` — the server reads the account from the TLS client
certificate (CertFP), and no password is sent. Set the certificate with
`Server::tls(..).with_client_cert_pem(..)`, and register its fingerprint with
the network first.
- `with_password(password)` — a server password (`PASS`). This authenticates to
the server itself, not to its services.

**A failed SASL exchange fails the connection.** If the network offers no SASL,
or rejects the credentials, `connect` returns an error. The bot does not
continue unauthenticated: a bot that loses its identity without saying so is the
fault SASL exists to prevent.

`with_capabilities` asks for further IRCv3 capabilities, for example
`server-time` or `multi-prefix`. The framework requests the ones the server
advertises and skips the rest. It does not interpret them itself — what they
change reaches handlers on the raw message.

A bot with no credentials sends the same bare `NICK`/`USER` handshake as before,
and no `CAP` line at all.

## TLS

TLS is behind the optional `tls` feature, which pulls in
Expand Down Expand Up @@ -114,8 +158,9 @@ Server::tls("irc.internal.example:6697")
.with_extra_root_pem(std::fs::read("ca.pem")?)
```

`with_client_cert_pem` presents a client certificate for CertFP / SASL EXTERNAL,
and `with_sni` overrides the verified hostname when connecting by IP.
`with_client_cert_pem` presents a client certificate for CertFP, which
`with_sasl_external` then authenticates with. `with_sni` overrides the verified
hostname when connecting by IP.
`danger_accept_invalid_certs` disables verification entirely — it is meant for a
development server on `localhost`, leaves the connection unauthenticated, and
logs a warning on every connect.
Expand Down
19 changes: 16 additions & 3 deletions ircbot/examples/tls_bot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,26 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error + Send + Sy
// Server::tls("irc.internal.example:6697")
// .with_extra_root_pem(std::fs::read("ca.pem")?)
//
// A client certificate authenticates the bot via CertFP / SASL EXTERNAL:
// When connecting to an IP address whose certificate names a hostname,
// `with_sni("irc.example.net")` sets the name to verify against.
//
// Authentication happens during registration, so the credentials go on the
// server. SASL EXTERNAL proves the bot's identity with a client
// certificate, and sends no password at all — register the certificate's
// fingerprint with the network first:
//
// Server::tls("irc.libera.chat:6697")
// .with_client_cert_pem(std::fs::read("bot.pem")?)
// .with_sasl_external()
//
// When connecting to an IP address whose certificate names a hostname,
// `with_sni("irc.example.net")` sets the name to verify against.
// SASL PLAIN uses an account name and a password instead. Read it from the
// environment rather than writing it into the source:
//
// Server::tls("irc.libera.chat:6697")
// .with_sasl_plain("mybot", std::env::var("IRC_PASSWORD")?)
//
// Either way, a network that refuses the login fails the connection rather
// than letting the bot arrive unauthenticated.

println!("tls_bot example compiled successfully.");
println!("Connecting for real is two lines:");
Expand Down
Loading
Loading