diff --git a/doc/ext/bouncer-networks.md b/doc/ext/bouncer-networks.md new file mode 100644 index 0000000..5d76cbb --- /dev/null +++ b/doc/ext/bouncer-networks.md @@ -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 + +#### `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 + +#### `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 + +#### `ADDNETWORK` subcommand + +The `ADDNETWORK` subcommand registers a new upstream network in the bouncer. + + BOUNCER ADDNETWORK + +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 + +#### `CHANGENETWORK` subcommand + +The `CHANGENETWORK` subcommand changes attributes of an existing upstream +network. + + BOUNCER CHANGENETWORK + +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 + +#### `DELNETWORK` subcommand + +The `DELNETWORK` subcommand removes an existing upstream network. + + BOUNCER DELNETWORK + +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 + +### 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 + +When a network is removed, the bouncer MUST broadcast a `BOUNCER NETWORK` +message to all connected clients: + + BOUNCER NETWORK * + +### Errors + +Errors are returned using the standard replies syntax. The general syntax is: + + FAIL BOUNCER [context...] + +If a client sends an unknown subcommand, the server MUST reply with: + + FAIL BOUNCER UNKNOWN_COMMAND :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 :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 :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 :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 :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 :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 diff --git a/downstream.go b/downstream.go index b154b08..b7308f2 100644 --- a/downstream.go +++ b/downstream.go @@ -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) diff --git a/user.go b/user.go index 5f2e0bf..48dbcff 100644 --- a/user.go +++ b/user.go @@ -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 }