You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
524 lines
15 KiB
524 lines
15 KiB
package itest |
|
|
|
import ( |
|
"bytes" |
|
"context" |
|
"crypto/tls" |
|
"encoding/base64" |
|
"encoding/hex" |
|
"fmt" |
|
"io" |
|
"io/ioutil" |
|
"net/http" |
|
"regexp" |
|
"strings" |
|
"testing" |
|
"time" |
|
|
|
"github.com/golang/protobuf/jsonpb" |
|
"github.com/golang/protobuf/proto" |
|
"github.com/gorilla/websocket" |
|
"github.com/lightningnetwork/lnd/lnrpc" |
|
"github.com/lightningnetwork/lnd/lnrpc/autopilotrpc" |
|
"github.com/lightningnetwork/lnd/lnrpc/chainrpc" |
|
"github.com/lightningnetwork/lnd/lnrpc/routerrpc" |
|
"github.com/lightningnetwork/lnd/lnrpc/verrpc" |
|
"github.com/lightningnetwork/lnd/lnrpc/walletrpc" |
|
"github.com/lightningnetwork/lnd/lntest" |
|
"github.com/stretchr/testify/assert" |
|
"github.com/stretchr/testify/require" |
|
) |
|
|
|
var ( |
|
insecureTransport = &http.Transport{ |
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, |
|
} |
|
restClient = &http.Client{ |
|
Transport: insecureTransport, |
|
} |
|
jsonMarshaler = &jsonpb.Marshaler{ |
|
EmitDefaults: true, |
|
OrigName: true, |
|
Indent: " ", |
|
} |
|
urlEnc = base64.URLEncoding |
|
webSocketDialer = &websocket.Dialer{ |
|
HandshakeTimeout: 45 * time.Second, |
|
TLSClientConfig: insecureTransport.TLSClientConfig, |
|
} |
|
resultPattern = regexp.MustCompile("{\"result\":(.*)}") |
|
) |
|
|
|
// testRestAPI tests that the most important features of the REST API work |
|
// correctly. |
|
func testRestAPI(net *lntest.NetworkHarness, ht *harnessTest) { |
|
testCases := []struct { |
|
name string |
|
run func(*testing.T, *lntest.HarnessNode, *lntest.HarnessNode) |
|
}{{ |
|
name: "simple GET", |
|
run: func(t *testing.T, a, b *lntest.HarnessNode) { |
|
// Check that the parsing into the response proto |
|
// message works. |
|
resp := &lnrpc.GetInfoResponse{} |
|
err := invokeGET(a, "/v1/getinfo", resp) |
|
require.Nil(t, err, "getinfo") |
|
assert.Equal(t, "#3399ff", resp.Color, "node color") |
|
|
|
// Make sure we get the correct field names (snake |
|
// case). |
|
_, resp2, err := makeRequest( |
|
a, "/v1/getinfo", "GET", nil, nil, |
|
) |
|
require.Nil(t, err, "getinfo") |
|
assert.Contains( |
|
t, string(resp2), "best_header_timestamp", |
|
"getinfo", |
|
) |
|
}, |
|
}, { |
|
name: "simple POST and GET with query param", |
|
run: func(t *testing.T, a, b *lntest.HarnessNode) { |
|
// Add an invoice, testing POST in the process. |
|
req := &lnrpc.Invoice{Value: 1234} |
|
resp := &lnrpc.AddInvoiceResponse{} |
|
err := invokePOST(a, "/v1/invoices", req, resp) |
|
require.Nil(t, err, "add invoice") |
|
assert.Equal(t, 32, len(resp.RHash), "invoice rhash") |
|
|
|
// Make sure we can call a GET endpoint with a hex |
|
// encoded URL part. |
|
url := fmt.Sprintf("/v1/invoice/%x", resp.RHash) |
|
resp2 := &lnrpc.Invoice{} |
|
err = invokeGET(a, url, resp2) |
|
require.Nil(t, err, "query invoice") |
|
assert.Equal(t, int64(1234), resp2.Value, "invoice amt") |
|
}, |
|
}, { |
|
name: "GET with base64 encoded byte slice in path", |
|
run: func(t *testing.T, a, b *lntest.HarnessNode) { |
|
url := "/v2/router/mc/probability/%s/%s/%d" |
|
url = fmt.Sprintf( |
|
url, urlEnc.EncodeToString(a.PubKey[:]), |
|
urlEnc.EncodeToString(b.PubKey[:]), 1234, |
|
) |
|
resp := &routerrpc.QueryProbabilityResponse{} |
|
err := invokeGET(a, url, resp) |
|
require.Nil(t, err, "query probability") |
|
assert.Greater(t, resp.Probability, 0.5, "probability") |
|
}, |
|
}, { |
|
name: "GET with map type query param", |
|
run: func(t *testing.T, a, b *lntest.HarnessNode) { |
|
// Get a new wallet address from Alice. |
|
ctxb := context.Background() |
|
newAddrReq := &lnrpc.NewAddressRequest{ |
|
Type: lnrpc.AddressType_WITNESS_PUBKEY_HASH, |
|
} |
|
addrRes, err := a.NewAddress(ctxb, newAddrReq) |
|
require.Nil(t, err, "get address") |
|
|
|
// Create the full URL with the map query param. |
|
url := "/v1/transactions/fee?target_conf=%d&" + |
|
"AddrToAmount[%s]=%d" |
|
url = fmt.Sprintf(url, 2, addrRes.Address, 50000) |
|
resp := &lnrpc.EstimateFeeResponse{} |
|
err = invokeGET(a, url, resp) |
|
require.Nil(t, err, "estimate fee") |
|
assert.Greater(t, resp.FeeSat, int64(253), "fee") |
|
}, |
|
}, { |
|
name: "sub RPC servers REST support", |
|
run: func(t *testing.T, a, b *lntest.HarnessNode) { |
|
// Query autopilot status. |
|
res1 := &autopilotrpc.StatusResponse{} |
|
err := invokeGET(a, "/v2/autopilot/status", res1) |
|
require.Nil(t, err, "autopilot status") |
|
assert.Equal(t, false, res1.Active, "autopilot status") |
|
|
|
// Query the version RPC. |
|
res2 := &verrpc.Version{} |
|
err = invokeGET(a, "/v2/versioner/version", res2) |
|
require.Nil(t, err, "version") |
|
assert.Greater( |
|
t, res2.AppMinor, uint32(0), "lnd minor version", |
|
) |
|
|
|
// Request a new external address from the wallet kit. |
|
req1 := &walletrpc.AddrRequest{} |
|
res3 := &walletrpc.AddrResponse{} |
|
err = invokePOST( |
|
a, "/v2/wallet/address/next", req1, res3, |
|
) |
|
require.Nil(t, err, "address") |
|
assert.NotEmpty(t, res3.Addr, "address") |
|
}, |
|
}, { |
|
name: "CORS headers", |
|
run: func(t *testing.T, a, b *lntest.HarnessNode) { |
|
// Alice allows all origins. Make sure we get the same |
|
// value back in the CORS header that we send in the |
|
// Origin header. |
|
reqHeaders := make(http.Header) |
|
reqHeaders.Add("Origin", "https://foo.bar:9999") |
|
resHeaders, body, err := makeRequest( |
|
a, "/v1/getinfo", "OPTIONS", nil, reqHeaders, |
|
) |
|
require.Nil(t, err, "getinfo") |
|
assert.Equal( |
|
t, "https://foo.bar:9999", |
|
resHeaders.Get("Access-Control-Allow-Origin"), |
|
"CORS header", |
|
) |
|
assert.Equal(t, 0, len(body)) |
|
|
|
// Make sure that we don't get a value set for Bob which |
|
// doesn't allow any CORS origin. |
|
resHeaders, body, err = makeRequest( |
|
b, "/v1/getinfo", "OPTIONS", nil, reqHeaders, |
|
) |
|
require.Nil(t, err, "getinfo") |
|
assert.Equal( |
|
t, "", |
|
resHeaders.Get("Access-Control-Allow-Origin"), |
|
"CORS header", |
|
) |
|
assert.Equal(t, 0, len(body)) |
|
}, |
|
}, { |
|
name: "websocket subscription", |
|
run: func(t *testing.T, a, b *lntest.HarnessNode) { |
|
// Find out the current best block so we can subscribe |
|
// to the next one. |
|
hash, height, err := net.Miner.Client.GetBestBlock() |
|
require.Nil(t, err, "get best block") |
|
|
|
// Create a new subscription to get block epoch events. |
|
req := &chainrpc.BlockEpoch{ |
|
Hash: hash.CloneBytes(), |
|
Height: uint32(height), |
|
} |
|
url := "/v2/chainnotifier/register/blocks" |
|
c, err := openWebSocket(a, url, "POST", req, nil) |
|
require.Nil(t, err, "websocket") |
|
defer func() { |
|
_ = c.WriteMessage( |
|
websocket.CloseMessage, |
|
websocket.FormatCloseMessage( |
|
websocket.CloseNormalClosure, |
|
"done", |
|
), |
|
) |
|
_ = c.Close() |
|
}() |
|
|
|
msgChan := make(chan *chainrpc.BlockEpoch) |
|
errChan := make(chan error) |
|
timeout := time.After(defaultTimeout) |
|
|
|
// We want to read exactly one message. |
|
go func() { |
|
defer close(msgChan) |
|
|
|
_, msg, err := c.ReadMessage() |
|
if err != nil { |
|
errChan <- err |
|
return |
|
} |
|
|
|
// The chunked/streamed responses come wrapped |
|
// in either a {"result":{}} or {"error":{}} |
|
// wrapper which we'll get rid of here. |
|
msgStr := string(msg) |
|
if !strings.Contains(msgStr, "\"result\":") { |
|
errChan <- fmt.Errorf("invalid msg: %s", |
|
msgStr) |
|
return |
|
} |
|
msgStr = resultPattern.ReplaceAllString( |
|
msgStr, "${1}", |
|
) |
|
|
|
// Make sure we can parse the unwrapped message |
|
// into the expected proto message. |
|
protoMsg := &chainrpc.BlockEpoch{} |
|
err = jsonpb.UnmarshalString( |
|
msgStr, protoMsg, |
|
) |
|
if err != nil { |
|
errChan <- err |
|
return |
|
} |
|
|
|
select { |
|
case msgChan <- protoMsg: |
|
case <-timeout: |
|
} |
|
}() |
|
|
|
// Mine a block and make sure we get a message for it. |
|
blockHashes, err := net.Miner.Client.Generate(1) |
|
require.Nil(t, err, "generate blocks") |
|
assert.Equal(t, 1, len(blockHashes), "num blocks") |
|
select { |
|
case msg := <-msgChan: |
|
assert.Equal( |
|
t, blockHashes[0].CloneBytes(), |
|
msg.Hash, "block hash", |
|
) |
|
|
|
case err := <-errChan: |
|
t.Fatalf("Received error from WS: %v", err) |
|
|
|
case <-timeout: |
|
t.Fatalf("Timeout before message was received") |
|
} |
|
}, |
|
}, { |
|
name: "websocket subscription with macaroon in protocol", |
|
run: func(t *testing.T, a, b *lntest.HarnessNode) { |
|
// Find out the current best block so we can subscribe |
|
// to the next one. |
|
hash, height, err := net.Miner.Client.GetBestBlock() |
|
require.Nil(t, err, "get best block") |
|
|
|
// Create a new subscription to get block epoch events. |
|
req := &chainrpc.BlockEpoch{ |
|
Hash: hash.CloneBytes(), |
|
Height: uint32(height), |
|
} |
|
url := "/v2/chainnotifier/register/blocks" |
|
|
|
// This time we send the macaroon in the special header |
|
// Sec-Websocket-Protocol which is the only header field |
|
// available to browsers when opening a WebSocket. |
|
mac, err := a.ReadMacaroon( |
|
a.AdminMacPath(), defaultTimeout, |
|
) |
|
require.NoError(t, err, "read admin mac") |
|
macBytes, err := mac.MarshalBinary() |
|
require.NoError(t, err, "marshal admin mac") |
|
|
|
customHeader := make(http.Header) |
|
customHeader.Set( |
|
lnrpc.HeaderWebSocketProtocol, fmt.Sprintf( |
|
"Grpc-Metadata-Macaroon+%s", |
|
hex.EncodeToString(macBytes), |
|
), |
|
) |
|
c, err := openWebSocket( |
|
a, url, "POST", req, customHeader, |
|
) |
|
require.Nil(t, err, "websocket") |
|
defer func() { |
|
_ = c.WriteMessage( |
|
websocket.CloseMessage, |
|
websocket.FormatCloseMessage( |
|
websocket.CloseNormalClosure, |
|
"done", |
|
), |
|
) |
|
_ = c.Close() |
|
}() |
|
|
|
msgChan := make(chan *chainrpc.BlockEpoch) |
|
errChan := make(chan error) |
|
timeout := time.After(defaultTimeout) |
|
|
|
// We want to read exactly one message. |
|
go func() { |
|
defer close(msgChan) |
|
|
|
_, msg, err := c.ReadMessage() |
|
if err != nil { |
|
errChan <- err |
|
return |
|
} |
|
|
|
// The chunked/streamed responses come wrapped |
|
// in either a {"result":{}} or {"error":{}} |
|
// wrapper which we'll get rid of here. |
|
msgStr := string(msg) |
|
if !strings.Contains(msgStr, "\"result\":") { |
|
errChan <- fmt.Errorf("invalid msg: %s", |
|
msgStr) |
|
return |
|
} |
|
msgStr = resultPattern.ReplaceAllString( |
|
msgStr, "${1}", |
|
) |
|
|
|
// Make sure we can parse the unwrapped message |
|
// into the expected proto message. |
|
protoMsg := &chainrpc.BlockEpoch{} |
|
err = jsonpb.UnmarshalString( |
|
msgStr, protoMsg, |
|
) |
|
if err != nil { |
|
errChan <- err |
|
return |
|
} |
|
|
|
select { |
|
case msgChan <- protoMsg: |
|
case <-timeout: |
|
} |
|
}() |
|
|
|
// Mine a block and make sure we get a message for it. |
|
blockHashes, err := net.Miner.Client.Generate(1) |
|
require.Nil(t, err, "generate blocks") |
|
assert.Equal(t, 1, len(blockHashes), "num blocks") |
|
select { |
|
case msg := <-msgChan: |
|
assert.Equal( |
|
t, blockHashes[0].CloneBytes(), |
|
msg.Hash, "block hash", |
|
) |
|
|
|
case err := <-errChan: |
|
t.Fatalf("Received error from WS: %v", err) |
|
|
|
case <-timeout: |
|
t.Fatalf("Timeout before message was received") |
|
} |
|
}, |
|
}} |
|
|
|
// Make sure Alice allows all CORS origins. Bob will keep the default. |
|
net.Alice.Cfg.ExtraArgs = append( |
|
net.Alice.Cfg.ExtraArgs, "--restcors=\"*\"", |
|
) |
|
err := net.RestartNode(net.Alice, nil) |
|
if err != nil { |
|
ht.t.Fatalf("Could not restart Alice to set CORS config: %v", |
|
err) |
|
} |
|
|
|
for _, tc := range testCases { |
|
tc := tc |
|
ht.t.Run(tc.name, func(t *testing.T) { |
|
tc.run(t, net.Alice, net.Bob) |
|
}) |
|
} |
|
} |
|
|
|
// invokeGET calls the given URL with the GET method and appropriate macaroon |
|
// header fields then tries to unmarshal the response into the given response |
|
// proto message. |
|
func invokeGET(node *lntest.HarnessNode, url string, resp proto.Message) error { |
|
_, rawResp, err := makeRequest(node, url, "GET", nil, nil) |
|
if err != nil { |
|
return err |
|
} |
|
|
|
return jsonpb.Unmarshal(bytes.NewReader(rawResp), resp) |
|
} |
|
|
|
// invokePOST calls the given URL with the POST method, request body and |
|
// appropriate macaroon header fields then tries to unmarshal the response into |
|
// the given response proto message. |
|
func invokePOST(node *lntest.HarnessNode, url string, req, |
|
resp proto.Message) error { |
|
|
|
// Marshal the request to JSON using the jsonpb marshaler to get correct |
|
// field names. |
|
var buf bytes.Buffer |
|
if err := jsonMarshaler.Marshal(&buf, req); err != nil { |
|
return err |
|
} |
|
|
|
_, rawResp, err := makeRequest(node, url, "POST", &buf, nil) |
|
if err != nil { |
|
return err |
|
} |
|
|
|
return jsonpb.Unmarshal(bytes.NewReader(rawResp), resp) |
|
} |
|
|
|
// makeRequest calls the given URL with the given method, request body and |
|
// appropriate macaroon header fields and returns the raw response body. |
|
func makeRequest(node *lntest.HarnessNode, url, method string, |
|
request io.Reader, additionalHeaders http.Header) (http.Header, []byte, |
|
error) { |
|
|
|
// Assemble the full URL from the node's listening address then create |
|
// the request so we can set the macaroon on it. |
|
fullURL := fmt.Sprintf("https://%s%s", node.Cfg.RESTAddr(), url) |
|
req, err := http.NewRequest(method, fullURL, request) |
|
if err != nil { |
|
return nil, nil, err |
|
} |
|
if err := addAdminMacaroon(node, req.Header); err != nil { |
|
return nil, nil, err |
|
} |
|
for key, values := range additionalHeaders { |
|
for _, value := range values { |
|
req.Header.Add(key, value) |
|
} |
|
} |
|
|
|
// Do the actual call with the completed request object now. |
|
resp, err := restClient.Do(req) |
|
if err != nil { |
|
return nil, nil, err |
|
} |
|
defer func() { _ = resp.Body.Close() }() |
|
|
|
data, err := ioutil.ReadAll(resp.Body) |
|
return resp.Header, data, err |
|
} |
|
|
|
// openWebSocket opens a new WebSocket connection to the given URL with the |
|
// appropriate macaroon headers and sends the request message over the socket. |
|
func openWebSocket(node *lntest.HarnessNode, url, method string, |
|
req proto.Message, customHeader http.Header) (*websocket.Conn, error) { |
|
|
|
// Prepare our macaroon headers and assemble the full URL from the |
|
// node's listening address. WebSockets always work over GET so we need |
|
// to append the target request method as a query parameter. |
|
header := customHeader |
|
if header == nil { |
|
header = make(http.Header) |
|
if err := addAdminMacaroon(node, header); err != nil { |
|
return nil, err |
|
} |
|
} |
|
fullURL := fmt.Sprintf( |
|
"wss://%s%s?method=%s", node.Cfg.RESTAddr(), url, method, |
|
) |
|
conn, resp, err := webSocketDialer.Dial(fullURL, header) |
|
if err != nil { |
|
return nil, err |
|
} |
|
defer func() { _ = resp.Body.Close() }() |
|
|
|
// Send the given request message as the first message on the socket. |
|
reqMsg, err := jsonMarshaler.MarshalToString(req) |
|
if err != nil { |
|
return nil, err |
|
} |
|
err = conn.WriteMessage(websocket.TextMessage, []byte(reqMsg)) |
|
if err != nil { |
|
return nil, err |
|
} |
|
|
|
return conn, nil |
|
} |
|
|
|
// addAdminMacaroon reads the admin macaroon from the node and appends it to |
|
// the HTTP header fields. |
|
func addAdminMacaroon(node *lntest.HarnessNode, header http.Header) error { |
|
mac, err := node.ReadMacaroon(node.AdminMacPath(), defaultTimeout) |
|
if err != nil { |
|
return err |
|
} |
|
macBytes, err := mac.MarshalBinary() |
|
if err != nil { |
|
return err |
|
} |
|
|
|
header.Set("Grpc-Metadata-Macaroon", hex.EncodeToString(macBytes)) |
|
|
|
return nil |
|
}
|
|
|