Implement the soju.im/bouncer-networks extension

This commit is contained in:
Simon Ser 2021-01-22 20:55:53 +01:00
parent 61b68d6dfb
commit db0f745193
3 changed files with 564 additions and 10 deletions

264
doc/ext/bouncer-networks.md Normal file
View File

@ -0,0 +1,264 @@
---
title: Bouncer networks extension
layout: spec
work-in-progress: true
copyrights:
-
name: "Darren Whitlen"
period: "2020"
email: "darren@kiwiirc.com"
-
name: "Simon Ser"
period: "2021"
email: "contact@emersion.fr"
---
## Notes for implementing experimental vendor extension
This is an experimental specification for a vendored extension.
No guarantees are made regarding the stability of this extension.
Backwards-incompatible changes can be made at any time without prior notice.
Software implementing this work-in-progress specification MUST NOT use the
unprefixed `bouncer-networks` CAP names. Instead, implementations SHOULD use
the `soju.im/bouncer-networks` CAP names to be interoperable with other software
implementing a compatible work-in-progress version.
## Description
This document describes the `bouncer-networks` extension. This enables clients
to discover servers that are bouncers, list and edit upstream networks the
bouncer is connected to.
Each network has a unique per-user ID called "netid". It MUST NOT change during
the lifetime of the network. TODO: character restrictions for network IDs.
Networks also have attributes. Attributes are encoded in the message-tag
format. Clients MUST ignore unknown attributes.
## Implementation
The `bouncer-networks` extension defines a new `RPL_ISUPPORT` token and a new
`BOUNCER` command.
### `RPL_ISUPPORT` token
The server can advertise a `BOUNCER_NETID` token in its `RPL_ISUPPORT` message.
Its optional value is the network ID bound for the current connection.
### `bouncer-networks` batch
The `bouncer-networks` batch does not take any parameter and can only contain
`BOUNCER NETWORK` messages.
### `BOUNCER` command
A new `BOUNCER` command is introduced. It has a case-insensitive subcommand:
BOUNCER <subcommand> <params...>
#### `BIND` subcommand
The `BIND` subcommand selects an upstream network to bind to for the lifetime
of the current connection. Clients can only send it after authentication but
before the registration completes.
BOUNCER BIND <netid>
#### `LISTNETWORKS` subcommand
The `LISTNETWORKS` subcommand queries the list of upstream networks.
BOUNCER LISTNETWORKS
The server replies with a `bouncer-networks` batch, containing any number of
`BOUNCER NETWORK` messages:
BOUNCER NETWORK <netid> <attributes>
#### `ADDNETWORK` subcommand
The `ADDNETWORK` subcommand registers a new upstream network in the bouncer.
BOUNCER ADDNETWORK <attributes>
The bouncer MAY reject this new network for any reason, in this case it MUST
reply with an error. If the request is accepted, the bouncer MUST generate a
new unique network ID. The bouncer MAY populate unspecified attributes with
implementation-defined defaults.
Clients MUST specify at least the `host` attribute.
If the client doesn't specify the `tls` attribute, the server SHOULD use the
default `1`. If the client doesn't specify the `port` attribute, the server
SHOULD use the default `6697` if `tls=1` or `6667` if `tls=0`.
On success, the server replies with:
BOUNCER ADDNETWORK <netid>
#### `CHANGENETWORK` subcommand
The `CHANGENETWORK` subcommand changes attributes of an existing upstream
network.
BOUNCER CHANGENETWORK <netid> <attributes>
The bouncer MAY reject the change for any reason, in this case it MUST reply
with an error. At least one attribute MUST be specified by the client.
On success, the server replies with:
BOUNCER CHANGENETWORK <netid>
#### `DELNETWORK` subcommand
The `DELNETWORK` subcommand removes an existing upstream network.
BOUNCER DELNETWORK <netid>
The bouncer MAY reject the change for any reason, in this case it MUST reply
with an error.
On success, the server replies with:
BOUNCER DELNETWORK <netid>
### Network notifications
When a network attributes are updated, the bouncer MUST broadcast a
`BOUNCER NETWORK` message to all connected clients with the updated attributes:
BOUNCER NETWORK <netid> <attributes>
When a network is removed, the bouncer MUST broadcast a `BOUNCER NETWORK`
message to all connected clients:
BOUNCER NETWORK <netid> *
### Errors
Errors are returned using the standard replies syntax. The general syntax is:
FAIL BOUNCER <code> <subcommand> [context...] <description>
If a client sends an unknown subcommand, the server MUST reply with:
FAIL BOUNCER UNKNOWN_COMMAND <subcommand> :Unknown subcommand
#### `ACCOUNT_REQUIRED` error
If a client sends a `BIND` subcommand before authentication, the server MAY
reply with:
FAIL BOUNCER ACCOUNT_REQUIRED BIND :Authentication required
#### `REGISTRATION_IS_COMPLETED` error
If a client sends a `BIND` subcommand after registration, the server MAY reply
with:
FAIL BOUNCER REGISTRATION_IS_COMPLETED BIND :Cannot bind to a network after registration
#### `INVALID_NETID` error
If a client sends a subcommand with an invalid network ID, the server MUST
reply with:
FAIL BOUNCER INVALID_NETID <subcommand> <netid> :Network not found
#### `INVALID_ATTRIBUTE` error
If a client sends an `ADDNETWORK` or a `CHANGENETWORK` subcommand with an
invalid attribute, the server MUST reply with:
FAIL BOUNCER INVALID_ATTRIBUTE <subcommand> <netid> <attribute> :Invalid attribute value
If the `subcommand` is `ADDNETWORK`, `netid` MUST be set to the special `*`
value.
#### `READ_ONLY_ATTRIBUTE` error
If a client attempts to change a read-only network attribute using the
`ADDNETWORK` or `CHANGENETWORK` subcommand, the server MUST reply with:
FAIL BOUNCER READ_ONLY_ATTRIBUTE <subcommand> <netid> <attribute> :Read-only attribute
If the `subcommand` is `ADDNETWORK`, `netid` MUST be set to the special `*`
value.
#### `UNKNOWN_ATTRIBUTE` error
If a client sends an `ADDNETWORK` or a `CHANGENETWORK` subcommand with an
unknown attribute, the server MUST reply with:
FAIL BOUNCER UNKNOWN_ATTRIBUTE <subcommand> <netid> <attribute> :Unknown attribute
If the `subcommand` is `ADDNETWORK`, `netid` MUST be set to the special `*`
value.
#### `NEED_ATTRIBUTE` error
If a client sends an `ADDNETWORK` subcommand without a mandatory attribute, the
server MUST reply with:
FAIL BOUNCER NEED_ATTRIBUTE ADDNETWORK <attribute> :Missing required attribute
TODO: more errors
### Standard network attributes
Bouncers MUST recognise the following network attributes:
* `name`: the human-readable name for the network.
* `state` (read-only): one of `connected`, `connecting` or `disconnected`.
Indicates the current state of the connection to the upstream network.
* `host`: the hostname or literal IP address to connect to.
* `port`: the TCP port to connect to.
* `tls`: `1` to use a TLS connection, `0` to use a cleartext connection.
* `nickname`: the nickname to use during registration.
* `username`: the username to use during registration.
* `realname`: the realname to use during registration.
TODO: more attributes
### Examples
Binding to a network:
C: CAP LS 302
C: NICK emersion
C: USER emersion 0 0 :Simon
S: CAP * LS :sasl=PLAIN bouncer-networks
C: CAP REQ :sasl bouncer-networks
[SASL authentication]
C: BOUNCER BIND 42
C: CAP END
Listing networks:
C: BOUNCER LISTNETWORKS
S: BATCH +asdf bouncer-networks
S: @batch=asdf BOUNCER NETWORK 42 name=Freenode;state=connected
S: @batch=asdf BOUNCER NETWORK 43 name=My\sAwesome\sNetwork;state=disconnected
S: BATCH -asdf
Adding a new network:
C: BOUNCER ADDNETWORK name=OFTC;host=irc.oftc.net
S: BOUNCER NETWORK 44 status=connecting
S: BOUNCER ADDNETWORK 44
S: BOUNCER NETWORK 44 status=connected
Changing an existing network:
C: BOUNCER CHANGENETWORK 44 realname=Simon
S: BOUNCER NETWORK 44 realname=Simon
S: BOUNCER CHANGENETWORK 44
Removing an existing network:
C: BOUNCER DELNETWORK 44
S: BOUNCER NETWORK 44 *
S: BOUNCER DELNETWORK 44

View File

@ -57,6 +57,17 @@ var errAuthFailed = ircError{&irc.Message{
Params: []string{"*", "Invalid username or password"},
}}
func parseBouncerNetID(s string) (int64, error) {
id, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, ircError{&irc.Message{
Command: "FAIL",
Params: []string{"BOUNCER", "INVALID_NETID", s, "Invalid network ID"},
}}
}
return id, nil
}
// ' ' and ':' break the IRC message wire format, '@' and '!' break prefixes,
// '*' and '?' break masks
const illegalNickChars = " :@!*?"
@ -64,13 +75,14 @@ const illegalNickChars = " :@!*?"
// permanentDownstreamCaps is the list of always-supported downstream
// capabilities.
var permanentDownstreamCaps = map[string]string{
"batch": "",
"cap-notify": "",
"echo-message": "",
"invite-notify": "",
"message-tags": "",
"sasl": "PLAIN",
"server-time": "",
"batch": "",
"soju.im/bouncer-networks": "",
"cap-notify": "",
"echo-message": "",
"invite-notify": "",
"message-tags": "",
"sasl": "PLAIN",
"server-time": "",
}
// needAllDownstreamCaps is the list of downstream capabilities that
@ -168,12 +180,15 @@ func (dc *downstreamConn) prefix() *irc.Prefix {
func (dc *downstreamConn) forEachNetwork(f func(*network)) {
if dc.network != nil {
f(dc.network)
} else {
} else if !dc.caps["soju.im/bouncer-networks"] {
dc.user.forEachNetwork(f)
}
}
func (dc *downstreamConn) forEachUpstream(f func(*upstreamConn)) {
if dc.network == nil && dc.caps["soju.im/bouncer-networks"] {
return
}
dc.user.forEachUpstream(func(uc *upstreamConn) {
if dc.network != nil && uc.network != dc.network {
return
@ -557,6 +572,52 @@ func (dc *downstreamConn) handleMessageUnregistered(msg *irc.Message) error {
Params: []string{challengeStr},
})
}
case "BOUNCER":
var subcommand string
if err := parseMessageParams(msg, &subcommand); err != nil {
return err
}
switch strings.ToUpper(subcommand) {
case "BIND":
var idStr string
if err := parseMessageParams(msg, nil, &idStr); err != nil {
return err
}
if dc.registered {
return ircError{&irc.Message{
Command: "FAIL",
Params: []string{"BOUNCER", "REGISTRATION_IS_COMPLETED", "BIND", "Cannot bind bouncer network after registration"},
}}
}
if dc.user == nil {
return ircError{&irc.Message{
Command: "FAIL",
Params: []string{"BOUNCER", "ACCOUNT_REQUIRED", "BIND", "Authentication needed to bind to bouncer network"},
}}
}
id, err := parseBouncerNetID(idStr)
if err != nil {
return err
}
var match *network
dc.user.forEachNetwork(func(net *network) {
if net.ID == id {
match = net
}
})
if match == nil {
return ircError{&irc.Message{
Command: "FAIL",
Params: []string{"BOUNCER", "INVALID_NETID", idStr, "Unknown network ID"},
}}
}
dc.networkName = match.GetName()
}
default:
dc.logger.Printf("unhandled message: %v", msg)
return newUnknownCommandError(msg.Command)
@ -911,6 +972,10 @@ func (dc *downstreamConn) welcome() error {
"CASEMAPPING=ascii",
}
if dc.network != nil {
isupport = append(isupport, fmt.Sprintf("BOUNCER_NETID=%v", dc.network.ID))
}
if uc := dc.upstream(); uc != nil {
for k := range passthroughIsupport {
v, ok := uc.isupport[k]
@ -1906,6 +1971,181 @@ func (dc *downstreamConn) handleMessageRegistered(msg *irc.Message) error {
Command: "BATCH",
Params: []string{"-" + batchRef},
})
case "BOUNCER":
var subcommand string
if err := parseMessageParams(msg, &subcommand); err != nil {
return err
}
switch strings.ToUpper(subcommand) {
case "LISTNETWORKS":
dc.SendMessage(&irc.Message{
Prefix: dc.srv.prefix(),
Command: "BATCH",
Params: []string{"+networks", "bouncer-networks"},
})
dc.user.forEachNetwork(func(network *network) {
id := fmt.Sprintf("%v", network.ID)
state := "disconnected"
if uc := network.conn; uc != nil {
state = "connected"
}
attrs := irc.Tags{
"name": irc.TagValue(network.GetName()),
"state": irc.TagValue(state),
}
dc.SendMessage(&irc.Message{
Tags: irc.Tags{"batch": irc.TagValue("networks")},
Prefix: dc.srv.prefix(),
Command: "BOUNCER",
Params: []string{"NETWORK", id, attrs.String()},
})
})
dc.SendMessage(&irc.Message{
Prefix: dc.srv.prefix(),
Command: "BATCH",
Params: []string{"-networks"},
})
case "ADDNETWORK":
var attrsStr string
if err := parseMessageParams(msg, nil, &attrsStr); err != nil {
return err
}
attrs := irc.ParseTags(attrsStr)
host, ok := attrs.GetTag("host")
if !ok {
return ircError{&irc.Message{
Command: "FAIL",
Params: []string{"BOUNCER", "NEED_ATTRIBUTE", subcommand, "host", "Missing required host attribute"},
}}
}
addr := host
if port, ok := attrs.GetTag("port"); ok {
addr += ":" + port
}
if tlsStr, ok := attrs.GetTag("tls"); ok && tlsStr == "0" {
addr = "irc+insecure://" + tlsStr
}
nick, ok := attrs.GetTag("nickname")
if !ok {
nick = dc.nick
}
username, _ := attrs.GetTag("username")
realname, _ := attrs.GetTag("realname")
// TODO: reject unknown attributes
record := &Network{
Addr: addr,
Nick: nick,
Username: username,
Realname: realname,
}
network, err := dc.user.createNetwork(record)
if err != nil {
return ircError{&irc.Message{
Command: "FAIL",
Params: []string{"BOUNCER", "UNKNOWN_ERROR", subcommand, fmt.Sprintf("Failed to create network: %v", err)},
}}
}
dc.SendMessage(&irc.Message{
Prefix: dc.srv.prefix(),
Command: "BOUNCER",
Params: []string{"ADDNETWORK", fmt.Sprintf("%v", network.ID)},
})
case "CHANGENETWORK":
var idStr, attrsStr string
if err := parseMessageParams(msg, nil, &idStr, &attrsStr); err != nil {
return err
}
id, err := parseBouncerNetID(idStr)
if err != nil {
return err
}
attrs := irc.ParseTags(attrsStr)
net := dc.user.getNetworkByID(id)
if net == nil {
return ircError{&irc.Message{
Command: "FAIL",
Params: []string{"BOUNCER", "INVALID_NETID", idStr, "Invalid network ID"},
}}
}
record := net.Network // copy network record because we'll mutate it
for k, v := range attrs {
s := string(v)
switch k {
// TODO: host, port, tls
case "nickname":
record.Nick = s
case "username":
record.Username = s
case "realname":
record.Realname = s
default:
return ircError{&irc.Message{
Command: "FAIL",
Params: []string{"BOUNCER", "UNKNOWN_ATTRIBUTE", subcommand, k, "Unknown attribute"},
}}
}
}
_, err = dc.user.updateNetwork(&record)
if err != nil {
return ircError{&irc.Message{
Command: "FAIL",
Params: []string{"BOUNCER", "UNKNOWN_ERROR", subcommand, fmt.Sprintf("Failed to update network: %v", err)},
}}
}
dc.SendMessage(&irc.Message{
Prefix: dc.srv.prefix(),
Command: "BOUNCER",
Params: []string{"CHANGENETWORK", idStr},
})
case "DELNETWORK":
var idStr string
if err := parseMessageParams(msg, nil, &idStr); err != nil {
return err
}
id, err := parseBouncerNetID(idStr)
if err != nil {
return err
}
net := dc.user.getNetworkByID(id)
if net == nil {
return ircError{&irc.Message{
Command: "FAIL",
Params: []string{"BOUNCER", "INVALID_NETID", idStr, "Invalid network ID"},
}}
}
if err := dc.user.deleteNetwork(net.ID); err != nil {
return err
}
dc.SendMessage(&irc.Message{
Prefix: dc.srv.prefix(),
Command: "BOUNCER",
Params: []string{"DELNETWORK", idStr},
})
default:
return ircError{&irc.Message{
Command: "FAIL",
Params: []string{"BOUNCER", "UNKNOWN_COMMAND", subcommand, "Unknown subcommand"},
}}
}
default:
dc.logger.Printf("unhandled message: %v", msg)
return newUnknownCommandError(msg.Command)

54
user.go
View File

@ -141,6 +141,9 @@ func newNetwork(user *user, record *Network, channels []Channel) *network {
func (net *network) forEachDownstream(f func(*downstreamConn)) {
net.user.forEachDownstream(func(dc *downstreamConn) {
if dc.network == nil && dc.caps["soju.im/bouncer-networks"] {
return
}
if dc.network != nil && dc.network != net {
return
}
@ -511,9 +514,19 @@ func (u *user) run() {
uc.updateAway()
netIDStr := fmt.Sprintf("%v", uc.network.ID)
uc.forEachDownstream(func(dc *downstreamConn) {
dc.updateSupportedCaps()
sendServiceNOTICE(dc, fmt.Sprintf("connected to %s", uc.network.GetName()))
if dc.caps["soju.im/bouncer-networks"] {
dc.SendMessage(&irc.Message{
Prefix: dc.srv.prefix(),
Command: "BOUNCER",
Params: []string{"NETWORK", netIDStr, "status=connected"},
})
} else {
sendServiceNOTICE(dc, fmt.Sprintf("connected to %s", uc.network.GetName()))
}
dc.updateNick()
})
@ -640,13 +653,24 @@ func (u *user) handleUpstreamDisconnected(uc *upstreamConn) {
uch.updateAutoDetach(0)
}
netIDStr := fmt.Sprintf("%v", uc.network.ID)
uc.forEachDownstream(func(dc *downstreamConn) {
dc.updateSupportedCaps()
if dc.caps["soju.im/bouncer-networks"] {
dc.SendMessage(&irc.Message{
Prefix: dc.srv.prefix(),
Command: "BOUNCER",
Params: []string{"NETWORK", netIDStr, "status=disconnected"},
})
}
})
if uc.network.lastError == nil {
uc.forEachDownstream(func(dc *downstreamConn) {
sendServiceNOTICE(dc, fmt.Sprintf("disconnected from %s", uc.network.GetName()))
if !dc.caps["soju.im/bouncer-networks"] {
sendServiceNOTICE(dc, fmt.Sprintf("disconnected from %s", uc.network.GetName()))
}
})
}
}
@ -701,6 +725,18 @@ func (u *user) createNetwork(record *Network) (*network, error) {
u.addNetwork(network)
// TODO: broadcast network status
idStr := fmt.Sprintf("%v", network.ID)
u.forEachDownstream(func(dc *downstreamConn) {
if dc.caps["soju.im/bouncer-networks"] {
dc.SendMessage(&irc.Message{
Prefix: dc.srv.prefix(),
Command: "BOUNCER",
Params: []string{"NETWORK", idStr, "network=" + network.GetName()},
})
}
})
return network, nil
}
@ -754,6 +790,8 @@ func (u *user) updateNetwork(record *Network) (*network, error) {
// This will re-connect to the upstream server
u.addNetwork(updatedNetwork)
// TODO: broadcast BOUNCER NETWORK notifications
return updatedNetwork, nil
}
@ -768,6 +806,18 @@ func (u *user) deleteNetwork(id int64) error {
}
u.removeNetwork(network)
idStr := fmt.Sprintf("%v", network.ID)
u.forEachDownstream(func(dc *downstreamConn) {
if dc.caps["soju.im/bouncer-networks"] {
dc.SendMessage(&irc.Message{
Prefix: dc.srv.prefix(),
Command: "BOUNCER",
Params: []string{"NETWORK", idStr, "*"},
})
}
})
return nil
}