Browse Source

Merge pull request #1528 from slingamn/issue1176_operprivs

enhancements to operator privilege handling
tags/v2.6.0-rc1
Shivaram Lingamneni 3 years ago
parent
commit
bb39399f97
No account linked to committer's email address
17 changed files with 269 additions and 105 deletions
  1. 1
    0
      Makefile
  2. 1
    0
      default.yaml
  3. 4
    8
      irc/channel.go
  4. 1
    1
      irc/chanserv.go
  5. 1
    1
      irc/client.go
  6. 1
    1
      irc/client_lookup_set.go
  7. 6
    15
      irc/commands.go
  8. 23
    22
      irc/handlers.go
  9. 21
    19
      irc/modes.go
  10. 1
    4
      irc/modes/modes.go
  11. 32
    0
      irc/modes/modes_test.go
  12. 12
    11
      irc/server.go
  13. 15
    13
      irc/sno/constants.go
  14. 87
    0
      irc/sno/utils.go
  15. 53
    0
      irc/sno/utils_test.go
  16. 9
    10
      irc/snomanager.go
  17. 1
    0
      traditional.yaml

+ 1
- 0
Makefile View File

32
 	cd irc/modes && go test . && go vet .
32
 	cd irc/modes && go test . && go vet .
33
 	cd irc/mysql && go test . && go vet .
33
 	cd irc/mysql && go test . && go vet .
34
 	cd irc/passwd && go test . && go vet .
34
 	cd irc/passwd && go test . && go vet .
35
+	cd irc/sno && go test . && go vet .
35
 	cd irc/utils && go test . && go vet .
36
 	cd irc/utils && go test . && go vet .
36
 	./.check-gofmt.sh
37
 	./.check-gofmt.sh
37
 
38
 

+ 1
- 0
default.yaml View File

593
             - "vhosts"
593
             - "vhosts"
594
             - "sajoin"
594
             - "sajoin"
595
             - "samode"
595
             - "samode"
596
+            - "snomasks"
596
 
597
 
597
     # server admin: has full control of the ircd, including nickname and
598
     # server admin: has full control of the ircd, including nickname and
598
     # channel registrations
599
     # channel registrations

+ 4
- 8
irc/channel.go View File

439
 	channel.stateMutex.RLock()
439
 	channel.stateMutex.RLock()
440
 	clientData, isJoined := channel.members[client]
440
 	clientData, isJoined := channel.members[client]
441
 	channel.stateMutex.RUnlock()
441
 	channel.stateMutex.RUnlock()
442
-	isOper := client.HasMode(modes.Operator)
442
+	isOper := client.HasRoleCapabs("sajoin")
443
 	respectAuditorium := channel.flags.HasMode(modes.Auditorium) && !isOper &&
443
 	respectAuditorium := channel.flags.HasMode(modes.Auditorium) && !isOper &&
444
 		(!isJoined || clientData.modes.HighestChannelUserMode() == modes.Mode(0))
444
 		(!isJoined || clientData.modes.HighestChannelUserMode() == modes.Mode(0))
445
 	isMultiPrefix := rb.session.capabilities.Has(caps.MultiPrefix)
445
 	isMultiPrefix := rb.session.capabilities.Has(caps.MultiPrefix)
607
 
607
 
608
 // <mode> <mode params>
608
 // <mode> <mode params>
609
 func (channel *Channel) modeStrings(client *Client) (result []string) {
609
 func (channel *Channel) modeStrings(client *Client) (result []string) {
610
-	hasPrivs := client.HasMode(modes.Operator)
610
+	hasPrivs := client.HasRoleCapabs("sajoin")
611
 
611
 
612
 	channel.stateMutex.RLock()
612
 	channel.stateMutex.RLock()
613
 	defer channel.stateMutex.RUnlock()
613
 	defer channel.stateMutex.RUnlock()
1245
 
1245
 
1246
 // SetTopic sets the topic of this channel, if the client is allowed to do so.
1246
 // SetTopic sets the topic of this channel, if the client is allowed to do so.
1247
 func (channel *Channel) SetTopic(client *Client, topic string, rb *ResponseBuffer) {
1247
 func (channel *Channel) SetTopic(client *Client, topic string, rb *ResponseBuffer) {
1248
-	if !(client.HasMode(modes.Operator) || channel.hasClient(client)) {
1248
+	if !channel.hasClient(client) {
1249
 		rb.Add(nil, client.server.name, ERR_NOTONCHANNEL, client.Nick(), channel.Name(), client.t("You're not on that channel"))
1249
 		rb.Add(nil, client.server.name, ERR_NOTONCHANNEL, client.Nick(), channel.Name(), client.t("You're not on that channel"))
1250
 		return
1250
 		return
1251
 	}
1251
 	}
1252
 
1252
 
1253
-	if channel.flags.HasMode(modes.OpOnlyTopic) && !channel.ClientIsAtLeast(client, modes.Halfop) {
1253
+	if channel.flags.HasMode(modes.OpOnlyTopic) && !(channel.ClientIsAtLeast(client, modes.Halfop) || client.HasRoleCapabs("samode")) {
1254
 		rb.Add(nil, client.server.name, ERR_CHANOPRIVSNEEDED, client.Nick(), channel.Name(), client.t("You're not a channel operator"))
1254
 		rb.Add(nil, client.server.name, ERR_CHANOPRIVSNEEDED, client.Nick(), channel.Name(), client.t("You're not a channel operator"))
1255
 		return
1255
 		return
1256
 	}
1256
 	}
1487
 
1487
 
1488
 func (channel *Channel) Kick(client *Client, target *Client, comment string, rb *ResponseBuffer, hasPrivs bool) {
1488
 func (channel *Channel) Kick(client *Client, target *Client, comment string, rb *ResponseBuffer, hasPrivs bool) {
1489
 	if !hasPrivs {
1489
 	if !hasPrivs {
1490
-		if !(client.HasMode(modes.Operator) || channel.hasClient(client)) {
1491
-			rb.Add(nil, client.server.name, ERR_NOTONCHANNEL, client.Nick(), channel.Name(), client.t("You're not on that channel"))
1492
-			return
1493
-		}
1494
 		if !channel.ClientHasPrivsOver(client, target) {
1490
 		if !channel.ClientHasPrivsOver(client, target) {
1495
 			rb.Add(nil, client.server.name, ERR_CHANOPRIVSNEEDED, client.Nick(), channel.Name(), client.t("You don't have enough channel privileges"))
1491
 			rb.Add(nil, client.server.name, ERR_CHANOPRIVSNEEDED, client.Nick(), channel.Name(), client.t("You don't have enough channel privileges"))
1496
 			return
1492
 			return

+ 1
- 1
irc/chanserv.go View File

876
 		return
876
 		return
877
 	}
877
 	}
878
 
878
 
879
-	if !(channel.ClientIsAtLeast(client, modes.Operator) || client.HasRoleCapabs("samode")) {
879
+	if !(channel.ClientIsAtLeast(client, modes.ChannelOperator) || client.HasRoleCapabs("samode")) {
880
 		service.Notice(rb, client.t("Insufficient privileges"))
880
 		service.Notice(rb, client.t("Insufficient privileges"))
881
 		return
881
 		return
882
 	}
882
 	}

+ 1
- 1
irc/client.go View File

1512
 	// decrement stats if we have no more sessions, even if the client will not be destroyed
1512
 	// decrement stats if we have no more sessions, even if the client will not be destroyed
1513
 	if shouldDecrement {
1513
 	if shouldDecrement {
1514
 		invisible := client.HasMode(modes.Invisible)
1514
 		invisible := client.HasMode(modes.Invisible)
1515
-		operator := client.HasMode(modes.LocalOperator) || client.HasMode(modes.Operator)
1515
+		operator := client.HasMode(modes.Operator)
1516
 		client.server.stats.Remove(registered, invisible, operator)
1516
 		client.server.stats.Remove(registered, invisible, operator)
1517
 	}
1517
 	}
1518
 
1518
 

+ 1
- 1
irc/client_lookup_set.go View File

222
 		}
222
 		}
223
 		if numSessions == 1 {
223
 		if numSessions == 1 {
224
 			invisible := currentClient.HasMode(modes.Invisible)
224
 			invisible := currentClient.HasMode(modes.Invisible)
225
-			operator := currentClient.HasMode(modes.Operator) || currentClient.HasMode(modes.LocalOperator)
225
+			operator := currentClient.HasMode(modes.Operator)
226
 			client.server.stats.AddRegistered(invisible, operator)
226
 			client.server.stats.AddRegistered(invisible, operator)
227
 		}
227
 		}
228
 		session.autoreplayMissedSince = lastSeen
228
 		session.autoreplayMissedSince = lastSeen

+ 6
- 15
irc/commands.go View File

7
 
7
 
8
 import (
8
 import (
9
 	"github.com/goshuirc/irc-go/ircmsg"
9
 	"github.com/goshuirc/irc-go/ircmsg"
10
-	"github.com/oragono/oragono/irc/modes"
11
 )
10
 )
12
 
11
 
13
 // Command represents a command accepted from a client.
12
 // Command represents a command accepted from a client.
14
 type Command struct {
13
 type Command struct {
15
 	handler        func(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool
14
 	handler        func(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool
16
-	oper           bool
17
 	usablePreReg   bool
15
 	usablePreReg   bool
18
 	allowedInBatch bool // allowed in client-to-server batches
16
 	allowedInBatch bool // allowed in client-to-server batches
19
 	minParams      int
17
 	minParams      int
32
 			rb.Add(nil, server.name, ERR_NOTREGISTERED, "*", client.t("You need to register before you can use that command"))
30
 			rb.Add(nil, server.name, ERR_NOTREGISTERED, "*", client.t("You need to register before you can use that command"))
33
 			return false
31
 			return false
34
 		}
32
 		}
35
-		if cmd.oper && !client.HasMode(modes.Operator) {
36
-			rb.Add(nil, server.name, ERR_NOPRIVILEGES, client.Nick(), client.t("Permission Denied - You're not an IRC operator"))
37
-			return false
38
-		}
39
 		if len(cmd.capabs) > 0 && !client.HasRoleCapabs(cmd.capabs...) {
33
 		if len(cmd.capabs) > 0 && !client.HasRoleCapabs(cmd.capabs...) {
40
 			rb.Add(nil, server.name, ERR_NOPRIVILEGES, client.Nick(), client.t("Permission Denied"))
34
 			rb.Add(nil, server.name, ERR_NOPRIVILEGES, client.Nick(), client.t("Permission Denied"))
41
 			return false
35
 			return false
115
 		"DEBUG": {
109
 		"DEBUG": {
116
 			handler:   debugHandler,
110
 			handler:   debugHandler,
117
 			minParams: 1,
111
 			minParams: 1,
118
-			oper:      true,
112
+			capabs:    []string{"rehash"},
119
 		},
113
 		},
120
 		"DEFCON": {
114
 		"DEFCON": {
121
 			handler: defconHandler,
115
 			handler: defconHandler,
124
 		"DEOPER": {
118
 		"DEOPER": {
125
 			handler:   deoperHandler,
119
 			handler:   deoperHandler,
126
 			minParams: 0,
120
 			minParams: 0,
127
-			oper:      true,
128
 		},
121
 		},
129
 		"DLINE": {
122
 		"DLINE": {
130
 			handler:   dlineHandler,
123
 			handler:   dlineHandler,
131
 			minParams: 1,
124
 			minParams: 1,
132
-			oper:      true,
125
+			capabs:    []string{"ban"},
133
 		},
126
 		},
134
 		"EXTJWT": {
127
 		"EXTJWT": {
135
 			handler:   extjwtHandler,
128
 			handler:   extjwtHandler,
169
 		"KILL": {
162
 		"KILL": {
170
 			handler:   killHandler,
163
 			handler:   killHandler,
171
 			minParams: 1,
164
 			minParams: 1,
172
-			oper:      true,
173
 			capabs:    []string{"kill"},
165
 			capabs:    []string{"kill"},
174
 		},
166
 		},
175
 		"KLINE": {
167
 		"KLINE": {
176
 			handler:   klineHandler,
168
 			handler:   klineHandler,
177
 			minParams: 1,
169
 			minParams: 1,
178
-			oper:      true,
170
+			capabs:    []string{"ban"},
179
 		},
171
 		},
180
 		"LANGUAGE": {
172
 		"LANGUAGE": {
181
 			handler:      languageHandler,
173
 			handler:      languageHandler,
278
 		"SANICK": {
270
 		"SANICK": {
279
 			handler:   sanickHandler,
271
 			handler:   sanickHandler,
280
 			minParams: 2,
272
 			minParams: 2,
281
-			oper:      true,
273
+			capabs:    []string{"samode"},
282
 		},
274
 		},
283
 		"SAMODE": {
275
 		"SAMODE": {
284
 			handler:   modeHandler,
276
 			handler:   modeHandler,
308
 		"REHASH": {
300
 		"REHASH": {
309
 			handler:   rehashHandler,
301
 			handler:   rehashHandler,
310
 			minParams: 0,
302
 			minParams: 0,
311
-			oper:      true,
312
 			capabs:    []string{"rehash"},
303
 			capabs:    []string{"rehash"},
313
 		},
304
 		},
314
 		"TIME": {
305
 		"TIME": {
327
 		"UNDLINE": {
318
 		"UNDLINE": {
328
 			handler:   unDLineHandler,
319
 			handler:   unDLineHandler,
329
 			minParams: 1,
320
 			minParams: 1,
330
-			oper:      true,
321
+			capabs:    []string{"ban"},
331
 		},
322
 		},
332
 		"UNINVITE": {
323
 		"UNINVITE": {
333
 			handler:   inviteHandler,
324
 			handler:   inviteHandler,
336
 		"UNKLINE": {
327
 		"UNKLINE": {
337
 			handler:   unKLineHandler,
328
 			handler:   unKLineHandler,
338
 			minParams: 1,
329
 			minParams: 1,
339
-			oper:      true,
330
+			capabs:    []string{"ban"},
340
 		},
331
 		},
341
 		"USER": {
332
 		"USER": {
342
 			handler:      userHandler,
333
 			handler:      userHandler,

+ 23
- 22
irc/handlers.go View File

757
 		rb.Notice(fmt.Sprintf("CPU profiling stopped"))
757
 		rb.Notice(fmt.Sprintf("CPU profiling stopped"))
758
 
758
 
759
 	case "CRASHSERVER":
759
 	case "CRASHSERVER":
760
-		if !client.HasRoleCapabs("rehash") {
761
-			rb.Notice(client.t("You must have rehash permissions in order to execute DEBUG CRASHSERVER"))
762
-			return false
763
-		}
764
 		code := utils.ConfirmationCode(server.name, server.ctime)
760
 		code := utils.ConfirmationCode(server.name, server.ctime)
765
 		if len(msg.Params) == 1 || msg.Params[1] != code {
761
 		if len(msg.Params) == 1 || msg.Params[1] != code {
766
 			rb.Notice(fmt.Sprintf(client.t("To confirm, run this command: %s"), fmt.Sprintf("/DEBUG CRASHSERVER %s", code)))
762
 			rb.Notice(fmt.Sprintf(client.t("To confirm, run this command: %s"), fmt.Sprintf("/DEBUG CRASHSERVER %s", code)))
1293
 
1289
 
1294
 // KICK <channel>{,<channel>} <user>{,<user>} [<comment>]
1290
 // KICK <channel>{,<channel>} <user>{,<user>} [<comment>]
1295
 func kickHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
1291
 func kickHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
1292
+	hasPrivs := client.HasRoleCapabs("samode")
1296
 	channels := strings.Split(msg.Params[0], ",")
1293
 	channels := strings.Split(msg.Params[0], ",")
1297
 	users := strings.Split(msg.Params[1], ",")
1294
 	users := strings.Split(msg.Params[1], ",")
1298
 	if (len(channels) != len(users)) && (len(users) != 1) {
1295
 	if (len(channels) != len(users)) && (len(users) != 1) {
1336
 		if comment == "" {
1333
 		if comment == "" {
1337
 			comment = kick.nick
1334
 			comment = kick.nick
1338
 		}
1335
 		}
1339
-		channel.Kick(client, target, comment, rb, false)
1336
+		channel.Kick(client, target, comment, rb, hasPrivs)
1340
 	}
1337
 	}
1341
 	return false
1338
 	return false
1342
 }
1339
 }
1618
 		rb.Add(nil, client.server.name, RPL_LIST, nick, name, strconv.Itoa(members), topic)
1615
 		rb.Add(nil, client.server.name, RPL_LIST, nick, name, strconv.Itoa(members), topic)
1619
 	}
1616
 	}
1620
 
1617
 
1621
-	clientIsOp := client.HasMode(modes.Operator)
1618
+	clientIsOp := client.HasRoleCapabs("sajoin")
1622
 	if len(channels) == 0 {
1619
 	if len(channels) == 0 {
1623
 		for _, channel := range server.channels.Channels() {
1620
 		for _, channel := range server.channels.Channels() {
1624
 			if !clientIsOp && channel.flags.HasMode(modes.Secret) {
1621
 			if !clientIsOp && channel.flags.HasMode(modes.Secret) {
1775
 		rb.Add(nil, cDetails.nickMask, "MODE", args...)
1772
 		rb.Add(nil, cDetails.nickMask, "MODE", args...)
1776
 	} else if hasPrivs {
1773
 	} else if hasPrivs {
1777
 		rb.Add(nil, server.name, RPL_UMODEIS, targetNick, target.ModeString())
1774
 		rb.Add(nil, server.name, RPL_UMODEIS, targetNick, target.ModeString())
1778
-		if target.HasMode(modes.LocalOperator) || target.HasMode(modes.Operator) {
1775
+		if target.HasMode(modes.Operator) {
1779
 			masks := server.snomasks.String(target)
1776
 			masks := server.snomasks.String(target)
1780
 			if 0 < len(masks) {
1777
 			if 0 < len(masks) {
1781
 				rb.Add(nil, server.name, RPL_SNOMASKIS, targetNick, masks, client.t("Server notice masks"))
1778
 				rb.Add(nil, server.name, RPL_SNOMASKIS, targetNick, masks, client.t("Server notice masks"))
1959
 	success := false
1956
 	success := false
1960
 	channel := server.channels.Get(chname)
1957
 	channel := server.channels.Get(chname)
1961
 	if channel != nil {
1958
 	if channel != nil {
1962
-		if !channel.flags.HasMode(modes.Secret) || channel.hasClient(client) || client.HasMode(modes.Operator) {
1959
+		if !channel.flags.HasMode(modes.Secret) || channel.hasClient(client) || client.HasRoleCapabs("sajoin") {
1963
 			channel.Names(client, rb)
1960
 			channel.Names(client, rb)
1964
 			success = true
1961
 			success = true
1965
 		}
1962
 		}
2338
 
2335
 
2339
 // DEOPER
2336
 // DEOPER
2340
 func deoperHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
2337
 func deoperHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
2338
+	if client.Oper() == nil {
2339
+		rb.Notice(client.t("Insufficient oper privs"))
2340
+		return false
2341
+	}
2341
 	// pretend they sent /MODE $nick -o
2342
 	// pretend they sent /MODE $nick -o
2342
 	fakeModeMsg := ircmsg.MakeMessage(nil, "", "MODE", client.Nick(), "-o")
2343
 	fakeModeMsg := ircmsg.MakeMessage(nil, "", "MODE", client.Nick(), "-o")
2343
 	return umodeHandler(server, client, fakeModeMsg, rb)
2344
 	return umodeHandler(server, client, fakeModeMsg, rb)
2944
 
2945
 
2945
 // USERHOST <nickname>{ <nickname>}
2946
 // USERHOST <nickname>{ <nickname>}
2946
 func userhostHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
2947
 func userhostHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
2947
-	hasPrivs := client.HasMode(modes.Operator) // TODO(#1176) figure out the right capab for this
2948
+	hasPrivs := client.HasMode(modes.Operator)
2948
 	returnedClients := make(ClientSet)
2949
 	returnedClients := make(ClientSet)
2949
 
2950
 
2950
 	var tl utils.TokenLineBuilder
2951
 	var tl utils.TokenLineBuilder
3083
 // <channel> <user> <host> <server> <nick> <H|G>[*][~|&|@|%|+][B] :<hopcount> <real name>
3084
 // <channel> <user> <host> <server> <nick> <H|G>[*][~|&|@|%|+][B] :<hopcount> <real name>
3084
 // whox format:
3085
 // whox format:
3085
 // <type> <channel> <user> <ip> <host> <server> <nick> <H|G>[*][~|&|@|%|+][B] <hops> <idle> <account> <rank> :<real name>
3086
 // <type> <channel> <user> <ip> <host> <server> <nick> <H|G>[*][~|&|@|%|+][B] <hops> <idle> <account> <rank> :<real name>
3086
-func (client *Client) rplWhoReply(channel *Channel, target *Client, rb *ResponseBuffer, hasPrivs, includeRFlag, isWhox bool, fields whoxFields, whoType string) {
3087
+func (client *Client) rplWhoReply(channel *Channel, target *Client, rb *ResponseBuffer, canSeeIPs, canSeeOpers, includeRFlag, isWhox bool, fields whoxFields, whoType string) {
3087
 	params := []string{client.Nick()}
3088
 	params := []string{client.Nick()}
3088
 
3089
 
3089
 	details := target.Details()
3090
 	details := target.Details()
3103
 	}
3104
 	}
3104
 	if fields.Has('i') {
3105
 	if fields.Has('i') {
3105
 		fIP := "255.255.255.255"
3106
 		fIP := "255.255.255.255"
3106
-		if hasPrivs || client == target {
3107
+		if canSeeIPs || client == target {
3107
 			// you can only see a target's IP if they're you or you're an oper
3108
 			// you can only see a target's IP if they're you or you're an oper
3108
 			fIP = target.IPString()
3109
 			fIP = target.IPString()
3109
 		}
3110
 		}
3126
 			flags.WriteRune('H') // Here
3127
 			flags.WriteRune('H') // Here
3127
 		}
3128
 		}
3128
 
3129
 
3129
-		if target.HasMode(modes.Operator) && operStatusVisible(client, target, hasPrivs) {
3130
+		if target.HasMode(modes.Operator) && operStatusVisible(client, target, canSeeOpers) {
3130
 			flags.WriteRune('*')
3131
 			flags.WriteRune('*')
3131
 		}
3132
 		}
3132
 
3133
 
3229
 	//	operatorOnly = true
3230
 	//	operatorOnly = true
3230
 	//}
3231
 	//}
3231
 
3232
 
3232
-	isOper := client.HasMode(modes.Operator)
3233
+	oper := client.Oper()
3234
+	hasPrivs := oper.HasRoleCapab("sajoin")
3235
+	canSeeIPs := oper.HasRoleCapab("ban")
3233
 	if mask[0] == '#' {
3236
 	if mask[0] == '#' {
3234
-		// TODO implement wildcard matching
3235
-		//TODO(dan): ^ only for opers
3236
 		channel := server.channels.Get(mask)
3237
 		channel := server.channels.Get(mask)
3237
 		if channel != nil {
3238
 		if channel != nil {
3238
 			isJoined := channel.hasClient(client)
3239
 			isJoined := channel.hasClient(client)
3239
-			if !channel.flags.HasMode(modes.Secret) || isJoined || isOper {
3240
+			if !channel.flags.HasMode(modes.Secret) || isJoined || hasPrivs {
3240
 				var members []*Client
3241
 				var members []*Client
3241
-				if isOper {
3242
+				if hasPrivs {
3242
 					members = channel.Members()
3243
 					members = channel.Members()
3243
 				} else {
3244
 				} else {
3244
 					members = channel.auditoriumFriends(client)
3245
 					members = channel.auditoriumFriends(client)
3245
 				}
3246
 				}
3246
 				for _, member := range members {
3247
 				for _, member := range members {
3247
-					if !member.HasMode(modes.Invisible) || isJoined || isOper {
3248
-						client.rplWhoReply(channel, member, rb, isOper, includeRFlag, isWhox, fields, whoType)
3248
+					if !member.HasMode(modes.Invisible) || isJoined || hasPrivs {
3249
+						client.rplWhoReply(channel, member, rb, canSeeIPs, oper != nil, includeRFlag, isWhox, fields, whoType)
3249
 					}
3250
 					}
3250
 				}
3251
 				}
3251
 			}
3252
 			}
3275
 		}
3276
 		}
3276
 
3277
 
3277
 		for mclient := range server.clients.FindAll(mask) {
3278
 		for mclient := range server.clients.FindAll(mask) {
3278
-			if isOper || !mclient.HasMode(modes.Invisible) || isFriend(mclient) {
3279
-				client.rplWhoReply(nil, mclient, rb, isOper, includeRFlag, isWhox, fields, whoType)
3279
+			if hasPrivs || !mclient.HasMode(modes.Invisible) || isFriend(mclient) {
3280
+				client.rplWhoReply(nil, mclient, rb, canSeeIPs, oper != nil, includeRFlag, isWhox, fields, whoType)
3280
 			}
3281
 			}
3281
 		}
3282
 		}
3282
 	}
3283
 	}
3319
 		return true
3320
 		return true
3320
 	}
3321
 	}
3321
 
3322
 
3322
-	hasPrivs := client.HasMode(modes.Operator) // TODO(#1176) figure out the right capab for this
3323
+	hasPrivs := client.HasRoleCapabs("samode")
3323
 	if hasPrivs {
3324
 	if hasPrivs {
3324
 		for _, mask := range strings.Split(masksString, ",") {
3325
 		for _, mask := range strings.Split(masksString, ",") {
3325
 			matches := server.clients.FindAll(mask)
3326
 			matches := server.clients.FindAll(mask)

+ 21
- 19
irc/modes.go View File

37
 		if change.Mode != modes.ServerNotice {
37
 		if change.Mode != modes.ServerNotice {
38
 			switch change.Op {
38
 			switch change.Op {
39
 			case modes.Add:
39
 			case modes.Add:
40
-				if (change.Mode == modes.Operator || change.Mode == modes.LocalOperator) && !(force && oper != nil) {
40
+				if (change.Mode == modes.Operator) && !(force && oper != nil) {
41
 					continue
41
 					continue
42
 				}
42
 				}
43
 
43
 
44
 				if client.SetMode(change.Mode, true) {
44
 				if client.SetMode(change.Mode, true) {
45
 					if change.Mode == modes.Invisible {
45
 					if change.Mode == modes.Invisible {
46
 						client.server.stats.ChangeInvisible(1)
46
 						client.server.stats.ChangeInvisible(1)
47
-					} else if change.Mode == modes.Operator || change.Mode == modes.LocalOperator {
47
+					} else if change.Mode == modes.Operator {
48
 						client.server.stats.ChangeOperators(1)
48
 						client.server.stats.ChangeOperators(1)
49
 					}
49
 					}
50
 					applied = append(applied, change)
50
 					applied = append(applied, change)
55
 				if client.SetMode(change.Mode, false) {
55
 				if client.SetMode(change.Mode, false) {
56
 					if change.Mode == modes.Invisible {
56
 					if change.Mode == modes.Invisible {
57
 						client.server.stats.ChangeInvisible(-1)
57
 						client.server.stats.ChangeInvisible(-1)
58
-					} else if change.Mode == modes.Operator || change.Mode == modes.LocalOperator {
58
+					} else if change.Mode == modes.Operator {
59
 						removedSnomasks = client.server.snomasks.String(client)
59
 						removedSnomasks = client.server.snomasks.String(client)
60
 						client.server.stats.ChangeOperators(-1)
60
 						client.server.stats.ChangeOperators(-1)
61
 						applyOper(client, nil, nil)
61
 						applyOper(client, nil, nil)
75
 			}
75
 			}
76
 		} else {
76
 		} else {
77
 			// server notices are weird
77
 			// server notices are weird
78
-			if !client.HasMode(modes.Operator) {
78
+			if !client.HasMode(modes.Operator) || change.Op == modes.List {
79
 				continue
79
 				continue
80
 			}
80
 			}
81
-			var masks []sno.Mask
82
-			if change.Op == modes.Add || change.Op == modes.Remove {
83
-				var newArg string
84
-				for _, char := range change.Arg {
85
-					mask := sno.Mask(char)
86
-					if sno.ValidMasks[mask] {
87
-						masks = append(masks, mask)
88
-						newArg += string(char)
89
-					}
81
+
82
+			currentMasks := client.server.snomasks.MasksEnabled(client)
83
+			addMasks, removeMasks, newArg := sno.EvaluateSnomaskChanges(change.Op == modes.Add, change.Arg, currentMasks)
84
+
85
+			success := false
86
+			if len(addMasks) != 0 {
87
+				oper := client.Oper()
88
+				// #1176: require special operator privileges to subscribe to snomasks
89
+				if oper.HasRoleCapab("snomasks") || oper.HasRoleCapab("ban") {
90
+					success = true
91
+					client.server.snomasks.AddMasks(client, addMasks...)
90
 				}
92
 				}
91
-				change.Arg = newArg
92
 			}
93
 			}
93
-			if change.Op == modes.Add {
94
-				client.server.snomasks.AddMasks(client, masks...)
95
-				applied = append(applied, change)
96
-			} else if change.Op == modes.Remove {
97
-				client.server.snomasks.RemoveMasks(client, masks...)
94
+			if len(removeMasks) != 0 {
95
+				success = true
96
+				client.server.snomasks.RemoveMasks(client, removeMasks...)
97
+			}
98
+			if success {
99
+				change.Arg = newArg
98
 				applied = append(applied, change)
100
 				applied = append(applied, change)
99
 			}
101
 			}
100
 		}
102
 		}

+ 1
- 4
irc/modes/modes.go View File

101
 const (
101
 const (
102
 	Bot             Mode = 'B'
102
 	Bot             Mode = 'B'
103
 	Invisible       Mode = 'i'
103
 	Invisible       Mode = 'i'
104
-	LocalOperator   Mode = 'O'
105
 	Operator        Mode = 'o'
104
 	Operator        Mode = 'o'
106
 	Restricted      Mode = 'r'
105
 	Restricted      Mode = 'r'
107
 	RegisteredOnly  Mode = 'R'
106
 	RegisteredOnly  Mode = 'R'
213
 			// put arg into modechange if needed
212
 			// put arg into modechange if needed
214
 			switch Mode(mode) {
213
 			switch Mode(mode) {
215
 			case ServerNotice:
214
 			case ServerNotice:
216
-				// always require arg
215
+				// arg is optional for ServerNotice (we accept bare `-s`)
217
 				if len(params) > skipArgs {
216
 				if len(params) > skipArgs {
218
 					change.Arg = params[skipArgs]
217
 					change.Arg = params[skipArgs]
219
 					skipArgs++
218
 					skipArgs++
220
-				} else {
221
-					continue
222
 				}
219
 				}
223
 			}
220
 			}
224
 
221
 

+ 32
- 0
irc/modes/modes_test.go View File

15
 	}
15
 	}
16
 }
16
 }
17
 
17
 
18
+func TestParseUserModeChanges(t *testing.T) {
19
+	emptyUnknown := make(map[rune]bool)
20
+	changes, unknown := ParseUserModeChanges("+i")
21
+	assertEqual(unknown, emptyUnknown, t)
22
+	assertEqual(changes, ModeChanges{ModeChange{Op: Add, Mode: Invisible}}, t)
23
+
24
+	// no-op change to sno
25
+	changes, unknown = ParseUserModeChanges("+is")
26
+	assertEqual(unknown, emptyUnknown, t)
27
+	assertEqual(changes, ModeChanges{ModeChange{Op: Add, Mode: Invisible}, ModeChange{Op: Add, Mode: ServerNotice}}, t)
28
+
29
+	// add snomasks
30
+	changes, unknown = ParseUserModeChanges("+is", "ac")
31
+	assertEqual(unknown, emptyUnknown, t)
32
+	assertEqual(changes, ModeChanges{ModeChange{Op: Add, Mode: Invisible}, ModeChange{Op: Add, Mode: ServerNotice, Arg: "ac"}}, t)
33
+
34
+	// remove snomasks
35
+	changes, unknown = ParseUserModeChanges("+s", "-cx")
36
+	assertEqual(unknown, emptyUnknown, t)
37
+	assertEqual(changes, ModeChanges{ModeChange{Op: Add, Mode: ServerNotice, Arg: "-cx"}}, t)
38
+
39
+	// remove all snomasks (arg is parsed but has no meaning)
40
+	changes, unknown = ParseUserModeChanges("-is", "ac")
41
+	assertEqual(unknown, emptyUnknown, t)
42
+	assertEqual(changes, ModeChanges{ModeChange{Op: Remove, Mode: Invisible}, ModeChange{Op: Remove, Mode: ServerNotice, Arg: "ac"}}, t)
43
+
44
+	// remove all snomasks
45
+	changes, unknown = ParseUserModeChanges("-is")
46
+	assertEqual(unknown, emptyUnknown, t)
47
+	assertEqual(changes, ModeChanges{ModeChange{Op: Remove, Mode: Invisible}, ModeChange{Op: Remove, Mode: ServerNotice}}, t)
48
+}
49
+
18
 func TestIssue874(t *testing.T) {
50
 func TestIssue874(t *testing.T) {
19
 	emptyUnknown := make(map[rune]bool)
51
 	emptyUnknown := make(map[rune]bool)
20
 	modes, unknown := ParseChannelModeChanges("+k")
52
 	modes, unknown := ParseChannelModeChanges("+k")

+ 12
- 11
irc/server.go View File

459
 	rb.Add(nil, server.name, RPL_ENDOFMOTD, client.nick, client.t("End of MOTD command"))
459
 	rb.Add(nil, server.name, RPL_ENDOFMOTD, client.nick, client.t("End of MOTD command"))
460
 }
460
 }
461
 
461
 
462
-// WhoisChannelsNames returns the common channel names between two users.
463
-func (client *Client) WhoisChannelsNames(target *Client, multiPrefix bool) []string {
462
+func (client *Client) whoisChannelsNames(target *Client, multiPrefix bool, hasPrivs bool) []string {
464
 	var chstrs []string
463
 	var chstrs []string
464
+	targetInvis := target.HasMode(modes.Invisible)
465
 	for _, channel := range target.Channels() {
465
 	for _, channel := range target.Channels() {
466
-		// channel is secret and the target can't see it
467
-		if !client.HasMode(modes.Operator) {
468
-			if (target.HasMode(modes.Invisible) || channel.flags.HasMode(modes.Secret)) && !channel.hasClient(client) {
469
-				continue
470
-			}
466
+		if !hasPrivs && (targetInvis || channel.flags.HasMode(modes.Secret)) && !channel.hasClient(client) {
467
+			// client can't see *this* channel membership
468
+			continue
471
 		}
469
 		}
472
 		chstrs = append(chstrs, channel.ClientPrefixes(target, multiPrefix)+channel.name)
470
 		chstrs = append(chstrs, channel.ClientPrefixes(target, multiPrefix)+channel.name)
473
 	}
471
 	}
475
 }
473
 }
476
 
474
 
477
 func (client *Client) getWhoisOf(target *Client, hasPrivs bool, rb *ResponseBuffer) {
475
 func (client *Client) getWhoisOf(target *Client, hasPrivs bool, rb *ResponseBuffer) {
476
+	oper := client.Oper()
478
 	cnick := client.Nick()
477
 	cnick := client.Nick()
479
 	targetInfo := target.Details()
478
 	targetInfo := target.Details()
480
 	rb.Add(nil, client.server.name, RPL_WHOISUSER, cnick, targetInfo.nick, targetInfo.username, targetInfo.hostname, "*", targetInfo.realname)
479
 	rb.Add(nil, client.server.name, RPL_WHOISUSER, cnick, targetInfo.nick, targetInfo.username, targetInfo.hostname, "*", targetInfo.realname)
481
 	tnick := targetInfo.nick
480
 	tnick := targetInfo.nick
482
 
481
 
483
-	whoischannels := client.WhoisChannelsNames(target, rb.session.capabilities.Has(caps.MultiPrefix))
482
+	whoischannels := client.whoisChannelsNames(target, rb.session.capabilities.Has(caps.MultiPrefix), oper.HasRoleCapab("sajoin"))
484
 	if whoischannels != nil {
483
 	if whoischannels != nil {
485
 		rb.Add(nil, client.server.name, RPL_WHOISCHANNELS, cnick, tnick, strings.Join(whoischannels, " "))
484
 		rb.Add(nil, client.server.name, RPL_WHOISCHANNELS, cnick, tnick, strings.Join(whoischannels, " "))
486
 	}
485
 	}
487
-	if target.HasMode(modes.Operator) && operStatusVisible(client, target, hasPrivs) {
486
+	if target.HasMode(modes.Operator) && operStatusVisible(client, target, oper != nil) {
488
 		tOper := target.Oper()
487
 		tOper := target.Oper()
489
 		if tOper != nil {
488
 		if tOper != nil {
490
 			rb.Add(nil, client.server.name, RPL_WHOISOPERATOR, cnick, tnick, tOper.WhoisLine)
489
 			rb.Add(nil, client.server.name, RPL_WHOISOPERATOR, cnick, tnick, tOper.WhoisLine)
491
 		}
490
 		}
492
 	}
491
 	}
493
-	if client == target || hasPrivs {
492
+	if client == target || oper.HasRoleCapab("ban") {
494
 		rb.Add(nil, client.server.name, RPL_WHOISACTUALLY, cnick, tnick, fmt.Sprintf("%s@%s", targetInfo.username, target.RawHostname()), target.IPString(), client.t("Actual user@host, Actual IP"))
493
 		rb.Add(nil, client.server.name, RPL_WHOISACTUALLY, cnick, tnick, fmt.Sprintf("%s@%s", targetInfo.username, target.RawHostname()), target.IPString(), client.t("Actual user@host, Actual IP"))
494
+	}
495
+	if client == target || oper.HasRoleCapab("samode") {
495
 		rb.Add(nil, client.server.name, RPL_WHOISMODES, cnick, tnick, fmt.Sprintf(client.t("is using modes +%s"), target.modes.String()))
496
 		rb.Add(nil, client.server.name, RPL_WHOISMODES, cnick, tnick, fmt.Sprintf(client.t("is using modes +%s"), target.modes.String()))
496
 	}
497
 	}
497
 	if target.HasMode(modes.TLS) {
498
 	if target.HasMode(modes.TLS) {
504
 		rb.Add(nil, client.server.name, RPL_WHOISBOT, cnick, tnick, fmt.Sprintf(ircfmt.Unescape(client.t("is a $bBot$b on %s")), client.server.Config().Network.Name))
505
 		rb.Add(nil, client.server.name, RPL_WHOISBOT, cnick, tnick, fmt.Sprintf(ircfmt.Unescape(client.t("is a $bBot$b on %s")), client.server.Config().Network.Name))
505
 	}
506
 	}
506
 
507
 
507
-	if client == target || hasPrivs {
508
+	if client == target || oper.HasRoleCapab("ban") {
508
 		for _, session := range target.Sessions() {
509
 		for _, session := range target.Sessions() {
509
 			if session.certfp != "" {
510
 			if session.certfp != "" {
510
 				rb.Add(nil, client.server.name, RPL_WHOISCERTFP, cnick, tnick, fmt.Sprintf(client.t("has client certificate fingerprint %s"), session.certfp))
511
 				rb.Add(nil, client.server.name, RPL_WHOISCERTFP, cnick, tnick, fmt.Sprintf(client.t("has client certificate fingerprint %s"), session.certfp))

+ 15
- 13
irc/sno/constants.go View File

7
 // Mask is a type of server notice mask.
7
 // Mask is a type of server notice mask.
8
 type Mask rune
8
 type Mask rune
9
 
9
 
10
+type Masks []Mask
11
+
10
 // Notice mask types
12
 // Notice mask types
11
 const (
13
 const (
12
 	LocalAnnouncements Mask = 'a'
14
 	LocalAnnouncements Mask = 'a'
18
 	LocalQuits         Mask = 'q'
20
 	LocalQuits         Mask = 'q'
19
 	Stats              Mask = 't'
21
 	Stats              Mask = 't'
20
 	LocalAccounts      Mask = 'u'
22
 	LocalAccounts      Mask = 'u'
21
-	LocalXline         Mask = 'x'
22
 	LocalVhosts        Mask = 'v'
23
 	LocalVhosts        Mask = 'v'
24
+	LocalXline         Mask = 'x'
23
 )
25
 )
24
 
26
 
25
 var (
27
 var (
39
 	}
41
 	}
40
 
42
 
41
 	// ValidMasks contains the snomasks that we support.
43
 	// ValidMasks contains the snomasks that we support.
42
-	ValidMasks = map[Mask]bool{
43
-		LocalAnnouncements: true,
44
-		LocalConnects:      true,
45
-		LocalChannels:      true,
46
-		LocalKills:         true,
47
-		LocalNicks:         true,
48
-		LocalOpers:         true,
49
-		LocalQuits:         true,
50
-		Stats:              true,
51
-		LocalAccounts:      true,
52
-		LocalXline:         true,
53
-		LocalVhosts:        true,
44
+	ValidMasks = []Mask{
45
+		LocalAnnouncements,
46
+		LocalConnects,
47
+		LocalChannels,
48
+		LocalKills,
49
+		LocalNicks,
50
+		LocalOpers,
51
+		LocalQuits,
52
+		Stats,
53
+		LocalAccounts,
54
+		LocalVhosts,
55
+		LocalXline,
54
 	}
56
 	}
55
 )
57
 )

+ 87
- 0
irc/sno/utils.go View File

1
+// Copyright (c) 2020 Shivaram Lingamneni
2
+// released under the MIT license
3
+
4
+package sno
5
+
6
+import (
7
+	"strings"
8
+)
9
+
10
+func IsValidMask(r rune) bool {
11
+	for _, m := range ValidMasks {
12
+		if m == Mask(r) {
13
+			return true
14
+		}
15
+	}
16
+	return false
17
+}
18
+
19
+func (masks Masks) String() string {
20
+	var buf strings.Builder
21
+	buf.Grow(len(masks))
22
+	for _, m := range masks {
23
+		buf.WriteRune(rune(m))
24
+	}
25
+	return buf.String()
26
+}
27
+
28
+func (masks Masks) Contains(mask Mask) bool {
29
+	for _, m := range masks {
30
+		if mask == m {
31
+			return true
32
+		}
33
+	}
34
+	return false
35
+}
36
+
37
+// Evaluate changes to snomasks made with MODE. There are several cases:
38
+// adding snomasks with `/mode +s a` or `/mode +s +a`, removing them with `/mode +s -a`,
39
+// adding all with `/mode +s *` or `/mode +s +*`, removing all with `/mode +s -*` or `/mode -s`
40
+func EvaluateSnomaskChanges(add bool, arg string, currentMasks Masks) (addMasks, removeMasks Masks, newArg string) {
41
+	if add {
42
+		if len(arg) == 0 {
43
+			return
44
+		}
45
+		add := true
46
+		switch arg[0] {
47
+		case '+':
48
+			arg = arg[1:]
49
+		case '-':
50
+			add = false
51
+			arg = arg[1:]
52
+		default:
53
+			// add
54
+		}
55
+		if strings.IndexByte(arg, '*') != -1 {
56
+			if add {
57
+				for _, mask := range ValidMasks {
58
+					if !currentMasks.Contains(mask) {
59
+						addMasks = append(addMasks, mask)
60
+					}
61
+				}
62
+			} else {
63
+				removeMasks = currentMasks
64
+			}
65
+		} else {
66
+			for _, r := range arg {
67
+				if IsValidMask(r) {
68
+					m := Mask(r)
69
+					if add && !currentMasks.Contains(m) {
70
+						addMasks = append(addMasks, m)
71
+					} else if !add && currentMasks.Contains(m) {
72
+						removeMasks = append(removeMasks, m)
73
+					}
74
+				}
75
+			}
76
+		}
77
+		if len(addMasks) != 0 {
78
+			newArg = "+" + addMasks.String()
79
+		} else if len(removeMasks) != 0 {
80
+			newArg = "-" + removeMasks.String()
81
+		}
82
+	} else {
83
+		removeMasks = currentMasks
84
+		newArg = ""
85
+	}
86
+	return
87
+}

+ 53
- 0
irc/sno/utils_test.go View File

1
+// Copyright (c) 2020 Shivaram Lingamneni
2
+// released under the MIT license
3
+
4
+package sno
5
+
6
+import (
7
+	"fmt"
8
+	"reflect"
9
+	"testing"
10
+)
11
+
12
+func assertEqual(supplied, expected interface{}, t *testing.T) {
13
+	if !reflect.DeepEqual(supplied, expected) {
14
+		panic(fmt.Sprintf("expected %#v but got %#v", expected, supplied))
15
+	}
16
+}
17
+
18
+func TestEvaluateSnomaskChanges(t *testing.T) {
19
+	add, remove, newArg := EvaluateSnomaskChanges(true, "*", nil)
20
+	assertEqual(add, Masks{'a', 'c', 'j', 'k', 'n', 'o', 'q', 't', 'u', 'v', 'x'}, t)
21
+	assertEqual(len(remove), 0, t)
22
+	assertEqual(newArg, "+acjknoqtuvx", t)
23
+
24
+	add, remove, newArg = EvaluateSnomaskChanges(true, "*", Masks{'a', 'u'})
25
+	assertEqual(add, Masks{'c', 'j', 'k', 'n', 'o', 'q', 't', 'v', 'x'}, t)
26
+	assertEqual(len(remove), 0, t)
27
+	assertEqual(newArg, "+cjknoqtvx", t)
28
+
29
+	add, remove, newArg = EvaluateSnomaskChanges(true, "-a", Masks{'a', 'u'})
30
+	assertEqual(len(add), 0, t)
31
+	assertEqual(remove, Masks{'a'}, t)
32
+	assertEqual(newArg, "-a", t)
33
+
34
+	add, remove, newArg = EvaluateSnomaskChanges(true, "-*", Masks{'a', 'u'})
35
+	assertEqual(len(add), 0, t)
36
+	assertEqual(remove, Masks{'a', 'u'}, t)
37
+	assertEqual(newArg, "-au", t)
38
+
39
+	add, remove, newArg = EvaluateSnomaskChanges(true, "+c", Masks{'a', 'u'})
40
+	assertEqual(add, Masks{'c'}, t)
41
+	assertEqual(len(remove), 0, t)
42
+	assertEqual(newArg, "+c", t)
43
+
44
+	add, remove, newArg = EvaluateSnomaskChanges(false, "", Masks{'a', 'u'})
45
+	assertEqual(len(add), 0, t)
46
+	assertEqual(remove, Masks{'a', 'u'}, t)
47
+	assertEqual(newArg, "", t)
48
+
49
+	add, remove, newArg = EvaluateSnomaskChanges(false, "*", Masks{'a', 'u'})
50
+	assertEqual(len(add), 0, t)
51
+	assertEqual(remove, Masks{'a', 'u'}, t)
52
+	assertEqual(newArg, "", t)
53
+}

+ 9
- 10
irc/snomanager.go View File

24
 	defer m.sendListMutex.Unlock()
24
 	defer m.sendListMutex.Unlock()
25
 
25
 
26
 	for _, mask := range masks {
26
 	for _, mask := range masks {
27
-		// confirm mask is valid
28
-		if !sno.ValidMasks[mask] {
29
-			continue
30
-		}
31
-
32
 		currentClientList := m.sendLists[mask]
27
 		currentClientList := m.sendLists[mask]
33
 
28
 
34
 		if currentClientList == nil {
29
 		if currentClientList == nil {
101
 	}
96
 	}
102
 }
97
 }
103
 
98
 
104
-// String returns the snomasks currently enabled.
105
-func (m *SnoManager) String(client *Client) string {
99
+// MasksEnabled returns the snomasks currently enabled.
100
+func (m *SnoManager) MasksEnabled(client *Client) (result sno.Masks) {
106
 	m.sendListMutex.RLock()
101
 	m.sendListMutex.RLock()
107
 	defer m.sendListMutex.RUnlock()
102
 	defer m.sendListMutex.RUnlock()
108
 
103
 
109
-	var masks string
110
 	for mask, clients := range m.sendLists {
104
 	for mask, clients := range m.sendLists {
111
 		for c := range clients {
105
 		for c := range clients {
112
 			if c == client {
106
 			if c == client {
113
-				masks += string(mask)
107
+				result = append(result, mask)
114
 				break
108
 				break
115
 			}
109
 			}
116
 		}
110
 		}
117
 	}
111
 	}
118
-	return masks
112
+	return
113
+}
114
+
115
+func (m *SnoManager) String(client *Client) string {
116
+	masks := m.MasksEnabled(client)
117
+	return masks.String()
119
 }
118
 }

+ 1
- 0
traditional.yaml View File

565
             - "vhosts"
565
             - "vhosts"
566
             - "sajoin"
566
             - "sajoin"
567
             - "samode"
567
             - "samode"
568
+            - "snomasks"
568
 
569
 
569
     # server admin: has full control of the ircd, including nickname and
570
     # server admin: has full control of the ircd, including nickname and
570
     # channel registrations
571
     # channel registrations

Loading…
Cancel
Save