mirror of https://github.com/lightninglabs/loop
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
201 lines
6.2 KiB
Go
201 lines
6.2 KiB
Go
package lsat
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"regexp"
|
|
"sync"
|
|
|
|
"github.com/lightninglabs/loop/lndclient"
|
|
"github.com/lightningnetwork/lnd/lnwire"
|
|
"github.com/lightningnetwork/lnd/macaroons"
|
|
"github.com/lightningnetwork/lnd/zpay32"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/metadata"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
const (
|
|
// GRPCErrCode is the error code we receive from a gRPC call if the
|
|
// server expects a payment.
|
|
GRPCErrCode = codes.Internal
|
|
|
|
// GRPCErrMessage is the error message we receive from a gRPC call in
|
|
// conjunction with the GRPCErrCode to signal the client that a payment
|
|
// is required to access the service.
|
|
GRPCErrMessage = "payment required"
|
|
|
|
// AuthHeader is is the HTTP response header that contains the payment
|
|
// challenge.
|
|
AuthHeader = "WWW-Authenticate"
|
|
|
|
// MaxRoutingFee is the maximum routing fee in satoshis that we are
|
|
// going to pay to acquire an LSAT token.
|
|
// TODO(guggero): make this configurable
|
|
MaxRoutingFeeSats = 10
|
|
)
|
|
|
|
var (
|
|
// authHeaderRegex is the regular expression the payment challenge must
|
|
// match for us to be able to parse the macaroon and invoice.
|
|
authHeaderRegex = regexp.MustCompile(
|
|
"LSAT macaroon='(.*?)' invoice='(.*?)'",
|
|
)
|
|
)
|
|
|
|
// Interceptor is a gRPC client interceptor that can handle LSAT authentication
|
|
// challenges with embedded payment requests. It uses a connection to lnd to
|
|
// automatically pay for an authentication token.
|
|
type Interceptor struct {
|
|
lnd *lndclient.LndServices
|
|
store Store
|
|
lock sync.Mutex
|
|
}
|
|
|
|
// NewInterceptor creates a new gRPC client interceptor that uses the provided
|
|
// lnd connection to automatically acquire and pay for LSAT tokens, unless the
|
|
// indicated store already contains a usable token.
|
|
func NewInterceptor(lnd *lndclient.LndServices, store Store) *Interceptor {
|
|
return &Interceptor{
|
|
lnd: lnd,
|
|
store: store,
|
|
}
|
|
}
|
|
|
|
// UnaryInterceptor is an interceptor method that can be used directly by gRPC
|
|
// for unary calls. If the store contains a token, it is attached as credentials
|
|
// to every call before patching it through. The response error is also
|
|
// intercepted for every call. If there is an error returned and it is
|
|
// indicating a payment challenge, a token is acquired and paid for
|
|
// automatically. The original request is then repeated back to the server, now
|
|
// with the new token attached.
|
|
func (i *Interceptor) UnaryInterceptor(ctx context.Context, method string,
|
|
req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker,
|
|
opts ...grpc.CallOption) error {
|
|
|
|
// To avoid paying for a token twice if two parallel requests are
|
|
// happening, we require an exclusive lock here.
|
|
i.lock.Lock()
|
|
defer i.lock.Unlock()
|
|
|
|
addLsatCredentials := func(token *Token) error {
|
|
macaroon, err := token.PaidMacaroon()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
opts = append(opts, grpc.PerRPCCredentials(
|
|
macaroons.NewMacaroonCredential(macaroon),
|
|
))
|
|
return nil
|
|
}
|
|
|
|
// If we already have a token, let's append it.
|
|
if i.store.HasToken() {
|
|
lsat, err := i.store.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = addLsatCredentials(lsat); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// We need a way to extract the response headers sent by the
|
|
// server. This can only be done through the experimental
|
|
// grpc.Trailer call option.
|
|
// We execute the request and inspect the error. If it's the
|
|
// LSAT specific payment required error, we might execute the
|
|
// same method again later with the paid LSAT token.
|
|
trailerMetadata := &metadata.MD{}
|
|
opts = append(opts, grpc.Trailer(trailerMetadata))
|
|
err := invoker(ctx, method, req, reply, cc, opts...)
|
|
|
|
// Only handle the LSAT error message that comes in the form of
|
|
// a gRPC status error.
|
|
if isPaymentRequired(err) {
|
|
lsat, err := i.payLsatToken(ctx, trailerMetadata)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = addLsatCredentials(lsat); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Execute the same request again, now with the LSAT
|
|
// token added as an RPC credential.
|
|
return invoker(ctx, method, req, reply, cc, opts...)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// payLsatToken reads the payment challenge from the response metadata and tries
|
|
// to pay the invoice encoded in them, returning a paid LSAT token if
|
|
// successful.
|
|
func (i *Interceptor) payLsatToken(ctx context.Context, md *metadata.MD) (
|
|
*Token, error) {
|
|
|
|
// First parse the authentication header that was stored in the
|
|
// metadata.
|
|
authHeader := md.Get(AuthHeader)
|
|
if len(authHeader) == 0 {
|
|
return nil, fmt.Errorf("auth header not found in response")
|
|
}
|
|
matches := authHeaderRegex.FindStringSubmatch(authHeader[0])
|
|
if len(matches) != 3 {
|
|
return nil, fmt.Errorf("invalid auth header "+
|
|
"format: %s", authHeader[0])
|
|
}
|
|
|
|
// Decode the base64 macaroon and the invoice so we can store the
|
|
// information in our store later.
|
|
macBase64, invoiceStr := matches[1], matches[2]
|
|
macBytes, err := base64.StdEncoding.DecodeString(macBase64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("base64 decode of macaroon failed: "+
|
|
"%v", err)
|
|
}
|
|
invoice, err := zpay32.Decode(invoiceStr, i.lnd.ChainParams)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to decode invoice: %v", err)
|
|
}
|
|
|
|
// Pay invoice now and wait for the result to arrive or the main context
|
|
// being canceled.
|
|
// TODO(guggero): Store payment information so we can track the payment
|
|
// later in case the client shuts down while the payment is in flight.
|
|
respChan := i.lnd.Client.PayInvoice(
|
|
ctx, invoiceStr, MaxRoutingFeeSats, nil,
|
|
)
|
|
select {
|
|
case result := <-respChan:
|
|
if result.Err != nil {
|
|
return nil, result.Err
|
|
}
|
|
token, err := NewToken(
|
|
macBytes, invoice.PaymentHash, result.Preimage,
|
|
lnwire.NewMSatFromSatoshis(result.PaidAmt),
|
|
lnwire.NewMSatFromSatoshis(result.PaidFee),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to create token: %v",
|
|
err)
|
|
}
|
|
return token, i.store.StoreToken(token)
|
|
|
|
case <-ctx.Done():
|
|
return nil, fmt.Errorf("context canceled")
|
|
}
|
|
}
|
|
|
|
// isPaymentRequired inspects an error to find out if it's the specific gRPC
|
|
// error returned by the server to indicate a payment is required to access the
|
|
// service.
|
|
func isPaymentRequired(err error) bool {
|
|
statusErr, ok := status.FromError(err)
|
|
return ok &&
|
|
statusErr.Message() == GRPCErrMessage &&
|
|
statusErr.Code() == GRPCErrCode
|
|
}
|