diff --git a/irc/client.go b/irc/client.go index 20bf4244..70e6628d 100644 --- a/irc/client.go +++ b/irc/client.go @@ -90,6 +90,7 @@ type Client struct { lastSeen map[string]time.Time // maps device ID (including "") to time of last received command lastSeenLastWrite time.Time // last time `lastSeen` was written to the datastore loginThrottle connection_limits.GenericThrottle + nextSessionID int64 // Incremented when a new session is established nick string nickCasefolded string nickMaskCasefolded string @@ -150,6 +151,7 @@ type Session struct { idleTimer *time.Timer pingSent bool // we sent PING to a putatively idle connection and we're waiting for PONG + sessionID int64 socket *Socket realIP net.IP proxiedIP net.IP @@ -353,6 +355,7 @@ func (server *Server) RunClient(conn IRCConn) { realIP: realIP, proxiedIP: proxiedIP, requireSASL: requireSASL, + nextSessionID: 1, } if requireSASL { client.requireSASLMessage = banMsg @@ -420,6 +423,8 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, chnames []string, alwaysOn: true, realname: realname, + + nextSessionID: 1, } client.SetMode(modes.TLS, true) diff --git a/irc/getters.go b/irc/getters.go index ec4dc8c8..00f31fd6 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -65,12 +65,13 @@ func (client *Client) GetSessionByResumeID(resumeID string) (result *Session) { } type SessionData struct { - ctime time.Time - atime time.Time - ip net.IP - hostname string - certfp string - deviceID string + ctime time.Time + atime time.Time + ip net.IP + hostname string + certfp string + deviceID string + sessionID int64 } func (client *Client) AllSessionData(currentSession *Session) (data []SessionData, currentIndex int) { @@ -84,11 +85,12 @@ func (client *Client) AllSessionData(currentSession *Session) (data []SessionDat currentIndex = i } data[i] = SessionData{ - atime: session.lastActive, - ctime: session.ctime, - hostname: session.rawHostname, - certfp: session.certfp, - deviceID: session.deviceID, + atime: session.lastActive, + ctime: session.ctime, + hostname: session.rawHostname, + certfp: session.certfp, + deviceID: session.deviceID, + sessionID: session.sessionID, } if session.proxiedIP != nil { data[i].ip = session.proxiedIP @@ -109,6 +111,8 @@ func (client *Client) AddSession(session *Session) (success bool, numSessions in } // success, attach the new session to the client session.client = client + session.sessionID = client.nextSessionID + client.nextSessionID++ newSessions := make([]*Session, len(client.sessions)+1) copy(newSessions, client.sessions) newSessions[len(newSessions)-1] = session diff --git a/irc/nickserv.go b/irc/nickserv.go index 197a72cd..e7d49a15 100644 --- a/irc/nickserv.go +++ b/irc/nickserv.go @@ -43,6 +43,23 @@ const nickservHelp = `NickServ lets you register, log in to, and manage an accou var ( nickservCommands = map[string]*serviceCommand{ + "clients": { + handler: nsClientsHandler, + help: `Syntax: $bCLIENTS LIST [nickname]$b + +CLIENTS LIST shows information about the clients currently attached, via +the server's multiclient functionality, to your nickname. An administrator +can use this command to list another user's clients. + +Syntax: $bCLIENTS LOGOUT [nickname] [client_id/all]$b + +CLIENTS LOGOUT detaches a single client, or all other clients currently +attached, via the server's multiclient functionality, to your nickname. An +administrator can use this command to logout another user's clients.`, + helpShort: `$bCLIENTS$b can list and logout the sessions attached to a nickname.`, + enabled: servCmdRequiresBouncerEnabled, + minParams: 1, + }, "drop": { handler: nsDropHandler, help: `Syntax: $bDROP [nickname]$b @@ -150,14 +167,13 @@ an administrator can set use this command to set up user accounts.`, minParams: 1, }, "sessions": { - handler: nsSessionsHandler, + hidden: true, + handler: nsClientsHandler, help: `Syntax: $bSESSIONS [nickname]$b -SESSIONS lists information about the sessions currently attached, via -the server's multiclient functionality, to your nickname. An administrator -can use this command to list another user's sessions.`, - helpShort: `$bSESSIONS$b lists the sessions attached to a nickname.`, - enabled: servCmdRequiresBouncerEnabled, +SESSIONS is an alias for $bCLIENTS LIST$b. See the help entry for $bCLIENTS$b +for more information.`, + enabled: servCmdRequiresBouncerEnabled, }, "unregister": { handler: nsUnregisterHandler, @@ -1065,9 +1081,30 @@ func nsEnforceHandler(server *Server, client *Client, command string, params []s } } -func nsSessionsHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { - target := client +func nsClientsHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + var verb string + if command == "sessions" { + // Legacy "SESSIONS" command is an alias for CLIENTS LIST. + verb = "list" + } else if len(params) > 0 { + verb = strings.ToLower(params[0]) + params = params[1:] + } + + switch verb { + case "list": + nsClientsListHandler(server, client, params, rb) + case "logout": + nsClientsLogoutHandler(server, client, params, rb) + default: + nsNotice(rb, client.t("Invalid parameters")) + return + } +} + +func nsClientsListHandler(server *Server, client *Client, params []string, rb *ResponseBuffer) { + target := client if 0 < len(params) { target = server.clients.Get(params[0]) if target == nil { @@ -1082,12 +1119,12 @@ func nsSessionsHandler(server *Server, client *Client, command string, params [] } sessionData, currentIndex := target.AllSessionData(rb.session) - nsNotice(rb, fmt.Sprintf(client.t("Nickname %[1]s has %[2]d attached session(s)"), target.Nick(), len(sessionData))) + nsNotice(rb, fmt.Sprintf(client.t("Nickname %[1]s has %[2]d attached clients(s)"), target.Nick(), len(sessionData))) for i, session := range sessionData { if currentIndex == i { - nsNotice(rb, fmt.Sprintf(client.t("Session %d (currently attached session):"), i+1)) + nsNotice(rb, fmt.Sprintf(client.t("Client %d (currently attached client):"), session.sessionID)) } else { - nsNotice(rb, fmt.Sprintf(client.t("Session %d:"), i+1)) + nsNotice(rb, fmt.Sprintf(client.t("Client %d:"), session.sessionID)) } if session.deviceID != "" { nsNotice(rb, fmt.Sprintf(client.t("Device ID: %s"), session.deviceID)) @@ -1102,6 +1139,61 @@ func nsSessionsHandler(server *Server, client *Client, command string, params [] } } +func nsClientsLogoutHandler(server *Server, client *Client, params []string, rb *ResponseBuffer) { + if len(params) < 1 { + nsNotice(rb, client.t("Missing client ID to logout (or \"all\")")) + return + } + + target := client + if len(params) >= 2 { + // CLIENTS LOGOUT [nickname] [client ID] + target = server.clients.Get(params[0]) + if target == nil { + nsNotice(rb, client.t("No such nick")) + return + } + // User must have "local_kill" privileges to logout other user sessions. + if target != client { + oper := client.Oper() + if oper == nil || !oper.Class.Capabilities.Has("local_kill") { + nsNotice(rb, client.t("Insufficient oper privs")) + return + } + } + params = params[1:] + } + + var sessionToDestroy *Session // target.destroy(nil) will logout all sessions + if strings.ToLower(params[0]) != "all" { + sessionID, err := strconv.ParseInt(params[0], 10, 64) + if err != nil { + nsNotice(rb, client.t("Client ID to logout should be an integer (or \"all\")")) + return + } + // Find the client ID that the user requested to logout. + sessions := target.Sessions() + for _, session := range sessions { + if session.sessionID == sessionID { + sessionToDestroy = session + } + } + if sessionToDestroy == nil { + nsNotice(rb, client.t("Specified client ID does not exist")) + return + } + } + + target.destroy(sessionToDestroy) + if (sessionToDestroy != nil && rb.session != sessionToDestroy) || client != target { + if sessionToDestroy != nil { + nsNotice(rb, client.t("Successfully logged out session")) + } else { + nsNotice(rb, client.t("Successfully logged out all sessions")) + } + } +} + func nsCertHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { verb := strings.ToLower(params[0]) params = params[1:]