Skip to content

Commit 8cecae5

Browse files
committed
lsat: add unary interceptor and simple store
1 parent 49cbe9a commit 8cecae5

File tree

5 files changed

+504
-6
lines changed

5 files changed

+504
-6
lines changed

client.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/btcsuite/btcutil"
1313
"github.com/lightninglabs/loop/lndclient"
1414
"github.com/lightninglabs/loop/loopdb"
15+
"github.com/lightninglabs/loop/lsat"
1516
"github.com/lightninglabs/loop/swap"
1617
"github.com/lightninglabs/loop/sweep"
1718
"github.com/lightningnetwork/lnd/lntypes"
@@ -78,9 +79,13 @@ func NewClient(dbDir string, serverAddress string, insecure bool,
7879
if err != nil {
7980
return nil, nil, err
8081
}
82+
lsatStore, err := lsat.NewFileStore(dbDir)
83+
if err != nil {
84+
return nil, nil, err
85+
}
8186

8287
swapServerClient, err := newSwapServerClient(
83-
serverAddress, insecure, tlsPathServer,
88+
serverAddress, insecure, tlsPathServer, lsatStore, lnd,
8489
)
8590
if err != nil {
8691
return nil, nil, err

lsat/interceptor.go

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package lsat
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"fmt"
7+
"regexp"
8+
"sync"
9+
10+
"github.com/lightninglabs/loop/lndclient"
11+
"github.com/lightningnetwork/lnd/lnwire"
12+
"github.com/lightningnetwork/lnd/macaroons"
13+
"github.com/lightningnetwork/lnd/zpay32"
14+
"google.golang.org/grpc"
15+
"google.golang.org/grpc/codes"
16+
"google.golang.org/grpc/metadata"
17+
"google.golang.org/grpc/status"
18+
)
19+
20+
const (
21+
// GRPCErrCode is the error code we receive from a gRPC call if the
22+
// server expects a payment.
23+
GRPCErrCode = codes.Internal
24+
25+
// GRPCErrMessage is the error message we receive from a gRPC call in
26+
// conjunction with the GRPCErrCode to signal the client that a payment
27+
// is required to access the service.
28+
GRPCErrMessage = "payment required"
29+
30+
// AuthHeader is is the HTTP response header that contains the payment
31+
// challenge.
32+
AuthHeader = "WWW-Authenticate"
33+
34+
// MaxRoutingFee is the maximum routing fee in satoshis that we are
35+
// going to pay to acquire an LSAT token.
36+
// TODO(guggero): make this configurable
37+
MaxRoutingFeeSats = 10
38+
)
39+
40+
var (
41+
// authHeaderRegex is the regular expression the payment challenge must
42+
// match for us to be able to parse the macaroon and invoice.
43+
authHeaderRegex = regexp.MustCompile(
44+
"LSAT macaroon='(.*?)' invoice='(.*?)'",
45+
)
46+
)
47+
48+
// Interceptor is a gRPC client interceptor that can handle LSAT authentication
49+
// challenges with embedded payment requests. It uses a connection to lnd to
50+
// automatically pay for an authentication token.
51+
type Interceptor struct {
52+
lnd *lndclient.LndServices
53+
store Store
54+
lock sync.Mutex
55+
}
56+
57+
// NewInterceptor creates a new gRPC client interceptor that uses the provided
58+
// lnd connection to automatically acquire and pay for LSAT tokens, unless the
59+
// indicated store already contains a usable token.
60+
func NewInterceptor(lnd *lndclient.LndServices, store Store) *Interceptor {
61+
return &Interceptor{
62+
lnd: lnd,
63+
store: store,
64+
}
65+
}
66+
67+
// UnaryInterceptor is an interceptor method that can be used directly by gRPC
68+
// for unary calls. If the store contains a token, it is attached as credentials
69+
// to every call before patching it through. The response error is also
70+
// intercepted for every call. If there is an error returned and it is
71+
// indicating a payment challenge, a token is acquired and paid for
72+
// automatically. The original request is then repeated back to the server, now
73+
// with the new token attached.
74+
func (i *Interceptor) UnaryInterceptor(ctx context.Context, method string,
75+
req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker,
76+
opts ...grpc.CallOption) error {
77+
78+
// To avoid paying for a token twice if two parallel requests are
79+
// happening, we require an exclusive lock here.
80+
i.lock.Lock()
81+
defer i.lock.Unlock()
82+
83+
addLsatCredentials := func(token *Token) error {
84+
macaroon, err := token.PaidMacaroon()
85+
if err != nil {
86+
return err
87+
}
88+
opts = append(opts, grpc.PerRPCCredentials(
89+
macaroons.NewMacaroonCredential(macaroon),
90+
))
91+
return nil
92+
}
93+
94+
// If we already have a token, let's append it.
95+
if i.store.HasToken() {
96+
lsat, err := i.store.Token()
97+
if err != nil {
98+
return err
99+
}
100+
if err = addLsatCredentials(lsat); err != nil {
101+
return err
102+
}
103+
}
104+
105+
// We need a way to extract the response headers sent by the
106+
// server. This can only be done through the experimental
107+
// grpc.Trailer call option.
108+
// We execute the request and inspect the error. If it's the
109+
// LSAT specific payment required error, we might execute the
110+
// same method again later with the paid LSAT token.
111+
trailerMetadata := &metadata.MD{}
112+
opts = append(opts, grpc.Trailer(trailerMetadata))
113+
err := invoker(ctx, method, req, reply, cc, opts...)
114+
115+
// Only handle the LSAT error message that comes in the form of
116+
// a gRPC status error.
117+
if isPaymentRequired(err) {
118+
lsat, err := i.payLsatToken(ctx, trailerMetadata)
119+
if err != nil {
120+
return err
121+
}
122+
if err = addLsatCredentials(lsat); err != nil {
123+
return err
124+
}
125+
126+
// Execute the same request again, now with the LSAT
127+
// token added as an RPC credential.
128+
return invoker(ctx, method, req, reply, cc, opts...)
129+
}
130+
return err
131+
}
132+
133+
// payLsatToken reads the payment challenge from the response metadata and tries
134+
// to pay the invoice encoded in them, returning a paid LSAT token if
135+
// successful.
136+
func (i *Interceptor) payLsatToken(ctx context.Context, md *metadata.MD) (
137+
*Token, error) {
138+
139+
// First parse the authentication header that was stored in the
140+
// metadata.
141+
authHeader := md.Get(AuthHeader)
142+
if len(authHeader) == 0 {
143+
return nil, fmt.Errorf("auth header not found in response")
144+
}
145+
matches := authHeaderRegex.FindStringSubmatch(authHeader[0])
146+
if len(matches) != 3 {
147+
return nil, fmt.Errorf("invalid auth header "+
148+
"format: %s", authHeader[0])
149+
}
150+
151+
// Decode the base64 macaroon and the invoice so we can store the
152+
// information in our store later.
153+
macBase64, invoiceStr := matches[1], matches[2]
154+
macBytes, err := base64.StdEncoding.DecodeString(macBase64)
155+
if err != nil {
156+
return nil, fmt.Errorf("base64 decode of macaroon failed: "+
157+
"%v", err)
158+
}
159+
invoice, err := zpay32.Decode(invoiceStr, i.lnd.ChainParams)
160+
if err != nil {
161+
return nil, fmt.Errorf("unable to decode invoice: %v", err)
162+
}
163+
164+
// Pay invoice now and wait for the result to arrive or the main context
165+
// being canceled.
166+
// TODO(guggero): Store payment information so we can track the payment
167+
// later in case the client shuts down while the payment is in flight.
168+
respChan := i.lnd.Client.PayInvoice(
169+
ctx, invoiceStr, MaxRoutingFeeSats, nil,
170+
)
171+
select {
172+
case result := <-respChan:
173+
if result.Err != nil {
174+
return nil, result.Err
175+
}
176+
token, err := NewToken(
177+
macBytes, invoice.PaymentHash, result.Preimage,
178+
lnwire.NewMSatFromSatoshis(result.PaidAmt),
179+
lnwire.NewMSatFromSatoshis(result.PaidFee),
180+
)
181+
if err != nil {
182+
return nil, fmt.Errorf("unable to create token: %v",
183+
err)
184+
}
185+
return token, i.store.StoreToken(token)
186+
187+
case <-ctx.Done():
188+
return nil, fmt.Errorf("context canceled")
189+
}
190+
}
191+
192+
// isPaymentRequired inspects an error to find out if it's the specific gRPC
193+
// error returned by the server to indicate a payment is required to access the
194+
// service.
195+
func isPaymentRequired(err error) bool {
196+
statusErr, ok := status.FromError(err)
197+
return ok &&
198+
statusErr.Message() == GRPCErrMessage &&
199+
statusErr.Code() == GRPCErrCode
200+
}

lsat/store.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package lsat
2+
3+
import (
4+
"fmt"
5+
"io/ioutil"
6+
"os"
7+
"path/filepath"
8+
)
9+
10+
var (
11+
// ErrNoToken is the error returned when the store doesn't contain a
12+
// token yet.
13+
ErrNoToken = fmt.Errorf("no token in store")
14+
15+
storeFileName = "lsat.token"
16+
)
17+
18+
// Store is an interface that allows users to store and retrieve an LSAT token.
19+
type Store interface {
20+
// HasToken returns true if the store contains a token.
21+
HasToken() bool
22+
23+
// Token returns the token that is contained in the store or an error
24+
// if there is none.
25+
Token() (*Token, error)
26+
27+
// StoreToken saves a token to the store, overwriting any old token if
28+
// there is one.
29+
StoreToken(*Token) error
30+
}
31+
32+
// FileStore is an implementation of the Store interface that uses a single file
33+
// to save the serialized token.
34+
type FileStore struct {
35+
fileName string
36+
}
37+
38+
// A compile-time flag to ensure that FileStore implements the Store interface.
39+
var _ Store = (*FileStore)(nil)
40+
41+
// NewFileStore creates a new file based token store, creating its file in the
42+
// provided directory. If the directory does not exist, it will be created.
43+
func NewFileStore(storeDir string) (*FileStore, error) {
44+
// If the target path for the token store doesn't exist, then we'll
45+
// create it now before we proceed.
46+
if !fileExists(storeDir) {
47+
if err := os.MkdirAll(storeDir, 0700); err != nil {
48+
return nil, err
49+
}
50+
}
51+
52+
return &FileStore{
53+
fileName: filepath.Join(storeDir, storeFileName),
54+
}, nil
55+
}
56+
57+
// HasToken returns true if the store contains a token.
58+
//
59+
// NOTE: This is part of the Store interface.
60+
func (f *FileStore) HasToken() bool {
61+
return fileExists(f.fileName)
62+
}
63+
64+
// Token returns the token that is contained in the store or an error if there
65+
// is none.
66+
//
67+
// NOTE: This is part of the Store interface.
68+
func (f *FileStore) Token() (*Token, error) {
69+
if !f.HasToken() {
70+
return nil, ErrNoToken
71+
}
72+
bytes, err := ioutil.ReadFile(f.fileName)
73+
if err != nil {
74+
return nil, err
75+
}
76+
return deserializeToken(bytes)
77+
}
78+
79+
// StoreToken saves a token to the store, overwriting any old token if there is
80+
// one.
81+
//
82+
// NOTE: This is part of the Store interface.
83+
func (f *FileStore) StoreToken(token *Token) error {
84+
bytes, err := serializeToken(token)
85+
if err != nil {
86+
return err
87+
}
88+
return ioutil.WriteFile(f.fileName, bytes, 0600)
89+
}
90+
91+
// fileExists returns true if the file exists, and false otherwise.
92+
func fileExists(path string) bool {
93+
if _, err := os.Stat(path); err != nil {
94+
if os.IsNotExist(err) {
95+
return false
96+
}
97+
}
98+
99+
return true
100+
}

0 commit comments

Comments
 (0)