Repository: oragono/oragono
Pull request #1013 (2020-05-16 20:13:54)

Add support for Unix domain sockets


Patch:
diff --git a/conventional.yaml b/conventional.yaml
index 255dd4e8..6fcdded1 100644
--- a/conventional.yaml
+++ b/conventional.yaml
@@ -259,10 +259,6 @@ server:
     secure-nets:
         # - "10.0.0.0/8"
 
-    # oragono will write files to disk under certain circumstances, e.g.,
-    # CPU profiling or data export. by default, these files will be written
-    # to the working directory. set this to customize:
-    # output-path: "/home/oragono/out"
 
 # account options
 accounts:
@@ -560,7 +556,6 @@ oper-classes:
             - "samode"
             - "vhosts"
             - "chanreg"
-            - "history"
 
 # ircd operators
 opers:
@@ -756,8 +751,7 @@ roleplay:
     # add the real nickname, in parentheses, to the end of every roleplay message?
     add-suffix: true
 
-# history message storage: this is used by CHATHISTORY, HISTORY, znc.in/playback,
-# various autoreplay features, and the resume extension
+# message history tracking, for the RESUME extension and possibly other uses in future
 history:
     # should we store messages for later playback?
     # by default, messages are stored in RAM only; they do not persist
@@ -826,13 +820,3 @@ history:
         # if you enable this, strict nickname reservation is strongly recommended
         # as well.
         direct-messages: "opt-out"
-
-    # options to control how messages are stored and deleted:
-    retention:
-        # allow users to delete their own messages from history?
-        allow-individual-delete: false
-
-        # if persistent history is enabled, create additional index tables,
-        # allowing deletion of JSON export of an account's messages. this
-        # may be needed for compliance with data privacy regulations.
-        enable-account-indexing: false
diff --git a/gencapdefs.py b/gencapdefs.py
index df0ca9a1..e8bce779 100644
--- a/gencapdefs.py
+++ b/gencapdefs.py
@@ -119,9 +119,9 @@ CAPDEFS = [
     ),
     CapDef(
         identifier="SetName",
-        name="setname",
-        url="https://ircv3.net/specs/extensions/setname.html",
-        standard="IRCv3",
+        name="draft/setname",
+        url="https://github.com/ircv3/ircv3-specifications/pull/361",
+        standard="proposed IRCv3",
     ),
     CapDef(
         identifier="STS",
diff --git a/go.mod b/go.mod
index 83fd8bc0..455fcf2c 100644
--- a/go.mod
+++ b/go.mod
@@ -13,7 +13,7 @@ require (
 	github.com/onsi/ginkgo v1.12.0 // indirect
 	github.com/onsi/gomega v1.9.0 // indirect
 	github.com/oragono/confusables v0.0.0-20190624102032-fe1cf31a24b0
-	github.com/oragono/go-ident v0.0.0-20200511222032-830550b1d775
+	github.com/oragono/go-ident v0.0.0-20170110123031-337fed0fd21a
 	github.com/stretchr/testify v1.4.0 // indirect
 	github.com/tidwall/buntdb v1.1.2
 	github.com/toorop/go-dkim v0.0.0-20191019073156-897ad64a2eeb
diff --git a/go.sum b/go.sum
index 8aaa74ec..79889359 100644
--- a/go.sum
+++ b/go.sum
@@ -46,8 +46,6 @@ github.com/oragono/confusables v0.0.0-20190624102032-fe1cf31a24b0 h1:4qw57EiWD2M
 github.com/oragono/confusables v0.0.0-20190624102032-fe1cf31a24b0/go.mod h1:+uesPRay9e5tW6zhw4CJkRV3QOEbbZIJcsuo9ZnC+hE=
 github.com/oragono/go-ident v0.0.0-20170110123031-337fed0fd21a h1:tZApUffT5QuX4XhJLz2KfBJT8JgdwjLUBWtvmRwgFu4=
 github.com/oragono/go-ident v0.0.0-20170110123031-337fed0fd21a/go.mod h1:r5Fk840a4eu3ii1kxGDNSJupQu9Z1UC1nfJOZZXC24c=
-github.com/oragono/go-ident v0.0.0-20200511222032-830550b1d775 h1:AMAsAn/i4AgsmWQYdMoze9omwtHpbxrKuT+AT1LmhtI=
-github.com/oragono/go-ident v0.0.0-20200511222032-830550b1d775/go.mod h1:r5Fk840a4eu3ii1kxGDNSJupQu9Z1UC1nfJOZZXC24c=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
diff --git a/irc/accounts.go b/irc/accounts.go
index d4ecd93c..99e1239a 100644
--- a/irc/accounts.go
+++ b/irc/accounts.go
@@ -1103,30 +1103,6 @@ func (am *AccountManager) LoadAccount(accountName string) (result ClientAccount,
 	return
 }
 
-// look up the unfolded version of an account name, possibly after deletion
-func (am *AccountManager) AccountToAccountName(account string) (result string) {
-	casefoldedAccount, err := CasefoldName(account)
-	if err != nil {
-		return
-	}
-
-	unregisteredKey := fmt.Sprintf(keyAccountUnregistered, casefoldedAccount)
-	accountNameKey := fmt.Sprintf(keyAccountName, casefoldedAccount)
-
-	am.server.store.View(func(tx *buntdb.Tx) error {
-		if name, err := tx.Get(accountNameKey); err == nil {
-			result = name
-			return nil
-		}
-		if name, err := tx.Get(unregisteredKey); err == nil {
-			result = name
-		}
-		return nil
-	})
-
-	return
-}
-
 func (am *AccountManager) deserializeRawAccount(raw rawClientAccount, cfName string) (result ClientAccount, err error) {
 	result.Name = raw.Name
 	result.NameCasefolded = cfName
diff --git a/irc/caps/defs.go b/irc/caps/defs.go
index 64455d48..1050b4de 100644
--- a/irc/caps/defs.go
+++ b/irc/caps/defs.go
@@ -61,6 +61,10 @@ const (
 	// https://github.com/DanielOaks/ircv3-specifications/blob/master+resume/extensions/resume.md
 	Resume Capability = iota
 
+	// SetName is the proposed IRCv3 capability named "draft/setname":
+	// https://github.com/ircv3/ircv3-specifications/pull/361
+	SetName Capability = iota
+
 	// EchoMessage is the IRCv3 capability named "echo-message":
 	// https://ircv3.net/specs/extensions/echo-message-3.2.html
 	EchoMessage Capability = iota
@@ -97,10 +101,6 @@ const (
 	// https://ircv3.net/specs/extensions/server-time-3.2.html
 	ServerTime Capability = iota
 
-	// SetName is the IRCv3 capability named "setname":
-	// https://ircv3.net/specs/extensions/setname.html
-	SetName Capability = iota
-
 	// STS is the IRCv3 capability named "sts":
 	// https://ircv3.net/specs/extensions/sts.html
 	STS Capability = iota
@@ -133,6 +133,7 @@ var (
 		"draft/multiline",
 		"draft/rename",
 		"draft/resume-0.5",
+		"draft/setname",
 		"echo-message",
 		"extended-join",
 		"invite-notify",
@@ -142,7 +143,6 @@ var (
 		"oragono.io/nope",
 		"sasl",
 		"server-time",
-		"setname",
 		"sts",
 		"userhost-in-names",
 		"znc.in/playback",
diff --git a/irc/channel.go b/irc/channel.go
index 8c689744..c18eddc7 100644
--- a/irc/channel.go
+++ b/irc/channel.go
@@ -20,6 +20,10 @@ import (
 	"github.com/oragono/oragono/irc/utils"
 )
 
+const (
+	histServMask = "HistServ!HistServ@localhost"
+)
+
 type ChannelSettings struct {
 	History HistoryStatus
 }
@@ -511,13 +515,8 @@ func channelUserModeHasPrivsOver(clientMode modes.Mode, targetMode modes.Mode) b
 func (channel *Channel) ClientIsAtLeast(client *Client, permission modes.Mode) bool {
 	channel.stateMutex.RLock()
 	clientModes := channel.members[client]
-	founder := channel.registeredFounder
 	channel.stateMutex.RUnlock()
 
-	if founder != "" && founder == client.Account() {
-		return true
-	}
-
 	for _, mode := range modes.ChannelUserModes {
 		if clientModes.HasMode(mode) {
 			return true
@@ -637,14 +636,14 @@ func channelHistoryStatus(config *Config, registered bool, storedStatus HistoryS
 	}
 }
 
-func (channel *Channel) AddHistoryItem(item history.Item, account string) (err error) {
+func (channel *Channel) AddHistoryItem(item history.Item) (err error) {
 	if !item.IsStorable() {
 		return
 	}
 
 	status, target := channel.historyStatus(channel.server.Config())
 	if status == HistoryPersistent {
-		err = channel.server.historyDB.AddChannelItem(target, item, account)
+		err = channel.server.historyDB.AddChannelItem(target, item)
 	} else if status == HistoryEphemeral {
 		channel.history.Add(item)
 	}
@@ -742,7 +741,7 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp
 			Message:     message,
 		}
 		histItem.Params[0] = details.realname
-		channel.AddHistoryItem(histItem, details.account)
+		channel.AddHistoryItem(histItem)
 	}
 
 	client.addChannel(channel, rb == nil)
@@ -898,7 +897,7 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer)
 		Nick:        details.nickMask,
 		AccountName: details.accountName,
 		Message:     splitMessage,
-	}, details.account)
+	})
 
 	client.server.logger.Debug("part", fmt.Sprintf("%s left channel %s", details.nick, chname))
 }
@@ -1161,7 +1160,7 @@ func (channel *Channel) SetTopic(client *Client, topic string, rb *ResponseBuffe
 		Nick:        details.nickMask,
 		AccountName: details.accountName,
 		Message:     message,
-	}, details.account)
+	})
 
 	channel.MarkDirty(IncludeTopic)
 }
@@ -1218,7 +1217,8 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
 		return
 	}
 
-	details := client.Details()
+	nickmask := client.NickMaskString()
+	account := client.AccountName()
 	chname := channel.Name()
 
 	// STATUSMSG targets are prefixed with the supplied min-prefix, e.g., @#channel
@@ -1233,9 +1233,9 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
 			tagsToUse = clientOnlyTags
 		}
 		if histType == history.Tagmsg && rb.session.capabilities.Has(caps.MessageTags) {
-			rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, tagsToUse, command, chname)
+			rb.AddFromClient(message.Time, message.Msgid, nickmask, account, tagsToUse, command, chname)
 		} else {
-			rb.AddSplitMessageFromClient(details.nickMask, details.accountName, tagsToUse, command, chname, message)
+			rb.AddSplitMessageFromClient(nickmask, account, tagsToUse, command, chname, message)
 		}
 	}
 	// send echo-message to other connected sessions
@@ -1248,9 +1248,9 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
 			tagsToUse = clientOnlyTags
 		}
 		if histType == history.Tagmsg && session.capabilities.Has(caps.MessageTags) {
-			session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, tagsToUse, command, chname)
+			session.sendFromClientInternal(false, message.Time, message.Msgid, nickmask, account, tagsToUse, command, chname)
 		} else if histType != history.Tagmsg {
-			session.sendSplitMsgFromClientInternal(false, details.nickMask, details.accountName, tagsToUse, command, chname, message)
+			session.sendSplitMsgFromClientInternal(false, nickmask, account, tagsToUse, command, chname, message)
 		}
 	}
 
@@ -1277,9 +1277,9 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
 			}
 
 			if histType == history.Tagmsg {
-				session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, tagsToUse, command, chname)
+				session.sendFromClientInternal(false, message.Time, message.Msgid, nickmask, account, tagsToUse, command, chname)
 			} else {
-				session.sendSplitMsgFromClientInternal(false, details.nickMask, details.accountName, tagsToUse, command, chname, message)
+				session.sendSplitMsgFromClientInternal(false, nickmask, account, tagsToUse, command, chname, message)
 			}
 		}
 	}
@@ -1289,10 +1289,10 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
 		channel.AddHistoryItem(history.Item{
 			Type:        histType,
 			Message:     message,
-			Nick:        details.nickMask,
-			AccountName: details.accountName,
+			Nick:        nickmask,
+			AccountName: account,
 			Tags:        clientOnlyTags,
-		}, details.account)
+		})
 	}
 }
 
@@ -1386,27 +1386,28 @@ func (channel *Channel) Kick(client *Client, target *Client, comment string, rb
 	}
 
 	message := utils.MakeMessage(comment)
-	details := client.Details()
+	clientMask := client.NickMaskString()
+	clientAccount := client.AccountName()
 
 	targetNick := target.Nick()
 	chname := channel.Name()
 	for _, member := range channel.Members() {
 		for _, session := range member.Sessions() {
 			if session != rb.session {
-				session.sendFromClientInternal(false, message.Time, message.Msgid, details.nickMask, details.accountName, nil, "KICK", chname, targetNick, comment)
+				session.sendFromClientInternal(false, message.Time, message.Msgid, clientMask, clientAccount, nil, "KICK", chname, targetNick, comment)
 			}
 		}
 	}
-	rb.AddFromClient(message.Time, message.Msgid, details.nickMask, details.accountName, nil, "KICK", chname, targetNick, comment)
+	rb.Add(nil, clientMask, "KICK", chname, targetNick, comment)
 
 	histItem := history.Item{
 		Type:        history.Kick,
-		Nick:        details.nickMask,
-		AccountName: details.accountName,
+		Nick:        clientMask,
+		AccountName: target.AccountName(),
 		Message:     message,
 	}
 	histItem.Params[0] = targetNick
-	channel.AddHistoryItem(histItem, details.account)
+	channel.AddHistoryItem(histItem)
 
 	channel.Quit(target)
 }
diff --git a/irc/chanserv.go b/irc/chanserv.go
index b61d5756..ce225da7 100644
--- a/irc/chanserv.go
+++ b/irc/chanserv.go
@@ -255,7 +255,7 @@ func csAmodeHandler(server *Server, client *Client, command string, params []str
 				if member.Account() == change.Arg {
 					applied, change := channel.applyModeToMember(client, change, rb)
 					if applied {
-						announceCmodeChanges(channel, modes.ModeChanges{change}, chanservMask, "*", "", rb)
+						announceCmodeChanges(channel, modes.ModeChanges{change}, chanservMask, "*", rb)
 					}
 				}
 			}
@@ -302,7 +302,7 @@ func csOpHandler(server *Server, client *Client, command string, params []string
 		},
 		rb)
 	if applied {
-		announceCmodeChanges(channelInfo, modes.ModeChanges{change}, chanservMask, "*", "", rb)
+		announceCmodeChanges(channelInfo, modes.ModeChanges{change}, chanservMask, "*", rb)
 	}
 
 	csNotice(rb, fmt.Sprintf(client.t("Successfully op'd in channel %s"), channelName))
@@ -354,7 +354,7 @@ func csRegisterHandler(server *Server, client *Client, command string, params []
 		},
 		rb)
 	if applied {
-		announceCmodeChanges(channelInfo, modes.ModeChanges{change}, chanservMask, "*", "", rb)
+		announceCmodeChanges(channelInfo, modes.ModeChanges{change}, chanservMask, "*", rb)
 	}
 }
 
diff --git a/irc/client.go b/irc/client.go
index b5204b3d..0dc06516 100644
--- a/irc/client.go
+++ b/irc/client.go
@@ -27,8 +27,8 @@ import (
 )
 
 const (
-	// IdentTimeout is how long before our ident (username) check times out.
-	IdentTimeout         = time.Second + 500*time.Millisecond
+	// IdentTimeoutSeconds is how many seconds before our ident (username) check times out.
+	IdentTimeoutSeconds  = 1.5
 	IRCv3TimestampFormat = utils.IRCv3TimestampFormat
 )
 
@@ -146,7 +146,6 @@ type MultilineBatch struct {
 	target        string
 	responseLabel string // this is the value of the labeled-response tag sent with BATCH
 	message       utils.SplitMessage
-	lenBytes      int
 	tags          map[string]string
 }
 
@@ -169,14 +168,14 @@ func (s *Session) EndMultilineBatch(label string) (batch MultilineBatch, err err
 	s.fakelag.Unsuspend()
 
 	// heuristics to estimate how much data they used while fakelag was suspended
-	fakelagBill := (batch.lenBytes / 512) + 1
+	fakelagBill := (batch.message.LenBytes() / 512) + 1
 	fakelagBillLines := (batch.message.LenLines() * 60) / 512
 	if fakelagBill < fakelagBillLines {
 		fakelagBill = fakelagBillLines
 	}
 	s.deferredFakelagCount = fakelagBill
 
-	if batch.label == "" || batch.label != label || !batch.message.ValidMultiline() {
+	if batch.label == "" || batch.label != label || batch.message.LenLines() == 0 {
 		err = errInvalidMultilineBatch
 		return
 	}
@@ -283,7 +282,7 @@ func (server *Server) RunClient(conn IRCConn) {
 		return
 	}
 
-	server.logger.Info("connect-ip", fmt.Sprintf("Client connecting: real IP %v, proxied IP %v", realIP, proxiedIP))
+	server.logger.Info("connect-ip", fmt.Sprintf("Client connecting from %v", realIP))
 
 	now := time.Now().UTC()
 	config := server.Config()
@@ -328,19 +327,22 @@ func (server *Server) RunClient(conn IRCConn) {
 	session.resetFakelag()
 
 	ApplyUserModeChanges(client, config.Accounts.defaultUserModes, false, nil)
-	if proxiedConn.Secure {
-		client.SetMode(modes.TLS, true)
-	}
 
 	if proxiedConn.Config.TLSConfig != nil {
+		client.SetMode(modes.TLS, true)
 		// error is not useful to us here anyways so we can ignore it
 		session.certfp, _ = utils.GetCertFP(proxiedConn.Conn, RegisterTimeout)
 	}
 
 	if session.isTor {
+		client.SetMode(modes.TLS, true)
 		session.rawHostname = config.Server.TorListeners.Vhost
 		client.rawHostname = session.rawHostname
 	} else {
+		if realIP.IsLoopback() || utils.IPInNets(realIP, config.Server.secureNets) {
+			// treat local connections as secure (may be overridden later by WEBIRC)
+			client.SetMode(modes.TLS, true)
+		}
 		if config.Server.CheckIdent {
 			client.doIdentLookup(proxiedConn.Conn)
 		}
@@ -484,7 +486,7 @@ func (client *Client) doIdentLookup(conn net.Conn) {
 	clientPort := remoteTCPAddr.Port
 
 	client.Notice(client.t("*** Looking up your username"))
-	resp, err := ident.Query(remoteTCPAddr.IP.String(), serverPort, clientPort, IdentTimeout)
+	resp, err := ident.Query(remoteTCPAddr.IP.String(), serverPort, clientPort, IdentTimeoutSeconds)
 	if err == nil {
 		err := client.SetNames(resp.Identifier, "", true)
 		if err == nil {
@@ -1278,7 +1280,7 @@ func (client *Client) destroy(session *Session) {
 	// use a defer here to avoid writing to mysql while holding the destroy semaphore:
 	defer func() {
 		for _, channel := range channels {
-			channel.AddHistoryItem(quitItem, details.account)
+			channel.AddHistoryItem(quitItem)
 		}
 	}()
 
@@ -1358,14 +1360,9 @@ func (session *Session) sendSplitMsgFromClientInternal(blocking bool, nickmask,
 				session.SendRawMessage(msg, blocking)
 			}
 		} else {
-			msgidSent := false // send msgid on the first nonblank line
-			for _, messagePair := range message.Split {
-				if len(messagePair.Message) == 0 {
-					continue
-				}
+			for i, messagePair := range message.Split {
 				var msgid string
-				if !msgidSent {
-					msgidSent = true
+				if i == 0 {
 					msgid = message.Msgid
 				}
 				session.sendFromClientInternal(blocking, message.Time, msgid, nickmask, accountName, tags, command, target, messagePair.Message)
diff --git a/irc/client_lookup_set.go b/irc/client_lookup_set.go
index 95314509..6db2384d 100644
--- a/irc/client_lookup_set.go
+++ b/irc/client_lookup_set.go
@@ -7,12 +7,14 @@ package irc
 import (
 	"regexp"
 	"strings"
-	"sync"
 	"time"
 
+	"github.com/goshuirc/irc-go/ircmatch"
+
 	"github.com/oragono/oragono/irc/caps"
 	"github.com/oragono/oragono/irc/modes"
-	"github.com/oragono/oragono/irc/utils"
+
+	"sync"
 )
 
 // ClientManager keeps track of clients by nick, enforcing uniqueness of casefolded nicks
@@ -299,16 +301,12 @@ func (clients *ClientManager) FindAll(userhost string) (set ClientSet) {
 	if err != nil {
 		return set
 	}
-	matcher, err := utils.CompileGlob(userhost, false)
-	if err != nil {
-		// not much we can do here
-		return
-	}
+	matcher := ircmatch.MakeMatch(userhost)
 
 	clients.RLock()
 	defer clients.RUnlock()
 	for _, client := range clients.byNick {
-		if matcher.MatchString(client.NickMaskCasefolded()) {
+		if matcher.Match(client.NickMaskCasefolded()) {
 			set.Add(client)
 		}
 	}
@@ -332,9 +330,8 @@ type MaskInfo struct {
 // UserMaskSet holds a set of client masks and lets you match  hostnames to them.
 type UserMaskSet struct {
 	sync.RWMutex
-	serialCacheUpdateMutex sync.Mutex
-	masks                  map[string]MaskInfo
-	regexp                 *regexp.Regexp
+	masks  map[string]MaskInfo
+	regexp *regexp.Regexp
 }
 
 func NewUserMaskSet() *UserMaskSet {
@@ -348,9 +345,6 @@ func (set *UserMaskSet) Add(mask, creatorNickmask, creatorAccount string) (maskA
 		return
 	}
 
-	set.serialCacheUpdateMutex.Lock()
-	defer set.serialCacheUpdateMutex.Unlock()
-
 	set.Lock()
 	if set.masks == nil {
 		set.masks = make(map[string]MaskInfo)
@@ -379,9 +373,6 @@ func (set *UserMaskSet) Remove(mask string) (maskRemoved string, err error) {
 		return
 	}
 
-	set.serialCacheUpdateMutex.Lock()
-	defer set.serialCacheUpdateMutex.Unlock()
-
 	set.Lock()
 	_, removed := set.masks[mask]
 	if removed {
@@ -432,15 +423,38 @@ func (set *UserMaskSet) Length() int {
 	return len(set.masks)
 }
 
+// setRegexp generates a regular expression from the set of user mask
+// strings. Masks are split at the two types of wildcards, `*` and
+// `?`. All the pieces are meta-escaped. `*` is replaced with `.*`,
+// the regexp equivalent. Likewise, `?` is replaced with `.`. The
+// parts are re-joined and finally all masks are joined into a big
+// or-expression.
 func (set *UserMaskSet) setRegexp() {
+	var re *regexp.Regexp
+
 	set.RLock()
 	maskExprs := make([]string, len(set.masks))
+	index := 0
 	for mask := range set.masks {
-		maskExprs = append(maskExprs, mask)
+		manyParts := strings.Split(mask, "*")
+		manyExprs := make([]string, len(manyParts))
+		for mindex, manyPart := range manyParts {
+			oneParts := strings.Split(manyPart, "?")
+			oneExprs := make([]string, len(oneParts))
+			for oindex, onePart := range oneParts {
+				oneExprs[oindex] = regexp.QuoteMeta(onePart)
+			}
+			manyExprs[mindex] = strings.Join(oneExprs, ".")
+		}
+		maskExprs[index] = strings.Join(manyExprs, ".*")
+		index++
 	}
 	set.RUnlock()
 
-	re, _ := utils.CompileMasks(maskExprs)
+	if index > 0 {
+		expr := "^(" + strings.Join(maskExprs, "|") + ")$"
+		re, _ = regexp.Compile(expr)
+	}
 
 	set.Lock()
 	set.regexp = re
diff --git a/irc/config.go b/irc/config.go
index 6966fcf6..982afc3a 100644
--- a/irc/config.go
+++ b/irc/config.go
@@ -13,7 +13,6 @@ import (
 	"log"
 	"net"
 	"os"
-	"path/filepath"
 	"regexp"
 	"sort"
 	"strconv"
@@ -512,7 +511,6 @@ type Config struct {
 		supportedCaps *caps.Set
 		capValues     caps.Values
 		Casemapping   Casemapping
-		OutputPath    string `yaml:"output-path"`
 	}
 
 	Roleplay struct {
@@ -592,10 +590,6 @@ type Config struct {
 			RegisteredChannels   PersistentStatus `yaml:"registered-channels"`
 			DirectMessages       PersistentStatus `yaml:"direct-messages"`
 		}
-		Retention struct {
-			AllowIndividualDelete bool `yaml:"allow-individual-delete"`
-			EnableAccountIndexing bool `yaml:"enable-account-indexing"`
-		}
 	}
 
 	Filename string
@@ -859,7 +853,7 @@ func LoadConfig(filename string) (config *Config, err error) {
 	}
 
 	for _, glob := range config.Server.WebSockets.AllowedOrigins {
-		globre, err := utils.CompileGlob(glob, false)
+		globre, err := utils.CompileGlob(glob)
 		if err != nil {
 			return nil, fmt.Errorf("invalid websocket allowed-origin expression: %s", glob)
 		}
@@ -1117,7 +1111,6 @@ func LoadConfig(filename string) (config *Config, err error) {
 	config.Roleplay.addSuffix = utils.BoolDefaultTrue(config.Roleplay.AddSuffix)
 
 	config.Datastore.MySQL.ExpireTime = time.Duration(config.History.Restrictions.ExpireTime)
-	config.Datastore.MySQL.TrackAccountMessages = config.History.Retention.EnableAccountIndexing
 
 	config.Server.Cloaks.Initialize()
 	if config.Server.Cloaks.Enabled {
@@ -1140,10 +1133,6 @@ func LoadConfig(filename string) (config *Config, err error) {
 	return config, nil
 }
 
-func (config *Config) getOutputPath(filename string) string {
-	return filepath.Join(config.Server.OutputPath, filename)
-}
-
 // setISupport sets up our RPL_ISUPPORT reply.
 func (config *Config) generateISupport() (err error) {
 	maxTargetsString := strconv.Itoa(maxTargets)
@@ -1230,7 +1219,7 @@ func compileGuestRegexp(guestFormat string, casemapping Casemapping) (standard,
 		return
 	}
 
-	standard, err = utils.CompileGlob(guestFormat, true)
+	standard, err = utils.CompileGlob(guestFormat)
 	if err != nil {
 		return
 	}
@@ -1246,6 +1235,6 @@ func compileGuestRegexp(guestFormat string, casemapping Casemapping) (standard,
 	if err != nil {
 		return
 	}
-	folded, err = utils.CompileGlob(fmt.Sprintf("%s*%s", initialFolded, finalFolded), false)
+	folded, err = utils.CompileGlob(fmt.Sprintf("%s*%s", initialFolded, finalFolded))
 	return
 }
diff --git a/irc/gateways.go b/irc/gateways.go
index 57140b89..da58c67a 100644
--- a/irc/gateways.go
+++ b/irc/gateways.go
@@ -57,9 +57,8 @@ func (wc *webircConfig) Populate() (err error) {
 // ApplyProxiedIP applies the given IP to the client.
 func (client *Client) ApplyProxiedIP(session *Session, proxiedIP net.IP, tls bool) (err error, quitMsg string) {
 	// PROXY and WEBIRC are never accepted from a Tor listener, even if the address itself
-	// is whitelisted. Furthermore, don't accept PROXY or WEBIRC if we already accepted
-	// a proxied IP from any source (PROXY, WEBIRC, or X-Forwarded-For):
-	if session.isTor || session.proxiedIP != nil {
+	// is whitelisted:
+	if session.isTor {
 		return errBadProxyLine, ""
 	}
 
diff --git a/irc/handlers.go b/irc/handlers.go
index 467881e8..64d16cde 100644
--- a/irc/handlers.go
+++ b/irc/handlers.go
@@ -21,6 +21,7 @@ import (
 	"time"
 
 	"github.com/goshuirc/irc-go/ircfmt"
+	"github.com/goshuirc/irc-go/ircmatch"
 	"github.com/goshuirc/irc-go/ircmsg"
 	"github.com/oragono/oragono/irc/caps"
 	"github.com/oragono/oragono/irc/custime"
@@ -676,7 +677,7 @@ func debugHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res
 		rb.Notice(fmt.Sprintf("num goroutines: %d", count))
 
 	case "PROFILEHEAP":
-		profFile := server.Config().getOutputPath("oragono.mprof")
+		profFile := "oragono.mprof"
 		file, err := os.Create(profFile)
 		if err != nil {
 			rb.Notice(fmt.Sprintf("error: %s", err))
@@ -687,7 +688,7 @@ func debugHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res
 		rb.Notice(fmt.Sprintf("written to %s", profFile))
 
 	case "STARTCPUPROFILE":
-		profFile := server.Config().getOutputPath("oragono.prof")
+		profFile := "oragono.prof"
 		file, err := os.Create(profFile)
 		if err != nil {
 			rb.Notice(fmt.Sprintf("error: %s", err))
@@ -934,17 +935,50 @@ func historyHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R
 		return false
 	}
 
-	items, channel, err := easySelectHistory(server, client, msg.Params)
+	target := msg.Params[0]
+	if strings.ToLower(target) == "me" {
+		target = "*"
+	}
+	channel, sequence, err := server.GetHistorySequence(nil, client, target)
 
-	if err == errNoSuchChannel {
-		rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(msg.Params[0]), client.t("No such channel"))
-		return false
-	} else if err != nil {
-		rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), msg.Command, client.t("Could not retrieve history"))
+	if sequence == nil || err != nil {
+		// whatever
+		rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(target), client.t("No such channel"))
 		return false
 	}
 
-	if len(items) != 0 {
+	var duration time.Duration
+	maxChathistoryLimit := config.History.ChathistoryMax
+	limit := 100
+	if maxChathistoryLimit < limit {
+		limit = maxChathistoryLimit
+	}
+	if len(msg.Params) > 1 {
+		providedLimit, err := strconv.Atoi(msg.Params[1])
+		if err == nil && providedLimit != 0 {
+			limit = providedLimit
+			if maxChathistoryLimit < limit {
+				limit = maxChathistoryLimit
+			}
+		} else if err != nil {
+			duration, err = time.ParseDuration(msg.Params[1])
+			if err == nil {
+				limit = maxChathistoryLimit
+			}
+		}
+	}
+
+	var items []history.Item
+	if duration == 0 {
+		items, _, err = sequence.Between(history.Selector{}, history.Selector{}, limit)
+	} else {
+		now := time.Now().UTC()
+		start := history.Selector{Time: now}
+		end := history.Selector{Time: now.Add(-duration)}
+		items, _, err = sequence.Between(start, end, limit)
+	}
+
+	if err == nil && len(items) != 0 {
 		if channel != nil {
 			channel.replayHistoryItems(rb, items, false)
 		} else {
@@ -1246,14 +1280,10 @@ func klineHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res
 		return false
 	}
 
-	matcher, err := utils.CompileGlob(mask, false)
-	if err != nil {
-		rb.Add(nil, server.name, ERR_UNKNOWNERROR, details.nick, msg.Command, client.t("Erroneous nickname"))
-		return false
-	}
+	matcher := ircmatch.MakeMatch(mask)
 
 	for _, clientMask := range client.AllNickmasks() {
-		if !klineMyself && matcher.MatchString(clientMask) {
+		if !klineMyself && matcher.Match(clientMask) {
 			rb.Add(nil, server.name, ERR_UNKNOWNERROR, details.nick, msg.Command, client.t("This ban matches you. To KLINE yourself, you must use the command:  /KLINE MYSELF <arguments>"))
 			return false
 		}
@@ -1297,7 +1327,7 @@ func klineHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res
 
 		for _, mcl := range server.clients.AllClients() {
 			for _, clientMask := range mcl.AllNickmasks() {
-				if matcher.MatchString(clientMask) {
+				if matcher.Match(clientMask) {
 					clientsToKill = append(clientsToKill, mcl)
 					killedClientNicks = append(killedClientNicks, mcl.nick)
 				}
@@ -1500,12 +1530,12 @@ func cmodeHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Res
 	// process mode changes, include list operations (an empty set of changes does a list)
 	applied := channel.ApplyChannelModeChanges(client, msg.Command == "SAMODE", changes, rb)
 	details := client.Details()
-	announceCmodeChanges(channel, applied, details.nickMask, details.accountName, details.account, rb)
+	announceCmodeChanges(channel, applied, details.nickMask, details.accountName, rb)
 
 	return false
 }
 
-func announceCmodeChanges(channel *Channel, applied modes.ModeChanges, source, accountName, account string, rb *ResponseBuffer) {
+func announceCmodeChanges(channel *Channel, applied modes.ModeChanges, source, accountName string, rb *ResponseBuffer) {
 	// send out changes
 	if len(applied) > 0 {
 		message := utils.MakeMessage("")
@@ -1527,7 +1557,7 @@ func announceCmodeChanges(channel *Channel, applied modes.ModeChanges, source, a
 			Nick:        source,
 			AccountName: accountName,
 			Message:     message,
-		}, account)
+		})
 	}
 }
 
@@ -1793,39 +1823,33 @@ func nickHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp
 
 // helper to store a batched PRIVMSG in the session object
 func absorbBatchedMessage(server *Server, client *Client, msg ircmsg.IrcMessage, batchTag string, histType history.ItemType, rb *ResponseBuffer) {
-	var errorCode, errorMessage string
-	defer func() {
-		if errorCode != "" {
-			if histType != history.Notice {
-				rb.Add(nil, server.name, "FAIL", "BATCH", errorCode, errorMessage)
-			}
-			rb.session.EndMultilineBatch("")
-		}
-	}()
-
 	if batchTag != rb.session.batch.label {
-		errorCode, errorMessage = "MULTILINE_INVALID", client.t("Incorrect batch tag sent")
+		if histType != history.Notice {
+			rb.Add(nil, server.name, "FAIL", "BATCH", "MULTILINE_INVALID", client.t("Incorrect batch tag sent"))
+		}
+		rb.session.EndMultilineBatch("")
 		return
-	} else if len(msg.Params) < 2 {
-		errorCode, errorMessage = "MULTILINE_INVALID", client.t("Invalid multiline batch")
+	} else if len(msg.Params) < 2 || msg.Params[1] == "" {
+		if histType != history.Notice {
+			rb.Add(nil, server.name, "FAIL", "BATCH", "MULTILINE_INVALID", client.t("Invalid multiline batch"))
+		}
+		rb.session.EndMultilineBatch("")
 		return
 	}
 	rb.session.batch.command = msg.Command
 	isConcat, _ := msg.GetTag(caps.MultilineConcatTag)
-	if isConcat && len(msg.Params[1]) == 0 {
-		errorCode, errorMessage = "MULTILINE_INVALID", client.t("Cannot send a blank line with the multiline concat tag")
-		return
-	}
-	if !isConcat && len(rb.session.batch.message.Split) != 0 {
-		rb.session.batch.lenBytes++ // bill for the newline
-	}
 	rb.session.batch.message.Append(msg.Params[1], isConcat)
-	rb.session.batch.lenBytes += len(msg.Params[1])
 	config := server.Config()
-	if config.Limits.Multiline.MaxBytes < rb.session.batch.lenBytes {
-		errorCode, errorMessage = "MULTILINE_MAX_BYTES", strconv.Itoa(config.Limits.Multiline.MaxBytes)
+	if config.Limits.Multiline.MaxBytes < rb.session.batch.message.LenBytes() {
+		if histType != history.Notice {
+			rb.Add(nil, server.name, "FAIL", "BATCH", "MULTILINE_MAX_BYTES", strconv.Itoa(config.Limits.Multiline.MaxBytes))
+		}
+		rb.session.EndMultilineBatch("")
 	} else if config.Limits.Multiline.MaxLines != 0 && config.Limits.Multiline.MaxLines < rb.session.batch.message.LenLines() {
-		errorCode, errorMessage = "MULTILINE_MAX_LINES", strconv.Itoa(config.Limits.Multiline.MaxLines)
+		if histType != history.Notice {
+			rb.Add(nil, server.name, "FAIL", "BATCH", "MULTILINE_MAX_LINES", strconv.Itoa(config.Limits.Multiline.MaxLines))
+		}
+		rb.session.EndMultilineBatch("")
 	}
 }
 
diff --git a/irc/history/history.go b/irc/history/history.go
index bc5b45ed..5a1ece64 100644
--- a/irc/history/history.go
+++ b/irc/history/history.go
@@ -284,32 +284,6 @@ func (list *Buffer) matchInternal(predicate Predicate, ascending bool, limit int
 	return
 }
 
-// Delete deletes messages matching some predicate.
-func (list *Buffer) Delete(predicate Predicate) (count int) {
-	list.Lock()
-	defer list.Unlock()
-
-	if list.start == -1 || len(list.buffer) == 0 {
-		return
-	}
-
-	pos := list.start
-	stop := list.prev(list.end)
-
-	for {
-		if predicate(&list.buffer[pos]) {
-			list.buffer[pos] = Item{}
-			count++
-		}
-		if pos == stop {
-			break
-		}
-		pos = list.next(pos)
-	}
-
-	return
-}
-
 // latest returns the items most recently added, up to `limit`. If `limit` is 0,
 // it returns all items.
 func (list *Buffer) latest(limit int) (results []Item) {
diff --git a/irc/histserv.go b/irc/histserv.go
deleted file mode 100644
index 6f61552a..00000000
--- a/irc/histserv.go
+++ /dev/null
@@ -1,244 +0,0 @@
-// Copyright (c) 2020 Shivaram Lingamneni <slingamn@cs.stanford.edu>
-// released under the MIT license
-
-package irc
-
-import (
-	"bufio"
-	"fmt"
-	"os"
-	"runtime/debug"
-	"strconv"
-	"strings"
-	"time"
-
-	"github.com/oragono/oragono/irc/history"
-	"github.com/oragono/oragono/irc/utils"
-)
-
-const (
-	histservHelp = `HistServ provides commands related to history.`
-	histServMask = "HistServ!HistServ@localhost"
-)
-
-func histservEnabled(config *Config) bool {
-	return config.History.Enabled
-}
-
-func historyComplianceEnabled(config *Config) bool {
-	return config.History.Enabled && config.History.Persistent.Enabled && config.History.Retention.EnableAccountIndexing
-}
-
-var (
-	histservCommands = map[string]*serviceCommand{
-		"forget": {
-			handler: histservForgetHandler,
-			help: `Syntax: $bFORGET <account>$b
-
-FORGET deletes all history messages sent by an account.`,
-			helpShort: `$bFORGET$b deletes all history messages sent by an account.`,
-			capabs:    []string{"history"},
-			enabled:   histservEnabled,
-			minParams: 1,
-			maxParams: 1,
-		},
-		"delete": {
-			handler: histservDeleteHandler,
-			help: `Syntax: $bDELETE [target] <msgid>$b
-
-DELETE deletes an individual message by its msgid. The target is a channel
-name or nickname; depending on the history implementation, this may or may not
-be necessary to locate the message.`,
-			helpShort: `$bDELETE$b deletes an individual message by its msgid.`,
-			enabled:   histservEnabled,
-			minParams: 1,
-			maxParams: 2,
-		},
-		"export": {
-			handler: histservExportHandler,
-			help: `Syntax: $bEXPORT <account>$b
-
-EXPORT exports all messages sent by an account as JSON. This can be used at
-the request of the account holder.`,
-			helpShort: `$bEXPORT$b exports all messages sent by an account as JSON.`,
-			enabled:   historyComplianceEnabled,
-			capabs:    []string{"history"},
-			minParams: 1,
-			maxParams: 1,
-		},
-		"play": {
-			handler: histservPlayHandler,
-			help: `Syntax: $bPLAY <target> [limit]$b
-
-PLAY plays back history messages, rendering them into direct messages from
-HistServ. 'target' is a channel name (or 'me' for direct messages), and 'limit'
-is a message count or a time duration. Note that message playback may be
-incomplete or degraded, relative to direct playback from /HISTORY or
-CHATHISTORY.`,
-			helpShort: `$bPLAY$b plays back history messages.`,
-			enabled:   histservEnabled,
-			minParams: 1,
-			maxParams: 2,
-		},
-	}
-)
-
-// histNotice sends the client a notice from HistServ
-func histNotice(rb *ResponseBuffer, text string) {
-	rb.Add(nil, histServMask, "NOTICE", rb.target.Nick(), text)
-}
-
-func histservForgetHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
-	accountName := server.accounts.AccountToAccountName(params[0])
-	if accountName == "" {
-		histNotice(rb, client.t("Could not look up account name, proceeding anyway"))
-		accountName = params[0]
-	}
-
-	server.ForgetHistory(accountName)
-
-	histNotice(rb, fmt.Sprintf(client.t("Enqueued account %s for message deletion"), accountName))
-}
-
-func histservDeleteHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
-	var target, msgid string
-	if len(params) == 1 {
-		msgid = params[0]
-	} else {
-		target, msgid = params[0], params[1]
-	}
-
-	accountName := "*"
-	hasPrivs := client.HasRoleCapabs("history")
-	if !hasPrivs {
-		accountName = client.AccountName()
-		if !(server.Config().History.Retention.AllowIndividualDelete && accountName != "*") {
-			histNotice(rb, client.t("Insufficient privileges"))
-			return
-		}
-	}
-
-	err := server.DeleteMessage(target, msgid, accountName)
-	if err == nil {
-		histNotice(rb, client.t("Successfully deleted message"))
-	} else {
-		if hasPrivs {
-			histNotice(rb, fmt.Sprintf(client.t("Error deleting message: %v"), err))
-		} else {
-			histNotice(rb, client.t("Could not delete message"))
-		}
-	}
-}
-
-func histservExportHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
-	cfAccount, err := CasefoldName(params[0])
-	if err != nil {
-		histNotice(rb, client.t("Invalid account name"))
-		return
-	}
-
-	config := server.Config()
-	// don't include the account name in the filename because of escaping concerns
-	filename := fmt.Sprintf("%s-%s.json", utils.GenerateSecretToken(), time.Now().UTC().Format(IRCv3TimestampFormat))
-	pathname := config.getOutputPath(filename)
-	outfile, err := os.Create(pathname)
-	if err != nil {
-		histNotice(rb, fmt.Sprintf(client.t("Error opening export file: %v"), err))
-	} else {
-		histNotice(rb, fmt.Sprintf(client.t("Started exporting data for account %[1]s to file %[2]s"), cfAccount, filename))
-	}
-
-	go histservExportAndNotify(server, cfAccount, outfile, filename, client.Nick())
-}
-
-func histservExportAndNotify(server *Server, cfAccount string, outfile *os.File, filename, alertNick string) {
-	defer func() {
-		if r := recover(); r != nil {
-			server.logger.Error("history",
-				fmt.Sprintf("Panic in history export routine: %v\n%s", r, debug.Stack()))
-		}
-	}()
-
-	defer outfile.Close()
-	writer := bufio.NewWriter(outfile)
-	defer writer.Flush()
-
-	server.historyDB.Export(cfAccount, writer)
-
-	client := server.clients.Get(alertNick)
-	if client != nil && client.HasRoleCapabs("history") {
-		client.Send(nil, histServMask, "NOTICE", client.Nick(), fmt.Sprintf(client.t("Data export for %[1]s completed and written to %[2]s"), cfAccount, filename))
-	}
-}
-
-func histservPlayHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
-	items, _, err := easySelectHistory(server, client, params)
-	if err != nil {
-		histNotice(rb, client.t("Could not retrieve history"))
-		return
-	}
-
-	playMessage := func(timestamp time.Time, nick, message string) {
-		histNotice(rb, fmt.Sprintf("%s <%s> %s", timestamp.Format("15:04:05"), stripMaskFromNick(nick), message))
-	}
-
-	for _, item := range items {
-		// TODO: support a few more of these, maybe JOIN/PART/QUIT
-		if item.Type != history.Privmsg && item.Type != history.Notice {
-			continue
-		}
-		if len(item.Message.Split) == 0 {
-			playMessage(item.Message.Time, item.Nick, item.Message.Message)
-		} else {
-			for _, pair := range item.Message.Split {
-				playMessage(item.Message.Time, item.Nick, pair.Message)
-			}
-		}
-	}
-
-	histNotice(rb, client.t("End of history playback"))
-}
-
-// handles parameter parsing and history queries for /HISTORY and /HISTSERV PLAY
-func easySelectHistory(server *Server, client *Client, params []string) (items []history.Item, channel *Channel, err error) {
-	target := params[0]
-	if strings.ToLower(target) == "me" {
-		target = "*"
-	}
-	channel, sequence, err := server.GetHistorySequence(nil, client, target)
-
-	if sequence == nil || err != nil {
-		return nil, nil, errNoSuchChannel
-	}
-
-	var duration time.Duration
-	maxChathistoryLimit := server.Config().History.ChathistoryMax
-	limit := 100
-	if maxChathistoryLimit < limit {
-		limit = maxChathistoryLimit
-	}
-	if len(params) > 1 {
-		providedLimit, err := strconv.Atoi(params[1])
-		if err == nil && providedLimit != 0 {
-			limit = providedLimit
-			if maxChathistoryLimit < limit {
-				limit = maxChathistoryLimit
-			}
-		} else if err != nil {
-			duration, err = time.ParseDuration(params[1])
-			if err == nil {
-				limit = maxChathistoryLimit
-			}
-		}
-	}
-
-	if duration == 0 {
-		items, _, err = sequence.Between(history.Selector{}, history.Selector{}, limit)
-	} else {
-		now := time.Now().UTC()
-		start := history.Selector{Time: now}
-		end := history.Selector{Time: now.Add(-duration)}
-		items, _, err = sequence.Between(start, end, limit)
-	}
-	return
-}
diff --git a/irc/kline.go b/irc/kline.go
index ca669a5e..4099f482 100644
--- a/irc/kline.go
+++ b/irc/kline.go
@@ -6,14 +6,12 @@ package irc
 import (
 	"encoding/json"
 	"fmt"
-	"regexp"
 	"strings"
 	"sync"
 	"time"
 
+	"github.com/goshuirc/irc-go/ircmatch"
 	"github.com/tidwall/buntdb"
-
-	"github.com/oragono/oragono/irc/utils"
 )
 
 const (
@@ -25,7 +23,7 @@ type KLineInfo struct {
 	// Mask that is blocked.
 	Mask string
 	// Matcher, to facilitate fast matching.
-	Matcher *regexp.Regexp
+	Matcher ircmatch.Matcher
 	// Info contains information on the ban.
 	Info IPBanInfo
 }
@@ -82,14 +80,9 @@ func (km *KLineManager) AddMask(mask string, duration time.Duration, reason, ope
 }
 
 func (km *KLineManager) addMaskInternal(mask string, info IPBanInfo) {
-	re, err := utils.CompileGlob(mask, false)
-	// this is validated externally and shouldn't fail regardless
-	if err != nil {
-		return
-	}
 	kln := KLineInfo{
 		Mask:    mask,
-		Matcher: re,
+		Matcher: ircmatch.MakeMatch(mask),
 		Info:    info,
 	}
 
@@ -196,7 +189,7 @@ func (km *KLineManager) CheckMasks(masks ...string) (isBanned bool, info IPBanIn
 
 	for _, entryInfo := range km.entries {
 		for _, mask := range masks {
-			if entryInfo.Matcher.MatchString(mask) {
+			if entryInfo.Matcher.Match(mask) {
 				return true, entryInfo.Info
 			}
 		}
diff --git a/irc/listeners.go b/irc/listeners.go
index bc01c0aa..c6e6054f 100644
--- a/irc/listeners.go
+++ b/irc/listeners.go
@@ -88,6 +88,13 @@ func (nl *NetListener) Stop() error {
 	return nl.listener.Close()
 }
 
+// ensure that any IP we got from the PROXY line is trustworthy (otherwise, clear it)
+func validateProxiedIP(conn *utils.WrappedConn, config *Config) {
+	if !utils.IPInNets(utils.AddrToIP(conn.RemoteAddr()), config.Server.proxyAllowedFromNets) {
+		conn.ProxiedIP = nil
+	}
+}
+
 func (nl *NetListener) serve() {
 	for {
 		conn, err := nl.listener.Accept()
@@ -96,7 +103,9 @@ func (nl *NetListener) serve() {
 			// hand off the connection
 			wConn, ok := conn.(*utils.WrappedConn)
 			if ok {
-				confirmProxyData(wConn, "", "", "", nl.server.Config())
+				if wConn.ProxiedIP != nil {
+					validateProxiedIP(wConn, nl.server.Config())
+				}
 				go nl.server.RunClient(NewIRCStreamConn(wConn))
 			} else {
 				nl.server.logger.Error("internal", "invalid connection type", nl.addr)
@@ -150,9 +159,8 @@ func (wl *WSListener) Stop() error {
 
 func (wl *WSListener) handle(w http.ResponseWriter, r *http.Request) {
 	config := wl.server.Config()
-	remoteAddr := r.RemoteAddr
-	xff := r.Header.Get("X-Forwarded-For")
-	xfp := r.Header.Get("X-Forwarded-Proto")
+	proxyAllowedFrom := config.Server.proxyAllowedFromNets
+	proxiedIP := utils.HandleXForwardedFor(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), proxyAllowedFrom)
 
 	wsUpgrader := websocket.Upgrader{
 		CheckOrigin: func(r *http.Request) bool {
@@ -184,39 +192,18 @@ func (wl *WSListener) handle(w http.ResponseWriter, r *http.Request) {
 		conn.Close()
 		return
 	}
-
-	confirmProxyData(wConn, remoteAddr, xff, xfp, config)
+	if wConn.ProxiedIP != nil {
+		validateProxiedIP(wConn, config)
+	} else {
+		// if there was no PROXY protocol IP, use the validated X-Forwarded-For IP instead,
+		// unless it is redundant
+		if proxiedIP != nil && !proxiedIP.Equal(utils.AddrToIP(wConn.RemoteAddr())) {
+			wConn.ProxiedIP = proxiedIP
+		}
+	}
 
 	// avoid a DoS attack from buffering excessively large messages:
 	conn.SetReadLimit(maxReadQBytes)
 
 	go wl.server.RunClient(NewIRCWSConn(conn))
 }
-
-// validate conn.ProxiedIP and conn.Secure against config, HTTP headers, etc.
-func confirmProxyData(conn *utils.WrappedConn, remoteAddr, xForwardedFor, xForwardedProto string, config *Config) {
-	if conn.ProxiedIP != nil {
-		if !utils.IPInNets(utils.AddrToIP(conn.RemoteAddr()), config.Server.proxyAllowedFromNets) {
-			conn.ProxiedIP = nil
-		}
-	} else if xForwardedFor != "" {
-		proxiedIP := utils.HandleXForwardedFor(remoteAddr, xForwardedFor, config.Server.proxyAllowedFromNets)
-		// don't set proxied IP if it is redundant with the actual IP
-		if proxiedIP != nil && !proxiedIP.Equal(utils.AddrToIP(conn.RemoteAddr())) {
-			conn.ProxiedIP = proxiedIP
-		}
-	}
-
-	if conn.Config.TLSConfig != nil || conn.Config.Tor {
-		// we terminated our own encryption:
-		conn.Secure = true
-	} else if !conn.Config.WebSocket {
-		// plaintext normal connection: loopback and secureNets are secure
-		realIP := utils.AddrToIP(conn.RemoteAddr())
-		conn.Secure = realIP.IsLoopback() || utils.IPInNets(realIP, config.Server.secureNets)
-	} else {
-		// plaintext websocket: trust X-Forwarded-Proto from a trusted source
-		conn.Secure = utils.IPInNets(utils.AddrToIP(conn.RemoteAddr()), config.Server.proxyAllowedFromNets) &&
-			xForwardedProto == "https"
-	}
-}
diff --git a/irc/mysql/config.go b/irc/mysql/config.go
index c4a19ae5..c9e39437 100644
--- a/irc/mysql/config.go
+++ b/irc/mysql/config.go
@@ -18,6 +18,5 @@ type Config struct {
 	Timeout         time.Duration
 
 	// XXX these are copied from elsewhere in the config:
-	ExpireTime           time.Duration
-	TrackAccountMessages bool
+	ExpireTime time.Duration
 }
diff --git a/irc/mysql/history.go b/irc/mysql/history.go
index b1b86a23..76427f4e 100644
--- a/irc/mysql/history.go
+++ b/irc/mysql/history.go
@@ -7,10 +7,7 @@ import (
 	"bytes"
 	"context"
 	"database/sql"
-	"encoding/json"
-	"errors"
 	"fmt"
-	"io"
 	"runtime/debug"
 	"sync"
 	"sync/atomic"
@@ -22,10 +19,6 @@ import (
 	"github.com/oragono/oragono/irc/utils"
 )
 
-var (
-	ErrDisallowed = errors.New("disallowed")
-)
-
 const (
 	// maximum length in bytes of any message target (nickname or channel name) in its
 	// canonicalized (i.e., casefolded) state:
@@ -34,46 +27,30 @@ const (
 	// latest schema of the db
 	latestDbSchema   = "2"
 	keySchemaVersion = "db.version"
-	// minor version indicates rollback-safe upgrades, i.e.,
-	// you can downgrade oragono and everything will work
-	latestDbMinorVersion  = "1"
-	keySchemaMinorVersion = "db.minorversion"
-	cleanupRowLimit       = 50
-	cleanupPauseTime      = 10 * time.Minute
+	cleanupRowLimit  = 50
+	cleanupPauseTime = 10 * time.Minute
 )
 
-type e struct{}
-
 type MySQL struct {
-	timeout              int64
-	trackAccountMessages uint32
-	db                   *sql.DB
-	logger               *logger.Manager
+	timeout int64
+	db      *sql.DB
+	logger  *logger.Manager
 
-	insertHistory        *sql.Stmt
-	insertSequence       *sql.Stmt
-	insertConversation   *sql.Stmt
-	insertAccountMessage *sql.Stmt
+	insertHistory      *sql.Stmt
+	insertSequence     *sql.Stmt
+	insertConversation *sql.Stmt
 
 	stateMutex sync.Mutex
 	config     Config
-
-	wakeForgetter chan e
 }
 
 func (mysql *MySQL) Initialize(logger *logger.Manager, config Config) {
 	mysql.logger = logger
-	mysql.wakeForgetter = make(chan e, 1)
 	mysql.SetConfig(config)
 }
 
 func (mysql *MySQL) SetConfig(config Config) {
 	atomic.StoreInt64(&mysql.timeout, int64(config.Timeout))
-	var trackAccountMessages uint32
-	if config.TrackAccountMessages {
-		trackAccountMessages = 1
-	}
-	atomic.StoreUint32(&mysql.trackAccountMessages, trackAccountMessages)
 	mysql.stateMutex.Lock()
 	mysql.config = config
 	mysql.stateMutex.Unlock()
@@ -90,6 +67,8 @@ func (m *MySQL) Open() (err error) {
 	var address string
 	if m.config.Port != 0 {
 		address = fmt.Sprintf("tcp(%s:%d)", m.config.Host, m.config.Port)
+	} else {
+		address = fmt.Sprintf("unix(%s)", m.config.Host)
 	}
 
 	m.db, err = sql.Open("mysql", fmt.Sprintf("%s:%s@%s/%s", m.config.User, m.config.Password, address, m.config.HistoryDatabase))
@@ -108,7 +87,6 @@ func (m *MySQL) Open() (err error) {
 	}
 
 	go m.cleanupLoop()
-	go m.forgetLoop()
 
 	return nil
 }
@@ -133,35 +111,14 @@ func (mysql *MySQL) fixSchemas() (err error) {
 		if err != nil {
 			return
 		}
-		_, err = mysql.db.Exec(`insert into metadata (key_name, value) values (?, ?);`, keySchemaMinorVersion, latestDbMinorVersion)
-		if err != nil {
-			return
-		}
-		return
 	} else if err == nil && schema != latestDbSchema {
 		// TODO figure out what to do about schema changes
 		return &utils.IncompatibleSchemaError{CurrentVersion: schema, RequiredVersion: latestDbSchema}
-	} else if err != nil {
+	} else {
 		return err
 	}
 
-	var minorVersion string
-	err = mysql.db.QueryRow(`select value from metadata where key_name = ?;`, keySchemaMinorVersion).Scan(&minorVersion)
-	if err == sql.ErrNoRows {
-		// XXX for now, the only minor version upgrade is the account tracking tables
-		err = mysql.createComplianceTables()
-		if err != nil {
-			return
-		}
-		_, err = mysql.db.Exec(`insert into metadata (key_name, value) values (?, ?);`, keySchemaMinorVersion, latestDbMinorVersion)
-		if err != nil {
-			return
-		}
-	} else if err == nil && minorVersion != latestDbMinorVersion {
-		// TODO: if minorVersion < latestDbMinorVersion, upgrade,
-		// if latestDbMinorVersion < minorVersion, ignore because backwards compatible
-	}
-	return
+	return nil
 }
 
 func (mysql *MySQL) createTables() (err error) {
@@ -200,32 +157,6 @@ func (mysql *MySQL) createTables() (err error) {
 		return err
 	}
 
-	err = mysql.createComplianceTables()
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func (mysql *MySQL) createComplianceTables() (err error) {
-	_, err = mysql.db.Exec(fmt.Sprintf(`CREATE TABLE account_messages (
-		history_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
-		account VARBINARY(%[1]d) NOT NULL,
-		KEY (account, history_id)
-	) CHARSET=ascii COLLATE=ascii_bin;`, MaxTargetLength))
-	if err != nil {
-		return err
-	}
-
-	_, err = mysql.db.Exec(fmt.Sprintf(`CREATE TABLE forget (
-		id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
-		account VARBINARY(%[1]d) NOT NULL
-	) CHARSET=ascii COLLATE=ascii_bin;`, MaxTargetLength))
-	if err != nil {
-		return err
-	}
-
 	return nil
 }
 
@@ -262,10 +193,7 @@ func (mysql *MySQL) cleanupLoop() {
 }
 
 func (mysql *MySQL) doCleanup(age time.Duration) (count int, err error) {
-	ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
-	defer cancel()
-
-	ids, maxNanotime, err := mysql.selectCleanupIDs(ctx, age)
+	ids, maxNanotime, err := mysql.selectCleanupIDs(age)
 	if len(ids) == 0 {
 		mysql.logger.Debug("mysql", "found no rows to clean up")
 		return
@@ -273,10 +201,6 @@ func (mysql *MySQL) doCleanup(age time.Duration) (count int, err error) {
 
 	mysql.logger.Debug("mysql", fmt.Sprintf("deleting %d history rows, max age %s", len(ids), utils.NanoToTimestamp(maxNanotime)))
 
-	return len(ids), mysql.deleteHistoryIDs(ctx, ids)
-}
-
-func (mysql *MySQL) deleteHistoryIDs(ctx context.Context, ids []uint64) (err error) {
 	// can't use ? binding for a variable number of arguments, build the IN clause manually
 	var inBuf bytes.Buffer
 	inBuf.WriteByte('(')
@@ -288,30 +212,25 @@ func (mysql *MySQL) deleteHistoryIDs(ctx context.Context, ids []uint64) (err err
 	}
 	inBuf.WriteRune(')')
 
-	_, err = mysql.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM conversations WHERE history_id in %s;`, inBuf.Bytes()))
+	_, err = mysql.db.Exec(fmt.Sprintf(`DELETE FROM conversations WHERE history_id in %s;`, inBuf.Bytes()))
 	if err != nil {
 		return
 	}
-	_, err = mysql.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM sequence WHERE history_id in %s;`, inBuf.Bytes()))
+	_, err = mysql.db.Exec(fmt.Sprintf(`DELETE FROM sequence WHERE history_id in %s;`, inBuf.Bytes()))
 	if err != nil {
 		return
 	}
-	if mysql.isTrackingAccountMessages() {
-		_, err = mysql.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM account_messages WHERE history_id in %s;`, inBuf.Bytes()))
-		if err != nil {
-			return
-		}
-	}
-	_, err = mysql.db.ExecContext(ctx, fmt.Sprintf(`DELETE FROM history WHERE id in %s;`, inBuf.Bytes()))
+	_, err = mysql.db.Exec(fmt.Sprintf(`DELETE FROM history WHERE id in %s;`, inBuf.Bytes()))
 	if err != nil {
 		return
 	}
 
+	count = len(ids)
 	return
 }
 
-func (mysql *MySQL) selectCleanupIDs(ctx context.Context, age time.Duration) (ids []uint64, maxNanotime int64, err error) {
-	rows, err := mysql.db.QueryContext(ctx, `
+func (mysql *MySQL) selectCleanupIDs(age time.Duration) (ids []uint64, maxNanotime int64, err error) {
+	rows, err := mysql.db.Query(`
 		SELECT history.id, sequence.nanotime
 		FROM history
 		LEFT JOIN sequence ON history.id = sequence.history_id
@@ -349,109 +268,6 @@ func (mysql *MySQL) selectCleanupIDs(ctx context.Context, age time.Duration) (id
 	return
 }
 
-// wait for forget queue items and process them one by one
-func (mysql *MySQL) forgetLoop() {
-	defer func() {
-		if r := recover(); r != nil {
-			mysql.logger.Error("mysql",
-				fmt.Sprintf("Panic in forget routine: %v\n%s", r, debug.Stack()))
-			time.Sleep(cleanupPauseTime)
-			go mysql.forgetLoop()
-		}
-	}()
-
-	for {
-		for {
-			found, err := mysql.doForget()
-			mysql.logError("error processing forget", err)
-			if err != nil {
-				time.Sleep(cleanupPauseTime)
-			}
-			if !found {
-				break
-			}
-		}
-
-		<-mysql.wakeForgetter
-	}
-}
-
-// dequeue an item from the forget queue and process it
-func (mysql *MySQL) doForget() (found bool, err error) {
-	id, account, err := func() (id int64, account string, err error) {
-		ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
-		defer cancel()
-
-		row := mysql.db.QueryRowContext(ctx,
-			`SELECT forget.id, forget.account FROM forget LIMIT 1;`)
-		err = row.Scan(&id, &account)
-		if err == sql.ErrNoRows {
-			return 0, "", nil
-		}
-		return
-	}()
-
-	if err != nil || account == "" {
-		return false, err
-	}
-
-	found = true
-
-	var count int
-	for {
-		start := time.Now()
-		count, err = mysql.doForgetIteration(account)
-		elapsed := time.Since(start)
-		if err != nil {
-			return true, err
-		}
-		if count == 0 {
-			break
-		}
-		time.Sleep(elapsed)
-	}
-
-	mysql.logger.Debug("mysql", "forget complete for account", account)
-
-	ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
-	defer cancel()
-	_, err = mysql.db.ExecContext(ctx, `DELETE FROM forget where id = ?;`, id)
-	return
-}
-
-func (mysql *MySQL) doForgetIteration(account string) (count int, err error) {
-	ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
-	defer cancel()
-
-	rows, err := mysql.db.QueryContext(ctx, `
-		SELECT account_messages.history_id
-		FROM account_messages
-		WHERE account_messages.account = ?
-		LIMIT ?;`, account, cleanupRowLimit)
-	if err != nil {
-		return
-	}
-	defer rows.Close()
-
-	var ids []uint64
-	for rows.Next() {
-		var id uint64
-		err = rows.Scan(&id)
-		if err != nil {
-			return
-		}
-		ids = append(ids, id)
-	}
-
-	if len(ids) == 0 {
-		return
-	}
-
-	mysql.logger.Debug("mysql", fmt.Sprintf("deleting %d history rows from account %s", len(ids), account))
-	err = mysql.deleteHistoryIDs(ctx, ids)
-	return len(ids), err
-}
-
 func (mysql *MySQL) prepareStatements() (err error) {
 	mysql.insertHistory, err = mysql.db.Prepare(`INSERT INTO history
 		(data, msgid) VALUES (?, ?);`)
@@ -468,11 +284,6 @@ func (mysql *MySQL) prepareStatements() (err error) {
 	if err != nil {
 		return
 	}
-	mysql.insertAccountMessage, err = mysql.db.Prepare(`INSERT INTO account_messages
-		(history_id, account) VALUES (?, ?);`)
-	if err != nil {
-		return
-	}
 
 	return
 }
@@ -481,10 +292,6 @@ func (mysql *MySQL) getTimeout() time.Duration {
 	return time.Duration(atomic.LoadInt64(&mysql.timeout))
 }
 
-func (mysql *MySQL) isTrackingAccountMessages() bool {
-	return atomic.LoadUint32(&mysql.trackAccountMessages) != 0
-}
-
 func (mysql *MySQL) logError(context string, err error) (quit bool) {
 	if err != nil {
 		mysql.logger.Error("mysql", context, err.Error())
@@ -493,27 +300,7 @@ func (mysql *MySQL) logError(context string, err error) (quit bool) {
 	return false
 }
 
-func (mysql *MySQL) Forget(account string) {
-	if mysql.db == nil || account == "" {
-		return
-	}
-
-	ctx, cancel := context.WithTimeout(context.Background(), mysql.getTimeout())
-	defer cancel()
-
-	_, err := mysql.db.ExecContext(ctx, `INSERT INTO forget (account) VALUES (?);`, account)
-	if mysql.logError("can't insert into forget table", err) {
-		return
-	}
-
-	// wake up the forget goroutine if it's blocked:
-	select {
-	case mysql.wakeForgetter <- e{}:
-	default:
-	}
-}
-
-func (mysql *MySQL) AddChannelItem(target string, item history.Item, account string) (err error) {
+func (mysql *MySQL) AddChannelItem(target string, item history.Item) (err error) {
 	if mysql.db == nil {
 		return
 	}
@@ -531,15 +318,6 @@ func (mysql *MySQL) AddChannelItem(target string, item history.Item, account str
 	}
 
 	err = mysql.insertSequenceEntry(ctx, target, item.Message.Time.UnixNano(), id)
-	if err != nil {
-		return
-	}
-
-	err = mysql.insertAccountMessageEntry(ctx, id, account)
-	if err != nil {
-		return
-	}
-
 	return
 }
 
@@ -578,15 +356,6 @@ func (mysql *MySQL) insertBase(ctx context.Context, item history.Item) (id int64
 	return
 }
 
-func (mysql *MySQL) insertAccountMessageEntry(ctx context.Context, id int64, account string) (err error) {
-	if account == "" || !mysql.isTrackingAccountMessages() {
-		return
-	}
-	_, err = mysql.insertAccountMessage.ExecContext(ctx, id, account)
-	mysql.logError("could not insert account-message entry", err)
-	return
-}
-
 func (mysql *MySQL) AddDirectMessage(sender, senderAccount, recipient, recipientAccount string, item history.Item) (err error) {
 	if mysql.db == nil {
 		return
@@ -632,102 +401,10 @@ func (mysql *MySQL) AddDirectMessage(sender, senderAccount, recipient, recipient
 		}
 	}
 
-	err = mysql.insertAccountMessageEntry(ctx, id, senderAccount)
-	if err != nil {
-		return
-	}
-
-	return
-}
-
-// note that accountName is the unfolded name
-func (mysql *MySQL) DeleteMsgid(msgid, accountName string) (err error) {
-	if mysql.db == nil {
-		return nil
-	}
-
-	ctx, cancel := context.WithTimeout(context.Background(), mysql.getTimeout())
-	defer cancel()
-
-	_, id, data, err := mysql.lookupMsgid(ctx, msgid, true)
-	if err != nil {
-		return
-	}
-
-	if accountName != "*" {
-		var item history.Item
-		err = unmarshalItem(data, &item)
-		// delete if the entry is corrupt
-		if err == nil && item.AccountName != accountName {
-			return ErrDisallowed
-		}
-	}
-
-	err = mysql.deleteHistoryIDs(ctx, []uint64{id})
-	mysql.logError("couldn't delete msgid", err)
 	return
 }
 
-func (mysql *MySQL) Export(account string, writer io.Writer) {
-	if mysql.db == nil {
-		return
-	}
-
-	var err error
-	var lastSeen uint64
-	for {
-		rows := func() (count int) {
-			ctx, cancel := context.WithTimeout(context.Background(), cleanupPauseTime)
-			defer cancel()
-
-			rows, rowsErr := mysql.db.QueryContext(ctx, `
-				SELECT account_messages.history_id, history.data, sequence.target FROM account_messages
-				INNER JOIN history ON history.id = account_messages.history_id
-				INNER JOIN sequence ON account_messages.history_id = sequence.history_id
-				WHERE account_messages.account = ? AND account_messages.history_id > ?
-				LIMIT ?`, account, lastSeen, cleanupRowLimit)
-			if rowsErr != nil {
-				err = rowsErr
-				return
-			}
-			defer rows.Close()
-			for rows.Next() {
-				var id uint64
-				var blob, jsonBlob []byte
-				var target string
-				var item history.Item
-				err = rows.Scan(&id, &blob, &target)
-				if err != nil {
-					return
-				}
-				err = unmarshalItem(blob, &item)
-				if err != nil {
-					return
-				}
-				item.CfCorrespondent = target
-				jsonBlob, err = json.Marshal(item)
-				if err != nil {
-					return
-				}
-				count++
-				if lastSeen < id {
-					lastSeen = id
-				}
-				writer.Write(jsonBlob)
-				writer.Write([]byte{'\n'})
-			}
-			return
-		}()
-		if rows == 0 || err != nil {
-			break
-		}
-	}
-
-	mysql.logError("could not export history", err)
-	return
-}
-
-func (mysql *MySQL) lookupMsgid(ctx context.Context, msgid string, includeData bool) (result time.Time, id uint64, data []byte, err error) {
+func (mysql *MySQL) msgidToTime(ctx context.Context, msgid string) (result time.Time, err error) {
 	// in theory, we could optimize out a roundtrip to the database by using a subquery instead:
 	// sequence.nanotime > (
 	//     SELECT sequence.nanotime FROM sequence, history
@@ -740,27 +417,15 @@ func (mysql *MySQL) lookupMsgid(ctx context.Context, msgid string, includeData b
 	if err != nil {
 		return
 	}
-	cols := `sequence.nanotime`
-	if includeData {
-		cols = `sequence.nanotime, sequence.history_id, history.data`
-	}
-	row := mysql.db.QueryRowContext(ctx, fmt.Sprintf(`
-		SELECT %s FROM sequence
+	row := mysql.db.QueryRowContext(ctx, `
+		SELECT sequence.nanotime FROM sequence
 		INNER JOIN history ON history.id = sequence.history_id
-		WHERE history.msgid = ? LIMIT 1;`, cols), decoded)
+		WHERE history.msgid = ? LIMIT 1;`, decoded)
 	var nanotime int64
-	if !includeData {
-		err = row.Scan(&nanotime)
-	} else {
-		err = row.Scan(&nanotime, &id, &data)
-	}
-	if err != sql.ErrNoRows {
-		mysql.logError("could not resolve msgid to time", err)
-	}
-	if err != nil {
+	err = row.Scan(&nanotime)
+	if mysql.logError("could not resolve msgid to time", err) {
 		return
 	}
-
 	result = time.Unix(0, nanotime).UTC()
 	return
 }
@@ -856,14 +521,14 @@ func (s *mySQLHistorySequence) Between(start, end history.Selector, limit int) (
 
 	startTime := start.Time
 	if start.Msgid != "" {
-		startTime, _, _, err = s.mysql.lookupMsgid(ctx, start.Msgid, false)
+		startTime, err = s.mysql.msgidToTime(ctx, start.Msgid)
 		if err != nil {
 			return nil, false, err
 		}
 	}
 	endTime := end.Time
 	if end.Msgid != "" {
-		endTime, _, _, err = s.mysql.lookupMsgid(ctx, end.Msgid, false)
+		endTime, err = s.mysql.msgidToTime(ctx, end.Msgid)
 		if err != nil {
 			return nil, false, err
 		}
diff --git a/irc/nickname.go b/irc/nickname.go
index 793a5895..59d7beb2 100644
--- a/irc/nickname.go
+++ b/irc/nickname.go
@@ -80,7 +80,7 @@ func performNickChange(server *Server, client *Client, target *Client, session *
 	}
 
 	for _, channel := range client.Channels() {
-		channel.AddHistoryItem(histItem, details.account)
+		channel.AddHistoryItem(histItem)
 	}
 
 	if target.Registered() {
diff --git a/irc/roleplay.go b/irc/roleplay.go
index 3c1f750e..3e89d11c 100644
--- a/irc/roleplay.go
+++ b/irc/roleplay.go
@@ -91,7 +91,7 @@ func sendRoleplayMessage(server *Server, client *Client, source string, targetSt
 			Type:    history.Privmsg,
 			Message: splitMessage,
 			Nick:    source,
-		}, client.Account())
+		})
 	} else {
 		target, err := CasefoldName(targetString)
 		user := server.clients.Get(target)
diff --git a/irc/server.go b/irc/server.go
index e3016453..06016124 100644
--- a/irc/server.go
+++ b/irc/server.go
@@ -879,76 +879,6 @@ func (server *Server) GetHistorySequence(providedChannel *Channel, client *Clien
 	return
 }
 
-func (server *Server) ForgetHistory(accountName string) {
-	// sanity check
-	if accountName == "*" {
-		return
-	}
-
-	config := server.Config()
-	if !config.History.Enabled {
-		return
-	}
-
-	if cfAccount, err := CasefoldName(accountName); err == nil {
-		server.historyDB.Forget(cfAccount)
-	}
-
-	persistent := config.History.Persistent
-	if persistent.Enabled && persistent.UnregisteredChannels && persistent.RegisteredChannels == PersistentMandatory && persistent.DirectMessages == PersistentMandatory {
-		return
-	}
-
-	predicate := func(item *history.Item) bool { return item.AccountName == accountName }
-
-	for _, channel := range server.channels.Channels() {
-		channel.history.Delete(predicate)
-	}
-
-	for _, client := range server.clients.AllClients() {
-		client.history.Delete(predicate)
-	}
-}
-
-// deletes a message. target is a hint about what buffer it's in (not required for
-// persistent history, where all the msgids are indexed together). if accountName
-// is anything other than "*", it must match the recorded AccountName of the message
-func (server *Server) DeleteMessage(target, msgid, accountName string) (err error) {
-	config := server.Config()
-	var hist *history.Buffer
-
-	if target != "" {
-		if target[0] == '#' {
-			channel := server.channels.Get(target)
-			if channel != nil {
-				if status, _ := channel.historyStatus(config); status == HistoryEphemeral {
-					hist = &channel.history
-				}
-			}
-		} else {
-			client := server.clients.Get(target)
-			if client != nil {
-				if status, _ := client.historyStatus(config); status == HistoryEphemeral {
-					hist = &client.history
-				}
-			}
-		}
-	}
-
-	if hist == nil {
-		err = server.historyDB.DeleteMsgid(msgid, accountName)
-	} else {
-		count := hist.Delete(func(item *history.Item) bool {
-			return item.Message.Msgid == msgid && (accountName == "*" || item.AccountName == accountName)
-		})
-		if count == 0 {
-			err = errNoop
-		}
-	}
-
-	return
-}
-
 // elistMatcher takes and matches ELIST conditions
 type elistMatcher struct {
 	MinClientsActive bool
diff --git a/irc/services.go b/irc/services.go
index e2dfcd0a..0bcde401 100644
--- a/irc/services.go
+++ b/irc/services.go
@@ -82,13 +82,6 @@ var OragonoServices = map[string]*ircService{
 		Commands:       hostservCommands,
 		HelpBanner:     hostservHelp,
 	},
-	"histserv": {
-		Name:           "HistServ",
-		ShortName:      "HISTSERV",
-		CommandAliases: []string{"HISTSERV"},
-		Commands:       histservCommands,
-		HelpBanner:     histservHelp,
-	},
 }
 
 // all service commands at the protocol level, by uppercase command name
diff --git a/irc/utils/glob.go b/irc/utils/glob.go
index 04ee6116..58fb7f34 100644
--- a/irc/utils/glob.go
+++ b/irc/utils/glob.go
@@ -11,56 +11,21 @@ import (
 
 // yet another glob implementation in Go
 
-func addRegexp(buf *bytes.Buffer, glob string, submatch bool) (err error) {
+func CompileGlob(glob string) (result *regexp.Regexp, err error) {
+	var buf bytes.Buffer
+	buf.WriteByte('^')
 	for _, r := range glob {
 		switch r {
 		case '*':
-			if submatch {
-				buf.WriteString("(.*)")
-			} else {
-				buf.WriteString(".*")
-			}
+			buf.WriteString("(.*)")
 		case '?':
-			if submatch {
-				buf.WriteString("(.)")
-			} else {
-				buf.WriteString(".")
-			}
+			buf.WriteString("(.)")
 		case 0xFFFD:
-			return &syntax.Error{Code: syntax.ErrInvalidUTF8, Expr: glob}
+			return nil, &syntax.Error{Code: syntax.ErrInvalidUTF8, Expr: glob}
 		default:
 			buf.WriteString(regexp.QuoteMeta(string(r)))
 		}
 	}
-	return
-}
-
-func CompileGlob(glob string, submatch bool) (result *regexp.Regexp, err error) {
-	var buf bytes.Buffer
-	buf.WriteByte('^')
-	err = addRegexp(&buf, glob, submatch)
-	if err != nil {
-		return
-	}
 	buf.WriteByte('$')
 	return regexp.Compile(buf.String())
 }
-
-// Compile a list of globs into a single or-expression that matches any one of them.
-// This is used for channel ban/invite/exception lists. It's applicable to k-lines
-// but we're not using it there yet.
-func CompileMasks(masks []string) (result *regexp.Regexp, err error) {
-	var buf bytes.Buffer
-	buf.WriteString("^(")
-	for i, mask := range masks {
-		err = addRegexp(&buf, mask, false)
-		if err != nil {
-			return
-		}
-		if i != len(masks)-1 {
-			buf.WriteByte('|')
-		}
-	}
-	buf.WriteString(")$")
-	return regexp.Compile(buf.String())
-}
diff --git a/irc/utils/proxy.go b/irc/utils/proxy.go
index 1214530d..805d06a8 100644
--- a/irc/utils/proxy.go
+++ b/irc/utils/proxy.go
@@ -93,9 +93,6 @@ type WrappedConn struct {
 	net.Conn
 	ProxiedIP net.IP
 	Config    ListenerConfig
-	// Secure indicates whether we believe the connection between us and the client
-	// was secure against interception and modification (including all proxies):
-	Secure bool
 }
 
 // ReloadableListener is a wrapper for net.Listener that allows reloading
diff --git a/irc/utils/text.go b/irc/utils/text.go
index 62c49eb5..5d46b140 100644
--- a/irc/utils/text.go
+++ b/irc/utils/text.go
@@ -67,14 +67,14 @@ func (sm *SplitMessage) LenLines() int {
 	return len(sm.Split)
 }
 
-func (sm *SplitMessage) ValidMultiline() bool {
-	// must contain at least one nonblank line
+func (sm *SplitMessage) LenBytes() (result int) {
+	if sm.Split == nil {
+		return len(sm.Message)
+	}
 	for i := 0; i < len(sm.Split); i++ {
-		if len(sm.Split[i].Message) != 0 {
-			return true
-		}
+		result += len(sm.Split[i].Message)
 	}
-	return false
+	return
 }
 
 func (sm *SplitMessage) IsRestrictedCTCPMessage() bool {
diff --git a/oragono.yaml b/oragono.yaml
index a3a1582d..7e29c9ba 100644
--- a/oragono.yaml
+++ b/oragono.yaml
@@ -280,10 +280,6 @@ server:
     secure-nets:
         # - "10.0.0.0/8"
 
-    # oragono will write files to disk under certain circumstances, e.g.,
-    # CPU profiling or data export. by default, these files will be written
-    # to the working directory. set this to customize:
-    # output-path: "/home/oragono/out"
 
 # account options
 accounts:
@@ -581,7 +577,6 @@ oper-classes:
             - "samode"
             - "vhosts"
             - "chanreg"
-            - "history"
 
 # ircd operators
 opers:
@@ -777,8 +772,7 @@ roleplay:
     # add the real nickname, in parentheses, to the end of every roleplay message?
     add-suffix: true
 
-# history message storage: this is used by CHATHISTORY, HISTORY, znc.in/playback,
-# various autoreplay features, and the resume extension
+# message history tracking, for the RESUME extension and possibly other uses in future
 history:
     # should we store messages for later playback?
     # by default, messages are stored in RAM only; they do not persist
@@ -847,13 +841,3 @@ history:
         # if you enable this, strict nickname reservation is strongly recommended
         # as well.
         direct-messages: "opt-out"
-
-    # options to control how messages are stored and deleted:
-    retention:
-        # allow users to delete their own messages from history?
-        allow-individual-delete: false
-
-        # if persistent history is enabled, create additional index tables,
-        # allowing deletion of JSON export of an account's messages. this
-        # may be needed for compliance with data privacy regulations.
-        enable-account-indexing: false
diff --git a/vendor/github.com/goshuirc/e-nfa/.travis.yml b/vendor/github.com/goshuirc/e-nfa/.travis.yml
new file mode 100644
index 00000000..9e4d19ec
--- /dev/null
+++ b/vendor/github.com/goshuirc/e-nfa/.travis.yml
@@ -0,0 +1,19 @@
+language: go
+
+go:
+    - 1.4
+    - tip
+
+before_install:
+    - go get golang.org/x/tools/cmd/cover
+    - go get golang.org/x/tools/cmd/vet
+    - go get golang.org/x/tools/cmd/goimports
+    - go get github.com/golang/lint/golint
+    - go get github.com/mattn/goveralls
+
+script:
+    - go vet ./...
+#    - $HOME/gopath/bin/goveralls -coverprofile=coverage.cov -service=travis-ci
+#    - bash <(curl -s https://codecov.io/bash)
+    - go test -bench=. -benchmem ./...
+    #- sh ./install_all_cmd.sh
\ No newline at end of file
diff --git a/vendor/github.com/goshuirc/e-nfa/README.md b/vendor/github.com/goshuirc/e-nfa/README.md
new file mode 100644
index 00000000..cf13e416
--- /dev/null
+++ b/vendor/github.com/goshuirc/e-nfa/README.md
@@ -0,0 +1,122 @@
+ε-NFA: Epsilon-Nondeterministic finite automaton
+==============
+
+[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/kkdai/e-nfa/master/LICENSE)  [![GoDoc](https://godoc.org/github.com/kkdai/e-nfa?status.svg)](https://godoc.org/github.com/kkdai/e-nfa)  [![Build Status](https://travis-ci.org/kkdai/e-nfa.svg?branch=master)](https://travis-ci.org/kkdai/e-nfa)
+
+
+
+![image](https://upload.wikimedia.org/wikipedia/commons/thumb/0/0e/NFAexample.svg/250px-NFAexample.svg.png)
+
+
+
+What is Epsilon-Nondeterministic finite automaton
+=============
+
+`ε-NFA`: Epsilon-Nondeterministic finite automaton (so call:Nondeterministic finite automaton with ε-moves)
+
+In the automata theory, a nondeterministic finite automaton with ε-moves (NFA-ε)(also known as NFA-λ) is an extension of nondeterministic finite automaton(NFA), which allows a transformation to a new state without consuming any input symbols. The transitions without consuming an input symbol are called ε-transitions or λ-transitions. In the state diagrams, they are usually labeled with the Greek letter ε or λ.
+
+(sited from [here](https://en.wikipedia.org/wiki/Nondeterministic_finite_automaton))
+
+
+Looking for DFA implement?
+=============
+
+I also write a DFA implenent in Go here. [https://github.com/kkdai/dfa](https://github.com/kkdai/dfa)
+
+Looking for NFA implement?
+=============
+
+I also write a NFA implenent in Go here. [https://github.com/kkdai/nfa](https://github.com/kkdai/nfa)
+
+
+Installation and Usage
+=============
+
+
+Install
+---------------
+
+    go get github.com/kkdai/e-nfa
+
+
+
+Usage
+---------------
+
+Following is sample code to implement a epsilon-NFA automata diagram as follow:
+
+![image](image/eNFA.png)
+
+
+
+```go
+
+package main
+
+import (
+    "github.com/kkdai/enfa"
+    "fmt"
+)
+
+func main() {
+
+	nfa := NewENFA(0, false)
+	nfa.AddState(1, false)
+	nfa.AddState(2, false)
+	nfa.AddState(3, true)
+	nfa.AddState(4, false)
+	nfa.AddState(5, false)
+
+	nfa.AddTransition(0, "1", 1)
+	nfa.AddTransition(0, "0", 4)
+
+	nfa.AddTransition(1, "1", 2)
+	nfa.AddTransition(1, "", 3) //epsilon
+	nfa.AddTransition(2, "1", 3)
+	nfa.AddTransition(4, "0", 5)
+	nfa.AddTransition(4, "", 1, 2) //E -> epsilon B C
+	nfa.AddTransition(5, "0", 3)
+
+	nfa.PrintTransitionTable()
+
+	if !nfa.VerifyInputs([]string{"1"}) {
+		fmt.Printf("Verify inputs is failed")
+	}
+
+	nfa.Reset()
+
+	if !nfa.VerifyInputs([]string{"1", "1", "1"}) {
+		fmt.Printf("Verify inputs is failed")
+	}
+
+	nfa.Reset()
+
+	if !nfa.VerifyInputs([]string{"0", "1"}) {
+		fmt.Printf"Verify inputs is failed")
+	}
+
+	nfa.Reset()
+	if !nfa.VerifyInputs([]string{"0", "0", "0"}) {
+		fmt.Printf("Verify inputs is failed")
+	}
+}
+
+```
+
+Inspired By
+=============
+
+- [ε-NFA: Wiki](https://en.wikipedia.org/wiki/Nondeterministic_finite_automaton_with_%CE%B5-moves)
+- [Coursera: Automata](https://class.coursera.org/automata-004/)
+
+Project52
+---------------
+
+It is one of my [project 52](https://github.com/kkdai/project52).
+
+
+License
+---------------
+
+This package is licensed under MIT license. See LICENSE for details.
diff --git a/vendor/github.com/goshuirc/e-nfa/enfa.go b/vendor/github.com/goshuirc/e-nfa/enfa.go
new file mode 100644
index 00000000..e1d92be4
--- /dev/null
+++ b/vendor/github.com/goshuirc/e-nfa/enfa.go
@@ -0,0 +1,185 @@
+package enfa
+
+import "fmt"
+
+type transitionInput struct {
+	srcState int
+	input    string
+}
+
+type destState map[int]bool
+
+type ENFA struct {
+	initState    int
+	currentState map[int]bool
+	totalStates  []int
+	finalStates  []int
+	transition   map[transitionInput]destState
+	inputMap     map[string]bool
+}
+
+//New a new NFA
+func NewENFA(initState int, isFinal bool) *ENFA {
+
+	retNFA := &ENFA{
+		transition: make(map[transitionInput]destState),
+		inputMap:   make(map[string]bool),
+		initState:  initState}
+
+	retNFA.currentState = make(map[int]bool)
+	retNFA.currentState[initState] = true
+	retNFA.AddState(initState, isFinal)
+	return retNFA
+}
+
+//Add new state in this NFA
+func (d *ENFA) AddState(state int, isFinal bool) {
+	if state == -1 {
+		fmt.Println("Cannot add state as -1, it is dead state")
+		return
+	}
+
+	d.totalStates = append(d.totalStates, state)
+	if isFinal {
+		d.finalStates = append(d.finalStates, state)
+	}
+}
+
+//Add new transition function into NFA
+func (d *ENFA) AddTransition(srcState int, input string, dstStateList ...int) {
+	find := false
+
+	//find input if exist in NFA input List
+	if _, ok := d.inputMap[input]; !ok {
+		//not exist, new input in this NFA
+		d.inputMap[input] = true
+	}
+
+	for _, v := range d.totalStates {
+		if v == srcState {
+			find = true
+		}
+	}
+
+	if !find {
+		fmt.Println("No such state:", srcState, " in current NFA")
+		return
+	}
+
+	dstMap := make(map[int]bool)
+	for _, destState := range dstStateList {
+		dstMap[destState] = true
+	}
+
+	targetTrans := transitionInput{srcState: srcState, input: input}
+	d.transition[targetTrans] = dstMap
+}
+
+func (d *ENFA) CheckPathExist(src int, input string, dst int) bool {
+	retMap, _ := d.transition[transitionInput{srcState: src, input: input}]
+	if _, ok := retMap[dst]; ok {
+		return true
+	}
+	return false
+}
+
+func (d *ENFA) Input(testInput string) []int {
+	updateCurrentState := make(map[int]bool)
+	for current, _ := range d.currentState {
+		for _, realTestInput := range []string{testInput, "*", "?"} {
+			intputTrans := transitionInput{srcState: current, input: realTestInput}
+			valMap, ok := d.transition[intputTrans]
+			if ok {
+				for dst, _ := range valMap {
+					updateCurrentState[dst] = true
+
+					//Update epsilon input way... if exist
+					epsilonTrans := transitionInput{srcState: dst}
+					if eMap, ok := d.transition[epsilonTrans]; ok {
+						for eDst, _ := range eMap {
+							updateCurrentState[eDst] = true
+						}
+					}
+				}
+			} else {
+				//dead state, remove in current state
+				//do nothing.
+			}
+		}
+	}
+
+	//update curret state
+	d.currentState = updateCurrentState
+
+	//return result
+	var ret []int
+	for state, _ := range updateCurrentState {
+		ret = append(ret, state)
+	}
+	return ret
+}
+
+//To verify current state if it is final state
+func (d *ENFA) Verify() bool {
+	for _, val := range d.finalStates {
+		for cState, _ := range d.currentState {
+			if val == cState {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+//Reset NFA state to initilize state, but all state and transition function will remain
+func (d *ENFA) Reset() {
+	initState := make(map[int]bool)
+	initState[d.initState] = true
+	d.currentState = initState
+}
+
+//Verify if list of input could be accept by NFA or not
+func (d *ENFA) VerifyInputs(inputs []string) bool {
+	for _, v := range inputs {
+		d.Input(v)
+	}
+	return d.Verify()
+}
+
+//To print detail transition table contain of current NFA
+func (d *ENFA) PrintTransitionTable() {
+	fmt.Println("===================================================")
+	//list all inputs
+	var inputList []string
+	for key, _ := range d.inputMap {
+		if key == "" {
+			fmt.Printf("\tε|")
+		} else {
+			fmt.Printf("\t%s|", key)
+		}
+		inputList = append(inputList, key)
+	}
+
+	fmt.Printf("\n")
+	fmt.Println("---------------------------------------------------")
+
+	for _, state := range d.totalStates {
+		fmt.Printf("%d |", state)
+		for _, key := range inputList {
+			checkInput := transitionInput{srcState: state, input: key}
+			if dstState, ok := d.transition[checkInput]; ok {
+				fmt.Printf("\t")
+				for val, _ := range dstState {
+					fmt.Printf("%d,", val)
+				}
+				fmt.Printf("|")
+			} else {
+				fmt.Printf("\tNA|")
+			}
+		}
+		fmt.Printf("\n")
+	}
+
+	fmt.Println("---------------------------------------------------")
+	fmt.Println("===================================================")
+}
diff --git a/vendor/github.com/goshuirc/irc-go/ircmatch/doc.go b/vendor/github.com/goshuirc/irc-go/ircmatch/doc.go
new file mode 100644
index 00000000..0561b4d0
--- /dev/null
+++ b/vendor/github.com/goshuirc/irc-go/ircmatch/doc.go
@@ -0,0 +1,7 @@
+// written by Daniel Oaks <daniel@danieloaks.net>
+// released under the ISC license
+
+/*
+Package ircmatch handles matching IRC strings with the traditional glob-like syntax.
+*/
+package ircmatch
diff --git a/vendor/github.com/goshuirc/irc-go/ircmatch/ircmatch.go b/vendor/github.com/goshuirc/irc-go/ircmatch/ircmatch.go
new file mode 100644
index 00000000..03e7cf54
--- /dev/null
+++ b/vendor/github.com/goshuirc/irc-go/ircmatch/ircmatch.go
@@ -0,0 +1,57 @@
+package ircmatch
+
+import enfa "github.com/goshuirc/e-nfa"
+
+// Matcher represents an object that can match IRC strings.
+type Matcher struct {
+	internalENFA *enfa.ENFA
+}
+
+// MakeMatch creates a Matcher.
+func MakeMatch(globTemplate string) Matcher {
+	var newmatch Matcher
+
+	// assemble internal enfa
+	newmatch.internalENFA = enfa.NewENFA(0, false)
+
+	var currentState int
+	var lastWasStar bool
+	for _, char := range globTemplate {
+		if char == '*' {
+			if lastWasStar {
+				continue
+			}
+			newmatch.internalENFA.AddTransition(currentState, "*", currentState)
+			lastWasStar = true
+			continue
+		} else if char == '?' {
+			newmatch.internalENFA.AddState(currentState+1, false)
+			newmatch.internalENFA.AddTransition(currentState, "?", currentState+1)
+			currentState++
+		} else {
+			newmatch.internalENFA.AddState(currentState+1, false)
+			newmatch.internalENFA.AddTransition(currentState, string(char), currentState+1)
+			currentState++
+		}
+
+		lastWasStar = false
+	}
+
+	// create end state
+	newmatch.internalENFA.AddState(currentState+1, true)
+	newmatch.internalENFA.AddTransition(currentState, "", currentState+1)
+
+	return newmatch
+}
+
+// Match returns true if the given string matches this glob.
+func (menfa *Matcher) Match(search string) bool {
+	var searchChars []string
+	for _, char := range search {
+		searchChars = append(searchChars, string(char))
+	}
+
+	isMatch := menfa.internalENFA.VerifyInputs(searchChars)
+	menfa.internalENFA.Reset()
+	return isMatch
+}
diff --git a/vendor/github.com/oragono/go-ident/client.go b/vendor/github.com/oragono/go-ident/client.go
index 256bde7a..663da8a9 100644
--- a/vendor/github.com/oragono/go-ident/client.go
+++ b/vendor/github.com/oragono/go-ident/client.go
@@ -37,46 +37,49 @@ func (e ProtocolError) Error() string {
 }
 
 // Query makes an Ident query, if timeout is >0 the query is timed out after that many seconds.
-func Query(ip string, portOnServer, portOnClient int, timeout time.Duration) (response Response, err error) {
-	var conn net.Conn
+func Query(ip string, portOnServer, portOnClient int, timeout float64) (Response, error) {
+	var (
+		conn   net.Conn
+		err    error
+		fields []string
+		r      *bufio.Reader
+		resp   string
+	)
+
 	if timeout > 0 {
-		conn, err = net.DialTimeout("tcp", net.JoinHostPort(ip, "113"), timeout)
+		conn, err = net.DialTimeout("tcp", net.JoinHostPort(ip, "113"), time.Duration(timeout)*time.Second)
 	} else {
 		conn, err = net.Dial("tcp", net.JoinHostPort(ip, "113"))
 	}
 	if err != nil {
-		return
+		return Response{}, err
 	}
 
 	// stop the ident read after <timeout> seconds
 	if timeout > 0 {
-		conn.SetDeadline(time.Now().Add(timeout))
+		conn.SetDeadline(time.Now().Add(time.Second * time.Duration(timeout)))
 	}
 
 	_, err = conn.Write([]byte(fmt.Sprintf("%d, %d", portOnClient, portOnServer) + "\r\n"))
 	if err != nil {
-		return
+		return Response{}, err
 	}
 
-	r := bufio.NewReaderSize(conn, 1024)
-	respBytes, err := r.ReadSlice('\n')
+	r = bufio.NewReader(conn)
+	resp, err = r.ReadString('\n')
 	if err != nil {
-		return
+		return Response{}, err
 	}
-	resp := string(respBytes)
 
-	fields := strings.SplitN(resp, ":", 4)
+	fields = strings.SplitN(strings.TrimSpace(resp), " : ", 4)
 	if len(fields) < 3 {
-		return response, ProtocolError{resp}
-	}
-	for i, field := range fields {
-		fields[i] = strings.TrimSpace(field)
+		return Response{}, ProtocolError{resp}
 	}
 
 	switch fields[1] {
 	case "USERID":
 		if len(fields) != 4 {
-			return response, ProtocolError{resp}
+			return Response{}, ProtocolError{resp}
 		}
 
 		var os, charset string
@@ -96,12 +99,10 @@ func Query(ip string, portOnServer, portOnClient int, timeout time.Duration) (re
 		}, nil
 	case "ERROR":
 		if len(fields) != 3 {
-			return response, ProtocolError{resp}
+			return Response{}, ProtocolError{resp}
 		}
 
-		return response, ResponseError{fields[2]}
-	default:
-		err = ProtocolError{resp}
+		return Response{}, ResponseError{fields[2]}
 	}
-	return
+	return Response{}, err
 }
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 5a9312b3..1afed2a7 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -17,9 +17,11 @@ github.com/go-sql-driver/mysql
 github.com/gorilla/websocket
 # github.com/goshuirc/e-nfa v0.0.0-20160917075329-7071788e3940
 ## explicit
+github.com/goshuirc/e-nfa
 # github.com/goshuirc/irc-go v0.0.0-20200311142257-57fd157327ac
 ## explicit
 github.com/goshuirc/irc-go/ircfmt
+github.com/goshuirc/irc-go/ircmatch
 github.com/goshuirc/irc-go/ircmsg
 # github.com/onsi/ginkgo v1.12.0
 ## explicit
@@ -28,7 +30,7 @@ github.com/goshuirc/irc-go/ircmsg
 # github.com/oragono/confusables v0.0.0-20190624102032-fe1cf31a24b0
 ## explicit
 github.com/oragono/confusables
-# github.com/oragono/go-ident v0.0.0-20200511222032-830550b1d775
+# github.com/oragono/go-ident v0.0.0-20170110123031-337fed0fd21a
 ## explicit
 github.com/oragono/go-ident
 # github.com/stretchr/testify v1.4.0


Test patch:
diff --git a/irc/utils/glob_test.go b/irc/utils/glob_test.go
index 5cd83af8..2c9dee35 100644
--- a/irc/utils/glob_test.go
+++ b/irc/utils/glob_test.go
@@ -9,7 +9,7 @@ import (
 )
 
 func globMustCompile(glob string) *regexp.Regexp {
-	re, err := CompileGlob(glob, false)
+	re, err := CompileGlob(glob)
 	if err != nil {
 		panic(err)
 	}
@@ -46,123 +46,3 @@ func TestGlob(t *testing.T) {
 	assertMatches("S*e", "Skåne", true, t)
 	assertMatches("Sk?ne", "Skåne", true, t)
 }
-
-func BenchmarkGlob(b *testing.B) {
-	g := globMustCompile("https://*google.com")
-	b.ResetTimer()
-	for i := 0; i < b.N; i++ {
-		g.MatchString("https://www.google.com")
-	}
-}
-
-func BenchmarkGlobCompilation(b *testing.B) {
-	for i := 0; i < b.N; i++ {
-		CompileGlob("https://*google.com", false)
-	}
-}
-
-// these are actual bans from my production network :-/
-var bans = []string{
-	"*!*@tor-network.onion",
-	"`!*@*",
-	"qanon!*@*",
-	"*!bibi@tor-network.onion",
-	"shivarm!*@*",
-	"8====d!*@*",
-	"shiviram!*@*",
-	"poop*!*@*",
-	"shivoram!*@*",
-	"shivvy!*@*",
-	"shavirim!*@*",
-	"shivarm_!*@*",
-	"_!*@*",
-}
-
-func TestMasks(t *testing.T) {
-	matcher, err := CompileMasks(bans)
-	if err != nil {
-		panic(err)
-	}
-
-	if !matcher.MatchString("evan!user@tor-network.onion") {
-		t.Errorf("match expected")
-	}
-	if !matcher.MatchString("`!evan@b9un4fv3he44q.example.com") {
-		t.Errorf("match expected")
-	}
-	if matcher.MatchString("horse!horse@t5dwi8vacg47y.example.com") {
-		t.Errorf("match not expected")
-	}
-	if matcher.MatchString("horse_!horse@t5dwi8vacg47y.example.com") {
-		t.Errorf("match not expected")
-	}
-	if matcher.MatchString("shivaram!shivaram@yrqgsrjy2p7my.example.com") {
-		t.Errorf("match not expected")
-	}
-}
-
-func BenchmarkMasksCompile(b *testing.B) {
-	for i := 0; i < b.N; i++ {
-		CompileMasks(bans)
-	}
-}
-
-func BenchmarkMasksMatch(b *testing.B) {
-	matcher, _ := CompileMasks(bans)
-
-	b.ResetTimer()
-	for i := 0; i < b.N; i++ {
-		matcher.MatchString("evan!user@tor-network.onion")
-		matcher.MatchString("horse_!horse@t5dwi8vacg47y.example.com")
-		matcher.MatchString("shivaram!shivaram@yrqgsrjy2p7my.example.com")
-	}
-}
-
-// compare performance to compilation of the | clauses as separate regexes
-// first for compilation, then for matching
-
-func compileAll(masks []string) (result []*regexp.Regexp, err error) {
-	a := make([]*regexp.Regexp, 0, len(masks))
-	for _, mask := range masks {
-		m, err := CompileGlob(mask, false)
-		if err != nil {
-			return nil, err
-		}
-		a = append(a, m)
-	}
-	return a, nil
-}
-
-func matchesAny(masks []*regexp.Regexp, str string) bool {
-	for _, r := range masks {
-		if r.MatchString(str) {
-			return true
-		}
-	}
-	return false
-}
-
-func BenchmarkLinearCompile(b *testing.B) {
-	for i := 0; i < b.N; i++ {
-		compileAll(bans)
-	}
-}
-
-func BenchmarkLinearMatch(b *testing.B) {
-	a, err := compileAll(bans)
-	if err != nil {
-		panic(err)
-	}
-	if matchesAny(a, "horse_!horse@t5dwi8vacg47y.example.com") {
-		panic("incorrect match")
-	}
-	if !matchesAny(a, "evan!user@tor-network.onion") {
-		panic("incorrect match")
-	}
-	b.ResetTimer()
-	for i := 0; i < b.N; i++ {
-		matchesAny(a, "horse_!horse@t5dwi8vacg47y.example.com")
-		matchesAny(a, "evan!user@tor-network.onion")
-		matchesAny(a, "shivaram!shivaram@yrqgsrjy2p7my.example.com")
-	}
-}