Index: trunk/doc/ext/bouncer-networks.md
===================================================================
--- trunk/doc/ext/bouncer-networks.md	(revision 532)
+++ trunk/doc/ext/bouncer-networks.md	(revision 532)
@@ -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
Index: trunk/downstream.go
===================================================================
--- trunk/downstream.go	(revision 531)
+++ trunk/downstream.go	(revision 532)
@@ -58,4 +58,15 @@
 }}
 
+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
@@ -65,11 +76,12 @@
 // 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":              "",
 }
 
@@ -169,5 +181,5 @@
 	if dc.network != nil {
 		f(dc.network)
-	} else {
+	} else if !dc.caps["soju.im/bouncer-networks"] {
 		dc.user.forEachNetwork(f)
 	}
@@ -175,4 +187,7 @@
 
 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 {
@@ -558,4 +573,50 @@
 			})
 		}
+	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)
@@ -910,4 +971,8 @@
 		fmt.Sprintf("CHATHISTORY=%v", dc.srv.HistoryLimit),
 		"CASEMAPPING=ascii",
+	}
+
+	if dc.network != nil {
+		isupport = append(isupport, fmt.Sprintf("BOUNCER_NETID=%v", dc.network.ID))
 	}
 
@@ -1907,4 +1972,179 @@
 			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)
Index: trunk/user.go
===================================================================
--- trunk/user.go	(revision 531)
+++ trunk/user.go	(revision 532)
@@ -142,4 +142,7 @@
 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
@@ -512,7 +515,17 @@
 			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()
@@ -641,11 +654,22 @@
 	}
 
+	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,4 +725,16 @@
 
 	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
@@ -755,4 +791,6 @@
 	u.addNetwork(updatedNetwork)
 
+	// TODO: broadcast BOUNCER NETWORK notifications
+
 	return updatedNetwork, nil
 }
@@ -769,4 +807,16 @@
 
 	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
 }
