diff --git a/bot/bot.go b/bot/bot.go index 16e1666..b3cf6b0 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -41,6 +41,8 @@ type Bot struct { warmupUntil map[string]time.Time } +const certFPInitialJoinDelay = 2 * time.Second + func New(cfg Config, db *storage.DB, plugins []Plugin, log *zap.Logger) *Bot { return NewWithStats(cfg, db, plugins, log, NewStats()) } @@ -293,9 +295,36 @@ func (b *Bot) connect(ctx context.Context) error { if b.shouldUseNickServFallback() { go b.nickServFallback(c, actualNick) } - for _, ch := range b.Config.Channels { - b.markChannelWarmup(ch) - c.Write("JOIN " + ch) + joinChannels := func() { + // A delayed callback must not send a JOIN on a connection that + // has already been replaced by a reconnect. + b.mu.RLock() + currentClient := b.client + b.mu.RUnlock() + if currentClient != c { + return + } + for _, ch := range b.Config.Channels { + b.markChannelWarmup(ch) + c.Write("JOIN " + ch) + } + } + if shouldDelayCertFPJoin(b.Config.Server, mechanism, capState) { + b.Log.Info("delaying initial channel joins for CertFP identification", + zap.String("network", b.Config.NetworkName), + zap.Duration("delay", certFPInitialJoinDelay), + ) + timer := time.NewTimer(certFPInitialJoinDelay) + go func() { + defer timer.Stop() + select { + case <-timer.C: + joinChannels() + case <-ctx.Done(): + } + }() + } else { + joinChannels() } } if m.Command == "NOTICE" && capState.certFPEnroll && capState.enrollSent && m.Prefix != nil && strings.EqualFold(m.Prefix.Name, capState.nickServName) { @@ -350,6 +379,13 @@ func (b *Bot) connect(ctx context.Context) error { } } +func shouldDelayCertFPJoin(server ServerConfig, mechanism string, state *capNegotiation) bool { + return strings.TrimSpace(server.ClientCert) != "" && + strings.EqualFold(strings.TrimSpace(mechanism), "EXTERNAL") && + state != nil && + !state.saslSucceeded +} + func (b *Bot) trackOwnChannelMembership(currentNick string, message *irc.Message) { if b.networkStats == nil || message == nil || message.Prefix == nil { return diff --git a/bot/join_test.go b/bot/join_test.go new file mode 100644 index 0000000..3300744 --- /dev/null +++ b/bot/join_test.go @@ -0,0 +1,33 @@ +package bot + +import "testing" + +func TestShouldDelayCertFPJoinOnlyForUnconfirmedExternalAuth(t *testing.T) { + server := ServerConfig{ClientCert: "/etc/gobot/oftc.pem"} + + tests := []struct { + name string + clientCert bool + mechanism string + state *capNegotiation + want bool + }{ + {name: "OFTC CertFP without SASL confirmation", clientCert: true, mechanism: "EXTERNAL", state: &capNegotiation{}, want: true}, + {name: "successful SASL EXTERNAL", clientCert: true, mechanism: "EXTERNAL", state: &capNegotiation{saslSucceeded: true}, want: false}, + {name: "password SASL", clientCert: true, mechanism: "PLAIN", state: &capNegotiation{}, want: false}, + {name: "no client certificate", mechanism: "EXTERNAL", state: &capNegotiation{}, want: false}, + {name: "missing negotiation state", clientCert: true, mechanism: "EXTERNAL", state: nil, want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + candidate := ServerConfig{} + if test.clientCert { + candidate = server + } + if got := shouldDelayCertFPJoin(candidate, test.mechanism, test.state); got != test.want { + t.Fatalf("shouldDelayCertFPJoin() = %v, want %v", got, test.want) + } + }) + } +} diff --git a/docs/nickserv-sasl.md b/docs/nickserv-sasl.md index 3445aef..cc9f3d7 100644 --- a/docs/nickserv-sasl.md +++ b/docs/nickserv-sasl.md @@ -183,5 +183,13 @@ For the final passwordless mode, the server must advertise SASL and the `EXTERNAL` mechanism. All DNS backends behind a shared IRC hostname should expose the same capability set. +Some networks, including OFTC, use the client certificate for TLS CertFP +identification but do not advertise SASL `EXTERNAL`. GoBot still presents the +configured certificate during the TLS handshake. When SASL `EXTERNAL` is not +confirmed, GoBot waits two seconds before its initial channel joins so services +can apply the account identification and cloak first. The warning that the +configured SASL mechanism was not advertised is expected for this network; +it does not mean that the TLS certificate failed. + If a network renames the bot to Guest..., review the optional nickserv_fallback and nickserv_ghost identity settings.