diff --git a/channelAcceptor.go b/channelAcceptor.go new file mode 100644 index 0000000..5656eb5 --- /dev/null +++ b/channelAcceptor.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "encoding/binary" + "encoding/hex" + "fmt" + "sync" + + "github.com/lightningnetwork/lnd/lnrpc" + log "github.com/sirupsen/logrus" +) + +func dispatchChannelAcceptor(ctx context.Context) { + client := ctx.Value(clientKey).(lnrpc.LightningClient) + + // wait group for channel acceptor + defer ctx.Value(ctxKeyWaitGroup).(*sync.WaitGroup).Done() + // get the lnd grpc connection + acceptClient, err := client.ChannelAcceptor(ctx) + if err != nil { + panic(err) + } + log.Infof("Listening for incoming channel requests") + for { + req := lnrpc.ChannelAcceptRequest{} + err = acceptClient.RecvMsg(&req) + if err != nil { + log.Errorf(err.Error()) + } + + // print the incoming channel request + alias, err := getNodeAlias(ctx, hex.EncodeToString(req.NodePubkey)) + if err != nil { + log.Errorf(err.Error()) + } + var node_info_string string + if alias != "" { + node_info_string = fmt.Sprintf("%s (%s)", alias, hex.EncodeToString(req.NodePubkey)) + } else { + node_info_string = hex.EncodeToString(req.NodePubkey) + } + log.Debugf("New channel request from %s", node_info_string) + + var accept bool + + if Configuration.ChannelMode == "whitelist" { + accept = false + for _, pubkey := range Configuration.ChannelWhitelist { + if hex.EncodeToString(req.NodePubkey) == pubkey { + accept = true + break + } + } + } else if Configuration.ChannelMode == "blacklist" { + accept = true + for _, pubkey := range Configuration.ChannelBlacklist { + if hex.EncodeToString(req.NodePubkey) == pubkey { + accept = false + break + } + } + } + + var channel_info_string string + if alias != "" { + channel_info_string = fmt.Sprintf("from %s (%s, %d sat, chan_id:%d)", alias, trimPubKey(req.NodePubkey), req.FundingAmt, binary.BigEndian.Uint64(req.PendingChanId)) + } else { + channel_info_string = fmt.Sprintf("from %s (%d sat, chan_id:%d)", trimPubKey(req.NodePubkey), req.FundingAmt, binary.BigEndian.Uint64(req.PendingChanId)) + } + + res := lnrpc.ChannelAcceptResponse{} + if accept { + log.Infof("✅ [channel-mode %s] Allow channel %s", Configuration.ChannelMode, channel_info_string) + res = lnrpc.ChannelAcceptResponse{Accept: true, + PendingChanId: req.PendingChanId, + CsvDelay: req.CsvDelay, + MaxHtlcCount: req.MaxAcceptedHtlcs, + ReserveSat: req.ChannelReserve, + InFlightMaxMsat: req.MaxValueInFlight, + MinHtlcIn: req.MinHtlc, + } + + } else { + log.Infof("❌ [channel-mode %s] Reject channel %s", Configuration.ChannelMode, channel_info_string) + res = lnrpc.ChannelAcceptResponse{Accept: false, + Error: Configuration.ChannelRejectMessage} + } + err = acceptClient.Send(&res) + if err != nil { + log.Errorf(err.Error()) + } + } +} diff --git a/config.go b/config.go index 213d618..47cd2d8 100644 --- a/config.go +++ b/config.go @@ -8,13 +8,17 @@ import ( ) var Configuration = struct { - Mode string `yaml:"mode"` - Host string `yaml:"host"` - MacaroonPath string `yaml:"macaroon_path"` - TLSPath string `yaml:"tls_path"` - Whitelist []string `yaml:"whitelist"` - Blacklist []string `yaml:"blacklist"` - RejectMessage string `yaml:"reject_message"` + ChannelMode string `yaml:"channel-mode"` + Host string `yaml:"host"` + MacaroonPath string `yaml:"macaroon_path"` + TLSPath string `yaml:"tls-path"` + Debug bool `yaml:"debug"` + ChannelWhitelist []string `yaml:"channel-whitelist"` + ChannelBlacklist []string `yaml:"channel-blacklist"` + ChannelRejectMessage string `yaml:"channel-reject-message"` + ForwardMode string `yaml:"forward-mode"` + ForwardWhitelist []string `yaml:"forward-whitelist"` + ForwardBlacklist []string `yaml:"forward-blacklist"` }{} func init() { @@ -26,20 +30,39 @@ func init() { } func checkConfig() { + setLogger(Configuration.Debug) + welcome() + if Configuration.Host == "" { panic(fmt.Errorf("no host specified in config.yaml")) } - - if len(Configuration.Whitelist) == 0 { - panic(fmt.Errorf("no accepted pubkeys specified in config.yaml")) + if Configuration.MacaroonPath == "" { + panic(fmt.Errorf("no macaroon path specified in config.yaml")) + } + if Configuration.TLSPath == "" { + panic(fmt.Errorf("no tls path specified in config.yaml")) } - if len(Configuration.RejectMessage) > 500 { - log.Warnf("reject message is too long. Trimming to 500 characters.") - Configuration.RejectMessage = Configuration.RejectMessage[:500] + if len(Configuration.ChannelRejectMessage) > 500 { + log.Warnf("channel reject message is too long. Trimming to 500 characters.") + Configuration.ChannelRejectMessage = Configuration.ChannelRejectMessage[:500] } - if len(Configuration.Mode) == 0 { - Configuration.Mode = "blacklist" + + if len(Configuration.ChannelMode) == 0 { + Configuration.ChannelMode = "blacklist" } - log.Infof("Running in %s mode", Configuration.Mode) + if Configuration.ChannelMode != "whitelist" && Configuration.ChannelMode != "blacklist" { + panic(fmt.Errorf("channel mode must be either whitelist or blacklist")) + } + + log.Infof("Channel acceptor running in %s mode", Configuration.ForwardMode) + + if len(Configuration.ForwardMode) == 0 { + Configuration.ForwardMode = "blacklist" + } + if Configuration.ForwardMode != "whitelist" && Configuration.ForwardMode != "blacklist" { + panic(fmt.Errorf("channel mode must be either whitelist or blacklist")) + } + + log.Infof("HTLC forwarder running in %s mode", Configuration.ForwardMode) } diff --git a/config.yaml.example b/config.yaml.example index 7a1f047..fc5b587 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -1,17 +1,31 @@ -# Mode can either be "blacklist" or "whitelist" -mode: "blacklist" - # Credentials for your node host: "127.0.0.1:10009" macaroon_path: "/home/bitcoin/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" -tls_path: "/home/bitcoin/.lnd/tls.cert" +tls-path: "/home/bitcoin/.lnd/tls.cert" + +# ----- Channel accept ----- + +# Mode can either be "blacklist" or "whitelist" +channel-mode: "blacklist" # This error message will be sent to the other party upon a reject -reject_message: "Contact me at user@email.com" +channel-reject-message: "Contact me at user@email.com" # List of nodes to whitelist or blacklist -whitelist: +channel-whitelist: - "03de70865239e99460041e127647b37101b9eb335b3c22de95c944671f0dabc2d0" - "0307299a290529c5ccb3a5e3bd2eb504daf64cc65c6d65b582c01cbd7e5ede14b6" -blacklist: - - "02853f9c1d15d479b433039885373b681683b84bb73e86dff861bee6697c17c1de" \ No newline at end of file +channel-blacklist: + - "02853f9c1d15d479b433039885373b681683b84bb73e86dff861bee6697c17c1de" + +# ----- HTLC forwarding ----- + +# Mode can either be "blacklist" or "whitelist" +forward-mode: "whitelist" + +# List of channel IDs to whitelist or blacklist +forward-whitelist: + - "229797930270721" +forward-blacklist: + - "131941395398657" + - "195713069809665" \ No newline at end of file diff --git a/htlcInterceptor.go b/htlcInterceptor.go new file mode 100644 index 0000000..dae7a51 --- /dev/null +++ b/htlcInterceptor.go @@ -0,0 +1,208 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" + "github.com/lightningnetwork/lnd/routing/route" + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" +) + +func dispatchHTLCAcceptor(ctx context.Context) { + conn := ctx.Value(connKey).(*grpc.ClientConn) + router := routerrpc.NewRouterClient(conn) + + // htlc event subscriber, reports on incoming htlc events + stream, err := router.SubscribeHtlcEvents(ctx, &routerrpc.SubscribeHtlcEventsRequest{}) + if err != nil { + return + } + + go func() { + err := logHtlcEvents(ctx, stream) + if err != nil { + log.Error("htlc events error", + "err", err) + } + }() + + // interceptor, decide whether to accept or reject + interceptor, err := router.HtlcInterceptor(ctx) + if err != nil { + return + } + + go func() { + err := interceptHtlcEvents(ctx, interceptor) + if err != nil { + log.Error("interceptor error", + "err", err) + } + }() + + log.Info("Listening for incoming HTLCs") +} + +func logHtlcEvents(ctx context.Context, stream routerrpc.Router_SubscribeHtlcEventsClient) error { + for { + event, err := stream.Recv() + if err != nil { + return err + } + + // we only care about HTLC forward events + if event.EventType != routerrpc.HtlcEvent_FORWARD { + continue + } + + switch event.Event.(type) { + case *routerrpc.HtlcEvent_SettleEvent: + log.Debugf("HTLC SettleEvent (chan_id:%d, htlc_id:%d)", event.IncomingChannelId, event.IncomingHtlcId) + + case *routerrpc.HtlcEvent_ForwardFailEvent: + log.Debugf("HTLC ForwardFailEvent (chan_id:%d, htlc_id:%d)", event.IncomingChannelId, event.IncomingHtlcId) + + case *routerrpc.HtlcEvent_ForwardEvent: + log.Debugf("HTLC ForwardEvent (chan_id:%d, htlc_id:%d)", event.IncomingChannelId, event.IncomingHtlcId) + + case *routerrpc.HtlcEvent_LinkFailEvent: + log.Debugf("HTLC LinkFailEvent (chan_id:%d, htlc_id:%d)", event.IncomingChannelId, event.IncomingHtlcId) + } + + } +} + +func interceptHtlcEvents(ctx context.Context, interceptor routerrpc.Router_HtlcInterceptorClient) error { + for { + event, err := interceptor.Recv() + if err != nil { + return err + } + go func() { + // decision for routing + decision_chan := make(chan bool, 1) + go htlcInterceptDecision(ctx, event, decision_chan) + + channelEdge, err := getPubKeyFromChannel(ctx, event.IncomingCircuitKey.ChanId) + if err != nil { + log.Error("Error getting pubkey for channel %d", event.IncomingCircuitKey.ChanId) + } + alias, err := getNodeAlias(ctx, channelEdge.node1Pub.String()) + if err != nil { + log.Errorf(err.Error()) + } + + var forward_info_string string + if alias != "" { + forward_info_string = fmt.Sprintf("from %s (%d sat, htlc_id:%d, chan_id:%d->%d)", alias, event.IncomingAmountMsat/1000, event.IncomingCircuitKey.HtlcId, event.IncomingCircuitKey.ChanId, event.OutgoingRequestedChanId) + } else { + forward_info_string = fmt.Sprintf("(%d sat, htlc_id:%d, chan_id:%d->%d)", event.IncomingAmountMsat/1000, event.IncomingCircuitKey.HtlcId, event.IncomingCircuitKey.ChanId, event.OutgoingRequestedChanId) + } + response := &routerrpc.ForwardHtlcInterceptResponse{ + IncomingCircuitKey: event.IncomingCircuitKey, + } + if <-decision_chan { + log.Infof("✅ [forward-mode %s] Accept HTLC %s", Configuration.ForwardMode, forward_info_string) + response.Action = routerrpc.ResolveHoldForwardAction_RESUME + } else { + log.Infof("❌ [forward-mode %s] Reject HTLC %s", Configuration.ForwardMode, forward_info_string) + response.Action = routerrpc.ResolveHoldForwardAction_FAIL + } + err = interceptor.Send(response) + if err != nil { + return + } + }() + } +} + +func htlcInterceptDecision(ctx context.Context, event *routerrpc.ForwardHtlcInterceptRequest, decision_chan chan bool) { + var accept bool + + if Configuration.ForwardMode == "whitelist" { + accept = false + for _, channel_id := range Configuration.ForwardWhitelist { + chan_id_int, err := strconv.ParseUint(channel_id, 10, 64) + if err != nil { + log.Error("Error parsing channel id %s", channel_id) + break + } + if event.IncomingCircuitKey.ChanId == chan_id_int { + accept = true + break + } + } + } else if Configuration.ForwardMode == "blacklist" { + accept = true + for _, channel_id := range Configuration.ForwardBlacklist { + chan_id_int, err := strconv.ParseUint(channel_id, 10, 64) + if err != nil { + log.Error("Error parsing channel id %s", channel_id) + break + } + if event.IncomingCircuitKey.ChanId == chan_id_int { + accept = false + break + } + } + } + decision_chan <- accept +} + +// Heavily inspired by by Joost Jager's circuitbreaker +func getNodeAlias(ctx context.Context, pubkey string) (string, error) { + client := ctx.Value(clientKey).(lnrpc.LightningClient) + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + info, err := client.GetNodeInfo(ctx, &lnrpc.NodeInfoRequest{ + PubKey: pubkey, + }) + if err != nil { + return "", err + } + + if info.Node == nil { + return "", errors.New("node info not available") + } + + return info.Node.Alias, nil +} + +type channelEdge struct { + node1Pub, node2Pub route.Vertex +} + +func getPubKeyFromChannel(ctx context.Context, chan_id uint64) (*channelEdge, error) { + client := ctx.Value(clientKey).(lnrpc.LightningClient) + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + info, err := client.GetChanInfo(ctx, &lnrpc.ChanInfoRequest{ + ChanId: chan_id, + }) + if err != nil { + return nil, err + } + + node1Pub, err := route.NewVertexFromStr(info.Node1Pub) + if err != nil { + return nil, err + } + + node2Pub, err := route.NewVertexFromStr(info.Node2Pub) + if err != nil { + return nil, err + } + + return &channelEdge{ + node1Pub: node1Pub, + node2Pub: node2Pub, + }, nil +} diff --git a/main.go b/main.go index 9fc528e..1da3470 100644 --- a/main.go +++ b/main.go @@ -2,9 +2,8 @@ package main import ( "context" - "encoding/hex" - "fmt" "io/ioutil" + "sync" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/macaroons" @@ -15,6 +14,17 @@ import ( "gopkg.in/macaroon.v2" ) +type key int + +const ( + ctxKeyWaitGroup key = iota +) + +type ContextKey string + +var connKey ContextKey = "connKey" +var clientKey ContextKey = "clientKey" + // gets the lnd grpc connection func getClientConnection(ctx context.Context) (*grpc.ClientConn, error) { creds, err := credentials.NewClientTLSFromFile(Configuration.TLSPath, "") @@ -47,70 +57,26 @@ func getClientConnection(ctx context.Context) (*grpc.ClientConn, error) { } -func trimPubKey(pubkey []byte) string { - return fmt.Sprintf("%s...%s", hex.EncodeToString(pubkey)[:6], hex.EncodeToString(pubkey)[len(hex.EncodeToString(pubkey))-6:]) -} - func main() { - conn, err := getClientConnection(context.Background()) + ctx := context.Background() + conn, err := getClientConnection(ctx) if err != nil { panic(err) } client := lnrpc.NewLightningClient(conn) - acceptClient, err := client.ChannelAcceptor(context.Background()) - if err != nil { - panic(err) - } - log.Infof("Listening for incoming channel requests") - for { - req := lnrpc.ChannelAcceptRequest{} - err = acceptClient.RecvMsg(&req) - if err != nil { - log.Errorf(err.Error()) - } - log.Infof("New channel request from %s", hex.EncodeToString(req.NodePubkey)) - var accept bool + var wg sync.WaitGroup + ctx = context.WithValue(ctx, ctxKeyWaitGroup, &wg) + wg.Add(1) - if Configuration.Mode == "whitelist" { - accept = false - for _, pubkey := range Configuration.Whitelist { - if hex.EncodeToString(req.NodePubkey) == pubkey { - accept = true - break - } - } - } else if Configuration.Mode == "blacklist" { - accept = true - for _, pubkey := range Configuration.Blacklist { - if hex.EncodeToString(req.NodePubkey) == pubkey { - accept = false - break - } - } - } + ctx = context.WithValue(ctx, clientKey, client) + ctx = context.WithValue(ctx, connKey, conn) - res := lnrpc.ChannelAcceptResponse{} - if accept { - log.Infof("✅ [%s mode] Allow channel from %s", Configuration.Mode, trimPubKey(req.NodePubkey)) - res = lnrpc.ChannelAcceptResponse{Accept: true, - PendingChanId: req.PendingChanId, - CsvDelay: req.CsvDelay, - MaxHtlcCount: req.MaxAcceptedHtlcs, - ReserveSat: req.ChannelReserve, - InFlightMaxMsat: req.MaxValueInFlight, - MinHtlcIn: req.MinHtlc, - } + // channel acceptor + go dispatchChannelAcceptor(ctx) - } else { - log.Infof("❌ [%s mode] Deny channel from %s", Configuration.Mode, trimPubKey(req.NodePubkey)) - res = lnrpc.ChannelAcceptResponse{Accept: false, - Error: Configuration.RejectMessage} - } - err = acceptClient.Send(&res) - if err != nil { - log.Errorf(err.Error()) - } - } + // htlc acceptor + go dispatchHTLCAcceptor(ctx) + wg.Wait() }