3
0
mirror of https://github.com/ergochat/ergo.git synced 2024-11-13 23:49:30 +01:00

draft/resume-0.2 implementation, message history support

This commit is contained in:
Shivaram Lingamneni 2018-11-26 05:23:27 -05:00
parent 70364f5f67
commit a0bf548fc5
28 changed files with 1294 additions and 317 deletions

View File

@ -20,6 +20,7 @@ test:
python3 ./gencapdefs.py | diff - ${capdef_file}
cd irc && go test . && go vet .
cd irc/caps && go test . && go vet .
cd irc/history && go test . && go vet .
cd irc/isupport && go test . && go vet .
cd irc/modes && go test . && go vet .
cd irc/passwd && go test . && go vet .

View File

@ -107,7 +107,7 @@ CAPDEFS = [
),
CapDef(
identifier="Resume",
name="draft/resume",
name="draft/resume-0.2",
url="https://github.com/DanielOaks/ircv3-specifications/blob/master+resume/extensions/resume.md",
standard="proposed IRCv3",
),

View File

@ -4,9 +4,6 @@
package irc
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@ -20,6 +17,7 @@ import (
"github.com/oragono/oragono/irc/caps"
"github.com/oragono/oragono/irc/passwd"
"github.com/oragono/oragono/irc/utils"
"github.com/tidwall/buntdb"
)
@ -336,9 +334,7 @@ func (am *AccountManager) dispatchCallback(client *Client, casefoldedAccount str
func (am *AccountManager) dispatchMailtoCallback(client *Client, casefoldedAccount string, callbackValue string) (code string, err error) {
config := am.server.AccountConfig().Registration.Callbacks.Mailto
buf := make([]byte, 16)
rand.Read(buf)
code = hex.EncodeToString(buf)
code = utils.GenerateSecretToken()
subject := config.VerifyMessageSubject
if subject == "" {
@ -412,7 +408,7 @@ func (am *AccountManager) Verify(client *Client, account string, code string) er
storedCode, err := tx.Get(verificationCodeKey)
if err == nil {
// this is probably unnecessary
if storedCode == "" || subtle.ConstantTimeCompare([]byte(code), []byte(storedCode)) == 1 {
if storedCode == "" || utils.SecretTokensMatch(storedCode, code) {
success = true
}
}

View File

@ -73,7 +73,7 @@ const (
// https://github.com/SaberUK/ircv3-specifications/blob/rename/extensions/rename.md
Rename Capability = iota
// Resume is the proposed IRCv3 capability named "draft/resume":
// Resume is the proposed IRCv3 capability named "draft/resume-0.2":
// https://github.com/DanielOaks/ircv3-specifications/blob/master+resume/extensions/resume.md
Resume Capability = iota
@ -112,7 +112,7 @@ var (
"draft/message-tags-0.2",
"multi-prefix",
"draft/rename",
"draft/resume",
"draft/resume-0.2",
"sasl",
"server-time",
"sts",

View File

@ -7,7 +7,6 @@ package irc
import (
"bytes"
"crypto/subtle"
"fmt"
"strconv"
"time"
@ -16,7 +15,9 @@ import (
"github.com/goshuirc/irc-go/ircmsg"
"github.com/oragono/oragono/irc/caps"
"github.com/oragono/oragono/irc/history"
"github.com/oragono/oragono/irc/modes"
"github.com/oragono/oragono/irc/utils"
)
// Channel represents a channel that clients can join.
@ -39,6 +40,7 @@ type Channel struct {
topicSetTime time.Time
userLimit uint64
accountToUMode map[string]modes.Mode
history history.Buffer
}
// NewChannel creates a new channel from a `Server` and a `name`
@ -65,14 +67,18 @@ func NewChannel(s *Server, name string, regInfo *RegisteredChannel) *Channel {
accountToUMode: make(map[string]modes.Mode),
}
config := s.Config()
if regInfo != nil {
channel.applyRegInfo(regInfo)
} else {
for _, mode := range s.DefaultChannelModes() {
for _, mode := range config.Channels.defaultModes {
channel.flags.SetMode(mode, true)
}
}
channel.history.Initialize(config.History.ChannelLength)
return channel
}
@ -214,9 +220,7 @@ func (channel *Channel) Names(client *Client, rb *ResponseBuffer) {
prefix := modes.Prefixes(isMultiPrefix)
if buffer.Len()+len(nick)+len(prefix)+1 > maxNamLen {
namesLines = append(namesLines, buffer.String())
// memset(&buffer, 0, sizeof(bytes.Buffer));
var newBuffer bytes.Buffer
buffer = newBuffer
buffer.Reset()
}
if buffer.Len() > 0 {
buffer.WriteString(" ")
@ -344,11 +348,7 @@ func (channel *Channel) IsFull() bool {
// CheckKey returns true if the key is not set or matches the given key.
func (channel *Channel) CheckKey(key string) bool {
chkey := channel.Key()
if chkey == "" {
return true
}
return subtle.ConstantTimeCompare([]byte(key), []byte(chkey)) == 1
return chkey == "" || utils.SecretTokensMatch(chkey, key)
}
func (channel *Channel) IsEmpty() bool {
@ -462,6 +462,12 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
if givenMode != 0 {
rb.Add(nil, client.server.name, "MODE", chname, modestr, nick)
}
channel.history.Add(history.Item{
Type: history.Join,
Nick: nickmask,
AccountName: accountName,
})
}
// Part parts the given client from this channel, with the given message.
@ -480,9 +486,126 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer)
}
rb.Add(nil, nickmask, "PART", chname, message)
channel.history.Add(history.Item{
Type: history.Part,
Nick: nickmask,
AccountName: client.AccountName(),
Message: utils.MakeSplitMessage(message, true),
})
client.server.logger.Debug("part", fmt.Sprintf("%s left channel %s", client.nick, chname))
}
// Resume is called after a successful global resume to:
// 1. Replace the old client with the new in the channel's data structures
// 2. Send JOIN and MODE lines to channel participants (including the new client)
// 3. Replay missed message history to the client
func (channel *Channel) Resume(newClient, oldClient *Client, timestamp time.Time) {
now := time.Now()
channel.resumeAndAnnounce(newClient, oldClient)
if !timestamp.IsZero() {
channel.replayHistory(newClient, timestamp, now)
}
}
func (channel *Channel) resumeAndAnnounce(newClient, oldClient *Client) {
var oldModeSet *modes.ModeSet
func() {
channel.joinPartMutex.Lock()
defer channel.joinPartMutex.Unlock()
defer channel.regenerateMembersCache()
channel.stateMutex.Lock()
defer channel.stateMutex.Unlock()
newClient.channels[channel] = true
oldModeSet = channel.members[oldClient]
if oldModeSet == nil {
oldModeSet = modes.NewModeSet()
}
channel.members.Remove(oldClient)
channel.members[newClient] = oldModeSet
}()
// construct fake modestring if necessary
oldModes := oldModeSet.String()
if 0 < len(oldModes) {
oldModes = "+" + oldModes
}
// send join for old clients
nick := newClient.Nick()
nickMask := newClient.NickMaskString()
accountName := newClient.AccountName()
realName := newClient.Realname()
for _, member := range channel.Members() {
if member.capabilities.Has(caps.Resume) {
continue
}
if member.capabilities.Has(caps.ExtendedJoin) {
member.Send(nil, nickMask, "JOIN", channel.name, accountName, realName)
} else {
member.Send(nil, nickMask, "JOIN", channel.name)
}
if 0 < len(oldModes) {
member.Send(nil, channel.server.name, "MODE", channel.name, oldModes, nick)
}
}
rb := NewResponseBuffer(newClient)
// use blocking i/o to synchronize with the later history replay
rb.SetBlocking(true)
if newClient.capabilities.Has(caps.ExtendedJoin) {
rb.Add(nil, nickMask, "JOIN", channel.name, accountName, realName)
} else {
rb.Add(nil, nickMask, "JOIN", channel.name)
}
channel.SendTopic(newClient, rb)
channel.Names(newClient, rb)
if 0 < len(oldModes) {
rb.Add(nil, newClient.server.name, "MODE", channel.name, oldModes, nick)
}
rb.Send()
}
func (channel *Channel) replayHistory(newClient *Client, after time.Time, before time.Time) {
chname := channel.Name()
extendedJoin := newClient.capabilities.Has(caps.ExtendedJoin)
items, complete := channel.history.Between(after, before)
for _, item := range items {
switch item.Type {
case history.Privmsg:
newClient.sendSplitMsgFromClientInternal(true, item.Time, item.Msgid, item.Nick, item.AccountName, nil, "PRIVMSG", chname, item.Message)
case history.Notice:
newClient.sendSplitMsgFromClientInternal(true, item.Time, item.Msgid, item.Nick, item.AccountName, nil, "NOTICE", chname, item.Message)
case history.Join:
if extendedJoin {
newClient.sendInternal(true, item.Time, nil, item.Nick, "JOIN", chname, item.AccountName, "")
} else {
newClient.sendInternal(true, item.Time, nil, item.Nick, "JOIN", chname)
}
case history.Quit:
// XXX: send QUIT as PART to avoid having to correctly deduplicate and synchronize
// QUIT messages across channels
fallthrough
case history.Part:
newClient.sendInternal(true, item.Time, nil, item.Nick, "PART", chname, item.Message.Original)
case history.Kick:
newClient.sendInternal(true, item.Time, nil, item.Nick, "KICK", chname, item.Msgid, item.Message.Original)
}
}
if !complete && !newClient.resumeDetails.HistoryIncomplete {
// warn here if we didn't warn already
newClient.sendInternal(true, time.Time{}, nil, "HistServ", "NOTICE", chname, newClient.t("Some additional message history may have been lost"))
}
}
// SendTopic sends the channel topic to the given client.
func (channel *Channel) SendTopic(client *Client, rb *ResponseBuffer) {
if !channel.hasClient(client) {
@ -622,16 +745,16 @@ func (channel *Channel) sendMessage(msgid, cmd string, requiredCaps []caps.Capab
}
// SplitPrivMsg sends a private message to everyone in this channel.
func (channel *Channel) SplitPrivMsg(msgid string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message SplitMessage, rb *ResponseBuffer) {
channel.sendSplitMessage(msgid, "PRIVMSG", minPrefix, clientOnlyTags, client, &message, rb)
func (channel *Channel) SplitPrivMsg(msgid string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message utils.SplitMessage, rb *ResponseBuffer) {
channel.sendSplitMessage(msgid, "PRIVMSG", history.Privmsg, minPrefix, clientOnlyTags, client, &message, rb)
}
// SplitNotice sends a private message to everyone in this channel.
func (channel *Channel) SplitNotice(msgid string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message SplitMessage, rb *ResponseBuffer) {
channel.sendSplitMessage(msgid, "NOTICE", minPrefix, clientOnlyTags, client, &message, rb)
func (channel *Channel) SplitNotice(msgid string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message utils.SplitMessage, rb *ResponseBuffer) {
channel.sendSplitMessage(msgid, "NOTICE", history.Notice, minPrefix, clientOnlyTags, client, &message, rb)
}
func (channel *Channel) sendSplitMessage(msgid, cmd string, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message *SplitMessage, rb *ResponseBuffer) {
func (channel *Channel) sendSplitMessage(msgid, cmd string, histType history.ItemType, minPrefix *modes.Mode, clientOnlyTags *map[string]ircmsg.TagValue, client *Client, message *utils.SplitMessage, rb *ResponseBuffer) {
if !channel.CanSpeak(client) {
rb.Add(nil, client.server.name, ERR_CANNOTSENDTOCHAN, channel.name, client.t("Cannot send to channel"))
return
@ -654,6 +777,10 @@ func (channel *Channel) sendSplitMessage(msgid, cmd string, minPrefix *modes.Mod
rb.AddSplitMessageFromClient(msgid, client, tagsToUse, cmd, channel.name, *message)
}
}
nickmask := client.NickMaskString()
account := client.AccountName()
for _, member := range channel.Members() {
if minPrefix != nil && !channel.ClientIsAtLeast(member, minPrefixMode) {
// STATUSMSG
@ -668,12 +795,21 @@ func (channel *Channel) sendSplitMessage(msgid, cmd string, minPrefix *modes.Mod
tagsToUse = clientOnlyTags
}
// TODO(slingamn) evaluate an optimization where we reuse `nickmask` and `account`
if message == nil {
member.SendFromClient(msgid, client, tagsToUse, cmd, channel.name)
} else {
member.SendSplitMsgFromClient(msgid, client, tagsToUse, cmd, channel.name, *message)
}
}
channel.history.Add(history.Item{
Type: histType,
Msgid: msgid,
Message: *message,
Nick: nickmask,
AccountName: account,
})
}
func (channel *Channel) applyModeToMember(client *Client, mode modes.Mode, op modes.ModeOp, nick string, rb *ResponseBuffer) (result *modes.ModeChange) {
@ -806,6 +942,14 @@ func (channel *Channel) Kick(client *Client, target *Client, comment string, rb
member.Send(nil, clientMask, "KICK", channel.name, targetNick, comment)
}
channel.history.Add(history.Item{
Type: history.Kick,
Nick: clientMask,
Message: utils.MakeSplitMessage(comment, true),
AccountName: target.AccountName(),
Msgid: targetNick, // XXX abuse this field
})
channel.Quit(target)
}

View File

@ -19,6 +19,7 @@ import (
"github.com/goshuirc/irc-go/ircmsg"
ident "github.com/oragono/go-ident"
"github.com/oragono/oragono/irc/caps"
"github.com/oragono/oragono/irc/history"
"github.com/oragono/oragono/irc/modes"
"github.com/oragono/oragono/irc/sno"
"github.com/oragono/oragono/irc/utils"
@ -27,12 +28,27 @@ import (
const (
// IdentTimeoutSeconds is how many seconds before our ident (username) check times out.
IdentTimeoutSeconds = 1.5
IRCv3TimestampFormat = "2006-01-02T15:04:05.999Z"
)
var (
LoopbackIP = net.ParseIP("127.0.0.1")
)
// ResumeDetails is a place to stash data at various stages of
// the resume process: when handling the RESUME command itself,
// when completing the registration, and when rejoining channels.
type ResumeDetails struct {
OldClient *Client
OldNick string
OldNickMask string
PresentedToken string
Timestamp time.Time
ResumedAt time.Time
Channels []string
HistoryIncomplete bool
}
// Client is an IRC client.
type Client struct {
account string
@ -71,6 +87,7 @@ type Client struct {
realname string
registered bool
resumeDetails *ResumeDetails
resumeToken string
saslInProgress bool
saslMechanism string
saslValue string
@ -79,6 +96,7 @@ type Client struct {
stateMutex sync.RWMutex // tier 1
username string
vhost string
history *history.Buffer
}
// NewClient sets up a new client and starts its goroutine.
@ -101,6 +119,7 @@ func NewClient(server *Server, conn net.Conn, isTLS bool) {
nick: "*", // * is used until actual nick is given
nickCasefolded: "*",
nickMaskString: "*", // * is used until actual nick is given
history: history.NewHistoryBuffer(config.History.ClientLength),
}
client.languages = server.languages.Default()
@ -350,124 +369,199 @@ func (client *Client) TryResume() {
}
server := client.server
// just grab these mutexes for safety. later we can work out whether we can grab+release them earlier
server.clients.Lock()
defer server.clients.Unlock()
server.channels.Lock()
defer server.channels.Unlock()
config := server.Config()
oldnick := client.resumeDetails.OldNick
timestamp := client.resumeDetails.Timestamp
var timestampString string
if timestamp != nil {
timestampString = timestamp.UTC().Format("2006-01-02T15:04:05.999Z")
if !timestamp.IsZero() {
timestampString = timestamp.UTC().Format(IRCv3TimestampFormat)
}
// can't use server.clients.Get since we hold server.clients' tier 1 mutex
casefoldedName, err := CasefoldName(oldnick)
if err != nil {
client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, old client not found"))
return
}
oldClient := server.clients.byNick[casefoldedName]
oldClient := server.clients.Get(oldnick)
if oldClient == nil {
client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, old client not found"))
client.Send(nil, server.name, "RESUME", "ERR", oldnick, client.t("Cannot resume connection, old client not found"))
client.resumeDetails = nil
return
}
oldNick := oldClient.Nick()
oldNickmask := oldClient.NickMaskString()
resumeAllowed := config.Server.AllowPlaintextResume || (oldClient.HasMode(modes.TLS) && client.HasMode(modes.TLS))
if !resumeAllowed {
client.Send(nil, server.name, "RESUME", "ERR", oldnick, client.t("Cannot resume connection, old and new clients must have TLS"))
client.resumeDetails = nil
return
}
oldAccountName := oldClient.Account()
newAccountName := client.Account()
if oldAccountName == "" || newAccountName == "" || oldAccountName != newAccountName {
client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, old and new clients must be logged into the same account"))
oldResumeToken := oldClient.ResumeToken()
if oldResumeToken == "" || !utils.SecretTokensMatch(oldResumeToken, client.resumeDetails.PresentedToken) {
client.Send(nil, server.name, "RESUME", "ERR", client.t("Cannot resume connection, invalid resume token"))
client.resumeDetails = nil
return
}
if !oldClient.HasMode(modes.TLS) || !client.HasMode(modes.TLS) {
client.Send(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, old and new clients must have TLS"))
err := server.clients.Resume(client, oldClient)
if err != nil {
client.resumeDetails = nil
client.Send(nil, server.name, "RESUME", "ERR", client.t("Cannot resume connection"))
return
}
// unmark the new client's nick as being occupied
server.clients.removeInternal(client)
// this is a bit racey
client.resumeDetails.ResumedAt = time.Now()
// send RESUMED to the reconnecting client
if timestamp == nil {
client.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname())
} else {
client.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname(), timestampString)
client.nickTimer.Touch()
// resume successful, proceed to copy client state (nickname, flags, etc.)
// after this, the server thinks that `newClient` owns the nickname
client.resumeDetails.OldClient = oldClient
// transfer monitor stuff
server.monitorManager.Resume(client, oldClient)
// record the names, not the pointers, of the channels,
// to avoid dumb annoying race conditions
channels := oldClient.Channels()
client.resumeDetails.Channels = make([]string, len(channels))
for i, channel := range channels {
client.resumeDetails.Channels[i] = channel.Name()
}
// send QUIT/RESUMED to friends
for friend := range oldClient.Friends() {
username := client.Username()
hostname := client.Hostname()
friends := make(ClientSet)
oldestLostMessage := time.Now()
// work out how much time, if any, is not covered by history buffers
for _, channel := range channels {
for _, member := range channel.Members() {
friends.Add(member)
lastDiscarded := channel.history.LastDiscarded()
if lastDiscarded.Before(oldestLostMessage) {
oldestLostMessage = lastDiscarded
}
}
}
personalHistory := oldClient.history.All()
lastDiscarded := oldClient.history.LastDiscarded()
if lastDiscarded.Before(oldestLostMessage) {
oldestLostMessage = lastDiscarded
}
for _, item := range personalHistory {
if item.Type == history.Privmsg || item.Type == history.Notice {
sender := server.clients.Get(item.Nick)
if sender != nil {
friends.Add(sender)
}
}
}
gap := lastDiscarded.Sub(timestamp)
client.resumeDetails.HistoryIncomplete = gap > 0
gapSeconds := int(gap.Seconds()) + 1 // round up to avoid confusion
// send quit/resume messages to friends
for friend := range friends {
if friend.capabilities.Has(caps.Resume) {
if timestamp == nil {
friend.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname())
if timestamp.IsZero() {
friend.Send(nil, oldNickmask, "RESUMED", username, hostname)
} else {
friend.Send(nil, oldClient.NickMaskString(), "RESUMED", oldClient.nick, client.username, client.Hostname(), timestampString)
friend.Send(nil, oldNickmask, "RESUMED", username, hostname, timestampString)
}
} else {
friend.Send(nil, oldClient.NickMaskString(), "QUIT", friend.t("Client reconnected"))
if client.resumeDetails.HistoryIncomplete {
friend.Send(nil, oldNickmask, "QUIT", fmt.Sprintf(friend.t("Client reconnected (up to %d seconds of history lost)"), gapSeconds))
} else {
friend.Send(nil, oldNickmask, "QUIT", fmt.Sprintf(friend.t("Client reconnected")))
}
}
}
// apply old client's details to new client
client.nick = oldClient.nick
client.updateNickMaskNoMutex()
rejoinChannel := func(channel *Channel) {
channel.joinPartMutex.Lock()
defer channel.joinPartMutex.Unlock()
channel.stateMutex.Lock()
client.channels[channel] = true
client.resumeDetails.SendFakeJoinsFor = append(client.resumeDetails.SendFakeJoinsFor, channel.name)
oldModeSet := channel.members[oldClient]
channel.members.Remove(oldClient)
channel.members[client] = oldModeSet
channel.stateMutex.Unlock()
channel.regenerateMembersCache()
// construct fake modestring if necessary
oldModes := oldModeSet.String()
var params []string
if 0 < len(oldModes) {
params = []string{channel.name, "+" + oldModes}
for range oldModes {
params = append(params, client.nick)
}
if client.resumeDetails.HistoryIncomplete {
client.Send(nil, "RESUME", "WARN", fmt.Sprintf(client.t("Resume may have lost up to %d seconds of history"), gapSeconds))
}
// send join for old clients
for member := range channel.members {
if member.capabilities.Has(caps.Resume) {
client.Send(nil, "RESUME", "SUCCESS", oldNick)
// after we send the rest of the registration burst, we'll try rejoining channels
}
func (client *Client) tryResumeChannels() {
details := client.resumeDetails
if details == nil {
return
}
channels := make([]*Channel, len(details.Channels))
for _, name := range details.Channels {
channel := client.server.channels.Get(name)
if channel == nil {
continue
}
if member.capabilities.Has(caps.ExtendedJoin) {
member.Send(nil, client.nickMaskString, "JOIN", channel.name, client.AccountName(), client.realname)
} else {
member.Send(nil, client.nickMaskString, "JOIN", channel.name)
channel.Resume(client, details.OldClient, details.Timestamp)
channels = append(channels, channel)
}
// send fake modestring if necessary
if 0 < len(oldModes) {
member.Send(nil, server.name, "MODE", params...)
// replay direct PRIVSMG history
if !details.Timestamp.IsZero() {
now := time.Now()
nick := client.Nick()
items, complete := client.history.Between(details.Timestamp, now)
for _, item := range items {
var command string
switch item.Type {
case history.Privmsg:
command = "PRIVMSG"
case history.Notice:
command = "NOTICE"
default:
continue
}
client.sendSplitMsgFromClientInternal(true, item.Time, item.Msgid, item.Nick, item.AccountName, nil, command, nick, item.Message)
}
if !complete {
client.Send(nil, "HistServ", "NOTICE", nick, client.t("Some additional message history may have been lost"))
}
}
for channel := range oldClient.channels {
rejoinChannel(channel)
details.OldClient.destroy(true)
}
server.clients.byNick[oldnick] = client
// copy applicable state from oldClient to client as part of a resume
func (client *Client) copyResumeData(oldClient *Client) {
oldClient.stateMutex.RLock()
flags := oldClient.flags
history := oldClient.history
nick := oldClient.nick
nickCasefolded := oldClient.nickCasefolded
vhost := oldClient.vhost
account := oldClient.account
accountName := oldClient.accountName
oldClient.stateMutex.RUnlock()
oldClient.destroy(true)
// copy all flags, *except* TLS (in the case that the admins enabled
// resume over plaintext)
hasTLS := client.flags.HasMode(modes.TLS)
temp := modes.NewModeSet()
temp.Copy(flags)
temp.SetMode(modes.TLS, hasTLS)
client.flags.Copy(temp)
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
// reuse the old client's history buffer
client.history = history
// copy other data
client.nick = nick
client.nickCasefolded = nickCasefolded
client.vhost = vhost
client.account = account
client.accountName = accountName
client.updateNickMaskNoMutex()
}
// IdleTime returns how long this client's been idle.
@ -501,6 +595,26 @@ func (client *Client) HasUsername() bool {
return client.username != "" && client.username != "*"
}
func (client *Client) SetNames(username, realname string) error {
_, err := CasefoldName(username)
if err != nil {
return errInvalidUsername
}
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
if client.username == "" {
client.username = "~" + username
}
if client.realname == "" {
client.realname = realname
}
return nil
}
// HasRoleCapabs returns true if client has the given (role) capabilities.
func (client *Client) HasRoleCapabs(capabs ...string) bool {
oper := client.Oper()
@ -561,7 +675,7 @@ func (client *Client) Friends(capabs ...caps.Capability) ClientSet {
func (client *Client) sendChghost(oldNickMask string, vhost string) {
username := client.Username()
for fClient := range client.Friends(caps.ChgHost) {
fClient.sendFromClientInternal("", client, oldNickMask, nil, "CHGHOST", username, vhost)
fClient.sendFromClientInternal(false, time.Time{}, "", oldNickMask, client.AccountName(), nil, "CHGHOST", username, vhost)
}
}
@ -711,14 +825,14 @@ func (client *Client) Quit(message string) {
// destroy gets rid of a client, removes them from server lists etc.
func (client *Client) destroy(beingResumed bool) {
// allow destroy() to execute at most once
if !beingResumed {
client.stateMutex.Lock()
}
isDestroyed := client.isDestroyed
client.isDestroyed = true
if !beingResumed {
quitMessage := client.quitMessage
nickMaskString := client.nickMaskString
accountName := client.accountName
client.stateMutex.Unlock()
}
if isDestroyed {
return
}
@ -758,6 +872,12 @@ func (client *Client) destroy(beingResumed bool) {
for _, channel := range client.Channels() {
if !beingResumed {
channel.Quit(client)
channel.history.Add(history.Item{
Type: history.Quit,
Nick: nickMaskString,
AccountName: accountName,
Message: utils.MakeSplitMessage(quitMessage, true),
})
}
for _, member := range channel.Members() {
friends.Add(member)
@ -791,10 +911,10 @@ func (client *Client) destroy(beingResumed bool) {
}
for friend := range friends {
if client.quitMessage == "" {
client.quitMessage = "Exited"
if quitMessage == "" {
quitMessage = "Exited"
}
friend.Send(nil, client.nickMaskString, "QUIT", client.quitMessage)
friend.Send(nil, client.nickMaskString, "QUIT", quitMessage)
}
}
if !client.exitedSnomaskSent {
@ -808,43 +928,50 @@ func (client *Client) destroy(beingResumed bool) {
// SendSplitMsgFromClient sends an IRC PRIVMSG/NOTICE coming from a specific client.
// Adds account-tag to the line as well.
func (client *Client) SendSplitMsgFromClient(msgid string, from *Client, tags *map[string]ircmsg.TagValue, command, target string, message SplitMessage) {
if client.capabilities.Has(caps.MaxLine) {
client.SendFromClient(msgid, from, tags, command, target, message.ForMaxLine)
func (client *Client) SendSplitMsgFromClient(msgid string, from *Client, tags Tags, command, target string, message utils.SplitMessage) {
client.sendSplitMsgFromClientInternal(false, time.Time{}, msgid, from.NickMaskString(), from.AccountName(), tags, command, target, message)
}
func (client *Client) sendSplitMsgFromClientInternal(blocking bool, serverTime time.Time, msgid string, nickmask, accountName string, tags Tags, command, target string, message utils.SplitMessage) {
if client.capabilities.Has(caps.MaxLine) || message.Wrapped == nil {
client.sendFromClientInternal(blocking, serverTime, msgid, nickmask, accountName, tags, command, target, message.Original)
} else {
for _, str := range message.For512 {
client.SendFromClient(msgid, from, tags, command, target, str)
for _, str := range message.Wrapped {
client.sendFromClientInternal(blocking, serverTime, msgid, nickmask, accountName, tags, command, target, str)
}
}
}
// SendFromClient sends an IRC line coming from a specific client.
// Adds account-tag to the line as well.
func (client *Client) SendFromClient(msgid string, from *Client, tags *map[string]ircmsg.TagValue, command string, params ...string) error {
return client.sendFromClientInternal(msgid, from, from.NickMaskString(), tags, command, params...)
func (client *Client) SendFromClient(msgid string, from *Client, tags Tags, command string, params ...string) error {
return client.sendFromClientInternal(false, time.Time{}, msgid, from.NickMaskString(), from.AccountName(), tags, command, params...)
}
// helper to add a tag to `tags` (or create a new tag set if the current one is nil)
func ensureTag(tags Tags, tagName, tagValue string) (result Tags) {
if tags == nil {
result = ircmsg.MakeTags(tagName, tagValue)
} else {
result = tags
(*tags)[tagName] = ircmsg.MakeTagValue(tagValue)
}
return
}
// XXX this is a hack where we allow overriding the client's nickmask
// this is to support CHGHOST, which requires that we send the *original* nickmask with the response
func (client *Client) sendFromClientInternal(msgid string, from *Client, nickmask string, tags *map[string]ircmsg.TagValue, command string, params ...string) error {
func (client *Client) sendFromClientInternal(blocking bool, serverTime time.Time, msgid string, nickmask, accountName string, tags Tags, command string, params ...string) error {
// attach account-tag
if client.capabilities.Has(caps.AccountTag) && from.LoggedIntoAccount() {
if tags == nil {
tags = ircmsg.MakeTags("account", from.AccountName())
} else {
(*tags)["account"] = ircmsg.MakeTagValue(from.AccountName())
}
if client.capabilities.Has(caps.AccountTag) && accountName != "*" {
tags = ensureTag(tags, "account", accountName)
}
// attach message-id
if len(msgid) > 0 && client.capabilities.Has(caps.MessageTags) {
if tags == nil {
tags = ircmsg.MakeTags("draft/msgid", msgid)
} else {
(*tags)["draft/msgid"] = ircmsg.MakeTagValue(msgid)
}
tags = ensureTag(tags, "draft/msgid", msgid)
}
return client.Send(tags, nickmask, command, params...)
return client.sendInternal(blocking, serverTime, tags, nickmask, command, params...)
}
var (
@ -861,7 +988,7 @@ var (
)
// SendRawMessage sends a raw message to the client.
func (client *Client) SendRawMessage(message ircmsg.IrcMessage) error {
func (client *Client) SendRawMessage(message ircmsg.IrcMessage, blocking bool) error {
// use dumb hack to force the last param to be a trailing param if required
var usedTrailingHack bool
if commandsThatMustUseTrailing[strings.ToUpper(message.Command)] && len(message.Params) > 0 {
@ -883,7 +1010,11 @@ func (client *Client) SendRawMessage(message ircmsg.IrcMessage) error {
message = ircmsg.MakeMessage(nil, client.server.name, ERR_UNKNOWNERROR, "*", "Error assembling message for sending")
line, _ := message.LineBytes()
if blocking {
client.socket.BlockingWrite(line)
} else {
client.socket.Write(line)
}
return err
}
@ -898,36 +1029,40 @@ func (client *Client) SendRawMessage(message ircmsg.IrcMessage) error {
client.server.logger.Debug("useroutput", client.nick, " ->", logline)
}
client.socket.Write(line)
return nil
}
// Send sends an IRC line to the client.
func (client *Client) Send(tags *map[string]ircmsg.TagValue, prefix string, command string, params ...string) error {
// attach server-time
if client.capabilities.Has(caps.ServerTime) {
t := time.Now().UTC().Format("2006-01-02T15:04:05.999Z")
if tags == nil {
tags = ircmsg.MakeTags("time", t)
if blocking {
return client.socket.BlockingWrite(line)
} else {
(*tags)["time"] = ircmsg.MakeTagValue(t)
return client.socket.Write(line)
}
}
func (client *Client) sendInternal(blocking bool, serverTime time.Time, tags Tags, prefix string, command string, params ...string) error {
// attach server time
if client.capabilities.Has(caps.ServerTime) {
if serverTime.IsZero() {
serverTime = time.Now()
}
tags = ensureTag(tags, "time", serverTime.UTC().Format(IRCv3TimestampFormat))
}
// send out the message
message := ircmsg.MakeMessage(tags, prefix, command, params...)
client.SendRawMessage(message)
client.SendRawMessage(message, blocking)
return nil
}
// Send sends an IRC line to the client.
func (client *Client) Send(tags Tags, prefix string, command string, params ...string) error {
return client.sendInternal(false, time.Time{}, tags, prefix, command, params...)
}
// Notice sends the client a notice from the server.
func (client *Client) Notice(text string) {
limit := 400
if client.capabilities.Has(caps.MaxLine) {
limit = client.server.Limits().LineLen.Rest - 110
}
lines := wordWrap(text, limit)
lines := utils.WordWrap(text, limit)
// force blank lines to be sent if we receive them
if len(lines) == 0 {
@ -950,3 +1085,20 @@ func (client *Client) removeChannel(channel *Channel) {
delete(client.channels, channel)
client.stateMutex.Unlock()
}
// Ensures the client has a cryptographically secure resume token, and returns
// its value. An error is returned if a token was previously assigned.
func (client *Client) generateResumeToken() (token string, err error) {
newToken := utils.GenerateSecretToken()
client.stateMutex.Lock()
defer client.stateMutex.Unlock()
if client.resumeToken == "" {
client.resumeToken = newToken
} else {
err = errResumeTokenAlreadySet
}
return client.resumeToken, err
}

View File

@ -63,17 +63,17 @@ func (clients *ClientManager) Get(nick string) *Client {
return nil
}
func (clients *ClientManager) removeInternal(client *Client) (removed bool) {
func (clients *ClientManager) removeInternal(client *Client) (err error) {
// requires holding the writable Lock()
oldcfnick := client.NickCasefolded()
currentEntry, present := clients.byNick[oldcfnick]
if present {
if currentEntry == client {
delete(clients.byNick, oldcfnick)
removed = true
} else {
// this shouldn't happen, but we can ignore it
client.server.logger.Warning("internal", fmt.Sprintf("clients for nick %s out of sync", oldcfnick))
err = errNickMissing
}
}
return
@ -87,7 +87,28 @@ func (clients *ClientManager) Remove(client *Client) error {
if !client.HasNick() {
return errNickMissing
}
clients.removeInternal(client)
return clients.removeInternal(client)
}
// Resume atomically replaces `oldClient` with `newClient`, updating
// newClient's data to match. It is the caller's responsibility first
// to verify that the resume is allowed, and then later to call oldClient.destroy().
func (clients *ClientManager) Resume(newClient, oldClient *Client) (err error) {
clients.Lock()
defer clients.Unlock()
// atomically grant the new client the old nick
err = clients.removeInternal(oldClient)
if err != nil {
// oldClient no longer owns its nick, fail out
return err
}
// nick has been reclaimed, grant it to the new client
clients.removeInternal(newClient)
clients.byNick[oldClient.NickCasefolded()] = newClient
newClient.copyResumeData(oldClient)
return nil
}

View File

@ -222,7 +222,7 @@ func init() {
"RESUME": {
handler: resumeHandler,
usablePreReg: true,
minParams: 1,
minParams: 2,
},
"SAJOIN": {
handler: sajoinHandler,

View File

@ -223,6 +223,7 @@ type Config struct {
WebIRC []webircConfig `yaml:"webirc"`
MaxSendQString string `yaml:"max-sendq"`
MaxSendQBytes int
AllowPlaintextResume bool `yaml:"allow-plaintext-resume"`
ConnectionLimiter connection_limits.LimiterConfig `yaml:"connection-limits"`
ConnectionThrottler connection_limits.ThrottlerConfig `yaml:"connection-throttling"`
}
@ -266,6 +267,12 @@ type Config struct {
Fakelag FakelagConfig
History struct {
Enabled bool
ChannelLength int `yaml:"channel-length"`
ClientLength int `yaml:"client-length"`
}
Filename string
}
@ -712,5 +719,13 @@ func LoadConfig(filename string) (config *Config, err error) {
config.Accounts.Registration.BcryptCost = passwd.DefaultCost
}
// in the current implementation, we disable history by creating a history buffer
// with zero capacity. but the `enabled` config option MUST be respected regardless
// of this detail
if !config.History.Enabled {
config.History.ChannelLength = 0
config.History.ClientLength = 0
}
return config, nil
}

View File

@ -38,6 +38,8 @@ var (
errRenamePrivsNeeded = errors.New("Only chanops can rename channels")
errInsufficientPrivs = errors.New("Insufficient privileges")
errSaslFail = errors.New("SASL failed")
errResumeTokenAlreadySet = errors.New("Client was already assigned a resume token")
errInvalidUsername = errors.New("Invalid username")
)
// Socket Errors

View File

@ -102,6 +102,12 @@ func (client *Client) Realname() string {
return client.realname
}
func (client *Client) ResumeToken() string {
client.stateMutex.RLock()
defer client.stateMutex.RUnlock()
return client.resumeToken
}
func (client *Client) Oper() *Oper {
client.stateMutex.RLock()
defer client.stateMutex.RUnlock()

View File

@ -26,6 +26,7 @@ import (
"github.com/goshuirc/irc-go/ircmsg"
"github.com/oragono/oragono/irc/caps"
"github.com/oragono/oragono/irc/custime"
"github.com/oragono/oragono/irc/history"
"github.com/oragono/oragono/irc/modes"
"github.com/oragono/oragono/irc/sno"
"github.com/oragono/oragono/irc/utils"
@ -483,6 +484,15 @@ func capHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Respo
client.capabilities.Union(capabilities)
rb.Add(nil, server.name, "CAP", client.nick, "ACK", capString)
// if this is the first time the client is requesting a resume token,
// send it to them
if capabilities.Has(caps.Resume) {
token, err := client.generateResumeToken()
if err == nil {
rb.Add(nil, server.name, "RESUME", "TOKEN", token)
}
}
case "END":
if !client.Registered() {
client.capState = caps.NegotiatedState
@ -1648,7 +1658,7 @@ func noticeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re
message := msg.Params[1]
// split privmsg
splitMsg := server.splitMessage(message, !client.capabilities.Has(caps.MaxLine))
splitMsg := utils.MakeSplitMessage(message, !client.capabilities.Has(caps.MaxLine))
for i, targetString := range targets {
// max of four targets per privmsg
@ -1699,6 +1709,14 @@ func noticeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re
if client.capabilities.Has(caps.EchoMessage) {
rb.AddSplitMessageFromClient(msgid, client, clientOnlyTags, "NOTICE", user.nick, splitMsg)
}
user.history.Add(history.Item{
Type: history.Notice,
Msgid: msgid,
Message: splitMsg,
Nick: client.NickMaskString(),
AccountName: client.AccountName(),
})
}
}
return false
@ -1848,7 +1866,7 @@ func privmsgHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
message := msg.Params[1]
// split privmsg
splitMsg := server.splitMessage(message, !client.capabilities.Has(caps.MaxLine))
splitMsg := utils.MakeSplitMessage(message, !client.capabilities.Has(caps.MaxLine))
for i, targetString := range targets {
// max of four targets per privmsg
@ -1905,6 +1923,14 @@ func privmsgHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
//TODO(dan): possibly implement cooldown of away notifications to users
rb.Add(nil, server.name, RPL_AWAY, user.nick, user.awayMessage)
}
user.history.Add(history.Item{
Type: history.Privmsg,
Msgid: msgid,
Message: splitMsg,
Nick: client.NickMaskString(),
AccountName: client.AccountName(),
})
}
}
return false
@ -2018,25 +2044,21 @@ func renameHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re
return false
}
// RESUME <oldnick> [timestamp]
// RESUME <oldnick> <token> [timestamp]
func resumeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
oldnick := msg.Params[0]
if strings.Contains(oldnick, " ") {
rb.Add(nil, server.name, ERR_CANNOT_RESUME, "*", client.t("Cannot resume connection, old nickname contains spaces"))
return false
}
token := msg.Params[1]
if client.Registered() {
rb.Add(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Cannot resume connection, connection registration has already been completed"))
return false
}
var timestamp *time.Time
if 1 < len(msg.Params) {
ts, err := time.Parse("2006-01-02T15:04:05.999Z", msg.Params[1])
var timestamp time.Time
if 2 < len(msg.Params) {
ts, err := time.Parse(IRCv3TimestampFormat, msg.Params[2])
if err == nil {
timestamp = &ts
timestamp = ts
} else {
rb.Add(nil, server.name, ERR_CANNOT_RESUME, oldnick, client.t("Timestamp is not in 2006-01-02T15:04:05.999Z format, ignoring it"))
}
@ -2045,6 +2067,7 @@ func resumeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re
client.resumeDetails = &ResumeDetails{
OldNick: oldnick,
Timestamp: timestamp,
PresentedToken: token,
}
return false
@ -2280,26 +2303,12 @@ func userHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp
return false
}
if client.username != "" && client.realname != "" {
return false
}
// confirm that username is valid
//
_, err := CasefoldName(msg.Params[0])
if err != nil {
err := client.SetNames(msg.Params[0], msg.Params[3])
if err == errInvalidUsername {
rb.Add(nil, "", "ERROR", client.t("Malformed username"))
return true
}
if !client.HasUsername() {
client.username = "~" + msg.Params[0]
// don't bother updating nickmask here, it's not valid anyway
}
if client.realname == "" {
client.realname = msg.Params[3]
}
return false
}

249
irc/history/history.go Normal file
View File

@ -0,0 +1,249 @@
// Copyright (c) 2018 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// released under the MIT license
package history
import (
"github.com/oragono/oragono/irc/utils"
"sync"
"sync/atomic"
"time"
)
type ItemType uint
const (
uninitializedItem ItemType = iota
Privmsg
Notice
Join
Part
Kick
Quit
Mode
)
// Item represents an event (e.g., a PRIVMSG or a JOIN) and its associated data
type Item struct {
Type ItemType
Time time.Time
Nick string
// this is the uncasefolded account name, if there's no account it should be set to "*"
AccountName string
Message utils.SplitMessage
Msgid string
}
// Buffer is a ring buffer holding message/event history for a channel or user
type Buffer struct {
sync.RWMutex
// ring buffer, see irc/whowas.go for conventions
buffer []Item
start int
end int
lastDiscarded time.Time
enabled uint32
}
func NewHistoryBuffer(size int) (result *Buffer) {
result = new(Buffer)
result.Initialize(size)
return
}
func (hist *Buffer) Initialize(size int) {
hist.buffer = make([]Item, size)
hist.start = -1
hist.end = -1
hist.setEnabled(size)
}
func (hist *Buffer) setEnabled(size int) {
var enabled uint32
if size != 0 {
enabled = 1
}
atomic.StoreUint32(&hist.enabled, enabled)
}
// Enabled returns whether the buffer is currently storing messages
// (a disabled buffer blackholes everything it sees)
func (list *Buffer) Enabled() bool {
return atomic.LoadUint32(&list.enabled) != 0
}
// Add adds a history item to the buffer
func (list *Buffer) Add(item Item) {
// fast path without a lock acquisition for when we are not storing history
if !list.Enabled() {
return
}
if item.Time.IsZero() {
item.Time = time.Now()
}
list.Lock()
defer list.Unlock()
var pos int
if list.start == -1 { // empty
pos = 0
list.start = 0
list.end = 1 % len(list.buffer)
} else if list.start != list.end { // partially full
pos = list.end
list.end = (list.end + 1) % len(list.buffer)
} else if list.start == list.end { // full
pos = list.end
list.end = (list.end + 1) % len(list.buffer)
list.start = list.end // advance start as well, overwriting first entry
// record the timestamp of the overwritten item
if list.lastDiscarded.Before(list.buffer[pos].Time) {
list.lastDiscarded = list.buffer[pos].Time
}
}
list.buffer[pos] = item
}
// Between returns all history items with a time `after` <= time <= `before`,
// with an indication of whether the results are complete or are missing items
// because some of that period was discarded. A zero value of `before` is considered
// higher than all other times.
func (list *Buffer) Between(after, before time.Time) (results []Item, complete bool) {
if !list.Enabled() {
return
}
list.RLock()
defer list.RUnlock()
complete = after.Equal(list.lastDiscarded) || after.After(list.lastDiscarded)
if list.start == -1 {
return
}
satisfies := func(itime time.Time) bool {
return (after.IsZero() || itime.After(after)) && (before.IsZero() || itime.Before(before))
}
// TODO: if we can guarantee that the insertion order is also the monotonic clock order,
// then this can do a single allocation and use binary search and 1-2 copy calls
pos := list.prev(list.end)
for {
if satisfies(list.buffer[pos].Time) {
results = append(results, list.buffer[pos])
}
if pos == list.start {
break
}
pos = list.prev(pos)
}
// reverse the results
for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
results[i], results[j] = results[j], results[i]
}
return
}
// All returns all available history items as a slice
func (list *Buffer) All() (results []Item) {
list.RLock()
defer list.RUnlock()
if list.start == -1 {
return
}
results = make([]Item, list.length())
if list.start < list.end {
copy(results, list.buffer[list.start:list.end])
} else {
initialSegment := copy(results, list.buffer[list.start:])
copy(results[initialSegment:], list.buffer[:list.end])
}
return
}
// LastDiscarded returns the latest time of any entry that was evicted
// from the ring buffer.
func (list *Buffer) LastDiscarded() time.Time {
list.RLock()
defer list.RUnlock()
return list.lastDiscarded
}
func (list *Buffer) prev(index int) int {
switch index {
case 0:
return len(list.buffer) - 1
default:
return index - 1
}
}
// Resize shrinks or expands the buffer
func (list *Buffer) Resize(size int) {
newbuffer := make([]Item, size)
list.Lock()
defer list.Unlock()
list.setEnabled(size)
if list.start == -1 {
// indices are already correct and nothing needs to be copied
} else if size == 0 {
// this is now the empty list
list.start = -1
list.end = -1
} else {
currentLength := list.length()
start := list.start
end := list.end
// if we're truncating, keep the latest entries, not the earliest
if size < currentLength {
start = list.end - size
if start < 0 {
start += len(list.buffer)
}
// update lastDiscarded for discarded entries
for i := list.start; i != start; i = (i + 1) % len(list.buffer) {
if list.lastDiscarded.Before(list.buffer[i].Time) {
list.lastDiscarded = list.buffer[i].Time
}
}
}
if start < end {
copied := copy(newbuffer, list.buffer[start:end])
list.start = 0
list.end = copied % size
} else {
lenInitial := len(list.buffer) - start
copied := copy(newbuffer, list.buffer[start:])
copied += copy(newbuffer[lenInitial:], list.buffer[:end])
list.start = 0
list.end = copied % size
}
}
list.buffer = newbuffer
}
func (hist *Buffer) length() int {
if hist.start == -1 {
return 0
} else if hist.start < hist.end {
return hist.end - hist.start
} else {
return len(hist.buffer) - (hist.start - hist.end)
}
}

156
irc/history/history_test.go Normal file
View File

@ -0,0 +1,156 @@
// Copyright (c) 2018 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// released under the MIT license
package history
import (
"reflect"
"testing"
"time"
)
const (
timeFormat = "2006-01-02 15:04:05Z"
)
func TestEmptyBuffer(t *testing.T) {
pastTime := easyParse(timeFormat)
buf := NewHistoryBuffer(0)
if buf.Enabled() {
t.Error("the buffer of size 0 must be considered disabled")
}
buf.Add(Item{
Nick: "testnick",
})
since, complete := buf.Between(pastTime, time.Now())
if len(since) != 0 {
t.Error("shouldn't be able to add to disabled buf")
}
if complete {
t.Error("the empty/disabled buffer should report results as incomplete")
}
buf.Resize(1)
if !buf.Enabled() {
t.Error("the buffer of size 1 must be considered enabled")
}
since, complete = buf.Between(pastTime, time.Now())
assertEqual(complete, true, t)
assertEqual(len(since), 0, t)
buf.Add(Item{
Nick: "testnick",
})
since, complete = buf.Between(pastTime, time.Now())
if len(since) != 1 {
t.Error("should be able to store items in a nonempty buffer")
}
if !complete {
t.Error("results should be complete")
}
if since[0].Nick != "testnick" {
t.Error("retrived junk data")
}
buf.Add(Item{
Nick: "testnick2",
})
since, complete = buf.Between(pastTime, time.Now())
if len(since) != 1 {
t.Error("expect exactly 1 item")
}
if complete {
t.Error("results must be marked incomplete")
}
if since[0].Nick != "testnick2" {
t.Error("retrieved junk data")
}
assertEqual(toNicks(buf.All()), []string{"testnick2"}, t)
}
func toNicks(items []Item) (result []string) {
result = make([]string, len(items))
for i, item := range items {
result[i] = item.Nick
}
return
}
func easyParse(timestamp string) time.Time {
result, err := time.Parse(timeFormat, timestamp)
if err != nil {
panic(err)
}
return result
}
func assertEqual(supplied, expected interface{}, t *testing.T) {
if !reflect.DeepEqual(supplied, expected) {
t.Errorf("expected %v but got %v", expected, supplied)
}
}
func TestBuffer(t *testing.T) {
start := easyParse("2006-01-01 00:00:00Z")
buf := NewHistoryBuffer(3)
buf.Add(Item{
Nick: "testnick0",
Time: easyParse("2006-01-01 15:04:05Z"),
})
buf.Add(Item{
Nick: "testnick1",
Time: easyParse("2006-01-02 15:04:05Z"),
})
buf.Add(Item{
Nick: "testnick2",
Time: easyParse("2006-01-03 15:04:05Z"),
})
since, complete := buf.Between(start, time.Now())
assertEqual(complete, true, t)
assertEqual(toNicks(since), []string{"testnick0", "testnick1", "testnick2"}, t)
// add another item, evicting the first
buf.Add(Item{
Nick: "testnick3",
Time: easyParse("2006-01-04 15:04:05Z"),
})
since, complete = buf.Between(start, time.Now())
assertEqual(complete, false, t)
assertEqual(toNicks(since), []string{"testnick1", "testnick2", "testnick3"}, t)
// now exclude the time of the discarded entry; results should be complete again
since, complete = buf.Between(easyParse("2006-01-02 00:00:00Z"), time.Now())
assertEqual(complete, true, t)
assertEqual(toNicks(since), []string{"testnick1", "testnick2", "testnick3"}, t)
since, complete = buf.Between(easyParse("2006-01-02 00:00:00Z"), easyParse("2006-01-03 00:00:00Z"))
assertEqual(complete, true, t)
assertEqual(toNicks(since), []string{"testnick1"}, t)
// shrink the buffer, cutting off testnick1
buf.Resize(2)
since, complete = buf.Between(easyParse("2006-01-02 00:00:00Z"), time.Now())
assertEqual(complete, false, t)
assertEqual(toNicks(since), []string{"testnick2", "testnick3"}, t)
buf.Resize(5)
buf.Add(Item{
Nick: "testnick4",
Time: easyParse("2006-01-05 15:04:05Z"),
})
buf.Add(Item{
Nick: "testnick5",
Time: easyParse("2006-01-06 15:04:05Z"),
})
buf.Add(Item{
Nick: "testnick6",
Time: easyParse("2006-01-07 15:04:05Z"),
})
since, complete = buf.Between(easyParse("2006-01-03 00:00:00Z"), time.Now())
assertEqual(complete, true, t)
assertEqual(toNicks(since), []string{"testnick2", "testnick3", "testnick4", "testnick5", "testnick6"}, t)
}

View File

@ -353,6 +353,11 @@ func (set *ModeSet) SetMode(mode Mode, on bool) (applied bool) {
return utils.BitsetSet(set[:], uint(mode)-minMode, on)
}
// copy the contents of another modeset on top of this one
func (set *ModeSet) Copy(other *ModeSet) {
utils.BitsetCopy(set[:], other[:])
}
// return the modes in the set as a slice
func (set *ModeSet) AllModes() (result []Mode) {
if set == nil {

View File

@ -81,6 +81,24 @@ func (manager *MonitorManager) Remove(client *Client, nick string) error {
return nil
}
func (manager *MonitorManager) Resume(newClient, oldClient *Client) error {
manager.Lock()
defer manager.Unlock()
// newClient is now watching everyone oldClient was watching
oldTargets := manager.watching[oldClient]
delete(manager.watching, oldClient)
manager.watching[newClient] = oldTargets
// update watchedby as well
for watchedNick := range oldTargets {
delete(manager.watchedby[watchedNick], oldClient)
manager.watchedby[watchedNick][newClient] = true
}
return nil
}
// RemoveAll unregisters `client` from receiving notifications about *all* nicks.
func (manager *MonitorManager) RemoveAll(client *Client) {
manager.Lock()

View File

@ -17,6 +17,7 @@ import (
var (
restrictedNicknames = map[string]bool{
"=scene=": true, // used for rp commands
"HistServ": true, // TODO(slingamn) this should become a real service
}
)

View File

@ -8,6 +8,7 @@ import (
"github.com/goshuirc/irc-go/ircmsg"
"github.com/oragono/oragono/irc/caps"
"github.com/oragono/oragono/irc/utils"
)
// ResponseBuffer - put simply - buffers messages and then outputs them to a given client.
@ -19,6 +20,7 @@ type ResponseBuffer struct {
Label string
target *Client
messages []ircmsg.IrcMessage
blocking bool
}
// GetLabel returns the label from the given message.
@ -33,6 +35,10 @@ func NewResponseBuffer(target *Client) *ResponseBuffer {
}
}
func (rb *ResponseBuffer) SetBlocking(blocking bool) {
rb.blocking = blocking
}
// Add adds a standard new message to our queue.
func (rb *ResponseBuffer) Add(tags *map[string]ircmsg.TagValue, prefix string, command string, params ...string) {
message := ircmsg.MakeMessage(tags, prefix, command, params...)
@ -63,11 +69,11 @@ func (rb *ResponseBuffer) AddFromClient(msgid string, from *Client, tags *map[st
}
// AddSplitMessageFromClient adds a new split message from a specific client to our queue.
func (rb *ResponseBuffer) AddSplitMessageFromClient(msgid string, from *Client, tags *map[string]ircmsg.TagValue, command string, target string, message SplitMessage) {
if rb.target.capabilities.Has(caps.MaxLine) {
rb.AddFromClient(msgid, from, tags, command, target, message.ForMaxLine)
func (rb *ResponseBuffer) AddSplitMessageFromClient(msgid string, from *Client, tags *map[string]ircmsg.TagValue, command string, target string, message utils.SplitMessage) {
if rb.target.capabilities.Has(caps.MaxLine) || message.Wrapped == nil {
rb.AddFromClient(msgid, from, tags, command, target, message.Original)
} else {
for _, str := range message.For512 {
for _, str := range message.Wrapped {
rb.AddFromClient(msgid, from, tags, command, target, str)
}
}
@ -103,7 +109,7 @@ func (rb *ResponseBuffer) Send() error {
for _, message := range rb.messages {
// attach server-time if needed
if rb.target.capabilities.Has(caps.ServerTime) {
t := time.Now().UTC().Format("2006-01-02T15:04:05.999Z")
t := time.Now().UTC().Format(IRCv3TimestampFormat)
message.Tags["time"] = ircmsg.MakeTagValue(t)
}
@ -113,7 +119,7 @@ func (rb *ResponseBuffer) Send() error {
}
// send message out
rb.target.SendRawMessage(message)
rb.target.SendRawMessage(message, rb.blocking)
}
// end batch if required
@ -122,7 +128,7 @@ func (rb *ResponseBuffer) Send() error {
}
// clear out any existing messages
rb.messages = []ircmsg.IrcMessage{}
rb.messages = rb.messages[:0]
return nil
}

View File

@ -430,6 +430,8 @@ func (server *Server) tryRegister(c *Client) {
// continue registration
server.logger.Debug("localconnect", fmt.Sprintf("Client connected [%s] [u:%s] [r:%s]", c.nick, c.username, c.realname))
server.snomasks.Send(sno.LocalConnects, fmt.Sprintf("Client connected [%s] [u:%s] [h:%s] [ip:%s] [r:%s]", c.nick, c.username, c.rawHostname, c.IPString(), c.realname))
// "register"; this includes the initial phase of session resumption
c.Register()
// send welcome text
@ -455,41 +457,7 @@ func (server *Server) tryRegister(c *Client) {
}
// if resumed, send fake channel joins
if c.resumeDetails != nil {
for _, name := range c.resumeDetails.SendFakeJoinsFor {
channel := server.channels.Get(name)
if channel == nil {
continue
}
if c.capabilities.Has(caps.ExtendedJoin) {
c.Send(nil, c.nickMaskString, "JOIN", channel.name, c.AccountName(), c.realname)
} else {
c.Send(nil, c.nickMaskString, "JOIN", channel.name)
}
// reuse the last rb
channel.SendTopic(c, rb)
channel.Names(c, rb)
rb.Send()
// construct and send fake modestring if necessary
c.stateMutex.RLock()
myModes := channel.members[c]
c.stateMutex.RUnlock()
if myModes == nil {
continue
}
oldModes := myModes.String()
if 0 < len(oldModes) {
params := []string{channel.name, "+" + oldModes}
for range oldModes {
params = append(params, c.nick)
}
c.Send(nil, server.name, "MODE", params...)
}
}
}
c.tryResumeChannels()
}
// t returns the translated version of the given string, based on the languages configured by the client.
@ -519,69 +487,6 @@ func (server *Server) MOTD(client *Client, rb *ResponseBuffer) {
rb.Add(nil, server.name, RPL_ENDOFMOTD, client.nick, client.t("End of MOTD command"))
}
// wordWrap wraps the given text into a series of lines that don't exceed lineWidth characters.
func wordWrap(text string, lineWidth int) []string {
var lines []string
var cacheLine, cacheWord string
for _, char := range text {
if char == '\r' {
continue
} else if char == '\n' {
cacheLine += cacheWord
lines = append(lines, cacheLine)
cacheWord = ""
cacheLine = ""
} else if (char == ' ' || char == '-') && len(cacheLine)+len(cacheWord)+1 < lineWidth {
// natural word boundary
cacheLine += cacheWord + string(char)
cacheWord = ""
} else if lineWidth <= len(cacheLine)+len(cacheWord)+1 {
// time to wrap to next line
if len(cacheLine) < (lineWidth / 2) {
// this word takes up more than half a line... just split in the middle of the word
cacheLine += cacheWord + string(char)
cacheWord = ""
} else {
cacheWord += string(char)
}
lines = append(lines, cacheLine)
cacheLine = ""
} else {
// normal character
cacheWord += string(char)
}
}
if 0 < len(cacheWord) {
cacheLine += cacheWord
}
if 0 < len(cacheLine) {
lines = append(lines, cacheLine)
}
return lines
}
// SplitMessage represents a message that's been split for sending.
type SplitMessage struct {
For512 []string
ForMaxLine string
}
func (server *Server) splitMessage(original string, origIs512 bool) SplitMessage {
var newSplit SplitMessage
newSplit.ForMaxLine = original
if !origIs512 {
newSplit.For512 = wordWrap(original, 400)
} else {
newSplit.For512 = []string{original}
}
return newSplit
}
// WhoisChannelsNames returns the common channel names between two users.
func (client *Client) WhoisChannelsNames(target *Client) []string {
isMultiPrefix := client.capabilities.Has(caps.MultiPrefix)
@ -817,6 +722,20 @@ func (server *Server) applyConfig(config *Config, initial bool) (err error) {
updatedCaps.Add(caps.STS)
}
// resize history buffers as needed
if oldConfig != nil {
if oldConfig.History.ChannelLength != config.History.ChannelLength {
for _, channel := range server.channels.Channels() {
channel.history.Resize(config.History.ChannelLength)
}
}
if oldConfig.History.ClientLength != config.History.ClientLength {
for _, client := range server.clients.AllClients() {
client.history.Resize(config.History.ClientLength)
}
}
}
// burst new and removed caps
var capBurstClients ClientSet
added := make(map[caps.Version]string)
@ -1107,13 +1026,6 @@ func (target *Client) RplList(channel *Channel, rb *ResponseBuffer) {
rb.Add(nil, target.server.name, RPL_LIST, target.nick, channel.name, strconv.Itoa(memberCount), channel.topic)
}
// ResumeDetails are the details that we use to resume connections.
type ResumeDetails struct {
OldNick string
Timestamp *time.Time
SendFakeJoinsFor []string
}
var (
infoString1 = strings.Split(` ·
·

View File

@ -146,6 +146,38 @@ func (socket *Socket) Write(data []byte) (err error) {
return
}
// BlockingWrite sends the given string out of Socket. Requirements:
// 1. MUST block until the message is sent
// 2. MUST bypass sendq (calls to BlockingWrite cannot, on their own, cause a sendq overflow)
// 3. MUST provide mutual exclusion for socket.conn.Write
// 4. MUST respect the same ordering guarantees as Write (i.e., if a call to Write that sends
// message m1 happens-before a call to BlockingWrite that sends message m2,
// m1 must be sent on the wire before m2
// Callers MUST be writing to the client's socket from the client's own goroutine;
// other callers must use the nonblocking Write call instead. Otherwise, a client
// with a slow/unreliable connection risks stalling the progress of the system as a whole.
func (socket *Socket) BlockingWrite(data []byte) (err error) {
if len(data) == 0 {
return
}
// blocking acquire of the trylock
socket.writerSemaphore.Acquire()
defer socket.writerSemaphore.Release()
// first, flush any buffered data, to preserve the ordering guarantees
closed := socket.performWrite()
if closed {
return io.EOF
}
_, err = socket.conn.Write(data)
if err != nil {
socket.finalize()
}
return
}
// wakeWriter starts the goroutine that actually performs the write, without blocking
func (socket *Socket) wakeWriter() {
if socket.writerSemaphore.TryAcquire() {
@ -199,7 +231,8 @@ func (socket *Socket) send() {
}
// write the contents of the buffer, then see if we need to close
func (socket *Socket) performWrite() {
// returns whether we closed
func (socket *Socket) performWrite() (closed bool) {
// retrieve the buffered data, clear the buffer
socket.Lock()
buffers := socket.buffers
@ -214,10 +247,14 @@ func (socket *Socket) performWrite() {
shouldClose := (err != nil) || socket.closed || socket.sendQExceeded
socket.Unlock()
if !shouldClose {
return
if shouldClose {
socket.finalize()
}
return shouldClose
}
// mark closed and send final data. you must be holding the semaphore to call this:
func (socket *Socket) finalize() {
// mark the socket closed (if someone hasn't already), then write error lines
socket.Lock()
socket.closed = true

View File

@ -6,6 +6,7 @@
package irc
import "github.com/oragono/oragono/irc/modes"
import "github.com/goshuirc/irc-go/ircmsg"
// ClientSet is a set of clients.
type ClientSet map[*Client]bool
@ -56,3 +57,5 @@ func (members MemberSet) AnyHasMode(mode modes.Mode) bool {
// ChannelSet is a set of channels.
type ChannelSet map[*Channel]bool
type Tags *map[string]ircmsg.TagValue

View File

@ -80,3 +80,12 @@ func BitsetUnion(set []uint64, other []uint64) {
}
}
}
// BitsetCopy copies the contents of `other` over `set`.
// Similar caveats about race conditions as with `BitsetUnion` apply.
func BitsetCopy(set []uint64, other []uint64) {
for i := 0; i < len(set); i++ {
data := atomic.LoadUint64(&other[i])
atomic.StoreUint64(&set[i], data)
}
}

View File

@ -62,4 +62,19 @@ func TestSets(t *testing.T) {
t.Error("all bits should be set except 72")
}
}
var t3 testBitset
t3s := t3[:]
BitsetSet(t3s, 72, true)
if !BitsetGet(t3s, 72) {
t.Error("bit 72 should be set")
}
// copy t1 on top of t2
BitsetCopy(t3s, t1s)
for i = 0; i < 128; i++ {
expected := (i != 72)
if BitsetGet(t1s, i) != expected {
t.Error("all bits should be set except 72")
}
}
}

30
irc/utils/crypto.go Normal file
View File

@ -0,0 +1,30 @@
// Copyright (c) 2018 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// released under the MIT license
package utils
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
)
// generate a secret token that cannot be brute-forced via online attacks
func GenerateSecretToken() string {
// 128 bits of entropy are enough to resist any online attack:
var buf [16]byte
rand.Read(buf[:])
// 32 ASCII characters, should be fine for most purposes
return hex.EncodeToString(buf[:])
}
// securely check if a supplied token matches a stored token
func SecretTokensMatch(storedToken string, suppliedToken string) bool {
// XXX fix a potential gotcha: if the stored token is uninitialized,
// then nothing should match it, not even supplying an empty token.
if len(storedToken) == 0 {
return false
}
return subtle.ConstantTimeCompare([]byte(storedToken), []byte(suppliedToken)) == 1
}

48
irc/utils/crypto_test.go Normal file
View File

@ -0,0 +1,48 @@
// Copyright (c) 2018 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// released under the MIT license
package utils
import (
"testing"
)
const (
storedToken = "1e82d113a59a874cccf82063ec603221"
badToken = "1e82d113a59a874cccf82063ec603222"
shortToken = "1e82d113a59a874cccf82063ec60322"
longToken = "1e82d113a59a874cccf82063ec6032211"
)
func TestGenerateSecretToken(t *testing.T) {
token := GenerateSecretToken()
if len(token) != 32 {
t.Errorf("bad token: %v", token)
}
}
func TestTokenCompare(t *testing.T) {
if !SecretTokensMatch(storedToken, storedToken) {
t.Error("matching tokens must match")
}
if SecretTokensMatch(storedToken, badToken) {
t.Error("non-matching tokens must not match")
}
if SecretTokensMatch(storedToken, shortToken) {
t.Error("non-matching tokens must not match")
}
if SecretTokensMatch(storedToken, longToken) {
t.Error("non-matching tokens must not match")
}
if SecretTokensMatch("", "") {
t.Error("the empty token should not match anything")
}
if SecretTokensMatch("", storedToken) {
t.Error("the empty token should not match anything")
}
}

67
irc/utils/text.go Normal file
View File

@ -0,0 +1,67 @@
// Copyright (c) 2017 Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package utils
import "bytes"
// WordWrap wraps the given text into a series of lines that don't exceed lineWidth characters.
func WordWrap(text string, lineWidth int) []string {
var lines []string
var cacheLine, cacheWord bytes.Buffer
for _, char := range text {
if char == '\r' {
continue
} else if char == '\n' {
cacheLine.Write(cacheWord.Bytes())
lines = append(lines, cacheLine.String())
cacheWord.Reset()
cacheLine.Reset()
} else if (char == ' ' || char == '-') && cacheLine.Len()+cacheWord.Len()+1 < lineWidth {
// natural word boundary
cacheLine.Write(cacheWord.Bytes())
cacheLine.WriteRune(char)
cacheWord.Reset()
} else if lineWidth <= cacheLine.Len()+cacheWord.Len()+1 {
// time to wrap to next line
if cacheLine.Len() < (lineWidth / 2) {
// this word takes up more than half a line... just split in the middle of the word
cacheLine.Write(cacheWord.Bytes())
cacheLine.WriteRune(char)
cacheWord.Reset()
} else {
cacheWord.WriteRune(char)
}
lines = append(lines, cacheLine.String())
cacheLine.Reset()
} else {
// normal character
cacheWord.WriteRune(char)
}
}
if 0 < cacheWord.Len() {
cacheLine.Write(cacheWord.Bytes())
}
if 0 < cacheLine.Len() {
lines = append(lines, cacheLine.String())
}
return lines
}
// SplitMessage represents a message that's been split for sending.
type SplitMessage struct {
Original string
Wrapped []string // if this is nil, Original didn't need wrapping and can be sent to anyone
}
func MakeSplitMessage(original string, origIs512 bool) (result SplitMessage) {
result.Original = original
if !origIs512 {
result.Wrapped = WordWrap(original, 400)
}
return
}

60
irc/utils/text_test.go Normal file
View File

@ -0,0 +1,60 @@
// Copyright (c) 2018 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// released under the MIT license
package utils
import (
"reflect"
"strings"
"testing"
)
const (
threeMusketeers = "In the meantime DArtagnan, who had plunged into a bypath, continued his route and reached St. Cloud; but instead of following the main street he turned behind the château, reached a sort of retired lane, and found himself soon in front of the pavilion named. It was situated in a very private spot. A high wall, at the angle of which was the pavilion, ran along one side of this lane, and on the other was a little garden connected with a poor cottage which was protected by a hedge from passers-by."
monteCristo = `Both the count and Baptistin had told the truth when they announced to Morcerf the proposed visit of the major, which had served Monte Cristo as a pretext for declining Albert's invitation. Seven o'clock had just struck, and M. Bertuccio, according to the command which had been given him, had two hours before left for Auteuil, when a cab stopped at the door, and after depositing its occupant at the gate, immediately hurried away, as if ashamed of its employment. The visitor was about fifty-two years of age, dressed in one of the green surtouts, ornamented with black frogs, which have so long maintained their popularity all over Europe. He wore trousers of blue cloth, boots tolerably clean, but not of the brightest polish, and a little too thick in the soles, buckskin gloves, a hat somewhat resembling in shape those usually worn by the gendarmes, and a black cravat striped with white, which, if the proprietor had not worn it of his own free will, might have passed for a halter, so much did it resemble one. Such was the picturesque costume of the person who rang at the gate, and demanded if it was not at No. 30 in the Avenue des Champs-Elysees that the Count of Monte Cristo lived, and who, being answered by the porter in the affirmative, entered, closed the gate after him, and began to ascend the steps.`
)
func assertWrapCorrect(text string, lineWidth int, allowSplitWords bool, t *testing.T) {
lines := WordWrap(text, lineWidth)
reconstructed := strings.Join(lines, "")
if text != reconstructed {
t.Errorf("text %v does not match original %v", text, reconstructed)
}
for _, line := range lines {
if len(line) > lineWidth {
t.Errorf("line too long: %d, %v", len(line), line)
}
}
if !allowSplitWords {
origWords := strings.Fields(text)
var newWords []string
for _, line := range lines {
newWords = append(newWords, strings.Fields(line)...)
}
if !reflect.DeepEqual(origWords, newWords) {
t.Errorf("words %v do not match wrapped words %v", origWords, newWords)
}
}
}
func TestWordWrap(t *testing.T) {
assertWrapCorrect("jackdaws love my big sphinx of quartz", 12, false, t)
// long word that will necessarily be split:
assertWrapCorrect("jackdawslovemybigsphinxofquartz", 12, true, t)
assertWrapCorrect(threeMusketeers, 40, true, t)
assertWrapCorrect(monteCristo, 20, false, t)
}
func BenchmarkWordWrap(b *testing.B) {
for i := 0; i < b.N; i++ {
WordWrap(threeMusketeers, 40)
WordWrap(monteCristo, 60)
}
}

View File

@ -92,6 +92,10 @@ server:
# - "127.0.0.1/8"
# - "0::1"
# allow use of the RESUME extension over plaintext connections:
# do not enable this unless the ircd is only accessible over internal networks
allow-plaintext-resume: false
# maximum length of clients' sendQ in bytes
# this should be big enough to hold /LIST and HELP replies
max-sendq: 16k
@ -439,3 +443,14 @@ fakelag:
# client status resets to the default state if they go this long without
# sending any commands:
cooldown: 2s
# message history tracking, for the RESUME extension and possibly other uses in future
history:
# should we store messages for later playback?
enabled: true
# how many channel-specific events (messages, joins, parts) should be tracked per channel?
channel-length: 256
# how many direct messages and notices should be tracked per user?
client-length: 64