Add support for post-connection-registration upstream SASL auth
Once the downstream connection has logged in with their bouncer credentials, allow them to issue more SASL auths which will be redirected to the upstream network. This allows downstream clients to provide UIs to login to transparently login to upstream networks.
This commit is contained in:
parent
4e84b41592
commit
313c6e7f97
275
downstream.go
275
downstream.go
@ -244,6 +244,11 @@ var passthroughIsupport = map[string]bool{
|
|||||||
"WHOX": true,
|
"WHOX": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type downstreamSASL struct {
|
||||||
|
server sasl.Server
|
||||||
|
plainUsername, plainPassword string
|
||||||
|
}
|
||||||
|
|
||||||
type downstreamConn struct {
|
type downstreamConn struct {
|
||||||
conn
|
conn
|
||||||
|
|
||||||
@ -267,12 +272,11 @@ type downstreamConn struct {
|
|||||||
capVersion int
|
capVersion int
|
||||||
supportedCaps map[string]string
|
supportedCaps map[string]string
|
||||||
caps map[string]bool
|
caps map[string]bool
|
||||||
|
sasl *downstreamSASL
|
||||||
|
|
||||||
lastBatchRef uint64
|
lastBatchRef uint64
|
||||||
|
|
||||||
monitored casemapMap
|
monitored casemapMap
|
||||||
|
|
||||||
saslServer sasl.Server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDownstreamConn(srv *Server, ic ircConn, id uint64) *downstreamConn {
|
func newDownstreamConn(srv *Server, ic ircConn, id uint64) *downstreamConn {
|
||||||
@ -686,102 +690,28 @@ func (dc *downstreamConn) handleMessageUnregistered(ctx context.Context, msg *ir
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case "AUTHENTICATE":
|
case "AUTHENTICATE":
|
||||||
if !dc.caps["sasl"] {
|
credentials, err := dc.handleAuthenticateCommand(msg)
|
||||||
return ircError{&irc.Message{
|
|
||||||
Prefix: dc.srv.prefix(),
|
|
||||||
Command: irc.ERR_SASLFAIL,
|
|
||||||
Params: []string{"*", "AUTHENTICATE requires the \"sasl\" capability to be enabled"},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
if len(msg.Params) == 0 {
|
|
||||||
return ircError{&irc.Message{
|
|
||||||
Prefix: dc.srv.prefix(),
|
|
||||||
Command: irc.ERR_SASLFAIL,
|
|
||||||
Params: []string{"*", "Missing AUTHENTICATE argument"},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp []byte
|
|
||||||
if msg.Params[0] == "*" {
|
|
||||||
dc.saslServer = nil
|
|
||||||
return ircError{&irc.Message{
|
|
||||||
Prefix: dc.srv.prefix(),
|
|
||||||
Command: irc.ERR_SASLABORTED,
|
|
||||||
Params: []string{"*", "SASL authentication aborted"},
|
|
||||||
}}
|
|
||||||
} else if dc.saslServer == nil {
|
|
||||||
mech := strings.ToUpper(msg.Params[0])
|
|
||||||
switch mech {
|
|
||||||
case "PLAIN":
|
|
||||||
dc.saslServer = sasl.NewPlainServer(sasl.PlainAuthenticator(func(identity, username, password string) error {
|
|
||||||
// TODO: we can't use the command context here, because it
|
|
||||||
// gets cancelled once the command handler returns. SASL
|
|
||||||
// might take multiple AUTHENTICATE commands to complete.
|
|
||||||
return dc.authenticate(context.TODO(), username, password)
|
|
||||||
}))
|
|
||||||
default:
|
|
||||||
return ircError{&irc.Message{
|
|
||||||
Prefix: dc.srv.prefix(),
|
|
||||||
Command: irc.ERR_SASLFAIL,
|
|
||||||
Params: []string{"*", fmt.Sprintf("Unsupported SASL mechanism %q", mech)},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
} else if msg.Params[0] == "+" {
|
|
||||||
resp = nil
|
|
||||||
} else {
|
|
||||||
// TODO: multi-line messages
|
|
||||||
var err error
|
|
||||||
resp, err = base64.StdEncoding.DecodeString(msg.Params[0])
|
|
||||||
if err != nil {
|
|
||||||
dc.saslServer = nil
|
|
||||||
return ircError{&irc.Message{
|
|
||||||
Prefix: dc.srv.prefix(),
|
|
||||||
Command: irc.ERR_SASLFAIL,
|
|
||||||
Params: []string{"*", "Invalid base64-encoded response"},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
challenge, done, err := dc.saslServer.Next(resp)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dc.saslServer = nil
|
return err
|
||||||
if ircErr, ok := err.(ircError); ok && ircErr.Message.Command == irc.ERR_PASSWDMISMATCH {
|
} else if credentials == nil {
|
||||||
return ircError{&irc.Message{
|
break
|
||||||
Prefix: dc.srv.prefix(),
|
}
|
||||||
Command: irc.ERR_SASLFAIL,
|
|
||||||
Params: []string{"*", ircErr.Message.Params[1]},
|
if err := dc.authenticate(ctx, credentials.plainUsername, credentials.plainPassword); err != nil {
|
||||||
}}
|
dc.logger.Printf("SASL authentication error: %v", err)
|
||||||
}
|
dc.endSASL(&irc.Message{
|
||||||
dc.SendMessage(&irc.Message{
|
|
||||||
Prefix: dc.srv.prefix(),
|
Prefix: dc.srv.prefix(),
|
||||||
Command: irc.ERR_SASLFAIL,
|
Command: irc.ERR_SASLFAIL,
|
||||||
Params: []string{"*", "SASL error"},
|
Params: []string{"Authentication failed"},
|
||||||
})
|
|
||||||
return fmt.Errorf("SASL authentication failed: %v", err)
|
|
||||||
} else if done {
|
|
||||||
dc.saslServer = nil
|
|
||||||
// Technically we should send RPL_LOGGEDIN here. However we use
|
|
||||||
// RPL_LOGGEDIN to mirror the upstream connection status. Let's see
|
|
||||||
// how many clients that breaks. See:
|
|
||||||
// https://github.com/ircv3/ircv3-specifications/pull/476
|
|
||||||
dc.SendMessage(&irc.Message{
|
|
||||||
Prefix: dc.srv.prefix(),
|
|
||||||
Command: irc.RPL_SASLSUCCESS,
|
|
||||||
Params: []string{dc.nick, "SASL authentication successful"},
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
challengeStr := "+"
|
|
||||||
if len(challenge) > 0 {
|
|
||||||
challengeStr = base64.StdEncoding.EncodeToString(challenge)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: multi-line messages
|
|
||||||
dc.SendMessage(&irc.Message{
|
|
||||||
Prefix: dc.srv.prefix(),
|
|
||||||
Command: "AUTHENTICATE",
|
|
||||||
Params: []string{challengeStr},
|
|
||||||
})
|
})
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Technically we should send RPL_LOGGEDIN here. However we use
|
||||||
|
// RPL_LOGGEDIN to mirror the upstream connection status. Let's
|
||||||
|
// see how many clients that breaks. See:
|
||||||
|
// https://github.com/ircv3/ircv3-specifications/pull/476
|
||||||
|
dc.endSASL(nil)
|
||||||
case "BOUNCER":
|
case "BOUNCER":
|
||||||
var subcommand string
|
var subcommand string
|
||||||
if err := parseMessageParams(msg, &subcommand); err != nil {
|
if err := parseMessageParams(msg, &subcommand); err != nil {
|
||||||
@ -951,6 +881,107 @@ func (dc *downstreamConn) handleCapCommand(cmd string, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (dc *downstreamConn) handleAuthenticateCommand(msg *irc.Message) (result *downstreamSASL, err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
dc.sasl = nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if !dc.caps["sasl"] {
|
||||||
|
return nil, ircError{&irc.Message{
|
||||||
|
Prefix: dc.srv.prefix(),
|
||||||
|
Command: irc.ERR_SASLFAIL,
|
||||||
|
Params: []string{"*", "AUTHENTICATE requires the \"sasl\" capability to be enabled"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if len(msg.Params) == 0 {
|
||||||
|
return nil, ircError{&irc.Message{
|
||||||
|
Prefix: dc.srv.prefix(),
|
||||||
|
Command: irc.ERR_SASLFAIL,
|
||||||
|
Params: []string{"*", "Missing AUTHENTICATE argument"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if msg.Params[0] == "*" {
|
||||||
|
return nil, ircError{&irc.Message{
|
||||||
|
Prefix: dc.srv.prefix(),
|
||||||
|
Command: irc.ERR_SASLABORTED,
|
||||||
|
Params: []string{"*", "SASL authentication aborted"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp []byte
|
||||||
|
if dc.sasl == nil {
|
||||||
|
mech := strings.ToUpper(msg.Params[0])
|
||||||
|
var server sasl.Server
|
||||||
|
switch mech {
|
||||||
|
case "PLAIN":
|
||||||
|
server = sasl.NewPlainServer(sasl.PlainAuthenticator(func(identity, username, password string) error {
|
||||||
|
dc.sasl.plainUsername = username
|
||||||
|
dc.sasl.plainPassword = password
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
default:
|
||||||
|
return nil, ircError{&irc.Message{
|
||||||
|
Prefix: dc.srv.prefix(),
|
||||||
|
Command: irc.ERR_SASLFAIL,
|
||||||
|
Params: []string{"*", fmt.Sprintf("Unsupported SASL mechanism %q", mech)},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
dc.sasl = &downstreamSASL{server: server}
|
||||||
|
} else {
|
||||||
|
// TODO: multi-line messages
|
||||||
|
if msg.Params[0] == "+" {
|
||||||
|
resp = nil
|
||||||
|
} else if resp, err = base64.StdEncoding.DecodeString(msg.Params[0]); err != nil {
|
||||||
|
return nil, ircError{&irc.Message{
|
||||||
|
Prefix: dc.srv.prefix(),
|
||||||
|
Command: irc.ERR_SASLFAIL,
|
||||||
|
Params: []string{"*", "Invalid base64-encoded response"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
challenge, done, err := dc.sasl.server.Next(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if done {
|
||||||
|
return dc.sasl, nil
|
||||||
|
} else {
|
||||||
|
challengeStr := "+"
|
||||||
|
if len(challenge) > 0 {
|
||||||
|
challengeStr = base64.StdEncoding.EncodeToString(challenge)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: multi-line messages
|
||||||
|
dc.SendMessage(&irc.Message{
|
||||||
|
Prefix: dc.srv.prefix(),
|
||||||
|
Command: "AUTHENTICATE",
|
||||||
|
Params: []string{challengeStr},
|
||||||
|
})
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dc *downstreamConn) endSASL(msg *irc.Message) {
|
||||||
|
if dc.sasl == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dc.sasl = nil
|
||||||
|
|
||||||
|
if msg != nil {
|
||||||
|
dc.SendMessage(msg)
|
||||||
|
} else {
|
||||||
|
dc.SendMessage(&irc.Message{
|
||||||
|
Prefix: dc.srv.prefix(),
|
||||||
|
Command: irc.RPL_SASLSUCCESS,
|
||||||
|
Params: []string{dc.nick, "SASL authentication successful"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (dc *downstreamConn) setSupportedCap(name, value string) {
|
func (dc *downstreamConn) setSupportedCap(name, value string) {
|
||||||
prevValue, hasPrev := dc.supportedCaps[name]
|
prevValue, hasPrev := dc.supportedCaps[name]
|
||||||
changed := !hasPrev || prevValue != value
|
changed := !hasPrev || prevValue != value
|
||||||
@ -1141,9 +1172,8 @@ func (dc *downstreamConn) register(ctx context.Context) error {
|
|||||||
return fmt.Errorf("tried to register twice")
|
return fmt.Errorf("tried to register twice")
|
||||||
}
|
}
|
||||||
|
|
||||||
if dc.saslServer != nil {
|
if dc.sasl != nil {
|
||||||
dc.saslServer = nil
|
dc.endSASL(&irc.Message{
|
||||||
dc.SendMessage(&irc.Message{
|
|
||||||
Prefix: dc.srv.prefix(),
|
Prefix: dc.srv.prefix(),
|
||||||
Command: irc.ERR_SASLABORTED,
|
Command: irc.ERR_SASLABORTED,
|
||||||
Params: []string{"*", "SASL authentication aborted"},
|
Params: []string{"*", "SASL authentication aborted"},
|
||||||
@ -2330,6 +2360,40 @@ func (dc *downstreamConn) handleMessageRegistered(ctx context.Context, msg *irc.
|
|||||||
Command: "INVITE",
|
Command: "INVITE",
|
||||||
Params: []string{upstreamUser, upstreamChannel},
|
Params: []string{upstreamUser, upstreamChannel},
|
||||||
})
|
})
|
||||||
|
case "AUTHENTICATE":
|
||||||
|
// Post-connection-registration AUTHENTICATE is unsupported in
|
||||||
|
// multi-upstream mode, or if the upstream doesn't support SASL
|
||||||
|
uc := dc.upstream()
|
||||||
|
if uc == nil || !uc.caps["sasl"] {
|
||||||
|
return ircError{&irc.Message{
|
||||||
|
Prefix: dc.srv.prefix(),
|
||||||
|
Command: irc.ERR_SASLFAIL,
|
||||||
|
Params: []string{dc.nick, "Upstream network authentication not supported"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials, err := dc.handleAuthenticateCommand(msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if credentials != nil {
|
||||||
|
if uc.saslClient != nil {
|
||||||
|
dc.endSASL(&irc.Message{
|
||||||
|
Prefix: dc.srv.prefix(),
|
||||||
|
Command: irc.ERR_SASLFAIL,
|
||||||
|
Params: []string{dc.nick, "Another authentication attempt is already in progress"},
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
uc.logger.Printf("starting post-registration SASL PLAIN authentication with username %q", credentials.plainUsername)
|
||||||
|
uc.saslClient = sasl.NewPlainClient("", credentials.plainUsername, credentials.plainPassword)
|
||||||
|
uc.enqueueCommand(dc, &irc.Message{
|
||||||
|
Command: "AUTHENTICATE",
|
||||||
|
Params: []string{"PLAIN"},
|
||||||
|
})
|
||||||
|
}
|
||||||
case "MONITOR":
|
case "MONITOR":
|
||||||
// MONITOR is unsupported in multi-upstream mode
|
// MONITOR is unsupported in multi-upstream mode
|
||||||
uc := dc.upstream()
|
uc := dc.upstream()
|
||||||
@ -2700,23 +2764,8 @@ func (dc *downstreamConn) handleMessageRegistered(ctx context.Context, msg *irc.
|
|||||||
|
|
||||||
func (dc *downstreamConn) handleNickServPRIVMSG(ctx context.Context, uc *upstreamConn, text string) {
|
func (dc *downstreamConn) handleNickServPRIVMSG(ctx context.Context, uc *upstreamConn, text string) {
|
||||||
username, password, ok := parseNickServCredentials(text, uc.nick)
|
username, password, ok := parseNickServCredentials(text, uc.nick)
|
||||||
if !ok {
|
if ok {
|
||||||
return
|
uc.network.autoSaveSASLPlain(ctx, username, password)
|
||||||
}
|
|
||||||
|
|
||||||
// User may have e.g. EXTERNAL mechanism configured. We do not want to
|
|
||||||
// automatically erase the key pair or any other credentials.
|
|
||||||
if uc.network.SASL.Mechanism != "" && uc.network.SASL.Mechanism != "PLAIN" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dc.logger.Printf("auto-saving NickServ credentials with username %q", username)
|
|
||||||
n := uc.network
|
|
||||||
n.SASL.Mechanism = "PLAIN"
|
|
||||||
n.SASL.Plain.Username = username
|
|
||||||
n.SASL.Plain.Password = password
|
|
||||||
if err := dc.srv.db.StoreNetwork(ctx, dc.user.ID, &n.Network); err != nil {
|
|
||||||
dc.logger.Printf("failed to save NickServ credentials: %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
34
upstream.go
34
upstream.go
@ -31,6 +31,7 @@ var permanentUpstreamCaps = map[string]bool{
|
|||||||
"labeled-response": true,
|
"labeled-response": true,
|
||||||
"message-tags": true,
|
"message-tags": true,
|
||||||
"multi-prefix": true,
|
"multi-prefix": true,
|
||||||
|
"sasl": true,
|
||||||
"server-time": true,
|
"server-time": true,
|
||||||
"setname": true,
|
"setname": true,
|
||||||
|
|
||||||
@ -293,6 +294,12 @@ func (uc *upstreamConn) endPendingCommands() {
|
|||||||
Command: irc.RPL_ENDOFWHO,
|
Command: irc.RPL_ENDOFWHO,
|
||||||
Params: []string{dc.nick, mask, "End of /WHO"},
|
Params: []string{dc.nick, mask, "End of /WHO"},
|
||||||
})
|
})
|
||||||
|
case "AUTHENTICATE":
|
||||||
|
dc.endSASL(&irc.Message{
|
||||||
|
Prefix: dc.srv.prefix(),
|
||||||
|
Command: irc.ERR_SASLABORTED,
|
||||||
|
Params: []string{dc.nick, "SASL authentication aborted"},
|
||||||
|
})
|
||||||
default:
|
default:
|
||||||
panic(fmt.Errorf("Unsupported pending command %q", pendingCmd.msg.Command))
|
panic(fmt.Errorf("Unsupported pending command %q", pendingCmd.msg.Command))
|
||||||
}
|
}
|
||||||
@ -311,7 +318,7 @@ func (uc *upstreamConn) sendNextPendingCommand(cmd string) {
|
|||||||
|
|
||||||
func (uc *upstreamConn) enqueueCommand(dc *downstreamConn, msg *irc.Message) {
|
func (uc *upstreamConn) enqueueCommand(dc *downstreamConn, msg *irc.Message) {
|
||||||
switch msg.Command {
|
switch msg.Command {
|
||||||
case "LIST", "WHO":
|
case "LIST", "WHO", "AUTHENTICATE":
|
||||||
// Supported
|
// Supported
|
||||||
default:
|
default:
|
||||||
panic(fmt.Errorf("Unsupported pending command %q", msg.Command))
|
panic(fmt.Errorf("Unsupported pending command %q", msg.Command))
|
||||||
@ -612,10 +619,20 @@ func (uc *upstreamConn) handleMessage(msg *irc.Message) error {
|
|||||||
uc.saslClient = nil
|
uc.saslClient = nil
|
||||||
uc.saslStarted = false
|
uc.saslStarted = false
|
||||||
|
|
||||||
uc.SendMessage(&irc.Message{
|
if dc, _ := uc.dequeueCommand("AUTHENTICATE"); dc != nil && dc.sasl != nil {
|
||||||
Command: "CAP",
|
if msg.Command == irc.RPL_SASLSUCCESS {
|
||||||
Params: []string{"END"},
|
uc.network.autoSaveSASLPlain(context.TODO(), dc.sasl.plainUsername, dc.sasl.plainPassword)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
dc.endSASL(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !uc.registered {
|
||||||
|
uc.SendMessage(&irc.Message{
|
||||||
|
Command: "CAP",
|
||||||
|
Params: []string{"END"},
|
||||||
|
})
|
||||||
|
}
|
||||||
case irc.RPL_WELCOME:
|
case irc.RPL_WELCOME:
|
||||||
uc.registered = true
|
uc.registered = true
|
||||||
uc.logger.Printf("connection registered")
|
uc.logger.Printf("connection registered")
|
||||||
@ -1704,10 +1721,6 @@ func (uc *upstreamConn) requestCaps() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if uc.requestSASL() && !uc.caps["sasl"] {
|
|
||||||
requestCaps = append(requestCaps, "sasl")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(requestCaps) == 0 {
|
if len(requestCaps) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -1749,6 +1762,9 @@ func (uc *upstreamConn) handleCapAck(name string, ok bool) error {
|
|||||||
|
|
||||||
switch name {
|
switch name {
|
||||||
case "sasl":
|
case "sasl":
|
||||||
|
if !uc.requestSASL() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
uc.logger.Printf("server refused to acknowledge the SASL capability")
|
uc.logger.Printf("server refused to acknowledge the SASL capability")
|
||||||
return nil
|
return nil
|
||||||
|
16
user.go
16
user.go
@ -404,6 +404,22 @@ func (net *network) detachedMessageNeedsRelay(ch *Channel, msg *irc.Message) boo
|
|||||||
return ch.RelayDetached == FilterMessage || ((ch.RelayDetached == FilterHighlight || ch.RelayDetached == FilterDefault) && highlight)
|
return ch.RelayDetached == FilterMessage || ((ch.RelayDetached == FilterHighlight || ch.RelayDetached == FilterDefault) && highlight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (net *network) autoSaveSASLPlain(ctx context.Context, username, password string) {
|
||||||
|
// User may have e.g. EXTERNAL mechanism configured. We do not want to
|
||||||
|
// automatically erase the key pair or any other credentials.
|
||||||
|
if net.SASL.Mechanism != "" && net.SASL.Mechanism != "PLAIN" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
net.logger.Printf("auto-saving SASL PLAIN credentials with username %q", username)
|
||||||
|
net.SASL.Mechanism = "PLAIN"
|
||||||
|
net.SASL.Plain.Username = username
|
||||||
|
net.SASL.Plain.Password = password
|
||||||
|
if err := net.user.srv.db.StoreNetwork(ctx, net.user.ID, &net.Network); err != nil {
|
||||||
|
net.logger.Printf("failed to save SASL PLAIN credentials: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type user struct {
|
type user struct {
|
||||||
User
|
User
|
||||||
srv *Server
|
srv *Server
|
||||||
|
Loading…
Reference in New Issue
Block a user