mirror of
https://github.com/lightninglabs/lightning-terminal.git
synced 2026-08-13 12:33:36 +02:00
To make the purpose and the flow of the RPC proxy more easy to understand, it is extracted into its own struct and cleaned up.
403 lines
12 KiB
Go
403 lines
12 KiB
Go
package terminal
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/improbable-eng/grpc-web/go/grpcweb"
|
|
"github.com/lightningnetwork/lnd/macaroons"
|
|
grpcProxy "github.com/mwitkow/grpc-proxy/proxy"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/backoff"
|
|
"google.golang.org/grpc/credentials"
|
|
"google.golang.org/grpc/metadata"
|
|
"gopkg.in/macaroon-bakery.v2/bakery"
|
|
"gopkg.in/macaroon.v2"
|
|
)
|
|
|
|
const (
|
|
contentTypeGrpc = "application/grpc"
|
|
|
|
// HeaderMacaroon is the HTTP header field name that is used to send
|
|
// the macaroon.
|
|
HeaderMacaroon = "Macaroon"
|
|
)
|
|
|
|
// newRpcProxy creates a new RPC proxy that can take any native gRPC, grpc-web
|
|
// or REST request and delegate (and convert if necessary) it to the correct
|
|
// component.
|
|
func newRpcProxy(cfg *Config, validator macaroons.MacaroonValidator,
|
|
permissionMap map[string][]bakery.Op) *rpcProxy {
|
|
|
|
// The gRPC web calls are protected by HTTP basic auth which is defined
|
|
// by base64(username:password). Because we only have a password, we
|
|
// just use base64(password:password).
|
|
basicAuth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(
|
|
"%s:%s", cfg.UIPassword, cfg.UIPassword,
|
|
)))
|
|
|
|
// Set up the final gRPC server that will serve gRPC web to the browser
|
|
// and translate all incoming gRPC web calls into native gRPC that are
|
|
// then forwarded to lnd's RPC interface. GRPC web has a few kinks that
|
|
// need to be addressed with a custom director that just takes care of a
|
|
// few HTTP header fields.
|
|
p := &rpcProxy{
|
|
cfg: cfg,
|
|
basicAuth: basicAuth,
|
|
macValidator: validator,
|
|
}
|
|
p.grpcServer = grpc.NewServer(
|
|
// From the grpxProxy doc: This codec is *crucial* to the
|
|
// functioning of the proxy.
|
|
grpc.CustomCodec(grpcProxy.Codec()),
|
|
grpc.ChainStreamInterceptor(p.StreamServerInterceptor(
|
|
permissionMap,
|
|
)),
|
|
grpc.ChainUnaryInterceptor(p.UnaryServerInterceptor(
|
|
permissionMap,
|
|
)),
|
|
grpc.UnknownServiceHandler(
|
|
grpcProxy.TransparentHandler(p.director),
|
|
),
|
|
)
|
|
|
|
// Create the gRPC web proxy that wraps the just created grpcServer and
|
|
// converts the browser's gRPC web calls into native gRPC.
|
|
options := []grpcweb.Option{
|
|
grpcweb.WithWebsockets(true),
|
|
grpcweb.WithWebsocketPingInterval(2 * time.Minute),
|
|
grpcweb.WithCorsForRegisteredEndpointsOnly(false),
|
|
}
|
|
p.grpcWebProxy = grpcweb.WrapServer(p.grpcServer, options...)
|
|
return p
|
|
}
|
|
|
|
// rpcProxy is an RPC proxy server that can take any native gRPC, grpc-web or
|
|
// REST request and delegate it to the correct component. Any grpc-web request
|
|
// is first converted into a native gRPC request. The gRPC call is then handed
|
|
// to our local gRPC server that has all in-process RPC servers registered. If
|
|
// the call is meant for a component that is registered as running in-process,
|
|
// it is handled there. If not, the director will forward the call to either a
|
|
// local or remote lnd instance.
|
|
//
|
|
// any RPC or REST call
|
|
// |
|
|
// V
|
|
// +---+----------------------+
|
|
// | grpc-web proxy |
|
|
// +---+----------------------+
|
|
// |
|
|
// v native gRPC call with basic auth
|
|
// +---+----------------------+
|
|
// | interceptors |
|
|
// +---+----------------------+
|
|
// |
|
|
// v native gRPC call with macaroon
|
|
// +---+----------------------+ registered call
|
|
// | gRPC server +--------------+
|
|
// +---+----------------------+ |
|
|
// | |
|
|
// v non-registered call |
|
|
// +---+----------------------+ +---------v----------+
|
|
// | director | | local subserver |
|
|
// +---+----------------------+ | - loop |
|
|
// | | - faraday |
|
|
// v authenticated call | |
|
|
// +---+----------------------+ +--------------------+
|
|
// | lnd (remote or local) |
|
|
// +--------------------------+
|
|
//
|
|
type rpcProxy struct {
|
|
cfg *Config
|
|
basicAuth string
|
|
|
|
macValidator macaroons.MacaroonValidator
|
|
|
|
lndConn *grpc.ClientConn
|
|
grpcServer *grpc.Server
|
|
grpcWebProxy *grpcweb.WrappedGrpcServer
|
|
}
|
|
|
|
// Start creates initial connection to lnd.
|
|
func (p *rpcProxy) Start() error {
|
|
// Setup the connection to lnd.
|
|
host, _, tlsPath, _, err := p.cfg.lndConnectParams()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.lndConn, err = dialLnd(host, tlsPath)
|
|
if err != nil {
|
|
return fmt.Errorf("could not dial lnd: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop shuts down the lnd connection.
|
|
func (p *rpcProxy) Stop() error {
|
|
p.grpcServer.Stop()
|
|
|
|
if p.lndConn != nil {
|
|
if err := p.lndConn.Close(); err != nil {
|
|
log.Errorf("Error closing lnd connection: %v", err)
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// isHandling checks if the specified request is something to be handled by lnd
|
|
// or any of the attached sub daemons. If true is returned, the call was handled
|
|
// by the RPC proxy and the caller MUST NOT handle it again. If false is
|
|
// returned, the request was not handled and the caller MUST handle it.
|
|
func (p *rpcProxy) isHandling(resp http.ResponseWriter,
|
|
req *http.Request) bool {
|
|
|
|
// gRPC web requests are easy to identify. Send them to the gRPC
|
|
// web proxy.
|
|
if p.grpcWebProxy.IsGrpcWebRequest(req) ||
|
|
p.grpcWebProxy.IsGrpcWebSocketRequest(req) {
|
|
|
|
log.Infof("Handling gRPC web request: %s", req.URL.Path)
|
|
p.grpcWebProxy.ServeHTTP(resp, req)
|
|
|
|
return true
|
|
}
|
|
|
|
// Normal gRPC requests are also easy to identify. These we can
|
|
// send directly to the lnd proxy's gRPC server.
|
|
if isGrpcRequest(req) {
|
|
log.Infof("Handling gRPC request: %s", req.URL.Path)
|
|
p.grpcServer.ServeHTTP(resp, req)
|
|
|
|
return true
|
|
}
|
|
|
|
// TODO(guggero): Handle REST calls as well.
|
|
|
|
return false
|
|
}
|
|
|
|
// director is a function that directs an incoming request to the correct
|
|
// backend, depending on what kind of authentication information is attached to
|
|
// the request.
|
|
func (p *rpcProxy) director(ctx context.Context,
|
|
_ string) (context.Context, *grpc.ClientConn, error) {
|
|
|
|
// If this header is present in the request from the web client,
|
|
// the actual connection to the backend will not be established.
|
|
// https://github.com/improbable-eng/grpc-web/issues/568
|
|
md, _ := metadata.FromIncomingContext(ctx)
|
|
mdCopy := md.Copy()
|
|
delete(mdCopy, "connection")
|
|
|
|
outCtx := metadata.NewOutgoingContext(ctx, mdCopy)
|
|
|
|
return outCtx, p.lndConn, nil
|
|
}
|
|
|
|
// UnaryServerInterceptor is a gRPC interceptor that checks whether the
|
|
// request is authorized by the included macaroons.
|
|
func (p *rpcProxy) UnaryServerInterceptor(
|
|
permissionMap map[string][]bakery.Op) grpc.UnaryServerInterceptor {
|
|
|
|
return func(ctx context.Context, req interface{},
|
|
info *grpc.UnaryServerInfo,
|
|
handler grpc.UnaryHandler) (interface{}, error) {
|
|
|
|
uriPermissions, ok := permissionMap[info.FullMethod]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s: unknown permissions "+
|
|
"required for method", info.FullMethod)
|
|
}
|
|
|
|
// For now, basic authentication is just a quick fix until we
|
|
// have proper macaroon support implemented in the UI. We allow
|
|
// gRPC web requests to have it and "convert" the auth into a
|
|
// proper macaroon now.
|
|
newCtx, err := p.basicAuthToMacaroon(ctx, info.FullMethod)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error upgrading basic auth: %v",
|
|
err)
|
|
}
|
|
|
|
// With the basic auth converted to a macaroon if necessary,
|
|
// let's now validate the macaroon.
|
|
err = p.macValidator.ValidateMacaroon(
|
|
newCtx, uriPermissions, info.FullMethod,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return handler(ctx, req)
|
|
}
|
|
}
|
|
|
|
// StreamServerInterceptor is a GRPC interceptor that checks whether the
|
|
// request is authorized by the included macaroons.
|
|
func (p *rpcProxy) StreamServerInterceptor(
|
|
permissionMap map[string][]bakery.Op) grpc.StreamServerInterceptor {
|
|
|
|
return func(srv interface{}, ss grpc.ServerStream,
|
|
info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
|
|
|
uriPermissions, ok := permissionMap[info.FullMethod]
|
|
if !ok {
|
|
return fmt.Errorf("%s: unknown permissions required "+
|
|
"for method", info.FullMethod)
|
|
}
|
|
|
|
// For now, basic authentication is just a quick fix until we
|
|
// have proper macaroon support implemented in the UI. We allow
|
|
// gRPC web requests to have it and "convert" the auth into a
|
|
// proper macaroon now.
|
|
ctx, err := p.basicAuthToMacaroon(ss.Context(), info.FullMethod)
|
|
if err != nil {
|
|
return fmt.Errorf("error upgrading basic auth: %v", err)
|
|
}
|
|
|
|
// With the basic auth converted to a macaroon if necessary,
|
|
// let's now validate the macaroon.
|
|
err = p.macValidator.ValidateMacaroon(
|
|
ctx, uriPermissions, info.FullMethod,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return handler(srv, ss)
|
|
}
|
|
}
|
|
|
|
// basicAuthToMacaroon checks that the incoming request context has the expected
|
|
// and valid basic authentication header then attaches the correct macaroon to
|
|
// the context so it can be forwarded to the actual gRPC server.
|
|
func (p *rpcProxy) basicAuthToMacaroon(ctx context.Context,
|
|
requestURI string) (context.Context, error) {
|
|
|
|
md, ok := metadata.FromIncomingContext(ctx)
|
|
if !ok {
|
|
return ctx, nil
|
|
}
|
|
|
|
authHeaders := md.Get("authorization")
|
|
if len(authHeaders) == 0 {
|
|
// No basic auth provided, we don't add a macaroon and let the
|
|
// gRPC security interceptor reject the request.
|
|
return ctx, nil
|
|
}
|
|
|
|
// The user specified an authorization header so this is very likely a
|
|
// gRPC Web call from the UI. But we only attach the macaroon if the
|
|
// auth is correct. That way an attacker doesn't know that basic auth
|
|
// is even allowed as the error message will only be the macaroon error
|
|
// from the lnd backend.
|
|
authHeaderParts := strings.Split(authHeaders[0], " ")
|
|
if len(authHeaderParts) != 2 {
|
|
return ctx, nil
|
|
}
|
|
if authHeaderParts[1] != p.basicAuth {
|
|
return ctx, nil
|
|
}
|
|
|
|
var (
|
|
macPath string
|
|
err error
|
|
)
|
|
switch {
|
|
case isLndURI(requestURI):
|
|
_, _, _, macPath, err = p.cfg.lndConnectParams()
|
|
macPath = path.Join(macPath, defaultLndMacaroon)
|
|
|
|
case isLoopURI(requestURI):
|
|
macPath = p.cfg.Loop.MacaroonPath
|
|
|
|
case isFaradayURI(requestURI):
|
|
macPath = p.cfg.Faraday.MacaroonPath
|
|
|
|
default:
|
|
return ctx, fmt.Errorf("unknown gRPC web request: %v",
|
|
requestURI)
|
|
}
|
|
if err != nil {
|
|
return ctx, fmt.Errorf("error getting macaroon path: %v", err)
|
|
}
|
|
|
|
// Now that we know which macaroon to load, do it and attach it to the
|
|
// request context.
|
|
macBytes, err := readMacaroon(macPath)
|
|
if err != nil {
|
|
return ctx, fmt.Errorf("error reading macaroon: %v", err)
|
|
}
|
|
md.Set(HeaderMacaroon, hex.EncodeToString(macBytes))
|
|
return metadata.NewIncomingContext(ctx, md), nil
|
|
}
|
|
|
|
// dialLnd connects to lnd through the given address and uses the given TLS
|
|
// certificate to authenticate the connection.
|
|
func dialLnd(dialAddr, tlsCertPath string) (*grpc.ClientConn, error) {
|
|
|
|
var opts []grpc.DialOption
|
|
tlsConfig, err := credentials.NewClientTLSFromFile(tlsCertPath, "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not read lnd TLS cert %s: %v",
|
|
tlsCertPath, err)
|
|
}
|
|
|
|
opts = append(
|
|
opts,
|
|
|
|
// From the grpxProxy doc: This codec is *crucial* to the
|
|
// functioning of the proxy.
|
|
grpc.WithCodec(grpcProxy.Codec()), // nolint
|
|
grpc.WithTransportCredentials(tlsConfig),
|
|
grpc.WithDefaultCallOptions(maxMsgRecvSize),
|
|
grpc.WithConnectParams(grpc.ConnectParams{
|
|
Backoff: backoff.DefaultConfig,
|
|
MinConnectTimeout: defaultConnectTimeout,
|
|
}),
|
|
)
|
|
|
|
log.Infof("Dialing lnd gRPC server at %s", dialAddr)
|
|
cc, err := grpc.Dial(dialAddr, opts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed dialing backend: %v", err)
|
|
}
|
|
return cc, nil
|
|
}
|
|
|
|
// readMacaroon tries to read the macaroon file at the specified path and create
|
|
// gRPC dial options from it.
|
|
func readMacaroon(macPath string) ([]byte, error) {
|
|
// Load the specified macaroon file.
|
|
macBytes, err := ioutil.ReadFile(macPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to read macaroon path : %v", err)
|
|
}
|
|
|
|
// Make sure it actually is a macaroon by parsing it.
|
|
mac := &macaroon.Macaroon{}
|
|
if err = mac.UnmarshalBinary(macBytes); err != nil {
|
|
return nil, fmt.Errorf("unable to decode macaroon: %v", err)
|
|
}
|
|
|
|
// It's a macaroon alright, let's return the binary data now.
|
|
return macBytes, nil
|
|
}
|
|
|
|
// isGrpcRequest determines if a request is a gRPC request by checking that the
|
|
// "content-type" is "application/grpc" and that the protocol is HTTP/2.
|
|
func isGrpcRequest(req *http.Request) bool {
|
|
contentType := req.Header.Get("content-type")
|
|
return req.ProtoMajor == 2 &&
|
|
strings.HasPrefix(contentType, contentTypeGrpc)
|
|
}
|