lndclient: check and cache version compatibility on connect

pull/181/head
Oliver Gugger 4 years ago
parent 211586e614
commit 8848d30fb3
No known key found for this signature in database
GPG Key ID: 8E4256593F177720

@ -2,6 +2,7 @@ package lndclient
import (
"context"
"errors"
"fmt"
"net"
"path/filepath"
@ -11,11 +12,46 @@ import (
"github.com/btcsuite/btcutil"
"github.com/lightninglabs/loop/swap"
"github.com/lightningnetwork/lnd/lncfg"
"github.com/lightningnetwork/lnd/lnrpc/verrpc"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/status"
)
var rpcTimeout = 30 * time.Second
var (
rpcTimeout = 30 * time.Second
// minimalCompatibleVersion is the minimum version and build tags
// required in lnd to get all functionality implemented in lndclient.
// Users can provide their own, specific version if needed. If only a
// subset of the lndclient functionality is needed, the required build
// tags can be adjusted accordingly. This default will be used as a fall
// back version if none is specified in the configuration.
minimalCompatibleVersion = &verrpc.Version{
AppMajor: 0,
AppMinor: 10,
AppPatch: 0,
BuildTags: []string{
"signrpc", "walletrpc", "chainrpc", "invoicesrpc",
},
}
// ErrVersionCheckNotImplemented is the error that is returned if the
// version RPC is not implemented in lnd. This means the version of lnd
// is lower than v0.10.0-beta.
ErrVersionCheckNotImplemented = errors.New("version check not " +
"implemented, need minimum lnd version of v0.10.0-beta")
// ErrVersionIncompatible is the error that is returned if the connected
// lnd instance is not supported.
ErrVersionIncompatible = errors.New("version incompatible")
// ErrBuildTagsMissing is the error that is returned if the
// connected lnd instance does not have all built tags activated that
// are required.
ErrBuildTagsMissing = errors.New("build tags missing")
)
// LndServicesConfig holds all configuration settings that are needed to connect
// to an lnd node.
@ -33,6 +69,12 @@ type LndServicesConfig struct {
// TLSPath is the path to lnd's TLS certificate file.
TLSPath string
// CheckVersion is the minimum version the connected lnd node needs to
// be in order to be compatible. The node will be checked against this
// when connecting. If no version is supplied, the default minimum
// version will be used.
CheckVersion *verrpc.Version
// Dialer is an optional dial function that can be passed in if the
// default lncfg.ClientAddressDialer should not be used.
Dialer DialerFunc
@ -54,6 +96,7 @@ type LndServices struct {
ChainParams *chaincfg.Params
NodeAlias string
NodePubkey [33]byte
Version *verrpc.Version
macaroons *macaroonPouch
}
@ -74,6 +117,11 @@ func NewLndServices(cfg *LndServicesConfig) (*GrpcLndServices, error) {
cfg.Dialer = lncfg.ClientAddressDialer(defaultRPCPort)
}
// Fall back to minimal compatible version if none if specified.
if cfg.CheckVersion == nil {
cfg.CheckVersion = minimalCompatibleVersion
}
// Based on the network, if the macaroon directory isn't set, then
// we'll use the expected default locations.
macaroonDir := cfg.MacaroonDir
@ -135,8 +183,8 @@ func NewLndServices(cfg *LndServicesConfig) (*GrpcLndServices, error) {
if err != nil {
return nil, err
}
nodeAlias, nodeKey, err := checkLndCompatibility(
conn, chainParams, readonlyMac, cfg.Network,
nodeAlias, nodeKey, version, err := checkLndCompatibility(
conn, chainParams, readonlyMac, cfg.Network, cfg.CheckVersion,
)
if err != nil {
return nil, err
@ -195,6 +243,7 @@ func NewLndServices(cfg *LndServicesConfig) (*GrpcLndServices, error) {
ChainParams: chainParams,
NodeAlias: nodeAlias,
NodePubkey: nodeKey,
Version: version,
macaroons: macaroons,
},
cleanup: cleanup,
@ -214,25 +263,36 @@ func (s *GrpcLndServices) Close() {
}
// checkLndCompatibility makes sure the connected lnd instance is running on the
// correct network.
// correct network, has the version RPC implemented, is the correct minimal
// version and supports all required build tags/subservers.
func checkLndCompatibility(conn *grpc.ClientConn, chainParams *chaincfg.Params,
readonlyMac serializedMacaroon, network string) (string, [33]byte,
error) {
readonlyMac serializedMacaroon, network string,
minVersion *verrpc.Version) (string, [33]byte, *verrpc.Version, error) {
// onErr is a closure that simplifies returning multiple values in the
// error case.
onErr := func(err error) (string, [33]byte, error) {
onErr := func(err error) (string, [33]byte, *verrpc.Version, error) {
closeErr := conn.Close()
if closeErr != nil {
log.Errorf("Error closing lnd connection: %v", closeErr)
}
return "", [33]byte{}, err
// Make static error messages a bit less cryptic by adding the
// version or build tag that we expect.
newErr := fmt.Errorf("lnd compatibility check failed: %v", err)
if err == ErrVersionIncompatible || err == ErrBuildTagsMissing {
newErr = fmt.Errorf("error checking connected lnd "+
"version. at least version \"%s\" is "+
"required", VersionString(minVersion))
}
return "", [33]byte{}, nil, newErr
}
// We use our own client with a readonly macaroon here, because we know
// We use our own clients with a readonly macaroon here, because we know
// that's all we need for the checks.
lightningClient := newLightningClient(conn, chainParams, readonlyMac)
versionerClient := newVersionerClient(conn, readonlyMac)
// With our readonly macaroon obtained, we'll ensure that the network
// for lnd matches our expected network.
@ -247,9 +307,101 @@ func checkLndCompatibility(conn *grpc.ClientConn, chainParams *chaincfg.Params,
return onErr(err)
}
// Now let's also check the version of the connected lnd node.
version, err := checkVersionCompatibility(versionerClient, minVersion)
if err != nil {
return onErr(err)
}
// Return the static part of the info we just queried from the node so
// it can be cached for later use.
return info.Alias, info.IdentityPubkey, nil
return info.Alias, info.IdentityPubkey, version, nil
}
// checkVersionCompatibility makes sure the connected lnd node has the correct
// version and required build tags enabled.
//
// NOTE: This check will **never** return a non-nil error for a version of
// lnd < 0.10.0 because any version previous to 0.10.0 doesn't have the version
// endpoint implemented!
func checkVersionCompatibility(client VersionerClient,
expected *verrpc.Version) (*verrpc.Version, error) {
// First, test that the version RPC is even implemented.
version, err := client.GetVersion(context.Background())
if err != nil {
// The version service has only been added in lnd v0.10.0. If
// we get an unimplemented error, it means the lnd version is
// definitely older than that.
s, ok := status.FromError(err)
if ok && s.Code() == codes.Unimplemented {
return nil, ErrVersionCheckNotImplemented
}
return nil, fmt.Errorf("GetVersion error: %v", err)
}
// Now check the version and make sure all required build tags are set.
err = assertVersionCompatible(version, expected)
if err != nil {
return nil, err
}
err = assertBuildTagsEnabled(version, expected.BuildTags)
if err != nil {
return nil, err
}
// All check positive, version is fully compatible.
return version, nil
}
// assertVersionCompatible makes sure the detected lnd version is compatible
// with our current version requirements.
func assertVersionCompatible(actual *verrpc.Version,
expected *verrpc.Version) error {
// We need to check the versions parts sequentially as they are
// hierarchical.
if actual.AppMajor != expected.AppMajor {
if actual.AppMajor > expected.AppMajor {
return nil
}
return ErrVersionIncompatible
}
if actual.AppMinor != expected.AppMinor {
if actual.AppMinor > expected.AppMinor {
return nil
}
return ErrVersionIncompatible
}
if actual.AppPatch != expected.AppPatch {
if actual.AppPatch > expected.AppPatch {
return nil
}
return ErrVersionIncompatible
}
// The actual version and expected version are identical.
return nil
}
// assertBuildTagsEnabled makes sure all required build tags are set.
func assertBuildTagsEnabled(actual *verrpc.Version,
requiredTags []string) error {
tagMap := make(map[string]struct{})
for _, tag := range actual.BuildTags {
tagMap[tag] = struct{}{}
}
for _, required := range requiredTags {
if _, ok := tagMap[required]; !ok {
return ErrBuildTagsMissing
}
}
// All tags found.
return nil
}
var (

@ -0,0 +1,158 @@
package lndclient
import (
"context"
"testing"
"github.com/lightningnetwork/lnd/lnrpc/verrpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type mockVersioner struct {
version *verrpc.Version
err error
}
func (m *mockVersioner) GetVersion(_ context.Context) (*verrpc.Version, error) {
return m.version, m.err
}
// TestCheckVersionCompatibility makes sure the correct error is returned if an
// old lnd is connected that doesn't implement the version RPC, has an older
// version or if an lnd with not all subservers enabled is connected.
func TestCheckVersionCompatibility(t *testing.T) {
// Make sure a version check against a node that doesn't implement the
// version RPC always fails.
unimplemented := &mockVersioner{
err: status.Error(codes.Unimplemented, "missing"),
}
_, err := checkVersionCompatibility(unimplemented, &verrpc.Version{
AppMajor: 0,
AppMinor: 10,
AppPatch: 0,
})
if err != ErrVersionCheckNotImplemented {
t.Fatalf("unexpected error. got '%v' wanted '%v'", err,
ErrVersionCheckNotImplemented)
}
// Next, make sure an older version than what we want is rejected.
oldVersion := &mockVersioner{
version: &verrpc.Version{
AppMajor: 0,
AppMinor: 10,
AppPatch: 0,
},
}
_, err = checkVersionCompatibility(oldVersion, &verrpc.Version{
AppMajor: 0,
AppMinor: 11,
AppPatch: 0,
})
if err != ErrVersionIncompatible {
t.Fatalf("unexpected error. got '%v' wanted '%v'", err,
ErrVersionIncompatible)
}
// Finally, make sure we also get the correct error when trying to run
// against an lnd that doesn't have all required build tags enabled.
buildTagsMissing := &mockVersioner{
version: &verrpc.Version{
AppMajor: 0,
AppMinor: 10,
AppPatch: 0,
BuildTags: []string{"dev", "lntest", "btcd", "signrpc"},
},
}
_, err = checkVersionCompatibility(buildTagsMissing, &verrpc.Version{
AppMajor: 0,
AppMinor: 10,
AppPatch: 0,
BuildTags: []string{"signrpc", "walletrpc"},
})
if err != ErrBuildTagsMissing {
t.Fatalf("unexpected error. got '%v' wanted '%v'", err,
ErrVersionIncompatible)
}
}
// TestLndVersionCheckComparison makes sure the version check comparison works
// correctly and considers all three version levels.
func TestLndVersionCheckComparison(t *testing.T) {
actual := &verrpc.Version{
AppMajor: 1,
AppMinor: 2,
AppPatch: 3,
}
testCases := []struct {
name string
expectMajor uint32
expectMinor uint32
expectPatch uint32
actual *verrpc.Version
expectedErr error
}{
{
name: "no expectation",
expectMajor: 0,
expectMinor: 0,
expectPatch: 0,
actual: actual,
expectedErr: nil,
},
{
name: "expect exact same version",
expectMajor: 1,
expectMinor: 2,
expectPatch: 3,
actual: actual,
expectedErr: nil,
},
{
name: "ignore patch if minor is bigger",
expectMajor: 12,
expectMinor: 9,
expectPatch: 14,
actual: &verrpc.Version{
AppMajor: 12,
AppMinor: 22,
AppPatch: 0,
},
expectedErr: nil,
},
{
name: "all fields different",
expectMajor: 3,
expectMinor: 2,
expectPatch: 1,
actual: actual,
expectedErr: ErrVersionIncompatible,
},
{
name: "patch version different",
expectMajor: 1,
expectMinor: 2,
expectPatch: 4,
actual: actual,
expectedErr: ErrVersionIncompatible,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
err := assertVersionCompatible(
tc.actual, &verrpc.Version{
AppMajor: tc.expectMajor,
AppMinor: tc.expectMinor,
AppPatch: tc.expectPatch,
},
)
if err != tc.expectedErr {
t.Fatalf("unexpected error, got '%v' wanted "+
"'%v'", err, tc.expectedErr)
}
})
}
}

@ -80,8 +80,10 @@ func NewMockLnd() *LndMockServices {
// Also simulate the cached info that is loaded on startup.
info, _ := lightningClient.GetInfo(context.Background())
version, _ := versioner.GetVersion(context.Background())
lnd.LndServices.NodeAlias = info.Alias
lnd.LndServices.NodePubkey = info.IdentityPubkey
lnd.LndServices.Version = version
lnd.WaitForFinished = func() {
chainNotifier.WaitForFinished()

Loading…
Cancel
Save