diff --git a/irc/accounts.go b/irc/accounts.go index c617f14b..81dfecb4 100644 --- a/irc/accounts.go +++ b/irc/accounts.go @@ -29,7 +29,7 @@ const ( keyAccountRegTime = "account.registered.time %s" keyAccountCredentials = "account.credentials %s" keyAccountAdditionalNicks = "account.additionalnicks %s" - keyAccountEnforcement = "account.customenforcement %s" + keyAccountSettings = "account.settings %s" keyAccountVHost = "account.vhost %s" keyCertToAccount = "account.creds.certfp %s" keyAccountChannels = "account.channels %s" @@ -55,14 +55,14 @@ type AccountManager struct { accountToClients map[string][]*Client nickToAccount map[string]string skeletonToAccount map[string]string - accountToMethod map[string]NickReservationMethod + accountToMethod map[string]NickEnforcementMethod } func (am *AccountManager) Initialize(server *Server) { am.accountToClients = make(map[string][]*Client) am.nickToAccount = make(map[string]string) am.skeletonToAccount = make(map[string]string) - am.accountToMethod = make(map[string]NickReservationMethod) + am.accountToMethod = make(map[string]NickEnforcementMethod) am.server = server am.buildNickToAccountIndex() @@ -76,7 +76,7 @@ func (am *AccountManager) buildNickToAccountIndex() { nickToAccount := make(map[string]string) skeletonToAccount := make(map[string]string) - accountToMethod := make(map[string]NickReservationMethod) + accountToMethod := make(map[string]NickEnforcementMethod) existsPrefix := fmt.Sprintf(keyAccountExists, "") am.serialCacheUpdateMutex.Lock() @@ -109,12 +109,16 @@ func (am *AccountManager) buildNickToAccountIndex() { } } - if methodStr, err := tx.Get(fmt.Sprintf(keyAccountEnforcement, account)); err == nil { - method, err := nickReservationFromString(methodStr) - if err == nil { - accountToMethod[account] = method + if rawPrefs, err := tx.Get(fmt.Sprintf(keyAccountSettings, account)); err == nil { + var prefs AccountSettings + err := json.Unmarshal([]byte(rawPrefs), &prefs) + if err == nil && prefs.NickEnforcement != NickEnforcementOptional { + accountToMethod[account] = prefs.NickEnforcement + } else { + am.server.logger.Error("internal", "corrupt account creds", account) } } + return true }) return err @@ -180,36 +184,44 @@ func (am *AccountManager) NickToAccount(nick string) string { return am.nickToAccount[cfnick] } +// given an account, combine stored enforcement method with the config settings +// to compute the actual enforcement method +func configuredEnforcementMethod(config *Config, storedMethod NickEnforcementMethod) (result NickEnforcementMethod) { + if !config.Accounts.NickReservation.Enabled { + return NickEnforcementNone + } + result = storedMethod + // if they don't have a custom setting, or customization is disabled, use the default + if result == NickEnforcementOptional || !config.Accounts.NickReservation.AllowCustomEnforcement { + result = config.Accounts.NickReservation.Method + } + if result == NickEnforcementOptional { + // enforcement was explicitly enabled neither in the config or by the user + result = NickEnforcementNone + } + return +} + // Given a nick, looks up the account that owns it and the method (none/timeout/strict) // used to enforce ownership. -func (am *AccountManager) EnforcementStatus(cfnick, skeleton string) (account string, method NickReservationMethod) { +func (am *AccountManager) EnforcementStatus(cfnick, skeleton string) (account string, method NickEnforcementMethod) { config := am.server.Config() if !config.Accounts.NickReservation.Enabled { - return "", NickReservationNone + return "", NickEnforcementNone } am.RLock() defer am.RUnlock() - // given an account, combine stored enforcement method with the config settings - // to compute the actual enforcement method - finalEnforcementMethod := func(account_ string) (result NickReservationMethod) { - result = am.accountToMethod[account_] - // if they don't have a custom setting, or customization is disabled, use the default - if result == NickReservationOptional || !config.Accounts.NickReservation.AllowCustomEnforcement { - result = config.Accounts.NickReservation.Method - } - if result == NickReservationOptional { - // enforcement was explicitly enabled neither in the config or by the user - result = NickReservationNone - } - return + finalEnforcementMethod := func(account_ string) (result NickEnforcementMethod) { + storedMethod := am.accountToMethod[account_] + return configuredEnforcementMethod(config, storedMethod) } nickAccount := am.nickToAccount[cfnick] skelAccount := am.skeletonToAccount[skeleton] if nickAccount == "" && skelAccount == "" { - return "", NickReservationNone + return "", NickEnforcementNone } else if nickAccount != "" && (skelAccount == nickAccount || skelAccount == "") { return nickAccount, finalEnforcementMethod(nickAccount) } else if skelAccount != "" && nickAccount == "" { @@ -220,75 +232,47 @@ func (am *AccountManager) EnforcementStatus(cfnick, skeleton string) (account st nickMethod := finalEnforcementMethod(nickAccount) skelMethod := finalEnforcementMethod(skelAccount) switch { - case skelMethod == NickReservationNone: + case skelMethod == NickEnforcementNone: return nickAccount, nickMethod - case nickMethod == NickReservationNone: + case nickMethod == NickEnforcementNone: return skelAccount, skelMethod default: // nobody can use this nick - return "!", NickReservationStrict + return "!", NickEnforcementStrict } } } -func (am *AccountManager) BouncerAllowed(account string, session *Session) bool { - // TODO stub - config := am.server.Config() - if !config.Accounts.Bouncer.Enabled { - return false - } - if config.Accounts.Bouncer.AllowedByDefault { - return true - } - return session != nil && session.capabilities.Has(caps.Bouncer) -} - -// Looks up the enforcement method stored in the database for an account -// (typically you want EnforcementStatus instead, which respects the config) -func (am *AccountManager) getStoredEnforcementStatus(account string) string { - am.RLock() - defer am.RUnlock() - return nickReservationToString(am.accountToMethod[account]) -} - // Sets a custom enforcement method for an account and stores it in the database. -func (am *AccountManager) SetEnforcementStatus(account string, method NickReservationMethod) (err error) { +func (am *AccountManager) SetEnforcementStatus(account string, method NickEnforcementMethod) (finalSettings AccountSettings, err error) { config := am.server.Config() if !(config.Accounts.NickReservation.Enabled && config.Accounts.NickReservation.AllowCustomEnforcement) { - return errFeatureDisabled + err = errFeatureDisabled + return } - var serialized string - if method == NickReservationOptional { - serialized = "" // normally this is "default", but we're going to delete the key - } else { - serialized = nickReservationToString(method) + setter := func(in AccountSettings) (out AccountSettings, err error) { + out = in + out.NickEnforcement = method + return out, nil } - key := fmt.Sprintf(keyAccountEnforcement, account) + _, err = am.ModifyAccountSettings(account, setter) + if err != nil { + return + } + // this update of the data plane is racey, but it's probably fine am.Lock() defer am.Unlock() - currentMethod := am.accountToMethod[account] - if method != currentMethod { - if method == NickReservationOptional { - delete(am.accountToMethod, account) - } else { - am.accountToMethod[account] = method - } - - return am.server.store.Update(func(tx *buntdb.Tx) (err error) { - if serialized != "" { - _, _, err = tx.Set(key, nickReservationToString(method), nil) - } else { - _, err = tx.Delete(key) - } - return - }) + if method == NickEnforcementOptional { + delete(am.accountToMethod, account) + } else { + am.accountToMethod[account] = method } - return nil + return } func (am *AccountManager) AccountToClients(account string) (result []*Client) { @@ -813,6 +797,12 @@ func (am *AccountManager) deserializeRawAccount(raw rawClientAccount) (result Cl // pretend they have no vhost and move on } } + if raw.Settings != "" { + e := json.Unmarshal([]byte(raw.Settings), &result.Settings) + if e != nil { + am.server.logger.Warning("internal", "could not unmarshal settings for account", result.Name, e.Error()) + } + } return } @@ -825,6 +815,7 @@ func (am *AccountManager) loadRawAccount(tx *buntdb.Tx, casefoldedAccount string callbackKey := fmt.Sprintf(keyAccountCallback, casefoldedAccount) nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount) vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount) + settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount) _, e := tx.Get(accountKey) if e == buntdb.ErrNotFound { @@ -838,6 +829,7 @@ func (am *AccountManager) loadRawAccount(tx *buntdb.Tx, casefoldedAccount string result.Callback, _ = tx.Get(callbackKey) result.AdditionalNicks, _ = tx.Get(nicksKey) result.VHost, _ = tx.Get(vhostKey) + result.Settings, _ = tx.Get(settingsKey) if _, e = tx.Get(verifiedKey); e == nil { result.Verified = true @@ -861,7 +853,7 @@ func (am *AccountManager) Unregister(account string) error { verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount) verifiedKey := fmt.Sprintf(keyAccountVerified, casefoldedAccount) nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount) - enforcementKey := fmt.Sprintf(keyAccountEnforcement, casefoldedAccount) + settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount) vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount) vhostQueueKey := fmt.Sprintf(keyVHostQueueAcctToId, casefoldedAccount) channelsKey := fmt.Sprintf(keyAccountChannels, casefoldedAccount) @@ -892,7 +884,7 @@ func (am *AccountManager) Unregister(account string) error { tx.Delete(registeredTimeKey) tx.Delete(callbackKey) tx.Delete(verificationCodeKey) - tx.Delete(enforcementKey) + tx.Delete(settingsKey) rawNicks, _ = tx.Get(nicksKey) tx.Delete(nicksKey) credText, err = tx.Get(credentialsKey) @@ -980,19 +972,13 @@ func (am *AccountManager) AuthenticateByCertFP(client *Client) error { } var account string - var rawAccount rawClientAccount certFPKey := fmt.Sprintf(keyCertToAccount, client.certfp) - err := am.server.store.Update(func(tx *buntdb.Tx) error { - var err error + err := am.server.store.View(func(tx *buntdb.Tx) error { account, _ = tx.Get(certFPKey) if account == "" { return errAccountInvalidCredentials } - rawAccount, err = am.loadRawAccount(tx, account) - if err != nil || !rawAccount.Verified { - return errAccountUnverified - } return nil }) @@ -1001,14 +987,57 @@ func (am *AccountManager) AuthenticateByCertFP(client *Client) error { } // ok, we found an account corresponding to their certificate - clientAccount, err := am.deserializeRawAccount(rawAccount) + clientAccount, err := am.LoadAccount(account) if err != nil { return err + } else if !clientAccount.Verified { + return errAccountUnverified } am.Login(client, clientAccount) return nil } +type settingsMunger func(input AccountSettings) (output AccountSettings, err error) + +func (am *AccountManager) ModifyAccountSettings(account string, munger settingsMunger) (newSettings AccountSettings, err error) { + casefoldedAccount, err := CasefoldName(account) + if err != nil { + return newSettings, errAccountDoesNotExist + } + // TODO implement this in general via a compare-and-swap API + accountData, err := am.LoadAccount(casefoldedAccount) + if err != nil { + return + } else if !accountData.Verified { + return newSettings, errAccountUnverified + } + newSettings, err = munger(accountData.Settings) + if err != nil { + return + } + text, err := json.Marshal(newSettings) + if err != nil { + return + } + key := fmt.Sprintf(keyAccountSettings, casefoldedAccount) + serializedValue := string(text) + err = am.server.store.Update(func(tx *buntdb.Tx) (err error) { + _, _, err = tx.Set(key, serializedValue, nil) + return + }) + if err != nil { + err = errAccountUpdateFailed + return + } + // success, push new settings into the client objects + am.Lock() + defer am.Unlock() + for _, client := range am.accountToClients[casefoldedAccount] { + client.SetAccountSettings(newSettings) + } + return +} + // represents someone's status in hostserv type VHostInfo struct { ApprovedVHost string @@ -1237,6 +1266,9 @@ func (am *AccountManager) Login(client *Client, account ClientAccount) { am.Lock() defer am.Unlock() am.accountToClients[casefoldedAccount] = append(am.accountToClients[casefoldedAccount], client) + for _, client := range am.accountToClients[casefoldedAccount] { + client.SetAccountSettings(account.Settings) + } } func (am *AccountManager) Logout(client *Client) { @@ -1283,6 +1315,21 @@ type AccountCredentials struct { Certificate string // fingerprint } +type BouncerAllowedSetting int + +const ( + BouncerAllowedServerDefault BouncerAllowedSetting = iota + BouncerDisallowedByUser + BouncerAllowedByUser +) + +type AccountSettings struct { + AutoreplayLines *int + NickEnforcement NickEnforcementMethod + AllowBouncer BouncerAllowedSetting + AutoreplayJoins bool +} + // ClientAccount represents a user account. type ClientAccount struct { // Name of the account. @@ -1293,6 +1340,7 @@ type ClientAccount struct { Verified bool AdditionalNicks []string VHost VHostInfo + Settings AccountSettings } // convenience for passing around raw serialized account data @@ -1304,6 +1352,7 @@ type rawClientAccount struct { Verified bool AdditionalNicks string VHost string + Settings string } // logoutOfAccount logs the client out of their current account. diff --git a/irc/caps/set.go b/irc/caps/set.go index 12b6fd16..0e5f7949 100644 --- a/irc/caps/set.go +++ b/irc/caps/set.go @@ -114,9 +114,3 @@ func (s *Set) String(version Version, values *Values) string { return strings.Join(strs, " ") } - -// returns whether we should send `znc.in/self-message`-style echo messages -// to sessions other than that which originated the message -func (capabs *Set) SelfMessagesEnabled() bool { - return capabs.Has(EchoMessage) || capabs.Has(ZNCSelfMessage) -} diff --git a/irc/channel.go b/irc/channel.go index 90306380..48e51cef 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -620,7 +620,17 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp // TODO #259 can be implemented as Flush(false) (i.e., nonblocking) while holding joinPartMutex rb.Flush(true) - replayLimit := channel.server.Config().History.AutoreplayOnJoin + var replayLimit int + customReplayLimit := client.AccountSettings().AutoreplayLines + if customReplayLimit != nil { + replayLimit = *customReplayLimit + maxLimit := channel.server.Config().History.ChathistoryMax + if maxLimit < replayLimit { + replayLimit = maxLimit + } + } else { + replayLimit = channel.server.Config().History.AutoreplayOnJoin + } if 0 < replayLimit { // TODO don't replay the client's own JOIN line? items := channel.history.Latest(replayLimit) @@ -782,6 +792,7 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I client := rb.target eventPlayback := rb.session.capabilities.Has(caps.EventPlayback) extendedJoin := rb.session.capabilities.Has(caps.ExtendedJoin) + playJoinsAsPrivmsg := (!autoreplay || client.AccountSettings().AutoreplayJoins) if len(items) == 0 { return @@ -808,7 +819,7 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "JOIN", chname) } } else { - if autoreplay { + if !playJoinsAsPrivmsg { continue // #474 } var message string @@ -823,7 +834,7 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I if eventPlayback { rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "PART", chname, item.Message.Message) } else { - if autoreplay { + if !playJoinsAsPrivmsg { continue // #474 } message := fmt.Sprintf(client.t("%[1]s left the channel (%[2]s)"), nick, item.Message.Message) @@ -840,7 +851,7 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I if eventPlayback { rb.AddFromClient(item.Message.Time, item.Message.Msgid, item.Nick, item.AccountName, nil, "HEVENT", "QUIT", item.Message.Message) } else { - if autoreplay { + if !playJoinsAsPrivmsg { continue // #474 } message := fmt.Sprintf(client.t("%[1]s quit (%[2]s)"), nick, item.Message.Message) @@ -989,7 +1000,7 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod } // send echo-message to other connected sessions for _, session := range client.Sessions() { - if session == rb.session || !session.capabilities.SelfMessagesEnabled() { + if session == rb.session { continue } var tagsToUse map[string]string @@ -998,7 +1009,7 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod } if histType == history.Tagmsg && session.capabilities.Has(caps.MessageTags) { session.sendFromClientInternal(false, message.Time, message.Msgid, nickmask, account, tagsToUse, command, chname) - } else { + } else if histType != history.Tagmsg { session.sendSplitMsgFromClientInternal(false, nickmask, account, tagsToUse, command, chname, message) } } diff --git a/irc/chanserv.go b/irc/chanserv.go index b37ba98d..4cfd2bf9 100644 --- a/irc/chanserv.go +++ b/irc/chanserv.go @@ -86,7 +86,7 @@ referenced by their registered account names, not their nicknames.`, // csNotice sends the client a notice from ChanServ func csNotice(rb *ResponseBuffer, text string) { - rb.Add(nil, "ChanServ", "NOTICE", rb.target.Nick(), text) + rb.Add(nil, "ChanServ!ChanServ@localhost", "NOTICE", rb.target.Nick(), text) } func csAmodeHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { diff --git a/irc/client.go b/irc/client.go index 3bd371ca..3beb80d6 100644 --- a/irc/client.go +++ b/irc/client.go @@ -48,6 +48,7 @@ type ResumeDetails struct { type Client struct { account string accountName string // display name of the account: uncasefolded, '*' if not logged in + accountSettings AccountSettings atime time.Time away bool awayMessage string @@ -600,7 +601,8 @@ func (client *Client) tryResumeChannels() { func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.Item, complete bool) { var batchID string - nick := client.Nick() + details := client.Details() + nick := details.nick if 0 < len(items) { batchID = rb.StartNestedHistoryBatch(nick) } @@ -626,7 +628,14 @@ func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.I if allowTags { tags = item.Tags } - rb.AddSplitMessageFromClient(item.Nick, item.AccountName, tags, command, nick, item.Message) + if item.Params[0] == "" { + // this message was sent *to* the client from another nick + rb.AddSplitMessageFromClient(item.Nick, item.AccountName, tags, command, nick, item.Message) + } else { + // this message was sent *from* the client to another nick; the target is item.Params[0] + // substitute the client's current nickmask in case they changed nick + rb.AddSplitMessageFromClient(details.nickMask, item.AccountName, tags, command, item.Params[0], item.Message) + } } rb.EndNestedBatch(batchID) diff --git a/irc/client_lookup_set.go b/irc/client_lookup_set.go index 65ac7127..1108f0d1 100644 --- a/irc/client_lookup_set.go +++ b/irc/client_lookup_set.go @@ -145,7 +145,20 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick reservedAccount, method := client.server.accounts.EnforcementStatus(newcfnick, newSkeleton) account := client.Account() - bouncerAllowed := client.server.accounts.BouncerAllowed(account, session) + config := client.server.Config() + var bouncerAllowed bool + if config.Accounts.Bouncer.Enabled { + if session != nil && session.capabilities.Has(caps.Bouncer) { + bouncerAllowed = true + } else { + settings := client.AccountSettings() + if config.Accounts.Bouncer.AllowedByDefault && settings.AllowBouncer != BouncerDisallowedByUser { + bouncerAllowed = true + } else if settings.AllowBouncer == BouncerAllowedByUser { + bouncerAllowed = true + } + } + } clients.Lock() defer clients.Unlock() @@ -168,7 +181,7 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick if skeletonHolder != nil && skeletonHolder != client { return errNicknameInUse } - if method == NickReservationStrict && reservedAccount != "" && reservedAccount != account { + if method == NickEnforcementStrict && reservedAccount != "" && reservedAccount != account { return errNicknameReserved } clients.removeInternal(client) diff --git a/irc/config.go b/irc/config.go index 8aea256b..965aa9d5 100644 --- a/irc/config.go +++ b/irc/config.go @@ -112,63 +112,63 @@ type VHostConfig struct { } `yaml:"user-requests"` } -type NickReservationMethod int +type NickEnforcementMethod int const ( - // NickReservationOptional is the zero value; it serializes to + // NickEnforcementOptional is the zero value; it serializes to // "optional" in the yaml config, and "default" as an arg to `NS ENFORCE`. // in both cases, it means "defer to the other source of truth", i.e., // in the config, defer to the user's custom setting, and as a custom setting, - // defer to the default in the config. if both are NickReservationOptional then + // defer to the default in the config. if both are NickEnforcementOptional then // there is no enforcement. - NickReservationOptional NickReservationMethod = iota - NickReservationNone - NickReservationWithTimeout - NickReservationStrict + // XXX: these are serialized as numbers in the database, so beware of collisions + // when refactoring (any numbers currently in use must keep their meanings, or + // else be fixed up by a schema change) + NickEnforcementOptional NickEnforcementMethod = iota + NickEnforcementNone + NickEnforcementWithTimeout + NickEnforcementStrict ) -func nickReservationToString(method NickReservationMethod) string { +func nickReservationToString(method NickEnforcementMethod) string { switch method { - case NickReservationOptional: + case NickEnforcementOptional: return "default" - case NickReservationNone: + case NickEnforcementNone: return "none" - case NickReservationWithTimeout: + case NickEnforcementWithTimeout: return "timeout" - case NickReservationStrict: + case NickEnforcementStrict: return "strict" default: return "" } } -func nickReservationFromString(method string) (NickReservationMethod, error) { - switch method { +func nickReservationFromString(method string) (NickEnforcementMethod, error) { + switch strings.ToLower(method) { case "default": - return NickReservationOptional, nil + return NickEnforcementOptional, nil case "optional": - return NickReservationOptional, nil + return NickEnforcementOptional, nil case "none": - return NickReservationNone, nil + return NickEnforcementNone, nil case "timeout": - return NickReservationWithTimeout, nil + return NickEnforcementWithTimeout, nil case "strict": - return NickReservationStrict, nil + return NickEnforcementStrict, nil default: - return NickReservationOptional, fmt.Errorf("invalid nick-reservation.method value: %s", method) + return NickEnforcementOptional, fmt.Errorf("invalid nick-reservation.method value: %s", method) } } -func (nr *NickReservationMethod) UnmarshalYAML(unmarshal func(interface{}) error) error { - var orig, raw string +func (nr *NickEnforcementMethod) UnmarshalYAML(unmarshal func(interface{}) error) error { + var orig string var err error if err = unmarshal(&orig); err != nil { return err } - if raw, err = Casefold(orig); err != nil { - return err - } - method, err := nickReservationFromString(raw) + method, err := nickReservationFromString(orig) if err == nil { *nr = method } @@ -178,7 +178,7 @@ func (nr *NickReservationMethod) UnmarshalYAML(unmarshal func(interface{}) error type NickReservationConfig struct { Enabled bool AdditionalNickLimit int `yaml:"additional-nick-limit"` - Method NickReservationMethod + Method NickEnforcementMethod AllowCustomEnforcement bool `yaml:"allow-custom-enforcement"` RenameTimeout time.Duration `yaml:"rename-timeout"` RenamePrefix string `yaml:"rename-prefix"` diff --git a/irc/database.go b/irc/database.go index c85cebc0..62e478b6 100644 --- a/irc/database.go +++ b/irc/database.go @@ -22,7 +22,7 @@ const ( // 'version' of the database schema keySchemaVersion = "db.version" // latest schema of the db - latestDbSchema = "5" + latestDbSchema = "6" ) type SchemaChanger func(*Config, *buntdb.Tx) error @@ -409,6 +409,37 @@ func schemaChangeV4ToV5(config *Config, tx *buntdb.Tx) error { return nil } +// custom nick enforcement was a separate db key, now it's part of settings +func schemaChangeV5ToV6(config *Config, tx *buntdb.Tx) error { + accountToEnforcement := make(map[string]NickEnforcementMethod) + prefix := "account.customenforcement " + tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool { + if !strings.HasPrefix(key, prefix) { + return false + } + account := strings.TrimPrefix(key, prefix) + method, err := nickReservationFromString(value) + if err == nil { + accountToEnforcement[account] = method + } else { + log.Printf("skipping corrupt custom enforcement value for %s\n", account) + } + return true + }) + + for account, method := range accountToEnforcement { + var settings AccountSettings + settings.NickEnforcement = method + text, err := json.Marshal(settings) + if err != nil { + return err + } + tx.Delete(prefix + account) + tx.Set(fmt.Sprintf("account.settings %s", account), string(text), nil) + } + return nil +} + func init() { allChanges := []SchemaChange{ { @@ -431,6 +462,11 @@ func init() { TargetVersion: "5", Changer: schemaChangeV4ToV5, }, + { + InitialVersion: "5", + TargetVersion: "6", + Changer: schemaChangeV5ToV6, + }, } // build the index diff --git a/irc/errors.go b/irc/errors.go index 1fa8e5f7..df47f8d2 100644 --- a/irc/errors.go +++ b/irc/errors.go @@ -5,7 +5,10 @@ package irc -import "errors" +import ( + "errors" + "github.com/oragono/oragono/irc/utils" +) // Runtime Errors var ( @@ -19,10 +22,10 @@ var ( errAccountNickReservationFailed = errors.New("Could not (un)reserve nick") errAccountNotLoggedIn = errors.New("You're not logged into an account") errAccountTooManyNicks = errors.New("Account has too many reserved nicks") - errAccountUnverified = errors.New("Account is not yet verified") + errAccountUnverified = errors.New(`Account is not yet verified`) errAccountVerificationFailed = errors.New("Account verification failed") errAccountVerificationInvalidCode = errors.New("Invalid account verification code") - errAccountUpdateFailed = errors.New("Error while updating your account information") + errAccountUpdateFailed = errors.New(`Error while updating your account information`) errAccountMustHoldNick = errors.New(`You must hold that nickname in order to register it`) errCallbackFailed = errors.New("Account verification could not be sent") errCertfpAlreadyExists = errors.New(`An account already exists for your certificate fingerprint`) @@ -40,7 +43,7 @@ var ( errInvalidUsername = errors.New("Invalid username") errFeatureDisabled = errors.New(`That feature is disabled`) errBanned = errors.New("IP or nickmask banned") - errInvalidParams = errors.New("Invalid parameters") + errInvalidParams = utils.ErrInvalidParams ) // Socket Errors diff --git a/irc/getters.go b/irc/getters.go index d231d66a..13018bba 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -275,6 +275,19 @@ func (client *Client) SetAccountName(account string) (changed bool) { return } +func (client *Client) AccountSettings() (result AccountSettings) { + client.stateMutex.RLock() + result = client.accountSettings + client.stateMutex.RUnlock() + return +} + +func (client *Client) SetAccountSettings(settings AccountSettings) { + client.stateMutex.Lock() + client.accountSettings = settings + client.stateMutex.Unlock() +} + func (client *Client) Languages() (languages []string) { client.stateMutex.RLock() languages = client.languages diff --git a/irc/handlers.go b/irc/handlers.go index 0745074d..038d296a 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -2046,12 +2046,12 @@ func messageHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R } // an echo-message may need to go out to other client sessions: for _, session := range client.Sessions() { - if session == rb.session || !rb.session.capabilities.SelfMessagesEnabled() { + if session == rb.session { continue } if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) { session.sendFromClientInternal(false, splitMsg.Time, splitMsg.Msgid, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick) - } else { + } else if histType != history.Tagmsg { session.sendSplitMsgFromClientInternal(false, nickMaskString, accountName, clientOnlyTags, msg.Command, tnick, splitMsg) } } @@ -2060,12 +2060,17 @@ func messageHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R rb.Add(nil, server.name, RPL_AWAY, cnick, tnick, user.AwayMessage()) } - user.history.Add(history.Item{ + item := history.Item{ Type: histType, Message: splitMsg, Nick: nickMaskString, AccountName: accountName, - }) + } + // add to the target's history: + user.history.Add(item) + // add this to the client's history as well, recording the target: + item.Params[0] = tnick + client.history.Add(item) } } return false diff --git a/irc/hostserv.go b/irc/hostserv.go index a0d7d49c..cc81fbb6 100644 --- a/irc/hostserv.go +++ b/irc/hostserv.go @@ -131,7 +131,7 @@ for the rejection.`, // hsNotice sends the client a notice from HostServ func hsNotice(rb *ResponseBuffer, text string) { - rb.Add(nil, "HostServ", "NOTICE", rb.target.Nick(), text) + rb.Add(nil, "HostServ!HostServ@localhost", "NOTICE", rb.target.Nick(), text) } // hsNotifyChannel notifies the designated channel of new vhost activity diff --git a/irc/idletimer.go b/irc/idletimer.go index fe158e46..8b7bc8e8 100644 --- a/irc/idletimer.go +++ b/irc/idletimer.go @@ -198,7 +198,7 @@ func (nt *NickTimer) Initialize(client *Client) { } config := &client.server.Config().Accounts.NickReservation - enabled := config.Enabled && (config.Method == NickReservationWithTimeout || config.AllowCustomEnforcement) + enabled := config.Enabled && (config.Method == NickEnforcementWithTimeout || config.AllowCustomEnforcement) nt.Lock() defer nt.Unlock() @@ -235,7 +235,7 @@ func (nt *NickTimer) Touch(rb *ResponseBuffer) { cfnick, skeleton := nt.client.uniqueIdentifiers() account := nt.client.Account() accountForNick, method := nt.client.server.accounts.EnforcementStatus(cfnick, skeleton) - enforceTimeout := method == NickReservationWithTimeout + enforceTimeout := method == NickEnforcementWithTimeout var shouldWarn, shouldRename bool @@ -258,7 +258,7 @@ func (nt *NickTimer) Touch(rb *ResponseBuffer) { if enforceTimeout && delinquent && (accountChanged || nt.timer == nil) { nt.timer = time.AfterFunc(nt.timeout, nt.processTimeout) shouldWarn = true - } else if method == NickReservationStrict && delinquent { + } else if method == NickEnforcementStrict && delinquent { shouldRename = true // this can happen if reservation was enabled by rehash } }() diff --git a/irc/nickserv.go b/irc/nickserv.go index 4a3496ee..ab139df5 100644 --- a/irc/nickserv.go +++ b/irc/nickserv.go @@ -5,11 +5,14 @@ package irc import ( "fmt" + "strconv" + "strings" "time" "github.com/goshuirc/irc-go/ircfmt" "github.com/oragono/oragono/irc/modes" + "github.com/oragono/oragono/irc/utils" ) // "enabled" callbacks for specific nickserv commands @@ -25,10 +28,6 @@ func servCmdRequiresNickRes(config *Config) bool { return config.Accounts.AuthenticationEnabled && config.Accounts.NickReservation.Enabled } -func nsEnforceEnabled(config *Config) bool { - return servCmdRequiresNickRes(config) && config.Accounts.NickReservation.AllowCustomEnforcement -} - func servCmdRequiresBouncerEnabled(config *Config) bool { return config.Accounts.Bouncer.Enabled } @@ -61,20 +60,14 @@ DROP de-links the given (or your current) nickname from your user account.`, authRequired: true, }, "enforce": { + hidden: true, handler: nsEnforceHandler, help: `Syntax: $bENFORCE [method]$b -ENFORCE lets you specify a custom enforcement mechanism for your registered -nicknames. Your options are: -1. 'none' [no enforcement, overriding the server default] -2. 'timeout' [anyone using the nick must authenticate before a deadline, - or else they will be renamed] -3. 'strict' [you must already be authenticated to use the nick] -4. 'default' [use the server default] -With no arguments, queries your current enforcement status.`, - helpShort: `$bENFORCE$b lets you change how your nicknames are reserved.`, +ENFORCE is an alias for $bGET enforce$b and $bSET enforce$b. See the help +entry for $bSET$b for more information.`, authRequired: true, - enabled: nsEnforceEnabled, + enabled: servCmdRequiresAccreg, }, "ghost": { handler: nsGhostHandler, @@ -194,12 +187,246 @@ password by supplying their username and then the desired password.`, enabled: servCmdRequiresAuthEnabled, minParams: 2, }, + "get": { + handler: nsGetHandler, + help: `Syntax: $bGET $b + +GET queries the current values of your account settings. For more information +on the settings and their possible values, see HELP SET.`, + helpShort: `$bGET$b queries the current values of your account settings`, + authRequired: true, + enabled: servCmdRequiresAccreg, + minParams: 1, + }, + "saget": { + handler: nsGetHandler, + help: `Syntax: $bSAGET $b + +SAGET queries the values of someone else's account settings. For more +information on the settings and their possible values, see HELP SET.`, + helpShort: `$bSAGET$b queries the current values of another user's account settings`, + enabled: servCmdRequiresAccreg, + minParams: 2, + capabs: []string{"accreg"}, + }, + "set": { + handler: nsSetHandler, + helpShort: `$bSET$b modifies your account settings`, + // these are broken out as separate strings so they can be translated separately + helpStrings: []string{ + `Syntax $bSET $b + +Set modifies your account settings. The following settings are available:`, + + `$bENFORCE$b +'enforce' lets you specify a custom enforcement mechanism for your registered +nicknames. Your options are: +1. 'none' [no enforcement, overriding the server default] +2. 'timeout' [anyone using the nick must authenticate before a deadline, + or else they will be renamed] +3. 'strict' [you must already be authenticated to use the nick] +4. 'default' [use the server default]`, + + `$bBOUNCER$b +If 'bouncer' is enabled and you are already logged in and using a nick, a +second client of yours that authenticates with SASL and requests the same nick +is allowed to attach to the nick as well (this is comparable to the behavior +of IRC "bouncers" like ZNC). Your options are 'on' (allow this behavior), +'off' (disallow it), and 'default' (use the server default value).`, + + `$bAUTOREPLAY-LINES$b +'autoreplay-lines' controls the number of lines of channel history that will +be replayed to you automatically when joining a channel. Your options are any +positive number, 0 to disable the feature, and 'default' to use the server +default.`, + + `$bAUTOREPLAY-JOINS$b +'autoreplay-joins' controls whether autoreplayed channel history will include +lines for join and part. This provides more information about the context of +messages, but may be spammy. Your options are 'on' and 'off'.`, + }, + authRequired: true, + enabled: servCmdRequiresAccreg, + minParams: 2, + }, + "saset": { + handler: nsSetHandler, + help: `Syntax: $bSASET $b + +SASET modifies the values of someone else's account settings. For more +information on the settings and their possible values, see HELP SET.`, + helpShort: `$bSASET$b modifies another user's account settings`, + enabled: servCmdRequiresAccreg, + minParams: 3, + capabs: []string{"accreg"}, + }, } ) // nsNotice sends the client a notice from NickServ func nsNotice(rb *ResponseBuffer, text string) { - rb.Add(nil, "NickServ", "NOTICE", rb.target.Nick(), text) + // XXX i can't figure out how to use OragonoServices[servicename].prefix here + // without creating a compile-time initialization loop + rb.Add(nil, "NickServ!NickServ@localhost", "NOTICE", rb.target.Nick(), text) +} + +func nsGetHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + var account string + if command == "saget" { + account = params[0] + params = params[1:] + } else { + account = client.Account() + } + + accountData, err := server.accounts.LoadAccount(account) + if err == errAccountDoesNotExist { + nsNotice(rb, client.t("No such account")) + return + } else if err != nil { + nsNotice(rb, client.t("Error loading account data")) + return + } + + displaySetting(params[0], accountData.Settings, client, rb) +} + +func displaySetting(settingName string, settings AccountSettings, client *Client, rb *ResponseBuffer) { + config := client.server.Config() + switch strings.ToLower(settingName) { + case "enforce": + storedValue := settings.NickEnforcement + serializedStoredValue := nickReservationToString(storedValue) + nsNotice(rb, fmt.Sprintf(client.t("Your stored nickname enforcement setting is: %s"), serializedStoredValue)) + serializedActualValue := nickReservationToString(configuredEnforcementMethod(config, storedValue)) + nsNotice(rb, fmt.Sprintf(client.t("Given current server settings, your nickname is enforced with: %s"), serializedActualValue)) + case "autoreplay-lines": + if settings.AutoreplayLines == nil { + nsNotice(rb, fmt.Sprintf(client.t("You will receive the server default of %d lines of autoreplayed history"), config.History.AutoreplayOnJoin)) + } else { + nsNotice(rb, fmt.Sprintf(client.t("You will receive %d lines of autoreplayed history"), *settings.AutoreplayLines)) + } + case "autoreplay-joins": + if settings.AutoreplayJoins { + nsNotice(rb, client.t("You will see JOINs and PARTs in autoreplayed history lines")) + } else { + nsNotice(rb, client.t("You will not see JOINs and PARTs in autoreplayed history lines")) + } + case "bouncer": + if !config.Accounts.Bouncer.Enabled { + nsNotice(rb, fmt.Sprintf(client.t("This feature has been disabled by the server administrators"))) + } else { + switch settings.AllowBouncer { + case BouncerAllowedServerDefault: + if config.Accounts.Bouncer.AllowedByDefault { + nsNotice(rb, fmt.Sprintf(client.t("Bouncer functionality is currently enabled for your account, but you can opt out"))) + } else { + nsNotice(rb, fmt.Sprintf(client.t("Bouncer functionality is currently disabled for your account, but you can opt in"))) + } + case BouncerDisallowedByUser: + nsNotice(rb, fmt.Sprintf(client.t("Bouncer functionality is currently disabled for your account"))) + case BouncerAllowedByUser: + nsNotice(rb, fmt.Sprintf(client.t("Bouncer functionality is currently enabled for your account"))) + } + } + default: + nsNotice(rb, client.t("No such setting")) + } +} + +func nsSetHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + var account string + if command == "saset" { + account = params[0] + params = params[1:] + } else { + account = client.Account() + } + + var munger settingsMunger + var finalSettings AccountSettings + var err error + switch strings.ToLower(params[0]) { + case "pass": + nsNotice(rb, client.t("To change a password, use the PASSWD command. For details, /msg NickServ HELP PASSWD")) + return + case "enforce": + var method NickEnforcementMethod + method, err = nickReservationFromString(params[1]) + if err != nil { + err = errInvalidParams + break + } + // updating enforcement settings is special-cased, because it requires + // an update to server.accounts.accountToMethod + finalSettings, err = server.accounts.SetEnforcementStatus(account, method) + if err == nil { + finalSettings.NickEnforcement = method // success + } + case "autoreplay-lines": + var newValue *int + if strings.ToLower(params[1]) != "default" { + val, err_ := strconv.Atoi(params[1]) + if err_ != nil || val < 0 { + err = errInvalidParams + break + } + newValue = new(int) + *newValue = val + } + munger = func(in AccountSettings) (out AccountSettings, err error) { + out = in + out.AutoreplayLines = newValue + return + } + case "bouncer": + var newValue BouncerAllowedSetting + if strings.ToLower(params[1]) == "default" { + newValue = BouncerAllowedServerDefault + } else { + var enabled bool + enabled, err = utils.StringToBool(params[1]) + if enabled { + newValue = BouncerAllowedByUser + } else { + newValue = BouncerDisallowedByUser + } + } + if err == nil { + munger = func(in AccountSettings) (out AccountSettings, err error) { + out = in + out.AllowBouncer = newValue + return + } + } + case "autoreplay-joins": + var newValue bool + newValue, err = utils.StringToBool(params[1]) + if err == nil { + munger = func(in AccountSettings) (out AccountSettings, err error) { + out = in + out.AutoreplayJoins = newValue + return + } + } + default: + err = errInvalidParams + } + + if munger != nil { + finalSettings, err = server.accounts.ModifyAccountSettings(account, munger) + } + + switch err { + case nil: + nsNotice(rb, client.t("Successfully changed your account settings")) + displaySetting(params[0], finalSettings, client, rb) + case errInvalidParams, errAccountDoesNotExist, errFeatureDisabled, errAccountUnverified, errAccountUpdateFailed: + nsNotice(rb, client.t(err.Error())) + default: + // unknown error + nsNotice(rb, client.t("An error occurred")) + } } func nsDropHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { @@ -550,7 +777,7 @@ func nsPasswdHandler(server *Server, client *Client, command string, params []st } } default: - errorMessage = "Invalid parameters" + errorMessage = `Invalid parameters` } if errorMessage != "" { @@ -568,22 +795,12 @@ func nsPasswdHandler(server *Server, client *Client, command string, params []st } func nsEnforceHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + newParams := []string{"enforce"} if len(params) == 0 { - status := server.accounts.getStoredEnforcementStatus(client.Account()) - nsNotice(rb, fmt.Sprintf(client.t("Your current nickname enforcement is: %s"), status)) + nsGetHandler(server, client, "get", newParams, rb) } else { - method, err := nickReservationFromString(params[0]) - if err != nil { - nsNotice(rb, client.t("Invalid parameters")) - return - } - err = server.accounts.SetEnforcementStatus(client.Account(), method) - if err == nil { - nsNotice(rb, client.t("Enforcement method set")) - } else { - server.logger.Error("internal", "couldn't store NS ENFORCE data", err.Error()) - nsNotice(rb, client.t("An error occurred")) - } + newParams = append(newParams, params[0]) + nsSetHandler(server, client, "set", newParams, rb) } } diff --git a/irc/resume.go b/irc/resume.go index 9b9b1d05..1556bb02 100644 --- a/irc/resume.go +++ b/irc/resume.go @@ -18,7 +18,7 @@ type resumeTokenPair struct { } type ResumeManager struct { - sync.RWMutex // level 2 + sync.Mutex // level 2 resumeIDtoCreds map[string]resumeTokenPair server *Server @@ -59,8 +59,8 @@ func (rm *ResumeManager) VerifyToken(token string) (client *Client) { return } - rm.RLock() - defer rm.RUnlock() + rm.Lock() + defer rm.Unlock() id := token[:utils.SecretTokenLength] pair, ok := rm.resumeIDtoCreds[id] diff --git a/irc/services.go b/irc/services.go index b8a63f1a..8bde6ac4 100644 --- a/irc/services.go +++ b/irc/services.go @@ -18,6 +18,7 @@ import ( type ircService struct { Name string ShortName string + prefix string CommandAliases []string Commands map[string]*serviceCommand HelpBanner string @@ -29,8 +30,10 @@ type serviceCommand struct { capabs []string // oper capabs the given user has to have to access this command handler func(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) help string + helpStrings []string helpShort string authRequired bool + hidden bool enabled func(*Config) bool // is this command enabled in the server config? minParams int maxParams int // split into at most n params, with last param containing remaining unsplit text @@ -139,7 +142,7 @@ func servicePrivmsgHandler(service *ircService, server *Server, client *Client, func serviceRunCommand(service *ircService, server *Server, client *Client, cmd *serviceCommand, commandName string, params []string, rb *ResponseBuffer) { nick := rb.target.Nick() sendNotice := func(notice string) { - rb.Add(nil, service.Name, "NOTICE", nick, notice) + rb.Add(nil, service.prefix, "NOTICE", nick, notice) } if cmd == nil { @@ -180,7 +183,7 @@ func serviceHelpHandler(service *ircService, server *Server, client *Client, par nick := rb.target.Nick() config := server.Config() sendNotice := func(notice string) { - rb.Add(nil, service.Name, "NOTICE", nick, notice) + rb.Add(nil, service.prefix, "NOTICE", nick, notice) } sendNotice(ircfmt.Unescape(fmt.Sprintf("*** $b%s HELP$b ***", service.Name))) @@ -194,7 +197,7 @@ func serviceHelpHandler(service *ircService, server *Server, client *Client, par if 0 < len(commandInfo.capabs) && !client.HasRoleCapabs(commandInfo.capabs...) { continue } - if commandInfo.aliasOf != "" { + if commandInfo.aliasOf != "" || commandInfo.hidden { continue // don't show help lines for aliases } if commandInfo.enabled != nil && !commandInfo.enabled(config) { @@ -226,8 +229,18 @@ func serviceHelpHandler(service *ircService, server *Server, client *Client, par if commandInfo == nil { sendNotice(client.t(fmt.Sprintf("Unknown command. To see available commands, run /%s HELP", service.ShortName))) } else { - for _, line := range strings.Split(ircfmt.Unescape(client.t(commandInfo.help)), "\n") { - sendNotice(line) + helpStrings := commandInfo.helpStrings + if helpStrings == nil { + hsArray := [1]string{commandInfo.help} + helpStrings = hsArray[:] + } + for i, helpString := range helpStrings { + if 0 < i { + sendNotice("") + } + for _, line := range strings.Split(ircfmt.Unescape(client.t(helpString)), "\n") { + sendNotice(line) + } } } } @@ -241,6 +254,8 @@ func initializeServices() { oragonoServicesByCommandAlias = make(map[string]*ircService) for serviceName, service := range OragonoServices { + service.prefix = fmt.Sprintf("%s!%s@localhost", service.Name, service.Name) + // make `/MSG ServiceName HELP` work correctly service.Commands["help"] = &servHelpCmd @@ -257,8 +272,10 @@ func initializeServices() { // force devs to write a help entry for every command for commandName, commandInfo := range service.Commands { - if commandInfo.aliasOf == "" && (commandInfo.help == "" || commandInfo.helpShort == "") { - log.Fatal(fmt.Sprintf("help entry missing for %s command %s", serviceName, commandName)) + if commandInfo.aliasOf == "" && !commandInfo.hidden { + if (commandInfo.help == "" && commandInfo.helpStrings == nil) || commandInfo.helpShort == "" { + log.Fatal(fmt.Sprintf("help entry missing for %s command %s", serviceName, commandName)) + } } } } diff --git a/irc/utils/args.go b/irc/utils/args.go index 8f087423..6fe42a6b 100644 --- a/irc/utils/args.go +++ b/irc/utils/args.go @@ -3,6 +3,15 @@ package utils +import ( + "errors" + "strings" +) + +var ( + ErrInvalidParams = errors.New("Invalid parameters") +) + // ArgsToStrings takes the arguments and splits them into a series of strings, // each argument separated by delim and each string bounded by maxLength. func ArgsToStrings(maxLength int, arguments []string, delim string) []string { @@ -33,3 +42,15 @@ func ArgsToStrings(maxLength int, arguments []string, delim string) []string { return messages } + +func StringToBool(str string) (result bool, err error) { + switch strings.ToLower(str) { + case "on", "true", "t", "yes", "y": + result = true + case "off", "false", "f", "no", "n": + result = false + default: + err = ErrInvalidParams + } + return +} diff --git a/irc/utils/args_test.go b/irc/utils/args_test.go new file mode 100644 index 00000000..9346ea3a --- /dev/null +++ b/irc/utils/args_test.go @@ -0,0 +1,23 @@ +// Copyright (c) 2019 Shivaram Lingamneni +// released under the MIT license + +package utils + +import "testing" + +func TestStringToBool(t *testing.T) { + val, err := StringToBool("on") + assertEqual(val, true, t) + assertEqual(err, nil, t) + + val, err = StringToBool("n") + assertEqual(val, false, t) + assertEqual(err, nil, t) + + val, err = StringToBool("OFF") + assertEqual(val, false, t) + assertEqual(err, nil, t) + + val, err = StringToBool("default") + assertEqual(err, ErrInvalidParams, t) +} diff --git a/oragono.yaml b/oragono.yaml index bd3c7534..4b483e12 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -134,7 +134,7 @@ server: # defaults to true when unset for that reason. force-trailing: true - # some clients (ZNC 1.6.x and lower, Pidgin 2.12 and lower, Adium) do not + # some clients (ZNC 1.6.x and lower, Pidgin 2.12 and lower) do not # respond correctly to SASL messages with the server name as a prefix: # https://github.com/znc/znc/issues/1212 # this works around that bug, allowing them to use SASL.