diff --git a/config.go b/config.go index 3971e721..71bb8345 100644 --- a/config.go +++ b/config.go @@ -57,6 +57,7 @@ const ( defaultTorDNSPort = 53 defaultTorControlPort = 9051 defaultTorV2PrivateKeyFilename = "v2_onion_private_key" + defaultTorV3PrivateKeyFilename = "v3_onion_private_key" defaultBroadcastDelta = 10 @@ -86,10 +87,9 @@ var ( defaultBitcoindDir = btcutil.AppDataDir("bitcoin", false) defaultLitecoindDir = btcutil.AppDataDir("litecoin", false) - defaultTorSOCKS = net.JoinHostPort("localhost", strconv.Itoa(defaultTorSOCKSPort)) - defaultTorDNS = net.JoinHostPort(defaultTorDNSHost, strconv.Itoa(defaultTorDNSPort)) - defaultTorControl = net.JoinHostPort("localhost", strconv.Itoa(defaultTorControlPort)) - defaultTorV2PrivateKeyPath = filepath.Join(defaultLndDir, defaultTorV2PrivateKeyFilename) + defaultTorSOCKS = net.JoinHostPort("localhost", strconv.Itoa(defaultTorSOCKSPort)) + defaultTorDNS = net.JoinHostPort(defaultTorDNSHost, strconv.Itoa(defaultTorDNSPort)) + defaultTorControl = net.JoinHostPort("localhost", strconv.Itoa(defaultTorControlPort)) ) type chainConfig struct { @@ -148,14 +148,14 @@ type autoPilotConfig struct { } type torConfig struct { - Active bool `long:"active" description:"Allow outbound and inbound connections to be routed through Tor"` - SOCKS string `long:"socks" description:"The host:port that Tor's exposed SOCKS5 proxy is listening on"` - DNS string `long:"dns" description:"The DNS server as host:port that Tor will use for SRV queries - NOTE must have TCP resolution enabled"` - StreamIsolation bool `long:"streamisolation" description:"Enable Tor stream isolation by randomizing user credentials for each connection."` - Control string `long:"control" description:"The host:port that Tor is listening on for Tor control connections"` - V2 bool `long:"v2" description:"Automatically set up a v2 onion service to listen for inbound connections"` - V2PrivateKeyPath string `long:"v2privatekeypath" description:"The path to the private key of the onion service being created"` - V3 bool `long:"v3" description:"Use a v3 onion service to listen for inbound connections"` + Active bool `long:"active" description:"Allow outbound and inbound connections to be routed through Tor"` + SOCKS string `long:"socks" description:"The host:port that Tor's exposed SOCKS5 proxy is listening on"` + DNS string `long:"dns" description:"The DNS server as host:port that Tor will use for SRV queries - NOTE must have TCP resolution enabled"` + StreamIsolation bool `long:"streamisolation" description:"Enable Tor stream isolation by randomizing user credentials for each connection."` + Control string `long:"control" description:"The host:port that Tor is listening on for Tor control connections"` + V2 bool `long:"v2" description:"Automatically set up a v2 onion service to listen for inbound connections"` + V3 bool `long:"v3" description:"Automatically set up a v3 onion service to listen for inbound connections"` + PrivateKeyPath string `long:"privatekeypath" description:"The path to the private key of the onion service being created"` } // config defines the configuration options for lnd. @@ -304,10 +304,9 @@ func loadConfig() (*config, error) { Color: defaultColor, MinChanSize: int64(minChanFundingSize), Tor: &torConfig{ - SOCKS: defaultTorSOCKS, - DNS: defaultTorDNS, - Control: defaultTorControl, - V2PrivateKeyPath: defaultTorV2PrivateKeyPath, + SOCKS: defaultTorSOCKS, + DNS: defaultTorDNS, + Control: defaultTorControl, }, net: &tor.ClearNet{}, } @@ -363,7 +362,6 @@ func loadConfig() (*config, error) { cfg.TLSCertPath = filepath.Join(lndDir, defaultTLSCertFilename) cfg.TLSKeyPath = filepath.Join(lndDir, defaultTLSKeyFilename) cfg.LogDir = filepath.Join(lndDir, defaultLogDirname) - cfg.Tor.V2PrivateKeyPath = filepath.Join(lndDir, defaultTorV2PrivateKeyFilename) } // Create the lnd directory if it doesn't already exist. @@ -399,7 +397,7 @@ func loadConfig() (*config, error) { cfg.LtcdMode.Dir = cleanAndExpandPath(cfg.LtcdMode.Dir) cfg.BitcoindMode.Dir = cleanAndExpandPath(cfg.BitcoindMode.Dir) cfg.LitecoindMode.Dir = cleanAndExpandPath(cfg.LitecoindMode.Dir) - cfg.Tor.V2PrivateKeyPath = cleanAndExpandPath(cfg.Tor.V2PrivateKeyPath) + cfg.Tor.PrivateKeyPath = cleanAndExpandPath(cfg.Tor.PrivateKeyPath) // Ensure that the user didn't attempt to specify negative values for // any of the autopilot params. @@ -490,6 +488,19 @@ func loadConfig() (*config, error) { cfg.DisableListen = true } + if cfg.Tor.PrivateKeyPath == "" { + switch { + case cfg.Tor.V2: + cfg.Tor.PrivateKeyPath = filepath.Join( + lndDir, defaultTorV2PrivateKeyFilename, + ) + case cfg.Tor.V3: + cfg.Tor.PrivateKeyPath = filepath.Join( + lndDir, defaultTorV3PrivateKeyFilename, + ) + } + } + // Set up the network-related functions that will be used throughout // the daemon. We use the standard Go "net" package functions by // default. If we should be proxying all traffic through Tor, then diff --git a/docs/configuring_tor.md b/docs/configuring_tor.md index eebef286..d17a2153 100644 --- a/docs/configuring_tor.md +++ b/docs/configuring_tor.md @@ -15,19 +15,14 @@ advertised IP address. Additionally, leaf nodes can also protect their location by using Tor for anonymous networking to establish connections. With widespread usage of Onion Services within the network, concerns about the -difficulty of proper NAT traversal are alleviated, as usage of Onion Services -allows nodes to accept inbound connections even if they're behind a NAT. +difficulty of proper NAT traversal are alleviated, as usage of onion services +allows nodes to accept inbound connections even if they're behind a NAT. At the +time of writing this documentation, `lnd` supports both types of onion services: +v2 and v3. -At the time of writing this documentation, `lnd` supports both types of onion -services: v2 and v3. However, only v2 onion services can automatically be -created and set up by `lnd` until Tor Control support for v3 onion services is -implemented in the stable release of the Tor daemon. v3 onion services can be -used as long as they are set up manually. We'll cover the steps on how to do -these things below. - -Before following the remainder of this documentation, you should ensure that -you already have Tor installed locally. Official instructions to install the -latest release of Tor can be found +Before following the remainder of this documentation, you should ensure that you +already have Tor installed locally. Official instructions to install the latest +release of Tor can be found [here](https://www.torproject.org/docs/tor-doc-unix.html.en). **NOTE**: This documentation covers how to ensure that `lnd`'s _Lightning @@ -80,13 +75,13 @@ At this point, we can now start `lnd` with the relevant arguments: Tor: --tor.active Allow outbound and inbound connections to be routed through Tor - --tor.socks= The port that Tor's exposed SOCKS5 proxy is listening on -- NOTE port must be between 1024 and 65535 (default: 9050) - --tor.dns= The DNS server as IP:PORT that Tor will use for SRV queries - NOTE must have TCP resolution enabled (default: soa.nodes.lightning.directory:53) + --tor.socks= The host:port that Tor's exposed SOCKS5 proxy is listening on (default: localhost:9050) + --tor.dns= The DNS server as host:port that Tor will use for SRV queries - NOTE must have TCP resolution enabled (default: soa.nodes.lightning.directory:53) --tor.streamisolation Enable Tor stream isolation by randomizing user credentials for each connection. - --tor.controlport= The port that Tor is listening on for Tor control connections -- NOTE port must be between 1024 and 65535 (default: 9051) + --tor.control= The host:port that Tor is listening on for Tor control connections (default: localhost:9051) --tor.v2 Automatically set up a v2 onion service to listen for inbound connections - --tor.v3 Use a v3 onion service to listen for inbound connections - --tor.privatekeypath= The path to the private key of the onion service being created (default: /Users/user/Library/Application Support/Lnd/onion_private_key) + --tor.v3 Automatically set up a v3 onion service to listen for inbound connections + --tor.privatekeypath= The path to the private key of the onion service being created ``` There are a couple things here, so let's dissect them. The `--tor.active` flag @@ -101,25 +96,27 @@ queries over Tor. So instead, we need to connect directly to the authoritative DNS server over TCP, in order query for `SRV` records that we can use to bootstrap our connections. -Inbound connections are possible due to `lnd` automatically creating a v2 onion +Inbound connections are possible due to `lnd` automatically creating an onion service. A path to save the onion service's private key can be specified with -the `--tor.privatekeypath` flag. A v3 onion service can also be used, but it -must be created manually. We'll expand on how this works in [Listening for -Inbound Connections](#listening-for-inbound-connections). +the `--tor.privatekeypath` flag. Most of these arguments have defaults, so as long as they apply to you, routing -all outbound and inbound connections through Tor can simply be done with: +all outbound and inbound connections through Tor can simply be done with either +v2 or v3 onion services: ```shell ⛰ ./lnd --tor.active --tor.v2 ``` +```shell +⛰ ./lnd --tor.active --tor.v3 +``` Outbound support only can also be used with: ```shell ⛰ ./lnd --tor.active ``` -This will allow you to make all outgoing connections over Tor, but still allow -regular (clearnet) incoming connections. +This will allow you to make all outgoing connections over Tor. Listening is +disabled to prevent inadvertent leaks. ## Tor Stream Isolation @@ -138,50 +135,24 @@ specification of an additional argument: ## Listening for Inbound Connections In order to listen for inbound connections through Tor, an onion service must be -created. There are two types of onion services: v2 and v3. +created. There are two types of onion services: v2 and v3. v3 onion services +are the latest generation of onion services and they provide a number of +advantages over the legacy v2 onion services. To learn more about these +benefits, see [Intro to Next Gen Onion Services](https://trac.torproject.org/projects/tor/wiki/doc/NextGenOnions). -### v2 Onion Services +Both types can be created and used automatically by `lnd`. Specifying which type +should be used can easily be done by either using the `tor.v2` or `tor.v3` flag. -v2 onion services can be created automatically by `lnd` and are currently the -default. To do so, run `lnd` with the following arguments: +For example, v3 onion services can be used with the following flags: ``` -⛰ ./lnd --tor.active --tor.v2 +⛰ ./lnd --tor.active --tor.v3 ``` This will automatically create a hidden service for your node to use to listen for inbound connections and advertise itself to the network. The onion service's -private key is saved to a file named `onion_private_key` in `lnd`'s base -directory. This will allow `lnd` to recreate the same hidden service upon +private key is saved to a file named `v2_onion_private_key` or +`v3_onion_private_key` depending on the type of onion service used in `lnd`'s +base directory. This will allow `lnd` to recreate the same hidden service upon restart. If you wish to generate a new onion service, you can simply delete this file. The path to this private key file can also be modified with the `--tor.privatekeypath` argument. - -### v3 Onion Services - -v3 onion services are the latest generation of onion services and they provide a -number of advantages over the legacy v2 onion services. To learn more about -these benefits, see [Intro to Next Gen Onion Services](https://trac.torproject.org/projects/tor/wiki/doc/NextGenOnions). - -Unfortunately, at the time of writing this, v3 onion service support is still -at an alpha level in the Tor daemon, so we're unable to automatically set them -up within `lnd` unlike with v2 onion services. However, they can still be run -manually! To do so, append the following lines to the torrc sample from above: -``` -HiddenServiceDir PATH_TO_HIDDEN_SERVICE -HiddenServiceVersion 3 -HiddenServicePort PORT_ONION_SERVICE_LISTENS_ON ADDRESS_LND_LISTENS_ON -``` - -If needed, instructions on how to set up a v3 onion service manually can be -found [here](https://trac.torproject.org/projects/tor/wiki/doc/NextGenOnions#Howtosetupyourownprop224service). - -Once the v3 onion service is set up, `lnd` is able to use it to listen for -inbound connections. You'll also need the onion service's hostname in order to -advertise your node to the network. To do so, run `lnd` with the following -arguments: -``` -⛰ ./lnd --tor.active --tor.v3 --externalip=ONION_SERVICE_HOSTNAME -``` - -Once v3 onion service support is stable, `lnd` will be updated to also -automatically set up v3 onion services. diff --git a/server.go b/server.go index 1ed9f6bf..35ef3689 100644 --- a/server.go +++ b/server.go @@ -432,10 +432,7 @@ func newServer(listenAddrs []net.Addr, chanDB *channeldb.DB, cc *chainControl, // If we were requested to route connections through Tor and to // automatically create an onion service, we'll initiate our Tor // controller and establish a connection to the Tor server. - // - // NOTE: v3 onion services cannot be created automatically yet. In the - // future, this will be expanded to do so. - if cfg.Tor.Active && cfg.Tor.V2 { + if cfg.Tor.Active { s.torController = tor.NewController(cfg.Tor.Control) } @@ -1479,34 +1476,36 @@ func (s *server) initTorController() error { // Determine the different ports the server is listening on. The onion // service's virtual port will map to these ports and one will be picked // at random when the onion service is being accessed. - listenPorts := make(map[int]struct{}) + listenPorts := make([]int, 0, len(s.listenAddrs)) for _, listenAddr := range s.listenAddrs { - // At this point, the listen addresses should have already been - // normalized, so it's safe to ignore the errors. - _, portStr, _ := net.SplitHostPort(listenAddr.String()) - port, _ := strconv.Atoi(portStr) - listenPorts[port] = struct{}{} + port := listenAddr.(*net.TCPAddr).Port + listenPorts = append(listenPorts, port) } // Once the port mapping has been set, we can go ahead and automatically // create our onion service. The service's private key will be saved to // disk in order to regain access to this service when restarting `lnd`. - virtToTargPorts := tor.VirtToTargPorts{defaultPeerPort: listenPorts} - onionServiceAddrs, err := s.torController.AddOnionV2( - cfg.Tor.V2PrivateKeyPath, virtToTargPorts, - ) + onionCfg := tor.AddOnionConfig{ + VirtualPort: defaultPeerPort, + TargetPorts: listenPorts, + PrivateKeyPath: cfg.Tor.PrivateKeyPath, + } + + switch { + case cfg.Tor.V2: + onionCfg.Type = tor.V2 + case cfg.Tor.V3: + onionCfg.Type = tor.V3 + } + + addr, err := s.torController.AddOnion(onionCfg) if err != nil { return err } - // Now that the onion service has been created, we'll add the different - // onion addresses it can be reached at to our list of advertised - // addresses. - for _, addr := range onionServiceAddrs { - s.currentNodeAnn.Addresses = append( - s.currentNodeAnn.Addresses, addr, - ) - } + // Now that the onion service has been created, we'll add the onion + // address it can be reached at to our list of advertised addresses. + s.currentNodeAnn.Addresses = append(s.currentNodeAnn.Addresses, addr) return nil } diff --git a/tor/controller.go b/tor/controller.go index 6a369ff8..d806016e 100644 --- a/tor/controller.go +++ b/tor/controller.go @@ -11,6 +11,7 @@ import ( "io/ioutil" "net/textproto" "os" + "strconv" "strings" "sync/atomic" ) @@ -30,6 +31,11 @@ const ( // ProtocolInfoVersion is the `protocolinfo` version currently supported // by the Tor server. ProtocolInfoVersion = 1 + + // MinTorVersion is the minimum supported version that the Tor server + // must be running on. This is needed in order to create v3 onion + // services through Tor's control port. + MinTorVersion = "0.3.3.6" ) var ( @@ -72,6 +78,9 @@ type Controller struct { // controlAddr is the host:port the Tor server is listening locally for // controller connections on. controlAddr string + + // version is the current version of the Tor server. + version string } // NewController returns a new Tor controller that will be able to interact with @@ -251,15 +260,19 @@ func (c *Controller) authenticate() error { } // getAuthCookie retrieves the authentication cookie in bytes from the Tor -// server. Cookie authentication must be enabled for this to work. +// server. Cookie authentication must be enabled for this to work. The boolean func (c *Controller) getAuthCookie() ([]byte, error) { // Retrieve the authentication methods currently supported by the Tor // server. - authMethods, cookieFilePath, _, err := c.ProtocolInfo() + authMethods, cookieFilePath, version, err := c.ProtocolInfo() if err != nil { return nil, err } + // With the version retrieved, we'll cache it now in case it needs to be + // used later on. + c.version = version + // Ensure that the Tor server supports the SAFECOOKIE authentication // method. safeCookieSupport := false @@ -294,6 +307,47 @@ func computeHMAC256(key, message []byte) []byte { return mac.Sum(nil) } +// supportsV3 is a helper function that parses the current version of the Tor +// server and determines whether it supports creationg v3 onion services through +// Tor's control port. The version string should be of the format: +// major.minor.revision.build +func supportsV3(version string) error { + // We'll split the minimum Tor version that's supported and the given + // version in order to individually compare each number. + requiredParts := strings.Split(MinTorVersion, ".") + parts := strings.Split(version, ".") + if len(parts) != 4 { + return errors.New("version string is not of the format " + + "major.minor.revision.build") + } + + // It's possible that the build number (the last part of the version + // string) includes a pre-release string, e.g. rc, beta, etc., so we'll + // parse that as well. + build := strings.Split(parts[len(parts)-1], "-") + parts[len(parts)-1] = build[0] + + // Convert them each number from its string representation to integers + // and check that they respect the minimum version. + for i := range parts { + n, err := strconv.Atoi(parts[i]) + if err != nil { + return err + } + requiredN, err := strconv.Atoi(requiredParts[i]) + if err != nil { + return err + } + + if n < requiredN { + return fmt.Errorf("version %v below minimum version "+ + "supported %v", version, MinTorVersion) + } + } + + return nil +} + // ProtocolInfo returns the different authentication methods supported by the // Tor server and the version of the Tor server. func (c *Controller) ProtocolInfo() ([]string, string, string, error) { @@ -338,60 +392,88 @@ func (c *Controller) ProtocolInfo() ([]string, string, string, error) { return authMethods, cookieFilePath, torVersion, nil } -// VirtToTargPorts is a mapping of virtual ports to target ports. When creating -// an onion service, it will be listening externally on each virtual port. Each -// virtual port can then be mapped to one or many target ports internally. When -// accessing the onion service at a specific virtual port, it will forward the -// traffic to a mapped randomly chosen target port. -type VirtToTargPorts = map[int]map[int]struct{} +// OnionType denotes the type of the onion service. +type OnionType int -// AddOnionV2 creates a new v2 onion service and returns its onion address(es). -// Once created, the new onion service will remain active until the connection -// between the controller and the Tor server is closed. The path to a private -// key can be provided in order to restore a previously created onion service. -// If a file at this path does not exist, a new onion service will be created -// and its private key will be saved to a file at this path. A mapping of -// virtual ports to target ports should also be provided. Each virtual port will -// be the ports where the onion service can be reached at, while the mapped -// target ports will be the ports where the onion service is running locally. -func (c *Controller) AddOnionV2(privateKeyFilename string, - virtToTargPorts VirtToTargPorts) ([]*OnionAddr, error) { +const ( + // V2 denotes that the onion service is V2. + V2 OnionType = iota + + // V3 denotes that the onion service is V3. + V3 +) + +// AddOnionConfig houses all of the required paramaters in order to succesfully +// create a new onion service or restore an existing one. +type AddOnionConfig struct { + // Type denotes the type of the onion service that should be created. + Type OnionType + + // VirtualPort is the externally reachable port of the onion address. + VirtualPort int + + // TargetPorts is the set of ports that the service will be listening on + // locally. The Tor server will use choose a random port from this set + // to forward the traffic from the virtual port. + // + // NOTE: If nil/empty, the virtual port will be used as the only target + // port. + TargetPorts []int + + // PrivateKeyPath is the full path to where the onion service's private + // key is stored. This can be used to restore an existing onion service. + PrivateKeyPath string +} + +// AddOnion creates an onion service and returns its onion address. Once +// created, the new onion service will remain active until the connection +// between the controller and the Tor server is closed. +func (c *Controller) AddOnion(cfg AddOnionConfig) (*OnionAddr, error) { + // Before sending the request to create an onion service to the Tor + // server, we'll make sure that it supports V3 onion services if that + // was the type requested. + if cfg.Type == V3 { + if err := supportsV3(c.version); err != nil { + return nil, err + } + } // We'll start off by checking if the file containing the private key // exists. If it does not, then we should request the server to create // a new onion service and return its private key. Otherwise, we'll // request the server to recreate the onion server from our private key. var keyParam string - if _, err := os.Stat(privateKeyFilename); os.IsNotExist(err) { - keyParam = "NEW:RSA1024" + if _, err := os.Stat(cfg.PrivateKeyPath); os.IsNotExist(err) { + switch cfg.Type { + case V2: + keyParam = "NEW:RSA1024" + case V3: + keyParam = "NEW:ED25519-V3" + } } else { - privateKey, err := ioutil.ReadFile(privateKeyFilename) + privateKey, err := ioutil.ReadFile(cfg.PrivateKeyPath) if err != nil { return nil, err } keyParam = string(privateKey) } - // Now, we'll determine the different virtual ports on which this onion - // service will be accessed by. + // Now, we'll create a mapping from the virtual port to each target + // port. If no target ports were specified, we'll use the virtual port + // to provide a one-to-one mapping. var portParam string - for virtPort, targPorts := range virtToTargPorts { - // If the virtual port doesn't map to any target ports, we'll - // use the virtual port as the target port. - if len(targPorts) == 0 { - portParam += fmt.Sprintf("Port=%d,%d ", virtPort, - virtPort) - continue - } - - // Otherwise, we'll create a mapping from the virtual port to - // each target port. - for targPort := range targPorts { - portParam += fmt.Sprintf("Port=%d,%d ", virtPort, - targPort) + if len(cfg.TargetPorts) == 0 { + portParam += fmt.Sprintf("Port=%d,%d ", cfg.VirtualPort, + cfg.VirtualPort) + } else { + for _, targetPort := range cfg.TargetPorts { + portParam += fmt.Sprintf("Port=%d,%d ", cfg.VirtualPort, + targetPort) } } + // Send the command to create the onion service to the Tor server and + // await its response. cmd := fmt.Sprintf("ADD_ONION %s %s", keyParam, portParam) _, reply, err := c.sendCommand(cmd) if err != nil { @@ -423,7 +505,7 @@ func (c *Controller) AddOnionV2(privateKeyFilename string, // recreated later on. if privateKey, ok := replyParams["PrivateKey"]; ok { err := ioutil.WriteFile( - privateKeyFilename, []byte(privateKey), 0600, + cfg.PrivateKeyPath, []byte(privateKey), 0600, ) if err != nil { return nil, fmt.Errorf("unable to write private key "+ @@ -431,18 +513,11 @@ func (c *Controller) AddOnionV2(privateKeyFilename string, } } - // Finally, return the different onion addresses composed of the service - // ID, along with the onion suffix, and the different virtual ports this - // onion service can be reached at. - onionService := serviceID + ".onion" - addrs := make([]*OnionAddr, 0, len(virtToTargPorts)) - for virtPort := range virtToTargPorts { - addr := &OnionAddr{ - OnionService: onionService, - Port: virtPort, - } - addrs = append(addrs, addr) - } - - return addrs, nil + // Finally, we'll return the onion address composed of the service ID, + // along with the onion suffix, and the port this onion service can be + // reached at externally. + return &OnionAddr{ + OnionService: serviceID + ".onion", + Port: cfg.VirtualPort, + }, nil } diff --git a/tor/controller_test.go b/tor/controller_test.go new file mode 100644 index 00000000..7ef9ca14 --- /dev/null +++ b/tor/controller_test.go @@ -0,0 +1,68 @@ +package tor + +import "testing" + +// TestParseTorVersion is a series of tests for different version strings that +// check the correctness of determining whether they support creating v3 onion +// services through Tor control's port. +func TestParseTorVersion(t *testing.T) { + t.Parallel() + + tests := []struct { + version string + valid bool + }{ + { + version: "0.3.3.6", + valid: true, + }, + { + version: "0.3.3.7", + valid: true, + }, + { + version: "0.3.4.6", + valid: true, + }, + { + version: "0.4.3.6", + valid: true, + }, + { + version: "1.3.3.6", + valid: true, + }, + { + version: "0.3.3.6-rc", + valid: true, + }, + { + version: "0.3.3.7-rc", + valid: true, + }, + { + version: "0.3.3.5-rc", + valid: false, + }, + { + version: "0.3.3.5", + valid: false, + }, + { + version: "0.3.2.6", + valid: false, + }, + { + version: "0.1.3.6", + valid: false, + }, + } + + for i, test := range tests { + err := supportsV3(test.version) + if test.valid != (err == nil) { + t.Fatalf("test %d with version string %v failed: %v", i, + test.version, err) + } + } +}