Public Access
IRC: join after NickServ login, keyed auto-join, SASL fallback
- With NickServ, auto-join waits for the logged-in reply (900) or 2 s
at most, so registered-only IRC channels let us in
- A failed SASL login falls back to NickServ (its own password, or the
SASL account and password)
- IDENTIFY names the account explicitly, and once logged in on a
fallback nick, REGAIN takes ours back from a stale session
- Auto-join entries take keys ("#private key, #public"); keys from
/join are reused when rejoining; keyed channels go first in JOIN
- Serial irc dump: whole Buffers, and which login is configured
(never the secrets)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
+18
-13
@@ -36,7 +36,8 @@ std::string IrcConfig::validate() const {
|
|||||||
if (port < 1 || port > 65535) return "Port must be 1 to 65535";
|
if (port < 1 || port > 65535) return "Port must be 1 to 65535";
|
||||||
if (!validNick(nick)) return "Nick: letters, digits and []\\`_^{|}- only, not starting with a digit";
|
if (!validNick(nick)) return "Nick: letters, digits and []\\`_^{|}- only, not starting with a digit";
|
||||||
for (auto& c : autojoin)
|
for (auto& c : autojoin)
|
||||||
if (c.size() < 2 || (c[0] != '#' && c[0] != '&')) return "IRC channels start with # or &";
|
if (c.channel.size() < 2 || (c.channel[0] != '#' && c.channel[0] != '&'))
|
||||||
|
return "Auto-join: IRC channels start with # or &, each followed by its key if it has one";
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,23 +57,27 @@ std::string IrcConfig::save() {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<std::string> IrcConfig::parseChannels(const std::string& text) {
|
std::vector<IrcChannel> IrcConfig::parseChannels(const std::string& text) {
|
||||||
std::vector<std::string> out;
|
std::vector<IrcChannel> out;
|
||||||
std::string current;
|
std::string word;
|
||||||
for (char c : text + " ") {
|
auto take = [&]() {
|
||||||
if (c == ' ' || c == ',') {
|
if (word.empty()) return;
|
||||||
if (!current.empty()) out.push_back(current);
|
bool channel = word[0] == '#' || word[0] == '&';
|
||||||
current.clear();
|
if (!channel && !out.empty() && out.back().key.empty()) out.back().key = word;
|
||||||
} else {
|
else out.push_back({word, ""}); // a stray word fails validation as a channel
|
||||||
current += c;
|
word.clear();
|
||||||
}
|
};
|
||||||
|
for (char c : text) {
|
||||||
|
if (c == ' ' || c == ',') take();
|
||||||
|
else word += c;
|
||||||
}
|
}
|
||||||
|
take();
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string IrcConfig::formatChannels(const std::vector<std::string>& channels) {
|
std::string IrcConfig::formatChannels(const std::vector<IrcChannel>& channels) {
|
||||||
std::string out;
|
std::string out;
|
||||||
for (auto& c : channels) out += (out.empty() ? "" : " ") + c;
|
for (auto& c : channels) out += (out.empty() ? "" : ", ") + c.channel + (c.key.empty() ? "" : " " + c.key);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
|
|
||||||
namespace roro {
|
namespace roro {
|
||||||
|
|
||||||
|
struct IrcChannel {
|
||||||
|
std::string channel; // "#roro"
|
||||||
|
std::string key; // empty when the IRC channel has none
|
||||||
|
};
|
||||||
|
|
||||||
// The one IRC server the IRC Service connects to, persisted in internal flash.
|
// The one IRC server the IRC Service connects to, persisted in internal flash.
|
||||||
class IrcConfig {
|
class IrcConfig {
|
||||||
public:
|
public:
|
||||||
@@ -20,7 +25,7 @@ class IrcConfig {
|
|||||||
std::string nick;
|
std::string nick;
|
||||||
std::string saslUser, saslPassword; // SASL PLAIN when both are set
|
std::string saslUser, saslPassword; // SASL PLAIN when both are set
|
||||||
std::string nickservPassword; // otherwise IDENTIFY with NickServ, if set
|
std::string nickservPassword; // otherwise IDENTIFY with NickServ, if set
|
||||||
std::vector<std::string> autojoin;
|
std::vector<IrcChannel> autojoin;
|
||||||
|
|
||||||
// A copy is a draft the UI can edit freely; copySettingsFrom() applies one.
|
// A copy is a draft the UI can edit freely; copySettingsFrom() applies one.
|
||||||
IrcConfig(const IrcConfig&) = default;
|
IrcConfig(const IrcConfig&) = default;
|
||||||
@@ -41,8 +46,10 @@ class IrcConfig {
|
|||||||
std::string save(); // empty on success, otherwise why it was refused
|
std::string save(); // empty on success, otherwise why it was refused
|
||||||
std::string validate() const;
|
std::string validate() const;
|
||||||
|
|
||||||
static std::vector<std::string> parseChannels(const std::string& text);
|
// "#private key, #public": a word starting with # or & is an IRC channel, the word after it
|
||||||
static std::string formatChannels(const std::vector<std::string>& channels);
|
// (if it doesn't) its key. Spaces or commas separate entries, so "#a #b" also reads.
|
||||||
|
static std::vector<IrcChannel> parseChannels(const std::string& text);
|
||||||
|
static std::string formatChannels(const std::vector<IrcChannel>& channels);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
KeyValueStore& store_;
|
KeyValueStore& store_;
|
||||||
|
|||||||
+59
-12
@@ -37,6 +37,13 @@ std::pair<std::string, std::string> firstWord(const std::string& text) {
|
|||||||
|
|
||||||
IrcSession::IrcSession(const IrcConfig& config) : config_(config), nick_(config.nick) {
|
IrcSession::IrcSession(const IrcConfig& config) : config_(config), nick_(config.nick) {
|
||||||
buffers_.push_back({config.host, IrcBuffer::Type::Server, {}, 0, false, true, ""});
|
buffers_.push_back({config.host, IrcBuffer::Type::Server, {}, 0, false, true, ""});
|
||||||
|
for (auto& c : config.autojoin)
|
||||||
|
if (!c.key.empty()) keys_[lower(c.channel)] = c.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
void IrcSession::tick(uint32_t nowMs) {
|
||||||
|
nowMs_ = nowMs;
|
||||||
|
if (joinsHeld_ && nowMs - heldSinceMs_ >= kNickservWaitMs) joinChannels();
|
||||||
}
|
}
|
||||||
|
|
||||||
int IrcSession::totalUnread() const {
|
int IrcSession::totalUnread() const {
|
||||||
@@ -108,6 +115,8 @@ void IrcSession::add(int b, IrcLine::Kind kind, const std::string& nick, const s
|
|||||||
|
|
||||||
void IrcSession::connected(int64_t) {
|
void IrcSession::connected(int64_t) {
|
||||||
registered_ = false;
|
registered_ = false;
|
||||||
|
joinsHeld_ = false;
|
||||||
|
saslFailed_ = false;
|
||||||
quit_ = false;
|
quit_ = false;
|
||||||
nick_ = config_.nick;
|
nick_ = config_.nick;
|
||||||
if (!config_.saslUser.empty() && !config_.saslPassword.empty()) send("CAP REQ :sasl");
|
if (!config_.saslUser.empty() && !config_.saslPassword.empty()) send("CAP REQ :sasl");
|
||||||
@@ -130,18 +139,49 @@ void IrcSession::onWelcome(const IrcMessage& m, int64_t utc) {
|
|||||||
registered_ = true;
|
registered_ = true;
|
||||||
nick_ = m.param(0);
|
nick_ = m.param(0);
|
||||||
info(0, "Connected to " + config_.host + " as " + nick_, utc);
|
info(0, "Connected to " + config_.host + " as " + nick_, utc);
|
||||||
if (!config_.nickservPassword.empty() && config_.saslUser.empty())
|
// NickServ when it's the configured login, or as the fallback when SASL failed.
|
||||||
send(IrcMessage::serialize("PRIVMSG", {"NickServ", "IDENTIFY " + config_.nickservPassword}));
|
// The account is named explicitly: we may be on a fallback nick if a stale session holds ours.
|
||||||
|
bool sasl = !config_.saslUser.empty() && !config_.saslPassword.empty();
|
||||||
std::vector<std::string> channels = config_.autojoin;
|
std::string account = sasl ? config_.saslUser : config_.nick;
|
||||||
for (auto& c : rejoin_)
|
std::string identify;
|
||||||
if (std::find(channels.begin(), channels.end(), c) == channels.end()) channels.push_back(c);
|
if (!config_.nickservPassword.empty() && (!sasl || saslFailed_)) identify = account + " " + config_.nickservPassword;
|
||||||
rejoin_.clear();
|
else if (sasl && saslFailed_) identify = account + " " + config_.saslPassword;
|
||||||
if (!channels.empty()) {
|
if (!identify.empty()) {
|
||||||
std::string list;
|
// IRC channels for registered users only would refuse us until NickServ has logged us in.
|
||||||
for (auto& c : channels) list += (list.empty() ? "" : ",") + c;
|
send(IrcMessage::serialize("PRIVMSG", {"NickServ", "IDENTIFY " + identify}));
|
||||||
send(IrcMessage::serialize("JOIN", {list}));
|
joinsHeld_ = true;
|
||||||
|
heldSinceMs_ = nowMs_;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
joinChannels();
|
||||||
|
}
|
||||||
|
|
||||||
|
void IrcSession::joinChannels() {
|
||||||
|
joinsHeld_ = false;
|
||||||
|
std::vector<std::string> channels;
|
||||||
|
auto addOnce = [&](const std::string& c) {
|
||||||
|
for (auto& existing : channels)
|
||||||
|
if (lower(existing) == lower(c)) return;
|
||||||
|
channels.push_back(c);
|
||||||
|
};
|
||||||
|
for (auto& c : config_.autojoin) addOnce(c.channel);
|
||||||
|
for (auto& c : rejoin_) addOnce(c);
|
||||||
|
rejoin_.clear();
|
||||||
|
if (channels.empty()) return;
|
||||||
|
|
||||||
|
// JOIN #keyed,#open key: IRC pairs keys with the first channels listed.
|
||||||
|
std::string keyed, open, keys;
|
||||||
|
for (auto& c : channels) {
|
||||||
|
auto k = keys_.find(lower(c));
|
||||||
|
if (k != keys_.end()) {
|
||||||
|
keyed += (keyed.empty() ? "" : ",") + c;
|
||||||
|
keys += (keys.empty() ? "" : ",") + k->second;
|
||||||
|
} else {
|
||||||
|
open += (open.empty() ? "" : ",") + c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::string list = keyed + (!keyed.empty() && !open.empty() ? "," : "") + open;
|
||||||
|
send(keys.empty() ? IrcMessage::serialize("JOIN", {list}) : IrcMessage::serialize("JOIN", {list, keys}));
|
||||||
}
|
}
|
||||||
|
|
||||||
void IrcSession::receive(const std::string& raw, int64_t utc) {
|
void IrcSession::receive(const std::string& raw, int64_t utc) {
|
||||||
@@ -163,11 +203,17 @@ void IrcSession::receive(const std::string& raw, int64_t utc) {
|
|||||||
} else if (cmd == "AUTHENTICATE" && m.param(0) == "+") {
|
} else if (cmd == "AUTHENTICATE" && m.param(0) == "+") {
|
||||||
std::string user = config_.saslUser;
|
std::string user = config_.saslUser;
|
||||||
send("AUTHENTICATE " + base64Encode(user + '\0' + user + '\0' + config_.saslPassword));
|
send("AUTHENTICATE " + base64Encode(user + '\0' + user + '\0' + config_.saslPassword));
|
||||||
|
} else if (cmd == "900") {
|
||||||
|
// Logged in. Take our nick back from a stale session, then join.
|
||||||
|
if (lower(nick_) != lower(config_.nick))
|
||||||
|
send(IrcMessage::serialize("PRIVMSG", {"NickServ", "REGAIN " + config_.nick}));
|
||||||
|
if (joinsHeld_) joinChannels();
|
||||||
} else if (cmd == "903") {
|
} else if (cmd == "903") {
|
||||||
info(0, "SASL login succeeded", utc);
|
info(0, "SASL login succeeded", utc);
|
||||||
send("CAP END");
|
send("CAP END");
|
||||||
} else if (cmd == "904" || cmd == "905" || cmd == "906" || cmd == "902") {
|
} else if (cmd == "904" || cmd == "905" || cmd == "906" || cmd == "902") {
|
||||||
info(0, "SASL login failed: " + m.param(m.params.size() - 1), utc);
|
info(0, "SASL login failed: " + m.param(m.params.size() - 1) + " (trying NickServ instead)", utc);
|
||||||
|
saslFailed_ = true;
|
||||||
send("CAP END");
|
send("CAP END");
|
||||||
} else if (cmd == "PRIVMSG" || cmd == "NOTICE") {
|
} else if (cmd == "PRIVMSG" || cmd == "NOTICE") {
|
||||||
onPrivmsg(m, utc, cmd == "NOTICE");
|
onPrivmsg(m, utc, cmd == "NOTICE");
|
||||||
@@ -284,6 +330,7 @@ void IrcSession::command(int b, const std::string& text, int64_t utc) {
|
|||||||
auto [channel, key] = firstWord(rest);
|
auto [channel, key] = firstWord(rest);
|
||||||
if (channel.empty()) return info(b, "Usage: /join #channel", utc);
|
if (channel.empty()) return info(b, "Usage: /join #channel", utc);
|
||||||
if (!isChannel(channel)) channel = "#" + channel;
|
if (!isChannel(channel)) channel = "#" + channel;
|
||||||
|
if (!key.empty()) keys_[lower(channel)] = key; // reused when rejoining
|
||||||
send(key.empty() ? IrcMessage::serialize("JOIN", {channel}) : IrcMessage::serialize("JOIN", {channel, key}));
|
send(key.empty() ? IrcMessage::serialize("JOIN", {channel}) : IrcMessage::serialize("JOIN", {channel, key}));
|
||||||
} else if (verb == "part") {
|
} else if (verb == "part") {
|
||||||
std::string channel = inChannel ? buf.name : "";
|
std::string channel = inChannel ? buf.name : "";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
|
#include <map>
|
||||||
#include <set>
|
#include <set>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -53,6 +54,12 @@ class IrcSession {
|
|||||||
|
|
||||||
explicit IrcSession(const IrcConfig& config);
|
explicit IrcSession(const IrcConfig& config);
|
||||||
|
|
||||||
|
// Joins wait this long for NickServ to confirm a login before going ahead anyway.
|
||||||
|
static constexpr uint32_t kNickservWaitMs = 2000;
|
||||||
|
|
||||||
|
// Uptime in ms, called regularly: releases joins held back for NickServ.
|
||||||
|
void tick(uint32_t nowMs);
|
||||||
|
|
||||||
void connected(int64_t utc);
|
void connected(int64_t utc);
|
||||||
void disconnected(int64_t utc, const std::string& reason);
|
void disconnected(int64_t utc, const std::string& reason);
|
||||||
void receive(const std::string& raw, int64_t utc);
|
void receive(const std::string& raw, int64_t utc);
|
||||||
@@ -83,6 +90,7 @@ class IrcSession {
|
|||||||
bool mentionsMe(const std::string& text) const;
|
bool mentionsMe(const std::string& text) const;
|
||||||
void onPrivmsg(const IrcMessage& m, int64_t utc, bool notice);
|
void onPrivmsg(const IrcMessage& m, int64_t utc, bool notice);
|
||||||
void onWelcome(const IrcMessage& m, int64_t utc);
|
void onWelcome(const IrcMessage& m, int64_t utc);
|
||||||
|
void joinChannels();
|
||||||
void command(int buffer, const std::string& text, int64_t utc);
|
void command(int buffer, const std::string& text, int64_t utc);
|
||||||
void say(int buffer, const std::string& text, int64_t utc, bool action);
|
void say(int buffer, const std::string& text, int64_t utc, bool action);
|
||||||
|
|
||||||
@@ -94,6 +102,11 @@ class IrcSession {
|
|||||||
int viewing_ = -1;
|
int viewing_ = -1;
|
||||||
std::set<std::string> namesRequested_;
|
std::set<std::string> namesRequested_;
|
||||||
std::vector<std::string> rejoin_; // IRC channels to join again after a reconnect
|
std::vector<std::string> rejoin_; // IRC channels to join again after a reconnect
|
||||||
|
std::map<std::string, std::string> keys_; // lower-case IRC channel -> key, from config and /join
|
||||||
|
bool joinsHeld_ = false; // waiting for NickServ before joining
|
||||||
|
bool saslFailed_ = false;
|
||||||
|
uint32_t heldSinceMs_ = 0;
|
||||||
|
uint32_t nowMs_ = 0;
|
||||||
IrcEffects effects_;
|
IrcEffects effects_;
|
||||||
uint32_t revision_ = 0;
|
uint32_t revision_ = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ void IrcApp::drawSettings(Canvas& c) {
|
|||||||
c.setTextColor(theme::kMuted);
|
c.setTextColor(theme::kMuted);
|
||||||
c.drawString(fieldLabel(fields_.selected()).c_str(), 4, area.y + 4);
|
c.drawString(fieldLabel(fields_.selected()).c_str(), 4, area.y + 4);
|
||||||
widgets::lineEditor(c, fieldEditor_, {4, area.y + 22, area.w - 8, 0});
|
widgets::lineEditor(c, fieldEditor_, {4, area.y + 22, area.w - 8, 0});
|
||||||
c.drawString(fields_.selected() == kAutojoin ? "e.g. #roro #meshtastic" : "Enter: OK `: cancel", 4,
|
c.drawString(fields_.selected() == kAutojoin ? "e.g. #roro, #private key" : "Enter: OK `: cancel", 4,
|
||||||
area.y + 44);
|
area.y + 44);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -193,11 +193,15 @@ static void serialCommands() {
|
|||||||
if (line == "irc dump") {
|
if (line == "irc dump") {
|
||||||
Serial.printf("irc: status %d, unread %d, heap %u min %u\n", (int)irc->status(), irc->totalUnread(),
|
Serial.printf("irc: status %d, unread %d, heap %u min %u\n", (int)irc->status(), irc->totalUnread(),
|
||||||
ESP.getFreeHeap(), ESP.getMinFreeHeap());
|
ESP.getFreeHeap(), ESP.getMinFreeHeap());
|
||||||
|
IrcConfig c = irc->draftConfig(); // which login is configured, never the secrets
|
||||||
|
Serial.printf("irc: nick %s, sasl %s, nickserv %s, autojoin %u channels\n", c.nick.c_str(),
|
||||||
|
c.saslUser.empty() || c.saslPassword.empty() ? "off" : "on",
|
||||||
|
c.nickservPassword.empty() ? "off" : "on", (unsigned)c.autojoin.size());
|
||||||
irc->withSession([](IrcSession& s) {
|
irc->withSession([](IrcSession& s) {
|
||||||
for (int i = 0; i < s.bufferCount(); i++) {
|
for (int i = 0; i < s.bufferCount(); i++) {
|
||||||
const auto& b = s.buffer(i);
|
const auto& b = s.buffer(i);
|
||||||
Serial.printf("irc buffer %d %s unread %d%s\n", i, b.name.c_str(), b.unread, b.joined ? " joined" : "");
|
Serial.printf("irc buffer %d %s unread %d%s\n", i, b.name.c_str(), b.unread, b.joined ? " joined" : "");
|
||||||
size_t from = b.lines.size() > 6 ? b.lines.size() - 6 : 0;
|
size_t from = 0;
|
||||||
for (size_t j = from; j < b.lines.size(); j++)
|
for (size_t j = from; j < b.lines.size(); j++)
|
||||||
Serial.printf(" <%s> %s\n", b.lines[j].nick.c_str(), b.lines[j].text.c_str());
|
Serial.printf(" <%s> %s\n", b.lines[j].nick.c_str(), b.lines[j].text.c_str());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,10 @@ void IrcService::loop() {
|
|||||||
pingSent_ = true;
|
pingSent_ = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
Lock l(lock_);
|
||||||
|
session_->tick(millis());
|
||||||
|
}
|
||||||
flushEffects();
|
flushEffects();
|
||||||
vTaskDelay(pdMS_TO_TICKS(open_ ? 30 : 250));
|
vTaskDelay(pdMS_TO_TICKS(open_ ? 30 : 250));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ void test_save_and_reload() {
|
|||||||
c.nick = "clement";
|
c.nick = "clement";
|
||||||
c.saslUser = "clement";
|
c.saslUser = "clement";
|
||||||
c.saslPassword = "s3cret";
|
c.saslPassword = "s3cret";
|
||||||
c.autojoin = {"#roro", "#meshtastic"};
|
c.autojoin = {{"#roro", ""}, {"#meshtastic", "k3y"}};
|
||||||
TEST_ASSERT_TRUE(c.save().empty());
|
TEST_ASSERT_TRUE(c.save().empty());
|
||||||
}
|
}
|
||||||
IrcConfig again(store);
|
IrcConfig again(store);
|
||||||
@@ -36,7 +36,8 @@ void test_save_and_reload() {
|
|||||||
TEST_ASSERT_EQUAL_STRING("clement", again.nick.c_str());
|
TEST_ASSERT_EQUAL_STRING("clement", again.nick.c_str());
|
||||||
TEST_ASSERT_EQUAL_STRING("s3cret", again.saslPassword.c_str());
|
TEST_ASSERT_EQUAL_STRING("s3cret", again.saslPassword.c_str());
|
||||||
TEST_ASSERT_EQUAL(2, again.autojoin.size());
|
TEST_ASSERT_EQUAL(2, again.autojoin.size());
|
||||||
TEST_ASSERT_EQUAL_STRING("#meshtastic", again.autojoin[1].c_str());
|
TEST_ASSERT_EQUAL_STRING("#meshtastic", again.autojoin[1].channel.c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("k3y", again.autojoin[1].key.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
void test_invalid_values_are_refused_with_a_reason() {
|
void test_invalid_values_are_refused_with_a_reason() {
|
||||||
@@ -54,14 +55,32 @@ void test_invalid_values_are_refused_with_a_reason() {
|
|||||||
c.host = "";
|
c.host = "";
|
||||||
TEST_ASSERT_FALSE(c.save().empty());
|
TEST_ASSERT_FALSE(c.save().empty());
|
||||||
c.host = "irc.libera.chat";
|
c.host = "irc.libera.chat";
|
||||||
c.autojoin = {"roro"}; // IRC channels start with # or &
|
c.autojoin = {{"roro", ""}}; // IRC channels start with # or &
|
||||||
TEST_ASSERT_FALSE(c.save().empty());
|
TEST_ASSERT_FALSE(c.save().empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
void test_autojoin_text_round_trip() {
|
void test_autojoin_text_round_trip() {
|
||||||
TEST_ASSERT_EQUAL(2, IrcConfig::parseChannels("#a, #b").size());
|
TEST_ASSERT_EQUAL(2, IrcConfig::parseChannels("#a, #b").size());
|
||||||
TEST_ASSERT_EQUAL(2, IrcConfig::parseChannels("#a #b").size());
|
TEST_ASSERT_EQUAL(2, IrcConfig::parseChannels("#a #b").size()); // the older, space-separated form
|
||||||
TEST_ASSERT_EQUAL_STRING("#a #b", IrcConfig::formatChannels({"#a", "#b"}).c_str());
|
TEST_ASSERT_EQUAL_STRING("#a, #b", IrcConfig::formatChannels({{"#a", ""}, {"#b", ""}}).c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_autojoin_entries_can_carry_a_key() {
|
||||||
|
auto list = IrcConfig::parseChannels("#private s3cret, #public &local");
|
||||||
|
TEST_ASSERT_EQUAL(3, list.size());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("#private", list[0].channel.c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("s3cret", list[0].key.c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", list[1].key.c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("&local", list[2].channel.c_str());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("#private s3cret, #public, &local", IrcConfig::formatChannels(list).c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_a_key_without_a_channel_is_refused() {
|
||||||
|
MemoryStore store;
|
||||||
|
IrcConfig c(store);
|
||||||
|
c.load("x");
|
||||||
|
c.autojoin = IrcConfig::parseChannels("orphan #a");
|
||||||
|
TEST_ASSERT_FALSE(c.validate().empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
void test_copy_settings_from_a_draft() {
|
void test_copy_settings_from_a_draft() {
|
||||||
@@ -70,7 +89,7 @@ void test_copy_settings_from_a_draft() {
|
|||||||
live.load("x");
|
live.load("x");
|
||||||
IrcConfig draft = live;
|
IrcConfig draft = live;
|
||||||
draft.host = "irc.example.org";
|
draft.host = "irc.example.org";
|
||||||
draft.autojoin = {"#a"};
|
draft.autojoin = {{"#a", ""}};
|
||||||
TEST_ASSERT_EQUAL_STRING("irc.libera.chat", live.host.c_str()); // the draft is independent
|
TEST_ASSERT_EQUAL_STRING("irc.libera.chat", live.host.c_str()); // the draft is independent
|
||||||
live.copySettingsFrom(draft);
|
live.copySettingsFrom(draft);
|
||||||
TEST_ASSERT_EQUAL_STRING("irc.example.org", live.host.c_str());
|
TEST_ASSERT_EQUAL_STRING("irc.example.org", live.host.c_str());
|
||||||
@@ -84,5 +103,7 @@ int main() {
|
|||||||
RUN_TEST(test_invalid_values_are_refused_with_a_reason);
|
RUN_TEST(test_invalid_values_are_refused_with_a_reason);
|
||||||
RUN_TEST(test_autojoin_text_round_trip);
|
RUN_TEST(test_autojoin_text_round_trip);
|
||||||
RUN_TEST(test_copy_settings_from_a_draft);
|
RUN_TEST(test_copy_settings_from_a_draft);
|
||||||
|
RUN_TEST(test_autojoin_entries_can_carry_a_key);
|
||||||
|
RUN_TEST(test_a_key_without_a_channel_is_refused);
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ struct Fixture {
|
|||||||
explicit Fixture(bool sasl = false) {
|
explicit Fixture(bool sasl = false) {
|
||||||
config.load("roro");
|
config.load("roro");
|
||||||
config.nick = "roro";
|
config.nick = "roro";
|
||||||
config.autojoin = {"#roro"};
|
config.autojoin = {{"#roro", ""}};
|
||||||
if (sasl) {
|
if (sasl) {
|
||||||
config.saslUser = "acct";
|
config.saslUser = "acct";
|
||||||
config.saslPassword = "pw";
|
config.saslPassword = "pw";
|
||||||
@@ -28,6 +28,7 @@ struct Fixture {
|
|||||||
session.reset(new IrcSession(config));
|
session.reset(new IrcSession(config));
|
||||||
}
|
}
|
||||||
void recv(const std::string& line) { session->receive(line, 1000); }
|
void recv(const std::string& line) { session->receive(line, 1000); }
|
||||||
|
void tick(uint32_t ms) { session->tick(ms); }
|
||||||
IrcEffects& take() {
|
IrcEffects& take() {
|
||||||
fx = session->takeEffects();
|
fx = session->takeEffects();
|
||||||
return fx;
|
return fx;
|
||||||
@@ -65,15 +66,127 @@ void test_welcome_registers_and_joins_autojoin_channels() {
|
|||||||
TEST_ASSERT_TRUE(f.sent("JOIN #roro"));
|
TEST_ASSERT_TRUE(f.sent("JOIN #roro"));
|
||||||
}
|
}
|
||||||
|
|
||||||
void test_nickserv_identify_after_welcome() {
|
void test_nickserv_identify_after_welcome_and_joins_wait_for_it() {
|
||||||
Fixture f;
|
Fixture f;
|
||||||
f.config.nickservPassword = "pw";
|
f.config.nickservPassword = "pw";
|
||||||
f.session.reset(new IrcSession(f.config));
|
f.session.reset(new IrcSession(f.config));
|
||||||
f.registerNow();
|
f.session->connected(0);
|
||||||
|
f.tick(0);
|
||||||
|
f.recv(":srv 001 roro :Welcome");
|
||||||
|
f.take();
|
||||||
|
TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :IDENTIFY roro pw"));
|
||||||
|
TEST_ASSERT_FALSE(f.sent("JOIN #roro")); // not before NickServ has had its say
|
||||||
|
f.tick(1000);
|
||||||
|
TEST_ASSERT_TRUE(f.take().send.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_joins_follow_the_logged_in_reply() {
|
||||||
|
Fixture f;
|
||||||
|
f.config.nickservPassword = "pw";
|
||||||
|
f.session.reset(new IrcSession(f.config));
|
||||||
|
f.session->connected(0);
|
||||||
|
f.tick(0);
|
||||||
|
f.recv(":srv 001 roro :Welcome");
|
||||||
|
f.take();
|
||||||
|
f.tick(300);
|
||||||
|
f.recv(":srv 900 roro roro!u@h roro :You are now logged in as roro");
|
||||||
|
f.take();
|
||||||
|
TEST_ASSERT_TRUE(f.sent("JOIN #roro"));
|
||||||
|
f.tick(5000);
|
||||||
|
TEST_ASSERT_TRUE(f.take().send.empty()); // and only once
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_joins_go_ahead_after_two_seconds_without_a_reply() {
|
||||||
|
Fixture f;
|
||||||
|
f.config.nickservPassword = "pw";
|
||||||
|
f.session.reset(new IrcSession(f.config));
|
||||||
|
f.session->connected(0);
|
||||||
|
f.tick(10000);
|
||||||
|
f.recv(":srv 001 roro :Welcome");
|
||||||
|
f.take();
|
||||||
|
f.tick(11999);
|
||||||
|
TEST_ASSERT_TRUE(f.take().send.empty());
|
||||||
|
f.tick(12000);
|
||||||
|
f.take();
|
||||||
|
TEST_ASSERT_TRUE(f.sent("JOIN #roro"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_failed_sasl_falls_back_to_nickserv_and_holds_joins() {
|
||||||
|
Fixture f(true);
|
||||||
|
f.config.nickservPassword = "nspw";
|
||||||
|
f.session.reset(new IrcSession(f.config));
|
||||||
|
f.session->connected(0);
|
||||||
|
f.recv(":srv CAP * ACK :sasl");
|
||||||
|
f.recv("AUTHENTICATE +");
|
||||||
|
f.recv(":srv 904 roro :SASL authentication failed");
|
||||||
|
f.recv(":srv 001 roro :Welcome");
|
||||||
|
f.take();
|
||||||
|
TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :IDENTIFY acct nspw"));
|
||||||
|
TEST_ASSERT_FALSE(f.sent("JOIN #roro"));
|
||||||
|
f.recv(":srv 900 roro roro!u@h roro :You are now logged in as roro");
|
||||||
|
f.take();
|
||||||
|
TEST_ASSERT_TRUE(f.sent("JOIN #roro"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_failed_sasl_without_nickserv_password_identifies_with_the_sasl_account() {
|
||||||
|
Fixture f(true); // SASL user "acct", password "pw"
|
||||||
|
f.session->connected(0);
|
||||||
|
f.recv(":srv 904 roro :SASL authentication failed");
|
||||||
|
f.recv(":srv 001 roro :Welcome");
|
||||||
|
f.take();
|
||||||
|
TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :IDENTIFY acct pw"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_successful_sasl_joins_at_once() {
|
||||||
|
Fixture f(true);
|
||||||
|
f.config.nickservPassword = "nspw";
|
||||||
|
f.session.reset(new IrcSession(f.config));
|
||||||
|
f.session->connected(0);
|
||||||
|
f.recv(":srv 903 roro :SASL authentication successful");
|
||||||
|
f.recv(":srv 001 roro :Welcome");
|
||||||
|
f.take();
|
||||||
|
TEST_ASSERT_TRUE(f.sent("JOIN #roro"));
|
||||||
|
TEST_ASSERT_FALSE(f.sent("PRIVMSG NickServ :IDENTIFY acct nspw"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_identify_names_the_account_even_on_a_fallback_nick_then_regains_it() {
|
||||||
|
Fixture f;
|
||||||
|
f.config.nickservPassword = "pw";
|
||||||
|
f.session.reset(new IrcSession(f.config));
|
||||||
|
f.session->connected(0);
|
||||||
|
f.recv(":srv 433 * roro :Nickname is already in use"); // a stale session still holds it
|
||||||
|
f.recv(":srv 001 roro_ :Welcome");
|
||||||
|
f.take();
|
||||||
|
TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :IDENTIFY roro pw"));
|
||||||
|
f.recv(":srv 900 roro_ roro_!u@h roro :You are now logged in as roro");
|
||||||
|
f.take();
|
||||||
|
TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :REGAIN roro"));
|
||||||
|
TEST_ASSERT_TRUE(f.sent("JOIN #roro"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_autojoin_sends_keyed_channels_first_with_their_keys() {
|
||||||
|
Fixture f;
|
||||||
|
f.config.autojoin = IrcConfig::parseChannels("#public, #private s3cret");
|
||||||
|
f.session.reset(new IrcSession(f.config));
|
||||||
f.session->connected(0);
|
f.session->connected(0);
|
||||||
f.recv(":srv 001 roro :Welcome");
|
f.recv(":srv 001 roro :Welcome");
|
||||||
f.take();
|
f.take();
|
||||||
TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :IDENTIFY pw"));
|
TEST_ASSERT_TRUE(f.sent("JOIN #private,#public s3cret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_keys_used_with_join_are_reused_on_rejoin() {
|
||||||
|
Fixture f;
|
||||||
|
f.registerNow();
|
||||||
|
f.recv(":roro!u@h JOIN #roro");
|
||||||
|
f.session->input(1, "/join #vault k3y", 0);
|
||||||
|
f.take();
|
||||||
|
TEST_ASSERT_TRUE(f.sent("JOIN #vault k3y"));
|
||||||
|
f.recv(":roro!u@h JOIN #vault");
|
||||||
|
f.session->disconnected(0, "Wi-Fi lost");
|
||||||
|
f.session->connected(0);
|
||||||
|
f.recv(":srv 001 roro :Welcome");
|
||||||
|
f.take();
|
||||||
|
TEST_ASSERT_TRUE(f.sent("JOIN #vault,#roro k3y"));
|
||||||
}
|
}
|
||||||
|
|
||||||
void test_sasl_plain_flow() {
|
void test_sasl_plain_flow() {
|
||||||
@@ -347,7 +460,15 @@ int main() {
|
|||||||
UNITY_BEGIN();
|
UNITY_BEGIN();
|
||||||
RUN_TEST(test_plain_registration_sends_nick_and_user);
|
RUN_TEST(test_plain_registration_sends_nick_and_user);
|
||||||
RUN_TEST(test_welcome_registers_and_joins_autojoin_channels);
|
RUN_TEST(test_welcome_registers_and_joins_autojoin_channels);
|
||||||
RUN_TEST(test_nickserv_identify_after_welcome);
|
RUN_TEST(test_nickserv_identify_after_welcome_and_joins_wait_for_it);
|
||||||
|
RUN_TEST(test_joins_follow_the_logged_in_reply);
|
||||||
|
RUN_TEST(test_joins_go_ahead_after_two_seconds_without_a_reply);
|
||||||
|
RUN_TEST(test_failed_sasl_falls_back_to_nickserv_and_holds_joins);
|
||||||
|
RUN_TEST(test_failed_sasl_without_nickserv_password_identifies_with_the_sasl_account);
|
||||||
|
RUN_TEST(test_successful_sasl_joins_at_once);
|
||||||
|
RUN_TEST(test_identify_names_the_account_even_on_a_fallback_nick_then_regains_it);
|
||||||
|
RUN_TEST(test_autojoin_sends_keyed_channels_first_with_their_keys);
|
||||||
|
RUN_TEST(test_keys_used_with_join_are_reused_on_rejoin);
|
||||||
RUN_TEST(test_sasl_plain_flow);
|
RUN_TEST(test_sasl_plain_flow);
|
||||||
RUN_TEST(test_sasl_failure_still_ends_negotiation_and_says_so);
|
RUN_TEST(test_sasl_failure_still_ends_negotiation_and_says_so);
|
||||||
RUN_TEST(test_nick_in_use_during_registration_tries_another);
|
RUN_TEST(test_nick_in_use_during_registration_tries_another);
|
||||||
|
|||||||
Reference in New Issue
Block a user