diff --git a/lndclient/lnd_services.go b/lndclient/lnd_services.go index 1921025..bae1cf5 100644 --- a/lndclient/lnd_services.go +++ b/lndclient/lnd_services.go @@ -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 ( diff --git a/lndclient/lnd_services_test.go b/lndclient/lnd_services_test.go new file mode 100644 index 0000000..30bc8be --- /dev/null +++ b/lndclient/lnd_services_test.go @@ -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) + } + }) + } +} diff --git a/test/lnd_services_mock.go b/test/lnd_services_mock.go index fb1b3e1..906b5fd 100644 --- a/test/lnd_services_mock.go +++ b/test/lnd_services_mock.go @@ -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()