Browse Source

Revert "remove draft/resume-0.5"

This reverts commit ba21987d03.
devel+resume
Shivaram Lingamneni 2 years ago
parent
commit
6067ce4200
14 changed files with 683 additions and 4 deletions
  1. 6
    0
      gencapdefs.py
  2. 6
    1
      irc/caps/defs.go
  3. 74
    0
      irc/channel.go
  4. 214
    3
      irc/client.go
  5. 20
    0
      irc/client_lookup_set.go
  6. 9
    0
      irc/commands.go
  7. 1
    0
      irc/config.go
  8. 30
    0
      irc/getters.go
  9. 58
    0
      irc/handlers.go
  10. 14
    0
      irc/help.go
  11. 133
    0
      irc/idletimer.go
  12. 6
    0
      irc/numerics.go
  13. 104
    0
      irc/resume.go
  14. 8
    0
      irc/server.go

+ 6
- 0
gencapdefs.py View File

@@ -105,6 +105,12 @@ CAPDEFS = [
105 105
         url="https://ircv3.net/specs/extensions/channel-rename",
106 106
         standard="draft IRCv3",
107 107
     ),
108
+    CapDef(
109
+        identifier="Resume",
110
+        name="draft/resume-0.5",
111
+        url="https://github.com/DanielOaks/ircv3-specifications/blob/master+resume/extensions/resume.md",
112
+        standard="proposed IRCv3",
113
+    ),
108 114
     CapDef(
109 115
         identifier="SASL",
110 116
         name="sasl",

+ 6
- 1
irc/caps/defs.go View File

@@ -7,7 +7,7 @@ package caps
7 7
 
8 8
 const (
9 9
 	// number of recognized capabilities:
10
-	numCapabs = 27
10
+	numCapabs = 28
11 11
 	// length of the uint64 array that represents the bitset:
12 12
 	bitsetLen = 1
13 13
 )
@@ -65,6 +65,10 @@ const (
65 65
 	// https://github.com/ircv3/ircv3-specifications/pull/417
66 66
 	Relaymsg Capability = iota
67 67
 
68
+	// Resume is the proposed IRCv3 capability named "draft/resume-0.5":
69
+	// https://github.com/DanielOaks/ircv3-specifications/blob/master+resume/extensions/resume.md
70
+	Resume Capability = iota
71
+
68 72
 	// EchoMessage is the IRCv3 capability named "echo-message":
69 73
 	// https://ircv3.net/specs/extensions/echo-message-3.2.html
70 74
 	EchoMessage Capability = iota
@@ -138,6 +142,7 @@ var (
138 142
 		"draft/multiline",
139 143
 		"draft/register",
140 144
 		"draft/relaymsg",
145
+		"draft/resume-0.5",
141 146
 		"echo-message",
142 147
 		"extended-join",
143 148
 		"invite-notify",

+ 74
- 0
irc/channel.go View File

@@ -1035,6 +1035,80 @@ func (channel *Channel) Part(client *Client, message string, rb *ResponseBuffer)
1035 1035
 	client.server.logger.Debug("channels", fmt.Sprintf("%s left channel %s", details.nick, chname))
1036 1036
 }
1037 1037
 
1038
+// Resume is called after a successful global resume to:
1039
+// 1. Replace the old client with the new in the channel's data structures
1040
+// 2. Send JOIN and MODE lines to channel participants (including the new client)
1041
+// 3. Replay missed message history to the client
1042
+func (channel *Channel) Resume(session *Session, timestamp time.Time) {
1043
+	channel.resumeAndAnnounce(session)
1044
+	if !timestamp.IsZero() {
1045
+		channel.replayHistoryForResume(session, timestamp, time.Time{})
1046
+	}
1047
+}
1048
+
1049
+func (channel *Channel) resumeAndAnnounce(session *Session) {
1050
+	channel.stateMutex.RLock()
1051
+	memberData, found := channel.members[session.client]
1052
+	channel.stateMutex.RUnlock()
1053
+	if !found {
1054
+		return
1055
+	}
1056
+	oldModes := memberData.modes.String()
1057
+	if 0 < len(oldModes) {
1058
+		oldModes = "+" + oldModes
1059
+	}
1060
+
1061
+	// send join for old clients
1062
+	chname := channel.Name()
1063
+	details := session.client.Details()
1064
+	// TODO: for now, skip this entirely for auditoriums,
1065
+	// but really we should send it to voiced clients
1066
+	if !channel.flags.HasMode(modes.Auditorium) {
1067
+		for _, member := range channel.Members() {
1068
+			for _, mSes := range member.Sessions() {
1069
+				if mSes == session || mSes.capabilities.Has(caps.Resume) {
1070
+					continue
1071
+				}
1072
+
1073
+				if mSes.capabilities.Has(caps.ExtendedJoin) {
1074
+					mSes.Send(nil, details.nickMask, "JOIN", chname, details.accountName, details.realname)
1075
+				} else {
1076
+					mSes.Send(nil, details.nickMask, "JOIN", chname)
1077
+				}
1078
+
1079
+				if 0 < len(oldModes) {
1080
+					mSes.Send(nil, channel.server.name, "MODE", chname, oldModes, details.nick)
1081
+				}
1082
+			}
1083
+		}
1084
+	}
1085
+
1086
+	rb := NewResponseBuffer(session)
1087
+	// use blocking i/o to synchronize with the later history replay
1088
+	if rb.session.capabilities.Has(caps.ExtendedJoin) {
1089
+		rb.Add(nil, details.nickMask, "JOIN", channel.name, details.accountName, details.realname)
1090
+	} else {
1091
+		rb.Add(nil, details.nickMask, "JOIN", channel.name)
1092
+	}
1093
+	channel.SendTopic(session.client, rb, false)
1094
+	channel.Names(session.client, rb)
1095
+	rb.Send(true)
1096
+}
1097
+
1098
+func (channel *Channel) replayHistoryForResume(session *Session, after time.Time, before time.Time) {
1099
+	var items []history.Item
1100
+	afterS, beforeS := history.Selector{Time: after}, history.Selector{Time: before}
1101
+	_, seq, _ := channel.server.GetHistorySequence(channel, session.client, "")
1102
+	if seq != nil {
1103
+		items, _ = seq.Between(afterS, beforeS, channel.server.Config().History.ZNCMax)
1104
+	}
1105
+	rb := NewResponseBuffer(session)
1106
+	if len(items) != 0 {
1107
+		channel.replayHistoryItems(rb, items, false)
1108
+	}
1109
+	rb.Send(true)
1110
+}
1111
+
1038 1112
 func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.Item, autoreplay bool) {
1039 1113
 	// send an empty batch if necessary, as per the CHATHISTORY spec
1040 1114
 	chname := channel.Name()

+ 214
- 3
irc/client.go View File

@@ -56,6 +56,8 @@ const (
56 56
 	// This is how long a client gets without sending any message, including the PONG to our
57 57
 	// PING, before we disconnect them:
58 58
 	DefaultTotalTimeout = 2*time.Minute + 30*time.Second
59
+	// Resumeable clients (clients who have negotiated caps.Resume) get longer:
60
+	ResumeableTotalTimeout = 3*time.Minute + 30*time.Second
59 61
 
60 62
 	// round off the ping interval by this much, see below:
61 63
 	PingCoalesceThreshold = time.Second
@@ -65,6 +67,15 @@ var (
65 67
 	MaxLineLen = DefaultMaxLineLen
66 68
 )
67 69
 
70
+// ResumeDetails is a place to stash data at various stages of
71
+// the resume process: when handling the RESUME command itself,
72
+// when completing the registration, and when rejoining channels.
73
+type ResumeDetails struct {
74
+	PresentedToken    string
75
+	Timestamp         time.Time
76
+	HistoryIncomplete bool
77
+}
78
+
68 79
 // Client is an IRC client.
69 80
 type Client struct {
70 81
 	account            string
@@ -72,6 +83,7 @@ type Client struct {
72 83
 	accountRegDate     time.Time
73 84
 	accountSettings    AccountSettings
74 85
 	awayMessage        string
86
+	brbTimer           BrbTimer
75 87
 	channels           ChannelSet
76 88
 	ctime              time.Time
77 89
 	destroyed          bool
@@ -101,6 +113,7 @@ type Client struct {
101 113
 	registered         bool
102 114
 	registerCmdSent    bool // already sent the draft/register command, can't send it again
103 115
 	registrationTimer  *time.Timer
116
+	resumeID           string
104 117
 	server             *Server
105 118
 	skeleton           string
106 119
 	sessions           []*Session
@@ -155,6 +168,7 @@ type Session struct {
155 168
 
156 169
 	fakelag              Fakelag
157 170
 	deferredFakelagCount int
171
+	destroyed            uint32
158 172
 
159 173
 	certfp     string
160 174
 	peerCerts  []*x509.Certificate
@@ -174,6 +188,8 @@ type Session struct {
174 188
 
175 189
 	registrationMessages int
176 190
 
191
+	resumeID              string
192
+	resumeDetails         *ResumeDetails
177 193
 	zncPlaybackTimes      *zncPlaybackTimes
178 194
 	autoreplayMissedSince time.Time
179 195
 
@@ -247,6 +263,20 @@ func (s *Session) IP() net.IP {
247 263
 	return s.realIP
248 264
 }
249 265
 
266
+// returns whether the session was actively destroyed (for example, by ping
267
+// timeout or NS GHOST).
268
+// avoids a race condition between asynchronous idle-timing-out of sessions,
269
+// and a condition that allows implicit BRB on connection errors (since
270
+// destroy()'s socket.Close() appears to socket.Read() as a connection error)
271
+func (session *Session) Destroyed() bool {
272
+	return atomic.LoadUint32(&session.destroyed) == 1
273
+}
274
+
275
+// sets the timed-out flag
276
+func (session *Session) SetDestroyed() {
277
+	atomic.StoreUint32(&session.destroyed, 1)
278
+}
279
+
250 280
 // returns whether the client supports a smart history replay cap,
251 281
 // and therefore autoreplay-on-join and similar should be suppressed
252 282
 func (session *Session) HasHistoryCaps() bool {
@@ -345,6 +375,7 @@ func (server *Server) RunClient(conn IRCConn) {
345 375
 		client.requireSASLMessage = banMsg
346 376
 	}
347 377
 	client.history.Initialize(config.History.ClientLength, time.Duration(config.History.AutoresizeWindow))
378
+	client.brbTimer.Initialize(client)
348 379
 	session := &Session{
349 380
 		client:     client,
350 381
 		socket:     socket,
@@ -432,6 +463,7 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus m
432 463
 		client.SetMode(m, true)
433 464
 	}
434 465
 	client.history.Initialize(0, 0)
466
+	client.brbTimer.Initialize(client)
435 467
 
436 468
 	server.accounts.Login(client, account)
437 469
 
@@ -525,7 +557,7 @@ func (client *Client) lookupHostname(session *Session, overwrite bool) {
525 557
 	cloakedHostname := config.Server.Cloaks.ComputeCloak(ip)
526 558
 	client.stateMutex.Lock()
527 559
 	defer client.stateMutex.Unlock()
528
-	// update the hostname if this is a new connection, but not if it's a reattach
560
+	// update the hostname if this is a new connection or a resume, but not if it's a reattach
529 561
 	if overwrite || client.rawHostname == "" {
530 562
 		client.rawHostname = hostname
531 563
 		client.cloakedHostname = cloakedHostname
@@ -643,7 +675,14 @@ func (client *Client) run(session *Session) {
643 675
 	isReattach := client.Registered()
644 676
 	if isReattach {
645 677
 		client.Touch(session)
646
-		client.playReattachMessages(session)
678
+		if session.resumeDetails != nil {
679
+			session.playResume()
680
+			session.resumeDetails = nil
681
+			client.brbTimer.Disable()
682
+			session.SetAway("") // clear BRB message if any
683
+		} else {
684
+			client.playReattachMessages(session)
685
+		}
647 686
 	}
648 687
 
649 688
 	firstLine := !isReattach
@@ -662,6 +701,11 @@ func (client *Client) run(session *Session) {
662 701
 				quitMessage = "connection closed"
663 702
 			}
664 703
 			client.Quit(quitMessage, session)
704
+			// since the client did not actually send us a QUIT,
705
+			// give them a chance to resume if applicable:
706
+			if !session.Destroyed() {
707
+				client.brbTimer.Enable()
708
+			}
665 709
 			break
666 710
 		}
667 711
 
@@ -812,6 +856,9 @@ func (client *Client) updateIdleTimer(session *Session, now time.Time) {
812 856
 
813 857
 func (session *Session) handleIdleTimeout() {
814 858
 	totalTimeout := DefaultTotalTimeout
859
+	if session.capabilities.Has(caps.Resume) {
860
+		totalTimeout = ResumeableTotalTimeout
861
+	}
815 862
 	pingTimeout := DefaultIdleTimeout
816 863
 	if session.isTor {
817 864
 		pingTimeout = TorIdleTimeout
@@ -868,6 +915,151 @@ func (session *Session) Ping() {
868 915
 	session.Send(nil, "", "PING", session.client.Nick())
869 916
 }
870 917
 
918
+// tryResume tries to resume if the client asked us to.
919
+func (session *Session) tryResume() (success bool) {
920
+	var oldResumeID string
921
+
922
+	defer func() {
923
+		if success {
924
+			// "On a successful request, the server [...] terminates the old client's connection"
925
+			oldSession := session.client.GetSessionByResumeID(oldResumeID)
926
+			if oldSession != nil {
927
+				session.client.destroy(oldSession)
928
+			}
929
+		} else {
930
+			session.resumeDetails = nil
931
+		}
932
+	}()
933
+
934
+	client := session.client
935
+	server := client.server
936
+	config := server.Config()
937
+
938
+	oldClient, oldResumeID := server.resumeManager.VerifyToken(client, session.resumeDetails.PresentedToken)
939
+	if oldClient == nil {
940
+		session.Send(nil, server.name, "FAIL", "RESUME", "INVALID_TOKEN", client.t("Cannot resume connection, token is not valid"))
941
+		return
942
+	}
943
+
944
+	resumeAllowed := config.Server.AllowPlaintextResume || (oldClient.HasMode(modes.TLS) && client.HasMode(modes.TLS))
945
+	if !resumeAllowed {
946
+		session.Send(nil, server.name, "FAIL", "RESUME", "INSECURE_SESSION", client.t("Cannot resume connection, old and new clients must have TLS"))
947
+		return
948
+	}
949
+
950
+	err := server.clients.Resume(oldClient, session)
951
+	if err != nil {
952
+		session.Send(nil, server.name, "FAIL", "RESUME", "CANNOT_RESUME", client.t("Cannot resume connection"))
953
+		return
954
+	}
955
+
956
+	success = true
957
+	client.server.logger.Debug("quit", fmt.Sprintf("%s is being resumed", oldClient.Nick()))
958
+
959
+	return
960
+}
961
+
962
+// playResume is called from the session's fresh goroutine after a resume;
963
+// it sends notifications to friends, then plays the registration burst and replays
964
+// stored history to the session
965
+func (session *Session) playResume() {
966
+	client := session.client
967
+	server := client.server
968
+	config := server.Config()
969
+
970
+	friends := make(ClientSet)
971
+	var oldestLostMessage time.Time
972
+
973
+	// work out how much time, if any, is not covered by history buffers
974
+	// assume that a persistent buffer covers the whole resume period
975
+	for _, channel := range client.Channels() {
976
+		for _, member := range channel.auditoriumFriends(client) {
977
+			friends.Add(member)
978
+		}
979
+		status, _, _ := channel.historyStatus(config)
980
+		if status == HistoryEphemeral {
981
+			lastDiscarded := channel.history.LastDiscarded()
982
+			if oldestLostMessage.Before(lastDiscarded) {
983
+				oldestLostMessage = lastDiscarded
984
+			}
985
+		}
986
+	}
987
+	cHistoryStatus, _ := client.historyStatus(config)
988
+	if cHistoryStatus == HistoryEphemeral {
989
+		lastDiscarded := client.history.LastDiscarded()
990
+		if oldestLostMessage.Before(lastDiscarded) {
991
+			oldestLostMessage = lastDiscarded
992
+		}
993
+	}
994
+
995
+	timestamp := session.resumeDetails.Timestamp
996
+	gap := oldestLostMessage.Sub(timestamp)
997
+	session.resumeDetails.HistoryIncomplete = gap > 0 || timestamp.IsZero()
998
+	gapSeconds := int(gap.Seconds()) + 1 // round up to avoid confusion
999
+
1000
+	details := client.Details()
1001
+	oldNickmask := details.nickMask
1002
+	client.lookupHostname(session, true)
1003
+	hostname := client.Hostname() // may be a vhost
1004
+	timestampString := timestamp.Format(IRCv3TimestampFormat)
1005
+
1006
+	// send quit/resume messages to friends
1007
+	for friend := range friends {
1008
+		if friend == client {
1009
+			continue
1010
+		}
1011
+		for _, fSession := range friend.Sessions() {
1012
+			if fSession.capabilities.Has(caps.Resume) {
1013
+				if !session.resumeDetails.HistoryIncomplete {
1014
+					fSession.Send(nil, oldNickmask, "RESUMED", hostname, "ok")
1015
+				} else if session.resumeDetails.HistoryIncomplete && !timestamp.IsZero() {
1016
+					fSession.Send(nil, oldNickmask, "RESUMED", hostname, timestampString)
1017
+				} else {
1018
+					fSession.Send(nil, oldNickmask, "RESUMED", hostname)
1019
+				}
1020
+			} else {
1021
+				if !session.resumeDetails.HistoryIncomplete {
1022
+					fSession.Send(nil, oldNickmask, "QUIT", friend.t("Client reconnected"))
1023
+				} else if session.resumeDetails.HistoryIncomplete && !timestamp.IsZero() {
1024
+					fSession.Send(nil, oldNickmask, "QUIT", fmt.Sprintf(friend.t("Client reconnected (up to %d seconds of message history lost)"), gapSeconds))
1025
+				} else {
1026
+					fSession.Send(nil, oldNickmask, "QUIT", friend.t("Client reconnected (message history may have been lost)"))
1027
+				}
1028
+			}
1029
+		}
1030
+	}
1031
+
1032
+	if session.resumeDetails.HistoryIncomplete {
1033
+		if !timestamp.IsZero() {
1034
+			session.Send(nil, client.server.name, "WARN", "RESUME", "HISTORY_LOST", fmt.Sprintf(client.t("Resume may have lost up to %d seconds of history"), gapSeconds))
1035
+		} else {
1036
+			session.Send(nil, client.server.name, "WARN", "RESUME", "HISTORY_LOST", client.t("Resume may have lost some message history"))
1037
+		}
1038
+	}
1039
+
1040
+	session.Send(nil, client.server.name, "RESUME", "SUCCESS", details.nick)
1041
+
1042
+	server.playRegistrationBurst(session)
1043
+
1044
+	for _, channel := range client.Channels() {
1045
+		channel.Resume(session, timestamp)
1046
+	}
1047
+
1048
+	// replay direct PRIVSMG history
1049
+	_, privmsgSeq, err := server.GetHistorySequence(nil, client, "")
1050
+	if !timestamp.IsZero() && err == nil && privmsgSeq != nil {
1051
+		after := history.Selector{Time: timestamp}
1052
+		items, _ := privmsgSeq.Between(after, history.Selector{}, config.History.ZNCMax)
1053
+		if len(items) != 0 {
1054
+			rb := NewResponseBuffer(session)
1055
+			client.replayPrivmsgHistory(rb, items, "")
1056
+			rb.Send(true)
1057
+		}
1058
+	}
1059
+
1060
+	session.resumeDetails = nil
1061
+}
1062
+
871 1063
 func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.Item, target string) {
872 1064
 	var batchID string
873 1065
 	details := client.Details()
@@ -1200,6 +1392,8 @@ func (client *Client) destroy(session *Session) {
1200 1392
 	client.stateMutex.Lock()
1201 1393
 
1202 1394
 	details := client.detailsNoMutex()
1395
+	brbState := client.brbTimer.state
1396
+	brbAt := client.brbTimer.brbAt
1203 1397
 	wasReattach := session != nil && session.client != client
1204 1398
 	sessionRemoved := false
1205 1399
 	registered := client.registered
@@ -1241,7 +1435,9 @@ func (client *Client) destroy(session *Session) {
1241 1435
 	}
1242 1436
 
1243 1437
 	// should we destroy the whole client this time?
1244
-	shouldDestroy := !client.destroyed && remainingSessions == 0 && !alwaysOn
1438
+	// BRB is not respected if this is a destroy of the whole client (i.e., session == nil)
1439
+	brbEligible := session != nil && brbState == BrbEnabled
1440
+	shouldDestroy := !client.destroyed && remainingSessions == 0 && !brbEligible && !alwaysOn
1245 1441
 	// decrement stats on a true destroy, or for the removal of the last connected session
1246 1442
 	// of an always-on client
1247 1443
 	shouldDecrement := shouldDestroy || (alwaysOn && len(sessionsToDestroy) != 0 && len(client.sessions) == 0)
@@ -1287,6 +1483,7 @@ func (client *Client) destroy(session *Session) {
1287 1483
 		// send quit/error message to client if they haven't been sent already
1288 1484
 		client.Quit("", session)
1289 1485
 		quitMessage = session.quitMessage // doesn't need synch, we already detached
1486
+		session.SetDestroyed()
1290 1487
 		session.socket.Close()
1291 1488
 
1292 1489
 		// clean up monitor state
@@ -1345,6 +1542,8 @@ func (client *Client) destroy(session *Session) {
1345 1542
 		client.server.whoWas.Append(client.WhoWas())
1346 1543
 	}
1347 1544
 
1545
+	client.server.resumeManager.Delete(client)
1546
+
1348 1547
 	// alert monitors
1349 1548
 	if registered {
1350 1549
 		client.server.monitorManager.AlertAbout(details.nick, details.nickCasefolded, false)
@@ -1366,8 +1565,20 @@ func (client *Client) destroy(session *Session) {
1366 1565
 	client.server.clients.Remove(client)
1367 1566
 
1368 1567
 	// clean up self
1568
+	client.brbTimer.Disable()
1569
+
1369 1570
 	client.server.accounts.Logout(client)
1370 1571
 
1572
+	// this happens under failure to return from BRB
1573
+	if quitMessage == "" {
1574
+		if brbState == BrbDead && !brbAt.IsZero() {
1575
+			awayMessage := client.AwayMessage()
1576
+			if awayMessage == "" {
1577
+				awayMessage = "Disconnected" // auto-BRB
1578
+			}
1579
+			quitMessage = fmt.Sprintf("%s [%s ago]", awayMessage, time.Since(brbAt).Truncate(time.Second).String())
1580
+		}
1581
+	}
1371 1582
 	if quitMessage == "" {
1372 1583
 		quitMessage = "Exited"
1373 1584
 	}

+ 20
- 0
irc/client_lookup_set.go View File

@@ -81,6 +81,26 @@ func (clients *ClientManager) Remove(client *Client) error {
81 81
 	return clients.removeInternal(client, oldcfnick, oldskeleton)
82 82
 }
83 83
 
84
+// Handles a RESUME by attaching a session to a designated client. It is the
85
+// caller's responsibility to verify that the resume is allowed (checking tokens,
86
+// TLS status, etc.) before calling this.
87
+func (clients *ClientManager) Resume(oldClient *Client, session *Session) (err error) {
88
+	clients.Lock()
89
+	defer clients.Unlock()
90
+
91
+	cfnick := oldClient.NickCasefolded()
92
+	if _, ok := clients.byNick[cfnick]; !ok {
93
+		return errNickMissing
94
+	}
95
+
96
+	success, _, _, _ := oldClient.AddSession(session)
97
+	if !success {
98
+		return errNickMissing
99
+	}
100
+
101
+	return nil
102
+}
103
+
84 104
 // SetNick sets a client's nickname, validating it against nicknames in use
85 105
 // XXX: dryRun validates a client's ability to claim a nick, without
86 106
 // actually claiming it

+ 9
- 0
irc/commands.go View File

@@ -93,6 +93,10 @@ func init() {
93 93
 			minParams:      1,
94 94
 			allowedInBatch: true,
95 95
 		},
96
+		"BRB": {
97
+			handler:   brbHandler,
98
+			minParams: 0,
99
+		},
96 100
 		"CAP": {
97 101
 			handler:      capHandler,
98 102
 			usablePreReg: true,
@@ -253,6 +257,11 @@ func init() {
253 257
 			handler:   renameHandler,
254 258
 			minParams: 2,
255 259
 		},
260
+		"RESUME": {
261
+			handler:      resumeHandler,
262
+			usablePreReg: true,
263
+			minParams:    1,
264
+		},
256 265
 		"SAJOIN": {
257 266
 			handler:   sajoinHandler,
258 267
 			minParams: 1,

+ 1
- 0
irc/config.go View File

@@ -570,6 +570,7 @@ type Config struct {
570 570
 		WebIRC               []webircConfig `yaml:"webirc"`
571 571
 		MaxSendQString       string         `yaml:"max-sendq"`
572 572
 		MaxSendQBytes        int
573
+		AllowPlaintextResume bool `yaml:"allow-plaintext-resume"`
573 574
 		Compatibility        struct {
574 575
 			ForceTrailing      *bool `yaml:"force-trailing"`
575 576
 			forceTrailing      bool

+ 30
- 0
irc/getters.go View File

@@ -54,6 +54,18 @@ func (client *Client) Sessions() (sessions []*Session) {
54 54
 	return
55 55
 }
56 56
 
57
+func (client *Client) GetSessionByResumeID(resumeID string) (result *Session) {
58
+	client.stateMutex.RLock()
59
+	defer client.stateMutex.RUnlock()
60
+
61
+	for _, session := range client.sessions {
62
+		if session.resumeID == resumeID {
63
+			return session
64
+		}
65
+	}
66
+	return
67
+}
68
+
57 69
 type SessionData struct {
58 70
 	ctime     time.Time
59 71
 	atime     time.Time
@@ -145,6 +157,12 @@ func (client *Client) removeSession(session *Session) (success bool, length int)
145 157
 	return
146 158
 }
147 159
 
160
+func (session *Session) SetResumeID(resumeID string) {
161
+	session.client.stateMutex.Lock()
162
+	session.resumeID = resumeID
163
+	session.client.stateMutex.Unlock()
164
+}
165
+
148 166
 func (client *Client) Nick() string {
149 167
 	client.stateMutex.RLock()
150 168
 	defer client.stateMutex.RUnlock()
@@ -247,6 +265,18 @@ func (client *Client) uniqueIdentifiers() (nickCasefolded string, skeleton strin
247 265
 	return client.nickCasefolded, client.skeleton
248 266
 }
249 267
 
268
+func (client *Client) ResumeID() string {
269
+	client.stateMutex.RLock()
270
+	defer client.stateMutex.RUnlock()
271
+	return client.resumeID
272
+}
273
+
274
+func (client *Client) SetResumeID(id string) {
275
+	client.stateMutex.Lock()
276
+	defer client.stateMutex.Unlock()
277
+	client.resumeID = id
278
+}
279
+
250 280
 func (client *Client) Oper() *Oper {
251 281
 	client.stateMutex.RLock()
252 282
 	defer client.stateMutex.RUnlock()

+ 58
- 0
irc/handlers.go View File

@@ -420,6 +420,31 @@ func batchHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon
420 420
 	return false
421 421
 }
422 422
 
423
+// BRB [message]
424
+func brbHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
425
+	success, duration := client.brbTimer.Enable()
426
+	if !success {
427
+		rb.Add(nil, server.name, "FAIL", "BRB", "CANNOT_BRB", client.t("Your client does not support BRB"))
428
+		return false
429
+	} else {
430
+		rb.Add(nil, server.name, "BRB", strconv.Itoa(int(duration.Seconds())))
431
+	}
432
+
433
+	var message string
434
+	if 0 < len(msg.Params) {
435
+		message = msg.Params[0]
436
+	} else {
437
+		message = client.t("I'll be right back")
438
+	}
439
+
440
+	if len(client.Sessions()) == 1 {
441
+		// true BRB
442
+		rb.session.SetAway(message)
443
+	}
444
+
445
+	return true
446
+}
447
+
423 448
 // CAP <subcmd> [<caps>]
424 449
 func capHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
425 450
 	details := client.Details()
@@ -515,6 +540,15 @@ func capHandler(server *Server, client *Client, msg ircmsg.Message, rb *Response
515 540
 		rb.session.capabilities.Subtract(toRemove)
516 541
 		rb.Add(nil, server.name, "CAP", details.nick, "ACK", capString)
517 542
 
543
+		// if this is the first time the client is requesting a resume token,
544
+		// send it to them
545
+		if toAdd.Has(caps.Resume) {
546
+			token, id := server.resumeManager.GenerateToken(client)
547
+			if token != "" {
548
+				rb.Add(nil, server.name, "RESUME", "TOKEN", token)
549
+				rb.session.SetResumeID(id)
550
+			}
551
+		}
518 552
 	case "END":
519 553
 		if !client.registered {
520 554
 			rb.session.capState = caps.NegotiatedState
@@ -2803,6 +2837,30 @@ func renameHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
2803 2837
 	return false
2804 2838
 }
2805 2839
 
2840
+// RESUME <token> [timestamp]
2841
+func resumeHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
2842
+	details := ResumeDetails{
2843
+		PresentedToken: msg.Params[0],
2844
+	}
2845
+
2846
+	if client.registered {
2847
+		rb.Add(nil, server.name, "FAIL", "RESUME", "REGISTRATION_IS_COMPLETED", client.t("Cannot resume connection, connection registration has already been completed"))
2848
+		return false
2849
+	}
2850
+
2851
+	if 1 < len(msg.Params) {
2852
+		ts, err := time.Parse(IRCv3TimestampFormat, msg.Params[1])
2853
+		if err == nil {
2854
+			details.Timestamp = ts
2855
+		} else {
2856
+			rb.Add(nil, server.name, "WARN", "RESUME", "HISTORY_LOST", client.t("Timestamp is not in 2006-01-02T15:04:05.999Z format, ignoring it"))
2857
+		}
2858
+	}
2859
+
2860
+	rb.session.resumeDetails = &details
2861
+	return false
2862
+}
2863
+
2806 2864
 // SANICK <oldnick> <nickname>
2807 2865
 func sanickHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
2808 2866
 	targetNick := msg.Params[0]

+ 14
- 0
irc/help.go View File

@@ -129,6 +129,14 @@ longer away.`,
129 129
 
130 130
 BATCH initiates an IRCv3 client-to-server batch. You should never need to
131 131
 issue this command manually.`,
132
+	},
133
+	"brb": {
134
+		text: `BRB [message]
135
+
136
+Disconnects you from the server, while instructing the server to keep you
137
+present for a short time window. During this window, you can either resume
138
+or reattach to your nickname. If [message] is sent, it is used as your away
139
+message (and as your quit message if you don't return in time).`,
132 140
 	},
133 141
 	"cap": {
134 142
 		text: `CAP <subcommand> [:<capabilities>]
@@ -487,6 +495,12 @@ Registers an account in accordance with the draft/register capability.`,
487 495
 		text: `REHASH
488 496
 
489 497
 Reloads the config file and updates TLS certificates on listeners`,
498
+	},
499
+	"resume": {
500
+		text: `RESUME <oldnick> [timestamp]
501
+
502
+Sent before registration has completed, this indicates that the client wants to
503
+resume their old connection <oldnick>.`,
490 504
 	},
491 505
 	"time": {
492 506
 		text: `TIME [server]

+ 133
- 0
irc/idletimer.go View File

@@ -0,0 +1,133 @@
1
+// Copyright (c) 2017 Shivaram Lingamneni <slingamn@cs.stanford.edu>
2
+// released under the MIT license
3
+
4
+package irc
5
+
6
+import (
7
+	"time"
8
+)
9
+
10
+// BrbTimer is a timer on the client as a whole (not an individual session) for implementing
11
+// the BRB command and related functionality (where a client can remain online without
12
+// having any connected sessions).
13
+
14
+type BrbState uint
15
+
16
+const (
17
+	// BrbDisabled is the default state; the client will be disconnected if it has no sessions
18
+	BrbDisabled BrbState = iota
19
+	// BrbEnabled allows the client to remain online without sessions; if a timeout is
20
+	// reached, it will be removed
21
+	BrbEnabled
22
+	// BrbDead is the state of a client after its timeout has expired; it will be removed
23
+	// and therefore new sessions cannot be attached to it
24
+	BrbDead
25
+)
26
+
27
+type BrbTimer struct {
28
+	// XXX we use client.stateMutex for synchronization, so we can atomically test
29
+	// conditions that use both brbTimer.state and client.sessions. This code
30
+	// is tightly coupled with the rest of Client.
31
+	client *Client
32
+
33
+	state    BrbState
34
+	brbAt    time.Time
35
+	duration time.Duration
36
+	timer    *time.Timer
37
+}
38
+
39
+func (bt *BrbTimer) Initialize(client *Client) {
40
+	bt.client = client
41
+}
42
+
43
+// attempts to enable BRB for a client, returns whether it succeeded
44
+func (bt *BrbTimer) Enable() (success bool, duration time.Duration) {
45
+	// TODO make this configurable
46
+	duration = ResumeableTotalTimeout
47
+
48
+	bt.client.stateMutex.Lock()
49
+	defer bt.client.stateMutex.Unlock()
50
+
51
+	if !bt.client.registered || bt.client.alwaysOn || bt.client.resumeID == "" {
52
+		return
53
+	}
54
+
55
+	switch bt.state {
56
+	case BrbDisabled, BrbEnabled:
57
+		bt.state = BrbEnabled
58
+		bt.duration = duration
59
+		bt.resetTimeout()
60
+		// only track the earliest BRB, if multiple sessions are BRB'ing at once
61
+		// TODO(#524) this is inaccurate in case of an auto-BRB
62
+		if bt.brbAt.IsZero() {
63
+			bt.brbAt = time.Now().UTC()
64
+		}
65
+		success = true
66
+	default:
67
+		// BrbDead
68
+		success = false
69
+	}
70
+	return
71
+}
72
+
73
+// turns off BRB for a client and stops the timer; used on resume and during
74
+// client teardown
75
+func (bt *BrbTimer) Disable() (brbAt time.Time) {
76
+	bt.client.stateMutex.Lock()
77
+	defer bt.client.stateMutex.Unlock()
78
+
79
+	if bt.state == BrbEnabled {
80
+		bt.state = BrbDisabled
81
+		brbAt = bt.brbAt
82
+		bt.brbAt = time.Time{}
83
+	}
84
+	bt.resetTimeout()
85
+	return
86
+}
87
+
88
+func (bt *BrbTimer) resetTimeout() {
89
+	if bt.timer != nil {
90
+		bt.timer.Stop()
91
+	}
92
+	if bt.state != BrbEnabled {
93
+		return
94
+	}
95
+	if bt.timer == nil {
96
+		bt.timer = time.AfterFunc(bt.duration, bt.processTimeout)
97
+	} else {
98
+		bt.timer.Reset(bt.duration)
99
+	}
100
+}
101
+
102
+func (bt *BrbTimer) processTimeout() {
103
+	dead := false
104
+	defer func() {
105
+		if dead {
106
+			bt.client.Quit(bt.client.AwayMessage(), nil)
107
+			bt.client.destroy(nil)
108
+		}
109
+	}()
110
+
111
+	bt.client.stateMutex.Lock()
112
+	defer bt.client.stateMutex.Unlock()
113
+
114
+	if bt.client.alwaysOn {
115
+		return
116
+	}
117
+
118
+	switch bt.state {
119
+	case BrbDisabled, BrbEnabled:
120
+		if len(bt.client.sessions) == 0 {
121
+			// client never returned, quit them
122
+			bt.state = BrbDead
123
+			dead = true
124
+		} else {
125
+			// client resumed, reattached, or has another active session
126
+			bt.state = BrbDisabled
127
+			bt.brbAt = time.Time{}
128
+		}
129
+	case BrbDead:
130
+		dead = true // shouldn't be possible but whatever
131
+	}
132
+	bt.resetTimeout()
133
+}

+ 6
- 0
irc/numerics.go View File

@@ -196,4 +196,10 @@ const (
196 196
 	RPL_REG_VERIFICATION_REQUIRED = "927"
197 197
 	ERR_TOOMANYLANGUAGES          = "981"
198 198
 	ERR_NOLANGUAGE                = "982"
199
+
200
+	// draft numerics
201
+	// these haven't been assigned actual codes, so we use RPL_NONE's code (300),
202
+	// since RPL_NONE is intended to be used when testing / debugging / etc features.
203
+
204
+	ERR_CANNOT_RESUME = "300"
199 205
 )

+ 104
- 0
irc/resume.go View File

@@ -0,0 +1,104 @@
1
+// Copyright (c) 2019 Shivaram Lingamneni <slingamn@cs.stanford.edu>
2
+// released under the MIT license
3
+
4
+package irc
5
+
6
+import (
7
+	"sync"
8
+
9
+	"github.com/ergochat/ergo/irc/utils"
10
+)
11
+
12
+// implements draft/resume, in particular the issuing, management, and verification
13
+// of resume tokens with two components: a unique ID and a secret key
14
+
15
+type resumeTokenPair struct {
16
+	client *Client
17
+	secret string
18
+}
19
+
20
+type ResumeManager struct {
21
+	sync.Mutex // level 2
22
+
23
+	resumeIDtoCreds map[string]resumeTokenPair
24
+	server          *Server
25
+}
26
+
27
+func (rm *ResumeManager) Initialize(server *Server) {
28
+	rm.resumeIDtoCreds = make(map[string]resumeTokenPair)
29
+	rm.server = server
30
+}
31
+
32
+// GenerateToken generates a resume token for a client. If the client has
33
+// already been assigned one, it returns "".
34
+func (rm *ResumeManager) GenerateToken(client *Client) (token string, id string) {
35
+	id = utils.GenerateSecretToken()
36
+	secret := utils.GenerateSecretToken()
37
+
38
+	rm.Lock()
39
+	defer rm.Unlock()
40
+
41
+	if client.ResumeID() != "" {
42
+		return
43
+	}
44
+
45
+	client.SetResumeID(id)
46
+	rm.resumeIDtoCreds[id] = resumeTokenPair{
47
+		client: client,
48
+		secret: secret,
49
+	}
50
+
51
+	return id + secret, id
52
+}
53
+
54
+// VerifyToken looks up the client corresponding to a resume token, returning
55
+// nil if there is no such client or the token is invalid. If successful,
56
+// the token is consumed and cannot be used to resume again.
57
+func (rm *ResumeManager) VerifyToken(newClient *Client, token string) (oldClient *Client, id string) {
58
+	if len(token) != 2*utils.SecretTokenLength {
59
+		return
60
+	}
61
+
62
+	rm.Lock()
63
+	defer rm.Unlock()
64
+
65
+	id = token[:utils.SecretTokenLength]
66
+	pair, ok := rm.resumeIDtoCreds[id]
67
+	if !ok {
68
+		return
69
+	}
70
+	// disallow resume of an unregistered client; this prevents the use of
71
+	// resume as an auth bypass
72
+	if !pair.client.Registered() {
73
+		return
74
+	}
75
+
76
+	if utils.SecretTokensMatch(pair.secret, token[utils.SecretTokenLength:]) {
77
+		oldClient = pair.client // success!
78
+		// consume the token, ensuring that at most one resume can succeed
79
+		delete(rm.resumeIDtoCreds, id)
80
+		// old client is henceforth resumeable under new client's creds (possibly empty)
81
+		newResumeID := newClient.ResumeID()
82
+		oldClient.SetResumeID(newResumeID)
83
+		if newResumeID != "" {
84
+			if newResumeCreds, ok := rm.resumeIDtoCreds[newResumeID]; ok {
85
+				newResumeCreds.client = oldClient
86
+				rm.resumeIDtoCreds[newResumeID] = newResumeCreds
87
+			}
88
+		}
89
+		// new client no longer "owns" newResumeID, remove the association
90
+		newClient.SetResumeID("")
91
+	}
92
+	return
93
+}
94
+
95
+// Delete stops tracking a client's resume token.
96
+func (rm *ResumeManager) Delete(client *Client) {
97
+	rm.Lock()
98
+	defer rm.Unlock()
99
+
100
+	currentID := client.ResumeID()
101
+	if currentID != "" {
102
+		delete(rm.resumeIDtoCreds, currentID)
103
+	}
104
+}

+ 8
- 0
irc/server.go View File

@@ -80,6 +80,7 @@ type Server struct {
80 80
 	rehashMutex       sync.Mutex // tier 4
81 81
 	rehashSignal      chan os.Signal
82 82
 	pprofServer       *http.Server
83
+	resumeManager     ResumeManager
83 84
 	signals           chan os.Signal
84 85
 	snomasks          SnoManager
85 86
 	store             *buntdb.DB
@@ -105,6 +106,7 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) {
105 106
 
106 107
 	server.clients.Initialize()
107 108
 	server.semaphores.Initialize()
109
+	server.resumeManager.Initialize(server)
108 110
 	server.whoWas.Initialize(config.Limits.WhowasEntries)
109 111
 	server.monitorManager.Initialize()
110 112
 	server.snomasks.Initialize()
@@ -271,6 +273,12 @@ func (server *Server) handleAlwaysOnExpirations() {
271 273
 //
272 274
 
273 275
 func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) {
276
+	// if the session just sent us a RESUME line, try to resume
277
+	if session.resumeDetails != nil {
278
+		session.tryResume()
279
+		return // whether we succeeded or failed, either way `c` is not getting registered
280
+	}
281
+
274 282
 	// XXX PROXY or WEBIRC MUST be sent as the first line of the session;
275 283
 	// if we are here at all that means we have the final value of the IP
276 284
 	if session.rawHostname == "" {

Loading…
Cancel
Save