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
42 changes: 39 additions & 3 deletions bot/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions bot/join_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
8 changes: 8 additions & 0 deletions docs/nickserv-sasl.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.