cmd/lncli: move commands and export

We want to export some of our CLI code to re-use in other projects. But
in Golang you cannot import code from a `main` package.
So we need to move the actual code into its own package and only have
the `func main()` in the `main` package.
This commit is contained in:
Oliver Gugger committed 2024-08-23 10:57:59 +02:00
1 parent 75477c8896
commit 22a3988222
43 files changed
+841 -674

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
package commands
import (
"regexp"
"strconv"
"strings"
"time"
)
// reTimeRange matches systemd.time-like short negative timeranges, e.g. "-200s".
var reTimeRange = regexp.MustCompile(`^-\d{1,18}[s|m|h|d|w|M|y]$`)
// secondsPer allows translating s(seconds), m(minutes), h(ours), d(ays),
// w(eeks), M(onths) and y(ears) into corresponding seconds.
var secondsPer = map[string]int64{
"s": 1,
"m": 60,
"h": 3600,
"d": 86400,
"w": 604800,
"M": 2630016, // 30.44 days
"y": 31557600, // 365.25 days
}
// parseTime parses UNIX timestamps or short timeranges inspired by systemd
// (when starting with "-"), e.g. "-1M" for one month (30.44 days) ago.
func parseTime(s string, base time.Time) (uint64, error) {
if reTimeRange.MatchString(s) {
last := len(s) - 1
d, err := strconv.ParseInt(s[1:last], 10, 64)
if err != nil {
return uint64(0), err
}
mul := secondsPer[string(s[last])]
return uint64(base.Unix() - d*mul), nil
}
return strconv.ParseUint(s, 10, 64)
}
var lightningPrefix = "lightning:"
// stripPrefix removes accidentally copied 'lightning:' prefix.
func stripPrefix(s string) string {
return strings.TrimSpace(strings.TrimPrefix(s, lightningPrefix))
}
+117
View File
@@ -0,0 +1,117 @@
package commands
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
var now = time.Date(2017, 11, 10, 7, 8, 9, 1234, time.UTC)
var partTimeTests = []struct {
in string
expected uint64
errExpected bool
}{
{
"12345",
uint64(12345),
false,
},
{
"-0s",
uint64(now.Unix()),
false,
},
{
"-1s",
uint64(time.Date(2017, 11, 10, 7, 8, 8, 1234, time.UTC).Unix()),
false,
},
{
"-2h",
uint64(time.Date(2017, 11, 10, 5, 8, 9, 1234, time.UTC).Unix()),
false,
},
{
"-3d",
uint64(time.Date(2017, 11, 7, 7, 8, 9, 1234, time.UTC).Unix()),
false,
},
{
"-4w",
uint64(time.Date(2017, 10, 13, 7, 8, 9, 1234, time.UTC).Unix()),
false,
},
{
"-5M",
uint64(now.Unix() - 30.44*5*24*60*60),
false,
},
{
"-6y",
uint64(now.Unix() - 365.25*6*24*60*60),
false,
},
{
"-999999999999999999s",
uint64(now.Unix() - 999999999999999999),
false,
},
{
"-9999999999999999991s",
0,
true,
},
{
"-7z",
0,
true,
},
}
// Test that parsing absolute and relative times works.
func TestParseTime(t *testing.T) {
for _, test := range partTimeTests {
actual, err := parseTime(test.in, now)
if test.errExpected == (err == nil) {
t.Fatalf("unexpected error for %s:\n%v\n", test.in, err)
}
if actual != test.expected {
t.Fatalf(
"for %s actual and expected do not match:\n%d\n%d\n",
test.in,
actual,
test.expected,
)
}
}
}
var stripPrefixTests = []struct {
in string
expected string
}{
{
"lightning:ln123",
"ln123",
},
{
"lightning: ln123",
"ln123",
},
{
"ln123",
"ln123",
},
}
func TestStripPrefix(t *testing.T) {
t.Parallel()
for _, test := range stripPrefixTests {
actual := stripPrefix(test.in)
require.Equal(t, test.expected, actual)
}
}
+162
View File
@@ -0,0 +1,162 @@
//go:build autopilotrpc
// +build autopilotrpc
package commands
import (
"github.com/lightningnetwork/lnd/lnrpc/autopilotrpc"
"github.com/urfave/cli"
)
func getAutopilotClient(ctx *cli.Context) (autopilotrpc.AutopilotClient, func()) {
conn := getClientConn(ctx, false)
cleanUp := func() {
conn.Close()
}
return autopilotrpc.NewAutopilotClient(conn), cleanUp
}
var getStatusCommand = cli.Command{
Name: "status",
Usage: "Get the active status of autopilot.",
Description: "",
Action: actionDecorator(getStatus),
}
func getStatus(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getAutopilotClient(ctx)
defer cleanUp()
req := &autopilotrpc.StatusRequest{}
resp, err := client.Status(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var enableCommand = cli.Command{
Name: "enable",
Usage: "Enable the autopilot.",
Description: "",
Action: actionDecorator(enable),
}
var disableCommand = cli.Command{
Name: "disable",
Usage: "Disable the active autopilot.",
Description: "",
Action: actionDecorator(disable),
}
func enable(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getAutopilotClient(ctx)
defer cleanUp()
// We will enable the autopilot.
req := &autopilotrpc.ModifyStatusRequest{
Enable: true,
}
resp, err := client.ModifyStatus(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
func disable(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getAutopilotClient(ctx)
defer cleanUp()
// We will disable the autopilot.
req := &autopilotrpc.ModifyStatusRequest{
Enable: false,
}
resp, err := client.ModifyStatus(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var queryScoresCommand = cli.Command{
Name: "query",
Usage: "Query the autopilot heuristics for nodes' scores.",
ArgsUsage: "[flags] <pubkey> <pubkey> <pubkey> ...",
Description: "",
Action: actionDecorator(queryScores),
Flags: []cli.Flag{
cli.BoolFlag{
Name: "ignorelocalstate, i",
Usage: "Ignore local channel state when calculating " +
"scores.",
},
},
}
func queryScores(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getAutopilotClient(ctx)
defer cleanUp()
args := ctx.Args()
var pubs []string
// Keep reading pubkeys as long as there are arguments.
loop:
for {
switch {
case args.Present():
pubs = append(pubs, args.First())
args = args.Tail()
default:
break loop
}
}
req := &autopilotrpc.QueryScoresRequest{
Pubkeys: pubs,
IgnoreLocalState: ctx.Bool("ignorelocalstate"),
}
resp, err := client.QueryScores(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
// autopilotCommands will return the set of commands to enable for autopilotrpc
// builds.
func autopilotCommands() []cli.Command {
return []cli.Command{
{
Name: "autopilot",
Category: "Autopilot",
Usage: "Interact with a running autopilot.",
Description: "",
Subcommands: []cli.Command{
getStatusCommand,
enableCommand,
disableCommand,
queryScoresCommand,
},
},
}
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !autopilotrpc
// +build !autopilotrpc
package commands
import "github.com/urfave/cli"
// autopilotCommands will return nil for non-autopilotrpc builds.
func autopilotCommands() []cli.Command {
return nil
}
+259
View File
@@ -0,0 +1,259 @@
//go:build chainrpc
// +build chainrpc
package commands
import (
"bytes"
"fmt"
"strconv"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd/lnrpc/chainrpc"
"github.com/urfave/cli"
)
// chainCommands will return the set of commands to enable for chainrpc builds.
func chainCommands() []cli.Command {
return []cli.Command{
{
Name: "chain",
Category: "On-chain",
Usage: "Interact with the bitcoin blockchain.",
Subcommands: []cli.Command{
getBlockCommand,
getBestBlockCommand,
getBlockHashCommand,
getBlockHeaderCommand,
},
},
}
}
func getChainClient(ctx *cli.Context) (chainrpc.ChainKitClient, func()) {
conn := getClientConn(ctx, false)
cleanUp := func() {
conn.Close()
}
return chainrpc.NewChainKitClient(conn), cleanUp
}
var getBlockCommand = cli.Command{
Name: "getblock",
Category: "On-chain",
Usage: "Get block by block hash.",
Description: "Returns a block given the corresponding block hash.",
Flags: []cli.Flag{
cli.StringFlag{
Name: "hash",
Usage: "the target block hash",
},
cli.BoolFlag{
Name: "verbose",
Usage: "print entire block as JSON",
},
},
Action: actionDecorator(getBlock),
}
func getBlock(ctx *cli.Context) error {
ctxc := getContext()
var (
args = ctx.Args()
blockHashString string
)
verbose := false
if ctx.IsSet("verbose") {
verbose = true
}
switch {
case ctx.IsSet("hash"):
blockHashString = ctx.String("hash")
case args.Present():
blockHashString = args.First()
default:
return fmt.Errorf("hash argument missing")
}
blockHash, err := chainhash.NewHashFromStr(blockHashString)
if err != nil {
return err
}
client, cleanUp := getChainClient(ctx)
defer cleanUp()
req := &chainrpc.GetBlockRequest{BlockHash: blockHash.CloneBytes()}
resp, err := client.GetBlock(ctxc, req)
if err != nil {
return err
}
// Convert raw block bytes into wire.MsgBlock.
msgBlock := &wire.MsgBlock{}
blockReader := bytes.NewReader(resp.RawBlock)
err = msgBlock.Deserialize(blockReader)
if err != nil {
return err
}
if verbose {
printJSON(msgBlock)
} else {
printJSON(msgBlock.Header)
}
return nil
}
var getBlockHeaderCommand = cli.Command{
Name: "getblockheader",
Usage: "Get a block header.",
Category: "On-chain",
Description: "Returns a block header with a particular block hash.",
ArgsUsage: "hash",
Action: actionDecorator(getBlockHeader),
}
func getBlockHeader(ctx *cli.Context) error {
ctxc := getContext()
args := ctx.Args()
// Display the command's help message if we do not have the expected
// number of arguments/flags.
if !args.Present() {
return cli.ShowCommandHelp(ctx, "getblockheader")
}
blockHash, err := chainhash.NewHashFromStr(args.First())
if err != nil {
return err
}
req := &chainrpc.GetBlockHeaderRequest{BlockHash: blockHash[:]}
client, cleanUp := getChainClient(ctx)
defer cleanUp()
resp, err := client.GetBlockHeader(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var getBestBlockCommand = cli.Command{
Name: "getbestblock",
Category: "On-chain",
Usage: "Get best block.",
Description: "Returns the latest block hash and height from the " +
"valid most-work chain.",
Action: actionDecorator(getBestBlock),
}
func getBestBlock(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getChainClient(ctx)
defer cleanUp()
resp, err := client.GetBestBlock(ctxc, &chainrpc.GetBestBlockRequest{})
if err != nil {
return err
}
// Cast gRPC block hash bytes as chain hash type.
var blockHash chainhash.Hash
copy(blockHash[:], resp.BlockHash)
printJSON(struct {
BlockHash chainhash.Hash `json:"block_hash"`
BlockHeight int32 `json:"block_height"`
}{
BlockHash: blockHash,
BlockHeight: resp.BlockHeight,
})
return nil
}
var getBlockHashCommand = cli.Command{
Name: "getblockhash",
Category: "On-chain",
Usage: "Get block hash by block height.",
Description: "Returns the block hash from the best chain at a given " +
"height.",
Flags: []cli.Flag{
cli.Int64Flag{
Name: "height",
Usage: "target block height",
},
},
Action: actionDecorator(getBlockHash),
}
func getBlockHash(ctx *cli.Context) error {
ctxc := getContext()
// Display the command's help message if we do not have the expected
// number of arguments/flags.
if ctx.NArg()+ctx.NumFlags() != 1 {
return cli.ShowCommandHelp(ctx, "getblockhash")
}
var (
args = ctx.Args()
blockHeight int64
)
switch {
case ctx.IsSet("height"):
blockHeight = ctx.Int64("height")
case args.Present():
blockHeightString := args.First()
// Convert block height positional argument from string to
// int64.
var err error
blockHeight, err = strconv.ParseInt(blockHeightString, 10, 64)
if err != nil {
return err
}
default:
return fmt.Errorf("block height argument missing")
}
client, cleanUp := getChainClient(ctx)
defer cleanUp()
req := &chainrpc.GetBlockHashRequest{BlockHeight: blockHeight}
resp, err := client.GetBlockHash(ctxc, req)
if err != nil {
return err
}
// Cast gRPC block hash bytes as chain hash type.
var blockHash chainhash.Hash
copy(blockHash[:], resp.BlockHash)
printJSON(struct {
BlockHash chainhash.Hash `json:"block_hash"`
}{
BlockHash: blockHash,
})
return nil
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !chainrpc
// +build !chainrpc
package commands
import "github.com/urfave/cli"
// chainCommands will return nil for non-chainrpc builds.
func chainCommands() []cli.Command {
return nil
}
+83
View File
@@ -0,0 +1,83 @@
package commands
import (
"encoding/hex"
"fmt"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/urfave/cli"
)
var sendCustomCommand = cli.Command{
Name: "sendcustom",
Flags: []cli.Flag{
cli.StringFlag{
Name: "peer",
},
cli.Uint64Flag{
Name: "type",
},
cli.StringFlag{
Name: "data",
},
},
Action: actionDecorator(sendCustom),
}
func sendCustom(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getClient(ctx)
defer cleanUp()
peer, err := hex.DecodeString(ctx.String("peer"))
if err != nil {
return err
}
msgType := ctx.Uint64("type")
data, err := hex.DecodeString(ctx.String("data"))
if err != nil {
return err
}
_, err = client.SendCustomMessage(
ctxc,
&lnrpc.SendCustomMessageRequest{
Peer: peer,
Type: uint32(msgType),
Data: data,
},
)
return err
}
var subscribeCustomCommand = cli.Command{
Name: "subscribecustom",
Action: actionDecorator(subscribeCustom),
}
func subscribeCustom(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getClient(ctx)
defer cleanUp()
stream, err := client.SubscribeCustomMessages(
ctxc,
&lnrpc.SubscribeCustomMessagesRequest{},
)
if err != nil {
return err
}
for {
msg, err := stream.Recv()
if err != nil {
return err
}
fmt.Printf("Received from peer %x: type=%d, data=%x\n",
msg.Peer, msg.Type, msg.Data)
}
}
+450
View File
@@ -0,0 +1,450 @@
package commands
import (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math"
"os"
"github.com/andybalholm/brotli"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/lightningnetwork/lnd"
"github.com/lightningnetwork/lnd/lnencrypt"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/urfave/cli"
"google.golang.org/protobuf/proto"
)
var getDebugInfoCommand = cli.Command{
Name: "getdebuginfo",
Category: "Debug",
Usage: "Returns debug information related to the active daemon.",
Action: actionDecorator(getDebugInfo),
}
func getDebugInfo(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getClient(ctx)
defer cleanUp()
req := &lnrpc.GetDebugInfoRequest{}
resp, err := client.GetDebugInfo(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
type DebugPackage struct {
EphemeralPubKey string `json:"ephemeral_public_key"`
EncryptedPayload string `json:"encrypted_payload"`
}
var encryptDebugPackageCommand = cli.Command{
Name: "encryptdebugpackage",
Category: "Debug",
Usage: "Collects a package of debug information and encrypts it.",
Description: `
When requesting support with lnd, it's often required to submit a lot of
debug information to the developer in order to track down a problem.
This command will collect all the relevant information and encrypt it
using the provided public key. The resulting file can then be sent to
the developer for further analysis.
Because the file is encrypted, it is safe to send it over insecure
channels or upload it to a GitHub issue.
The file by default contains the output of the following commands:
- lncli getinfo
- lncli getdebuginfo
- lncli getnetworkinfo
By specifying the following flags, additional information can be added
to the file (usually this will be requested by the developer depending
on the issue at hand):
--peers:
- lncli listpeers
--onchain:
- lncli listunspent
- lncli listchaintxns
--channels:
- lncli listchannels
- lncli pendingchannels
- lncli closedchannels
Use 'lncli encryptdebugpackage 0xxxxxx... > package.txt' to write the
encrypted package to a file called package.txt.
`,
ArgsUsage: "pubkey [--output_file F]",
Flags: []cli.Flag{
cli.StringFlag{
Name: "pubkey",
Usage: "the public key to encrypt the information " +
"for (hex-encoded, e.g. 02aabb..), this " +
"should be provided to you by the issue " +
"tracker or developer you're requesting " +
"support from",
},
cli.StringFlag{
Name: "output_file",
Usage: "(optional) the file to write the encrypted " +
"package to; if not specified, the debug " +
"package is printed to stdout",
},
cli.BoolFlag{
Name: "peers",
Usage: "include information about connected peers " +
"(lncli listpeers)",
},
cli.BoolFlag{
Name: "onchain",
Usage: "include information about on-chain " +
"transactions (lncli listunspent, " +
"lncli listchaintxns)",
},
cli.BoolFlag{
Name: "channels",
Usage: "include information about channels " +
"(lncli listchannels, lncli pendingchannels, " +
"lncli closedchannels)",
},
},
Action: actionDecorator(encryptDebugPackage),
}
func encryptDebugPackage(ctx *cli.Context) error {
if ctx.NArg() == 0 && ctx.NumFlags() == 0 {
return cli.ShowCommandHelp(ctx, "encryptdebugpackage")
}
var (
args = ctx.Args()
pubKeyBytes []byte
err error
)
switch {
case ctx.IsSet("pubkey"):
pubKeyBytes, err = hex.DecodeString(ctx.String("pubkey"))
case args.Present():
pubKeyBytes, err = hex.DecodeString(args.First())
}
if err != nil {
return fmt.Errorf("unable to decode pubkey argument: %w", err)
}
pubKey, err := btcec.ParsePubKey(pubKeyBytes)
if err != nil {
return fmt.Errorf("unable to parse pubkey: %w", err)
}
// Collect the information we want to send from the daemon.
payload, err := collectDebugPackageInfo(ctx)
if err != nil {
return fmt.Errorf("unable to collect debug package "+
"information: %w", err)
}
// We've collected the information we want to send, but before
// encrypting it, we want to compress it as much as possible to reduce
// the size of the final payload.
var (
compressBuf bytes.Buffer
options = brotli.WriterOptions{
Quality: brotli.BestCompression,
}
writer = brotli.NewWriterOptions(&compressBuf, options)
)
_, err = writer.Write(payload)
if err != nil {
return fmt.Errorf("unable to compress payload: %w", err)
}
if err := writer.Close(); err != nil {
return fmt.Errorf("unable to compress payload: %w", err)
}
// Now we have the full payload that we want to encrypt, so we'll create
// an ephemeral keypair to encrypt the payload with.
localKey, err := btcec.NewPrivateKey()
if err != nil {
return fmt.Errorf("unable to generate local key: %w", err)
}
enc, err := lnencrypt.ECDHEncrypter(localKey, pubKey)
if err != nil {
return fmt.Errorf("unable to create encrypter: %w", err)
}
var cipherBuf bytes.Buffer
err = enc.EncryptPayloadToWriter(compressBuf.Bytes(), &cipherBuf)
if err != nil {
return fmt.Errorf("unable to encrypt payload: %w", err)
}
response := DebugPackage{
EphemeralPubKey: hex.EncodeToString(
localKey.PubKey().SerializeCompressed(),
),
EncryptedPayload: hex.EncodeToString(
cipherBuf.Bytes(),
),
}
// If the user specified an output file, we'll write the encrypted
// payload to that file.
if ctx.IsSet("output_file") {
fileName := lnd.CleanAndExpandPath(ctx.String("output_file"))
jsonBytes, err := json.Marshal(response)
if err != nil {
return fmt.Errorf("unable to encode JSON: %w", err)
}
return os.WriteFile(fileName, jsonBytes, 0644)
}
// Finally, we'll print out the final payload as a JSON if no output
// file was specified.
printJSON(response)
return nil
}
// collectDebugPackageInfo collects the information we want to send to the
// developer(s) from the daemon.
func collectDebugPackageInfo(ctx *cli.Context) ([]byte, error) {
ctxc := getContext()
client, cleanUp := getClient(ctx)
defer cleanUp()
info, err := client.GetInfo(ctxc, &lnrpc.GetInfoRequest{})
if err != nil {
return nil, fmt.Errorf("error getting info: %w", err)
}
debugInfo, err := client.GetDebugInfo(
ctxc, &lnrpc.GetDebugInfoRequest{},
)
if err != nil {
return nil, fmt.Errorf("error getting debug info: %w", err)
}
networkInfo, err := client.GetNetworkInfo(
ctxc, &lnrpc.NetworkInfoRequest{},
)
if err != nil {
return nil, fmt.Errorf("error getting network info: %w", err)
}
var payloadBuf bytes.Buffer
addToBuf := func(msgs ...proto.Message) error {
for _, msg := range msgs {
jsonBytes, err := lnrpc.ProtoJSONMarshalOpts.Marshal(
msg,
)
if err != nil {
return fmt.Errorf("error encoding response: %w",
err)
}
payloadBuf.Write(jsonBytes)
}
return nil
}
if err := addToBuf(info); err != nil {
return nil, err
}
if err := addToBuf(debugInfo); err != nil {
return nil, err
}
if err := addToBuf(info, debugInfo, networkInfo); err != nil {
return nil, err
}
// Add optional information to the payload.
if ctx.Bool("peers") {
peers, err := client.ListPeers(ctxc, &lnrpc.ListPeersRequest{
LatestError: true,
})
if err != nil {
return nil, fmt.Errorf("error getting peers: %w", err)
}
if err := addToBuf(peers); err != nil {
return nil, err
}
}
if ctx.Bool("onchain") {
unspent, err := client.ListUnspent(
ctxc, &lnrpc.ListUnspentRequest{
MaxConfs: math.MaxInt32,
},
)
if err != nil {
return nil, fmt.Errorf("error getting unspent: %w", err)
}
chainTxns, err := client.GetTransactions(
ctxc, &lnrpc.GetTransactionsRequest{},
)
if err != nil {
return nil, fmt.Errorf("error getting chain txns: %w",
err)
}
if err := addToBuf(unspent, chainTxns); err != nil {
return nil, err
}
}
if ctx.Bool("channels") {
channels, err := client.ListChannels(
ctxc, &lnrpc.ListChannelsRequest{},
)
if err != nil {
return nil, fmt.Errorf("error getting channels: %w",
err)
}
pendingChannels, err := client.PendingChannels(
ctxc, &lnrpc.PendingChannelsRequest{},
)
if err != nil {
return nil, fmt.Errorf("error getting pending "+
"channels: %w", err)
}
closedChannels, err := client.ClosedChannels(
ctxc, &lnrpc.ClosedChannelsRequest{},
)
if err != nil {
return nil, fmt.Errorf("error getting closed "+
"channels: %w", err)
}
if err := addToBuf(
channels, pendingChannels, closedChannels,
); err != nil {
return nil, err
}
}
return payloadBuf.Bytes(), nil
}
var decryptDebugPackageCommand = cli.Command{
Name: "decryptdebugpackage",
Category: "Debug",
Usage: "Decrypts a package of debug information.",
Description: `
Decrypt a debug package that was created with the encryptdebugpackage
command. Decryption requires the private key that corresponds to the
public key the package was encrypted to.
The command expects the encrypted package JSON to be provided on stdin.
If decryption is successful, the information will be printed to stdout.
Use 'lncli decryptdebugpackage 0xxxxxx... < package.txt > decrypted.txt'
to read the encrypted package from a file called package.txt and to
write the decrypted content to a file called decrypted.txt.
`,
ArgsUsage: "privkey [--input_file F]",
Flags: []cli.Flag{
cli.StringFlag{
Name: "privkey",
Usage: "the hex encoded private key to decrypt the " +
"debug package",
},
cli.StringFlag{
Name: "input_file",
Usage: "(optional) the file to read the encrypted " +
"package from; if not specified, the debug " +
"package is read from stdin",
},
},
Action: actionDecorator(decryptDebugPackage),
}
func decryptDebugPackage(ctx *cli.Context) error {
if ctx.NArg() == 0 && ctx.NumFlags() == 0 {
return cli.ShowCommandHelp(ctx, "decryptdebugpackage")
}
var (
args = ctx.Args()
privKeyBytes []byte
err error
)
switch {
case ctx.IsSet("pubkey"):
privKeyBytes, err = hex.DecodeString(ctx.String("pubkey"))
case args.Present():
privKeyBytes, err = hex.DecodeString(args.First())
}
if err != nil {
return fmt.Errorf("unable to decode privkey argument: %w", err)
}
privKey, _ := btcec.PrivKeyFromBytes(privKeyBytes)
// Read the file from stdin and decode the JSON into a DebugPackage.
var pkg DebugPackage
if ctx.IsSet("input_file") {
fileName := lnd.CleanAndExpandPath(ctx.String("input_file"))
jsonBytes, err := os.ReadFile(fileName)
if err != nil {
return fmt.Errorf("unable to read file '%s': %w",
fileName, err)
}
err = json.Unmarshal(jsonBytes, &pkg)
if err != nil {
return fmt.Errorf("unable to decode JSON: %w", err)
}
} else {
err = json.NewDecoder(os.Stdin).Decode(&pkg)
if err != nil {
return fmt.Errorf("unable to decode JSON: %w", err)
}
}
// Decode the ephemeral public key and encrypted payload.
ephemeralPubKeyBytes, err := hex.DecodeString(pkg.EphemeralPubKey)
if err != nil {
return fmt.Errorf("unable to decode ephemeral public key: %w",
err)
}
encryptedPayloadBytes, err := hex.DecodeString(pkg.EncryptedPayload)
if err != nil {
return fmt.Errorf("unable to decode encrypted payload: %w", err)
}
// Parse the ephemeral public key and create an encrypter.
ephemeralPubKey, err := btcec.ParsePubKey(ephemeralPubKeyBytes)
if err != nil {
return fmt.Errorf("unable to parse ephemeral public key: %w",
err)
}
enc, err := lnencrypt.ECDHEncrypter(privKey, ephemeralPubKey)
if err != nil {
return fmt.Errorf("unable to create encrypter: %w", err)
}
// Decrypt the payload.
decryptedPayload, err := enc.DecryptPayloadFromReader(
bytes.NewReader(encryptedPayloadBytes),
)
if err != nil {
return fmt.Errorf("unable to decrypt payload: %w", err)
}
// Decompress the payload.
reader := brotli.NewReader(bytes.NewBuffer(decryptedPayload))
decompressedPayload, err := io.ReadAll(reader)
if err != nil {
return fmt.Errorf("unable to decompress payload: %w", err)
}
fmt.Println(string(decompressedPayload))
return nil
}
+100
View File
@@ -0,0 +1,100 @@
package commands
import (
"context"
"errors"
"fmt"
"strconv"
"github.com/lightningnetwork/lnd/lnrpc/routerrpc"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/urfave/cli"
)
const argsStr = "[source node] [dest node] [unix ts seconds] [amount in msat]"
var importMissionControlCommand = cli.Command{
Name: "importmc",
Category: "Payments",
Usage: "Import a result to the internal mission control state.",
ArgsUsage: fmt.Sprintf("importmc %v", argsStr),
Action: actionDecorator(importMissionControl),
Flags: []cli.Flag{
cli.BoolFlag{
Name: "failure",
Usage: "whether the routing history entry was a failure",
},
cli.BoolFlag{
Name: "force",
Usage: "whether to force the history entry import",
},
},
}
func importMissionControl(ctx *cli.Context) error {
conn := getClientConn(ctx, false)
defer conn.Close()
if ctx.NArg() != 4 {
return fmt.Errorf("please provide args: %v", argsStr)
}
args := ctx.Args()
sourceNode, err := route.NewVertexFromStr(args[0])
if err != nil {
return fmt.Errorf("please provide valid source node: %w", err)
}
destNode, err := route.NewVertexFromStr(args[1])
if err != nil {
return fmt.Errorf("please provide valid dest node: %w", err)
}
ts, err := strconv.ParseInt(args[2], 10, 64)
if err != nil {
return fmt.Errorf("please provide unix timestamp "+
"in seconds: %v", err)
}
if ts <= 0 {
return errors.New("please provide positive timestamp")
}
amt, err := strconv.ParseInt(args[3], 10, 64)
if err != nil {
return fmt.Errorf("please provide amount in msat: %w", err)
}
// Allow 0 value as failure amount.
if !ctx.IsSet("failure") && amt <= 0 {
return errors.New("success amount must be >0")
}
client := routerrpc.NewRouterClient(conn)
importResult := &routerrpc.PairHistory{
NodeFrom: sourceNode[:],
NodeTo: destNode[:],
History: &routerrpc.PairData{},
}
if ctx.IsSet("failure") {
importResult.History.FailAmtMsat = amt
importResult.History.FailTime = ts
} else {
importResult.History.SuccessAmtMsat = amt
importResult.History.SuccessTime = ts
}
req := &routerrpc.XImportMissionControlRequest{
Pairs: []*routerrpc.PairHistory{
importResult,
},
Force: ctx.IsSet("force"),
}
rpcCtx := context.Background()
_, err = client.XImportMissionControl(rpcCtx, req)
return err
}
+419
View File
@@ -0,0 +1,419 @@
package commands
import (
"encoding/hex"
"fmt"
"strconv"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/urfave/cli"
)
var AddInvoiceCommand = cli.Command{
Name: "addinvoice",
Category: "Invoices",
Usage: "Add a new invoice.",
Description: `
Add a new invoice, expressing intent for a future payment.
Invoices without an amount can be created by not supplying any
parameters or providing an amount of 0. These invoices allow the payer
to specify the amount of satoshis they wish to send.`,
ArgsUsage: "value preimage",
Flags: []cli.Flag{
cli.StringFlag{
Name: "memo",
Usage: "a description of the payment to attach along " +
"with the invoice (default=\"\")",
},
cli.StringFlag{
Name: "preimage",
Usage: "the hex-encoded preimage (32 byte) which will " +
"allow settling an incoming HTLC payable to this " +
"preimage. If not set, a random preimage will be " +
"created.",
},
cli.Int64Flag{
Name: "amt",
Usage: "the amt of satoshis in this invoice",
},
cli.Int64Flag{
Name: "amt_msat",
Usage: "the amt of millisatoshis in this invoice",
},
cli.StringFlag{
Name: "description_hash",
Usage: "SHA-256 hash of the description of the payment. " +
"Used if the purpose of payment cannot naturally " +
"fit within the memo. If provided this will be " +
"used instead of the description(memo) field in " +
"the encoded invoice.",
},
cli.StringFlag{
Name: "fallback_addr",
Usage: "fallback on-chain address that can be used in " +
"case the lightning payment fails",
},
cli.Int64Flag{
Name: "expiry",
Usage: "the invoice's expiry time in seconds. If not " +
"specified, an expiry of " +
"86400 seconds (24 hours) is implied.",
},
cli.Uint64Flag{
Name: "cltv_expiry_delta",
Usage: "The minimum CLTV delta to use for the final " +
"hop. If this is set to 0, the default value " +
"is used. The default value for " +
"cltv_expiry_delta is configured by the " +
"'bitcoin.timelockdelta' option.",
},
cli.BoolFlag{
Name: "private",
Usage: "encode routing hints in the invoice with " +
"private channels in order to assist the " +
"payer in reaching you. If amt and amt_msat " +
"are zero, a large number of hints with " +
"these channels can be included, which " +
"might not be desirable.",
},
cli.BoolFlag{
Name: "amp",
Usage: "creates an AMP invoice. If true, preimage " +
"should not be set.",
},
cli.BoolFlag{
Name: "blind",
Usage: "creates an invoice that contains blinded " +
"paths. Note that invoices with blinded " +
"paths will be signed using a random " +
"ephemeral key so as not to reveal the real " +
"node ID of this node.",
},
cli.UintFlag{
Name: "min_real_blinded_hops",
Usage: "The minimum number of real hops to use in a " +
"blinded path. This option will only be used " +
"if `--blind` has also been set.",
},
cli.UintFlag{
Name: "num_blinded_hops",
Usage: "The number of hops to use for each " +
"blinded path included in the invoice. This " +
"option will only be used if `--blind` has " +
"also been set. Dummy hops will be used to " +
"pad paths shorter than this.",
},
cli.UintFlag{
Name: "max_blinded_paths",
Usage: "The maximum number of blinded paths to add " +
"to an invoice. This option will only be " +
"used if `--blind` has also been set.",
},
cli.StringSliceFlag{
Name: "blinded_path_omit_node",
Usage: "The pub key (in hex) of a node not to " +
"use on a blinded path. The flag may be " +
"specified multiple times.",
},
},
Action: actionDecorator(addInvoice),
}
func addInvoice(ctx *cli.Context) error {
var (
preimage []byte
descHash []byte
amt int64
amtMsat int64
err error
)
ctxc := getContext()
client, cleanUp := getClient(ctx)
defer cleanUp()
args := ctx.Args()
amt = ctx.Int64("amt")
amtMsat = ctx.Int64("amt_msat")
if !ctx.IsSet("amt") && !ctx.IsSet("amt_msat") && args.Present() {
amt, err = strconv.ParseInt(args.First(), 10, 64)
args = args.Tail()
if err != nil {
return fmt.Errorf("unable to decode amt argument: %w",
err)
}
}
switch {
case ctx.IsSet("preimage"):
preimage, err = hex.DecodeString(ctx.String("preimage"))
case args.Present():
preimage, err = hex.DecodeString(args.First())
}
if err != nil {
return fmt.Errorf("unable to parse preimage: %w", err)
}
descHash, err = hex.DecodeString(ctx.String("description_hash"))
if err != nil {
return fmt.Errorf("unable to parse description_hash: %w", err)
}
if ctx.IsSet("private") && ctx.IsSet("blind") {
return fmt.Errorf("cannot include both route hints and " +
"blinded paths in the same invoice")
}
blindedPathCfg, err := parseBlindedPathCfg(ctx)
if err != nil {
return fmt.Errorf("could not parse blinded path config: %w",
err)
}
invoice := &lnrpc.Invoice{
Memo: ctx.String("memo"),
RPreimage: preimage,
Value: amt,
ValueMsat: amtMsat,
DescriptionHash: descHash,
FallbackAddr: ctx.String("fallback_addr"),
Expiry: ctx.Int64("expiry"),
CltvExpiry: ctx.Uint64("cltv_expiry_delta"),
Private: ctx.Bool("private"),
IsAmp: ctx.Bool("amp"),
IsBlinded: ctx.Bool("blind"),
BlindedPathConfig: blindedPathCfg,
}
resp, err := client.AddInvoice(ctxc, invoice)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
func parseBlindedPathCfg(ctx *cli.Context) (*lnrpc.BlindedPathConfig, error) {
if !ctx.Bool("blind") {
if ctx.IsSet("min_real_blinded_hops") ||
ctx.IsSet("num_blinded_hops") ||
ctx.IsSet("max_blinded_paths") ||
ctx.IsSet("blinded_path_omit_node") {
return nil, fmt.Errorf("blinded path options are " +
"only used if the `--blind` options is set")
}
return nil, nil
}
var blindCfg lnrpc.BlindedPathConfig
if ctx.IsSet("min_real_blinded_hops") {
minNumRealHops := uint32(ctx.Uint("min_real_blinded_hops"))
blindCfg.MinNumRealHops = &minNumRealHops
}
if ctx.IsSet("num_blinded_hops") {
numHops := uint32(ctx.Uint("num_blinded_hops"))
blindCfg.NumHops = &numHops
}
if ctx.IsSet("max_blinded_paths") {
maxPaths := uint32(ctx.Uint("max_blinded_paths"))
blindCfg.MaxNumPaths = &maxPaths
}
for _, pubKey := range ctx.StringSlice("blinded_path_omit_node") {
pubKeyBytes, err := hex.DecodeString(pubKey)
if err != nil {
return nil, err
}
blindCfg.NodeOmissionList = append(
blindCfg.NodeOmissionList, pubKeyBytes,
)
}
return &blindCfg, nil
}
var lookupInvoiceCommand = cli.Command{
Name: "lookupinvoice",
Category: "Invoices",
Usage: "Lookup an existing invoice by its payment hash.",
ArgsUsage: "rhash",
Flags: []cli.Flag{
cli.StringFlag{
Name: "rhash",
Usage: "the 32 byte payment hash of the invoice to query for, the hash " +
"should be a hex-encoded string",
},
},
Action: actionDecorator(lookupInvoice),
}
func lookupInvoice(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getClient(ctx)
defer cleanUp()
var (
rHash []byte
err error
)
switch {
case ctx.IsSet("rhash"):
rHash, err = hex.DecodeString(ctx.String("rhash"))
case ctx.Args().Present():
rHash, err = hex.DecodeString(ctx.Args().First())
default:
return fmt.Errorf("rhash argument missing")
}
if err != nil {
return fmt.Errorf("unable to decode rhash argument: %w", err)
}
req := &lnrpc.PaymentHash{
RHash: rHash,
}
invoice, err := client.LookupInvoice(ctxc, req)
if err != nil {
return err
}
printRespJSON(invoice)
return nil
}
var listInvoicesCommand = cli.Command{
Name: "listinvoices",
Category: "Invoices",
Usage: "List all invoices currently stored within the database. Any " +
"active debug invoices are ignored.",
Description: `
This command enables the retrieval of all invoices currently stored
within the database. It has full support for paginationed responses,
allowing users to query for specific invoices through their add_index.
This can be done by using either the first_index_offset or
last_index_offset fields included in the response as the index_offset of
the next request. Backward pagination is enabled by default to receive
current invoices first. If you wish to paginate forwards, set the
paginate-forwards flag. If none of the parameters are specified, then
the last 100 invoices will be returned.
For example: if you have 200 invoices, "lncli listinvoices" will return
the last 100 created. If you wish to retrieve the previous 100, the
first_offset_index of the response can be used as the index_offset of
the next listinvoices request.`,
Flags: []cli.Flag{
cli.BoolFlag{
Name: "pending_only",
Usage: "toggles if all invoices should be returned, " +
"or only those that are currently unsettled",
},
cli.Uint64Flag{
Name: "index_offset",
Usage: "the index of an invoice that will be used as " +
"either the start or end of a query to " +
"determine which invoices should be returned " +
"in the response",
},
cli.Uint64Flag{
Name: "max_invoices",
Usage: "the max number of invoices to return",
},
cli.BoolFlag{
Name: "paginate-forwards",
Usage: "if set, invoices succeeding the " +
"index_offset will be returned",
},
cli.Uint64Flag{
Name: "creation_date_start",
Usage: "timestamp in seconds, if set, filter " +
"invoices with creation date greater than or " +
"equal to it",
},
cli.Uint64Flag{
Name: "creation_date_end",
Usage: "timestamp in seconds, if set, filter " +
"invoices with creation date less than or " +
"equal to it",
},
},
Action: actionDecorator(listInvoices),
}
func listInvoices(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getClient(ctx)
defer cleanUp()
req := &lnrpc.ListInvoiceRequest{
PendingOnly: ctx.Bool("pending_only"),
IndexOffset: ctx.Uint64("index_offset"),
NumMaxInvoices: ctx.Uint64("max_invoices"),
Reversed: !ctx.Bool("paginate-forwards"),
CreationDateStart: ctx.Uint64("creation_date_start"),
CreationDateEnd: ctx.Uint64("creation_date_end"),
}
invoices, err := client.ListInvoices(ctxc, req)
if err != nil {
return err
}
printRespJSON(invoices)
return nil
}
var decodePayReqCommand = cli.Command{
Name: "decodepayreq",
Category: "Invoices",
Usage: "Decode a payment request.",
Description: "Decode the passed payment request revealing the destination, payment hash and value of the payment request",
ArgsUsage: "pay_req",
Flags: []cli.Flag{
cli.StringFlag{
Name: "pay_req",
Usage: "the bech32 encoded payment request",
},
},
Action: actionDecorator(decodePayReq),
}
func decodePayReq(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getClient(ctx)
defer cleanUp()
var payreq string
switch {
case ctx.IsSet("pay_req"):
payreq = ctx.String("pay_req")
case ctx.Args().Present():
payreq = ctx.Args().First()
default:
return fmt.Errorf("pay_req argument missing")
}
resp, err := client.DecodePayReq(ctxc, &lnrpc.PayReqString{
PayReq: stripPrefix(payreq),
})
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
+564
View File
@@ -0,0 +1,564 @@
package commands
import (
"bytes"
"encoding/hex"
"fmt"
"net"
"os"
"strconv"
"strings"
"unicode"
"github.com/lightningnetwork/lnd/lncfg"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/macaroons"
"github.com/urfave/cli"
"google.golang.org/protobuf/proto"
"gopkg.in/macaroon-bakery.v2/bakery"
"gopkg.in/macaroon.v2"
)
var (
macTimeoutFlag = cli.Uint64Flag{
Name: "timeout",
Usage: "the number of seconds the macaroon will be " +
"valid before it times out",
}
macIPAddressFlag = cli.StringFlag{
Name: "ip_address",
Usage: "the IP address the macaroon will be bound to",
}
macCustomCaveatNameFlag = cli.StringFlag{
Name: "custom_caveat_name",
Usage: "the name of the custom caveat to add",
}
macCustomCaveatConditionFlag = cli.StringFlag{
Name: "custom_caveat_condition",
Usage: "the condition of the custom caveat to add, can be " +
"empty if custom caveat doesn't need a value",
}
)
var bakeMacaroonCommand = cli.Command{
Name: "bakemacaroon",
Category: "Macaroons",
Usage: "Bakes a new macaroon with the provided list of permissions " +
"and restrictions.",
ArgsUsage: "[--save_to=] [--timeout=] [--ip_address=] " +
"[--custom_caveat_name= [--custom_caveat_condition=]] " +
"[--root_key_id=] [--allow_external_permissions] " +
"permissions...",
Description: `
Bake a new macaroon that grants the provided permissions and
optionally adds restrictions (timeout, IP address) to it.
The new macaroon can either be shown on command line in hex serialized
format or it can be saved directly to a file using the --save_to
argument.
A permission is a tuple of an entity and an action, separated by a
colon. Multiple operations can be added as arguments, for example:
lncli bakemacaroon info:read invoices:write foo:bar
For even more fine-grained permission control, it is also possible to
specify single RPC method URIs that are allowed to be accessed by a
macaroon. This can be achieved by specifying "uri:<methodURI>" pairs,
for example:
lncli bakemacaroon uri:/lnrpc.Lightning/GetInfo uri:/verrpc.Versioner/GetVersion
The macaroon created by this command would only be allowed to use the
"lncli getinfo" and "lncli version" commands.
To get a list of all available URIs and permissions, use the
"lncli listpermissions" command.
`,
Flags: []cli.Flag{
cli.StringFlag{
Name: "save_to",
Usage: "save the created macaroon to this file " +
"using the default binary format",
},
macTimeoutFlag,
macIPAddressFlag,
macCustomCaveatNameFlag,
macCustomCaveatConditionFlag,
cli.Uint64Flag{
Name: "root_key_id",
Usage: "the numerical root key ID used to create the " +
"macaroon",
},
cli.BoolFlag{
Name: "allow_external_permissions",
Usage: "whether permissions lnd is not familiar with " +
"are allowed",
},
},
Action: actionDecorator(bakeMacaroon),
}
func bakeMacaroon(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getClient(ctx)
defer cleanUp()
// Show command help if no arguments.
if ctx.NArg() == 0 {
return cli.ShowCommandHelp(ctx, "bakemacaroon")
}
args := ctx.Args()
var (
savePath string
rootKeyID uint64
parsedPermissions []*lnrpc.MacaroonPermission
err error
)
if ctx.String("save_to") != "" {
savePath = lncfg.CleanAndExpandPath(ctx.String("save_to"))
}
if ctx.IsSet("root_key_id") {
rootKeyID = ctx.Uint64("root_key_id")
}
// A command line argument can't be an empty string. So we'll check each
// entry if it's a valid entity:action tuple. The content itself is
// validated server side. We just make sure we can parse it correctly.
for _, permission := range args {
tuple := strings.Split(permission, ":")
if len(tuple) != 2 {
return fmt.Errorf("unable to parse "+
"permission tuple: %s", permission)
}
entity, action := tuple[0], tuple[1]
if entity == "" {
return fmt.Errorf("invalid permission [%s]. entity "+
"cannot be empty", permission)
}
if action == "" {
return fmt.Errorf("invalid permission [%s]. action "+
"cannot be empty", permission)
}
// No we can assume that we have a formally valid entity:action
// tuple. The rest of the validation happens server side.
parsedPermissions = append(
parsedPermissions, &lnrpc.MacaroonPermission{
Entity: entity,
Action: action,
},
)
}
// Now we have gathered all the input we need and can do the actual
// RPC call.
req := &lnrpc.BakeMacaroonRequest{
Permissions: parsedPermissions,
RootKeyId: rootKeyID,
AllowExternalPermissions: ctx.Bool("allow_external_permissions"),
}
resp, err := client.BakeMacaroon(ctxc, req)
if err != nil {
return err
}
// Now we should have gotten a valid macaroon. Unmarshal it so we can
// add first-party caveats (if necessary) to it.
macBytes, err := hex.DecodeString(resp.Macaroon)
if err != nil {
return err
}
unmarshalMac := &macaroon.Macaroon{}
if err = unmarshalMac.UnmarshalBinary(macBytes); err != nil {
return err
}
// Now apply the desired constraints to the macaroon. This will always
// create a new macaroon object, even if no constraints are added.
constrainedMac, err := applyMacaroonConstraints(ctx, unmarshalMac)
if err != nil {
return err
}
macBytes, err = constrainedMac.MarshalBinary()
if err != nil {
return err
}
// Now we can output the result. We either write it binary serialized to
// a file or write to the standard output using hex encoding.
switch {
case savePath != "":
err = os.WriteFile(savePath, macBytes, 0644)
if err != nil {
return err
}
fmt.Printf("Macaroon saved to %s\n", savePath)
default:
fmt.Printf("%s\n", hex.EncodeToString(macBytes))
}
return nil
}
var listMacaroonIDsCommand = cli.Command{
Name: "listmacaroonids",
Category: "Macaroons",
Usage: "List all macaroons root key IDs in use.",
Action: actionDecorator(listMacaroonIDs),
}
func listMacaroonIDs(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getClient(ctx)
defer cleanUp()
req := &lnrpc.ListMacaroonIDsRequest{}
resp, err := client.ListMacaroonIDs(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var deleteMacaroonIDCommand = cli.Command{
Name: "deletemacaroonid",
Category: "Macaroons",
Usage: "Delete a specific macaroon ID.",
ArgsUsage: "root_key_id",
Description: `
Remove a macaroon ID using the specified root key ID. For example:
lncli deletemacaroonid 1
WARNING
When the ID is deleted, all macaroons created from that root key will
be invalidated.
Note that the default root key ID 0 cannot be deleted.
`,
Action: actionDecorator(deleteMacaroonID),
}
func deleteMacaroonID(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getClient(ctx)
defer cleanUp()
// Validate args length. Only one argument is allowed.
if ctx.NArg() != 1 {
return cli.ShowCommandHelp(ctx, "deletemacaroonid")
}
rootKeyIDString := ctx.Args().First()
// Convert string into uint64.
rootKeyID, err := strconv.ParseUint(rootKeyIDString, 10, 64)
if err != nil {
return fmt.Errorf("root key ID must be a positive integer")
}
// Check that the value is not equal to DefaultRootKeyID. Note that the
// server also validates the root key ID when removing it. However, we check
// it here too so that we can give users a nice warning.
if bytes.Equal([]byte(rootKeyIDString), macaroons.DefaultRootKeyID) {
return fmt.Errorf("deleting the default root key ID 0 is not allowed")
}
// Make the actual RPC call.
req := &lnrpc.DeleteMacaroonIDRequest{
RootKeyId: rootKeyID,
}
resp, err := client.DeleteMacaroonID(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var listPermissionsCommand = cli.Command{
Name: "listpermissions",
Category: "Macaroons",
Usage: "Lists all RPC method URIs and the macaroon permissions they " +
"require to be invoked.",
Action: actionDecorator(listPermissions),
}
func listPermissions(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getClient(ctx)
defer cleanUp()
request := &lnrpc.ListPermissionsRequest{}
response, err := client.ListPermissions(ctxc, request)
if err != nil {
return err
}
printRespJSON(response)
return nil
}
type macaroonContent struct {
Version uint16 `json:"version"`
Location string `json:"location"`
RootKeyID string `json:"root_key_id"`
Permissions []string `json:"permissions"`
Caveats []string `json:"caveats"`
}
var printMacaroonCommand = cli.Command{
Name: "printmacaroon",
Category: "Macaroons",
Usage: "Print the content of a macaroon in a human readable format.",
ArgsUsage: "[macaroon_content_hex]",
Description: `
Decode a macaroon and show its content in a more human readable format.
The macaroon can either be passed as a hex encoded positional parameter
or loaded from a file.
`,
Flags: []cli.Flag{
cli.StringFlag{
Name: "macaroon_file",
Usage: "load the macaroon from a file instead of the " +
"command line directly",
},
},
Action: actionDecorator(printMacaroon),
}
func printMacaroon(ctx *cli.Context) error {
// Show command help if no arguments or flags are set.
if ctx.NArg() == 0 && ctx.NumFlags() == 0 {
return cli.ShowCommandHelp(ctx, "printmacaroon")
}
var (
macBytes []byte
err error
args = ctx.Args()
)
switch {
case ctx.IsSet("macaroon_file"):
macPath := lncfg.CleanAndExpandPath(ctx.String("macaroon_file"))
// Load the specified macaroon file.
macBytes, err = os.ReadFile(macPath)
if err != nil {
return fmt.Errorf("unable to read macaroon path %v: %v",
macPath, err)
}
case args.Present():
macBytes, err = hex.DecodeString(args.First())
if err != nil {
return fmt.Errorf("unable to hex decode macaroon: %w",
err)
}
default:
return fmt.Errorf("macaroon parameter missing")
}
// Decode the macaroon and its protobuf encoded internal identifier.
mac := &macaroon.Macaroon{}
if err = mac.UnmarshalBinary(macBytes); err != nil {
return fmt.Errorf("unable to decode macaroon: %w", err)
}
rawID := mac.Id()
if rawID[0] != byte(bakery.LatestVersion) {
return fmt.Errorf("invalid macaroon version: %x", rawID)
}
decodedID := &lnrpc.MacaroonId{}
idProto := rawID[1:]
err = proto.Unmarshal(idProto, decodedID)
if err != nil {
return fmt.Errorf("unable to decode macaroon version: %w", err)
}
// Prepare everything to be printed in a more human-readable format.
content := &macaroonContent{
Version: uint16(mac.Version()),
Location: mac.Location(),
RootKeyID: string(decodedID.StorageId),
Permissions: nil,
Caveats: nil,
}
for _, caveat := range mac.Caveats() {
content.Caveats = append(content.Caveats, string(caveat.Id))
}
for _, op := range decodedID.Ops {
for _, action := range op.Actions {
permission := fmt.Sprintf("%s:%s", op.Entity, action)
content.Permissions = append(
content.Permissions, permission,
)
}
}
printJSON(content)
return nil
}
var constrainMacaroonCommand = cli.Command{
Name: "constrainmacaroon",
Category: "Macaroons",
Usage: "Adds one or more restriction(s) to an existing macaroon",
ArgsUsage: "[--timeout=] [--ip_address=] [--custom_caveat_name= " +
"[--custom_caveat_condition=]] input-macaroon-file " +
"constrained-macaroon-file",
Description: `
Add one or more first-party caveat(s) (a.k.a. constraints/restrictions)
to an existing macaroon.
`,
Flags: []cli.Flag{
macTimeoutFlag,
macIPAddressFlag,
macCustomCaveatNameFlag,
macCustomCaveatConditionFlag,
},
Action: actionDecorator(constrainMacaroon),
}
func constrainMacaroon(ctx *cli.Context) error {
// Show command help if not enough arguments.
if ctx.NArg() != 2 {
return cli.ShowCommandHelp(ctx, "constrainmacaroon")
}
args := ctx.Args()
sourceMacFile := lncfg.CleanAndExpandPath(args.First())
args = args.Tail()
sourceMacBytes, err := os.ReadFile(sourceMacFile)
if err != nil {
return fmt.Errorf("error trying to read source macaroon file "+
"%s: %v", sourceMacFile, err)
}
destMacFile := lncfg.CleanAndExpandPath(args.First())
// Now we should have gotten a valid macaroon. Unmarshal it so we can
// add first-party caveats (if necessary) to it.
sourceMac := &macaroon.Macaroon{}
if err = sourceMac.UnmarshalBinary(sourceMacBytes); err != nil {
return fmt.Errorf("error unmarshalling source macaroon file "+
"%s: %v", sourceMacFile, err)
}
// Now apply the desired constraints to the macaroon. This will always
// create a new macaroon object, even if no constraints are added.
constrainedMac, err := applyMacaroonConstraints(ctx, sourceMac)
if err != nil {
return err
}
destMacBytes, err := constrainedMac.MarshalBinary()
if err != nil {
return fmt.Errorf("error marshaling destination macaroon "+
"file: %v", err)
}
// Now we can output the result.
err = os.WriteFile(destMacFile, destMacBytes, 0644)
if err != nil {
return fmt.Errorf("error writing destination macaroon file "+
"%s: %v", destMacFile, err)
}
fmt.Printf("Macaroon saved to %s\n", destMacFile)
return nil
}
// applyMacaroonConstraints parses and applies all currently supported macaroon
// condition flags from the command line to the given macaroon and returns a new
// macaroon instance.
func applyMacaroonConstraints(ctx *cli.Context,
mac *macaroon.Macaroon) (*macaroon.Macaroon, error) {
macConstraints := make([]macaroons.Constraint, 0)
if ctx.IsSet(macTimeoutFlag.Name) {
timeout := ctx.Int64(macTimeoutFlag.Name)
if timeout <= 0 {
return nil, fmt.Errorf("timeout must be greater than 0")
}
macConstraints = append(
macConstraints, macaroons.TimeoutConstraint(timeout),
)
}
if ctx.IsSet(macIPAddressFlag.Name) {
ipAddress := net.ParseIP(ctx.String(macIPAddressFlag.Name))
if ipAddress == nil {
return nil, fmt.Errorf("unable to parse ip_address: %s",
ctx.String("ip_address"))
}
macConstraints = append(
macConstraints,
macaroons.IPLockConstraint(ipAddress.String()),
)
}
if ctx.IsSet(macCustomCaveatNameFlag.Name) {
customCaveatName := ctx.String(macCustomCaveatNameFlag.Name)
if containsWhiteSpace(customCaveatName) {
return nil, fmt.Errorf("unexpected white space found " +
"in custom caveat name")
}
if customCaveatName == "" {
return nil, fmt.Errorf("invalid custom caveat name")
}
var customCaveatCond string
if ctx.IsSet(macCustomCaveatConditionFlag.Name) {
customCaveatCond = ctx.String(
macCustomCaveatConditionFlag.Name,
)
if containsWhiteSpace(customCaveatCond) {
return nil, fmt.Errorf("unexpected white " +
"space found in custom caveat " +
"condition")
}
if customCaveatCond == "" {
return nil, fmt.Errorf("invalid custom " +
"caveat condition")
}
}
// The custom caveat condition is optional, it could just be a
// marker tag in the macaroon with just a name. The interceptor
// itself doesn't care about the value anyway.
macConstraints = append(
macConstraints, macaroons.CustomConstraint(
customCaveatName, customCaveatCond,
),
)
}
constrainedMac, err := macaroons.AddConstraints(mac, macConstraints...)
if err != nil {
return nil, fmt.Errorf("error adding constraints: %w", err)
}
return constrainedMac, nil
}
// containsWhiteSpace returns true if the given string contains any character
// that is considered to be a white space or non-printable character such as
// space, tabulator, newline, carriage return and some more exotic ones.
func containsWhiteSpace(str string) bool {
return strings.IndexFunc(str, unicode.IsSpace) >= 0
}
+372
View File
@@ -0,0 +1,372 @@
package commands
import (
"fmt"
"strconv"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightningnetwork/lnd/lnrpc/routerrpc"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/urfave/cli"
)
var getCfgCommand = cli.Command{
Name: "getmccfg",
Category: "Mission Control",
Usage: "Display mission control's config.",
Description: `
Returns the config currently being used by mission control.
`,
Action: actionDecorator(getCfg),
}
func getCfg(ctx *cli.Context) error {
ctxc := getContext()
conn := getClientConn(ctx, false)
defer conn.Close()
client := routerrpc.NewRouterClient(conn)
resp, err := client.GetMissionControlConfig(
ctxc, &routerrpc.GetMissionControlConfigRequest{},
)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var setCfgCommand = cli.Command{
Name: "setmccfg",
Category: "Mission Control",
Usage: "Set mission control's config.",
Description: `
Update the config values being used by mission control to calculate the
probability that payment routes will succeed. The estimator type must be
provided to set estimator-related parameters.`,
Flags: []cli.Flag{
// General settings.
cli.UintFlag{
Name: "pmtnr",
Usage: "the number of payments mission control " +
"should store",
},
cli.DurationFlag{
Name: "failrelax",
Usage: "the amount of time to wait after a failure " +
"before raising failure amount",
},
// Probability estimator.
cli.StringFlag{
Name: "estimator",
Usage: "the probability estimator to use, choose " +
"between 'apriori' or 'bimodal' (bimodal is " +
"experimental)",
},
// Apriori config.
cli.DurationFlag{
Name: "apriorihalflife",
Usage: "the amount of time taken to restore a node " +
"or channel to 50% probability of success.",
},
cli.Float64Flag{
Name: "apriorihopprob",
Usage: "the probability of success assigned " +
"to hops that we have no information about",
},
cli.Float64Flag{
Name: "aprioriweight",
Usage: "the degree to which mission control should " +
"rely on historical results, expressed as " +
"value in [0, 1]",
},
cli.Float64Flag{
Name: "aprioricapacityfraction",
Usage: "the fraction of channels' capacities that is " +
"considered liquid in pathfinding, a value " +
"between [0.75-1.0]. a value of 1.0 disables " +
"this feature.",
},
// Bimodal config.
cli.DurationFlag{
Name: "bimodaldecaytime",
Usage: "the time span after which we phase out " +
"learnings from previous payment attempts",
},
cli.Uint64Flag{
Name: "bimodalscale",
Usage: "controls the assumed channel liquidity " +
"imbalance in the network, measured in msat. " +
"a low value (compared to typical channel " +
"capacity) anticipates unbalanced channels.",
},
cli.Float64Flag{
Name: "bimodalweight",
Usage: "controls the degree to which the probability " +
"estimator takes into account other channels " +
"of a router",
},
},
Action: actionDecorator(setCfg),
}
func setCfg(ctx *cli.Context) error {
ctxc := getContext()
conn := getClientConn(ctx, false)
defer conn.Close()
client := routerrpc.NewRouterClient(conn)
// Fetch current mission control config which we update to create our
// response.
mcCfg, err := client.GetMissionControlConfig(
ctxc, &routerrpc.GetMissionControlConfigRequest{},
)
if err != nil {
return err
}
// haveValue is a helper variable to determine if a flag has been set or
// the help should be displayed.
var haveValue bool
// Handle general mission control settings.
if ctx.IsSet("pmtnr") {
haveValue = true
mcCfg.Config.MaximumPaymentResults = uint32(ctx.Int("pmtnr"))
}
if ctx.IsSet("failrelax") {
haveValue = true
mcCfg.Config.MinimumFailureRelaxInterval = uint64(ctx.Duration(
"failrelax",
).Seconds())
}
// We switch between estimators and set corresponding configs. If
// estimator is not set, we ignore the values.
if ctx.IsSet("estimator") {
switch ctx.String("estimator") {
case routing.AprioriEstimatorName:
haveValue = true
// If we switch from another estimator, initialize with
// default values.
if mcCfg.Config.Model !=
routerrpc.MissionControlConfig_APRIORI {
dCfg := routing.DefaultAprioriConfig()
aParams := &routerrpc.AprioriParameters{
HalfLifeSeconds: uint64(
dCfg.PenaltyHalfLife.Seconds(),
),
HopProbability: dCfg.
AprioriHopProbability,
Weight: dCfg.AprioriWeight,
CapacityFraction: dCfg.CapacityFraction,
}
// We make sure the correct config is set.
mcCfg.Config.EstimatorConfig =
&routerrpc.MissionControlConfig_Apriori{
Apriori: aParams,
}
}
// We update all values for the apriori estimator.
mcCfg.Config.Model = routerrpc.
MissionControlConfig_APRIORI
aCfg := mcCfg.Config.GetApriori()
if ctx.IsSet("apriorihalflife") {
aCfg.HalfLifeSeconds = uint64(ctx.Duration(
"apriorihalflife",
).Seconds())
}
if ctx.IsSet("apriorihopprob") {
aCfg.HopProbability = ctx.Float64(
"apriorihopprob",
)
}
if ctx.IsSet("aprioriweight") {
aCfg.Weight = ctx.Float64("aprioriweight")
}
if ctx.IsSet("aprioricapacityfraction") {
aCfg.CapacityFraction =
ctx.Float64("aprioricapacityfraction")
}
case routing.BimodalEstimatorName:
haveValue = true
// If we switch from another estimator, initialize with
// default values.
if mcCfg.Config.Model !=
routerrpc.MissionControlConfig_BIMODAL {
dCfg := routing.DefaultBimodalConfig()
bParams := &routerrpc.BimodalParameters{
DecayTime: uint64(
dCfg.BimodalDecayTime.Seconds(),
),
ScaleMsat: uint64(
dCfg.BimodalScaleMsat,
),
NodeWeight: dCfg.BimodalNodeWeight,
}
// We make sure the correct config is set.
mcCfg.Config.EstimatorConfig =
&routerrpc.MissionControlConfig_Bimodal{
Bimodal: bParams,
}
}
// We update all values for the bimodal estimator.
mcCfg.Config.Model = routerrpc.
MissionControlConfig_BIMODAL
bCfg := mcCfg.Config.GetBimodal()
if ctx.IsSet("bimodaldecaytime") {
bCfg.DecayTime = uint64(ctx.Duration(
"bimodaldecaytime",
).Seconds())
}
if ctx.IsSet("bimodalscale") {
bCfg.ScaleMsat = ctx.Uint64("bimodalscale")
}
if ctx.IsSet("bimodalweight") {
bCfg.NodeWeight = ctx.Float64(
"bimodalweight",
)
}
default:
return fmt.Errorf("unknown estimator %v",
ctx.String("estimator"))
}
}
if !haveValue {
return cli.ShowCommandHelp(ctx, "setmccfg")
}
_, err = client.SetMissionControlConfig(
ctxc, &routerrpc.SetMissionControlConfigRequest{
Config: mcCfg.Config,
},
)
return err
}
var queryMissionControlCommand = cli.Command{
Name: "querymc",
Category: "Mission Control",
Usage: "Query the internal mission control state.",
Action: actionDecorator(queryMissionControl),
}
func queryMissionControl(ctx *cli.Context) error {
ctxc := getContext()
conn := getClientConn(ctx, false)
defer conn.Close()
client := routerrpc.NewRouterClient(conn)
req := &routerrpc.QueryMissionControlRequest{}
snapshot, err := client.QueryMissionControl(ctxc, req)
if err != nil {
return err
}
printRespJSON(snapshot)
return nil
}
var queryProbCommand = cli.Command{
Name: "queryprob",
Category: "Mission Control",
Usage: "Deprecated. Estimate a success probability.",
ArgsUsage: "from-node to-node amt",
Action: actionDecorator(queryProb),
Hidden: true,
}
func queryProb(ctx *cli.Context) error {
ctxc := getContext()
args := ctx.Args()
if len(args) != 3 {
return cli.ShowCommandHelp(ctx, "queryprob")
}
fromNode, err := route.NewVertexFromStr(args.Get(0))
if err != nil {
return fmt.Errorf("invalid from node key: %w", err)
}
toNode, err := route.NewVertexFromStr(args.Get(1))
if err != nil {
return fmt.Errorf("invalid to node key: %w", err)
}
amtSat, err := strconv.ParseUint(args.Get(2), 10, 64)
if err != nil {
return fmt.Errorf("invalid amt: %w", err)
}
amtMsat := lnwire.NewMSatFromSatoshis(
btcutil.Amount(amtSat),
)
conn := getClientConn(ctx, false)
defer conn.Close()
client := routerrpc.NewRouterClient(conn)
req := &routerrpc.QueryProbabilityRequest{
FromNode: fromNode[:],
ToNode: toNode[:],
AmtMsat: int64(amtMsat),
}
response, err := client.QueryProbability(ctxc, req)
if err != nil {
return err
}
printRespJSON(response)
return nil
}
var resetMissionControlCommand = cli.Command{
Name: "resetmc",
Category: "Mission Control",
Usage: "Reset internal mission control state.",
Action: actionDecorator(resetMissionControl),
}
func resetMissionControl(ctx *cli.Context) error {
ctxc := getContext()
conn := getClientConn(ctx, false)
defer conn.Close()
client := routerrpc.NewRouterClient(conn)
req := &routerrpc.ResetMissionControlRequest{}
_, err := client.ResetMissionControl(ctxc, req)
return err
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+452
View File
@@ -0,0 +1,452 @@
package commands
import (
"fmt"
"os"
"path"
"strings"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightningnetwork/lnd/lncfg"
"github.com/urfave/cli"
"gopkg.in/macaroon.v2"
)
var (
// defaultLncliDir is the default directory to store the profile file
// in. This defaults to:
// C:\Users\<username>\AppData\Local\Lncli\ on Windows
// ~/.lncli/ on Linux
// ~/Library/Application Support/Lncli/ on MacOS
defaultLncliDir = btcutil.AppDataDir("lncli", false)
// defaultProfileFile is the full, absolute path of the profile file.
defaultProfileFile = path.Join(defaultLncliDir, "profiles.json")
)
var profileSubCommand = cli.Command{
Name: "profile",
Category: "Profiles",
Usage: "Create and manage lncli profiles.",
Description: `
Profiles for lncli are an easy and comfortable way to manage multiple
nodes from the command line by storing node specific parameters like RPC
host, network, TLS certificate path or macaroons in a named profile.
To use a predefined profile, just use the '--profile=myprofile' (or
short version '-p=myprofile') with any lncli command.
A default profile can also be defined, lncli will then always use the
connection/node parameters from that profile instead of the default
values.
WARNING: Setting a default profile changes the default behavior of
lncli! To disable the use of the default profile for a single command,
set '--profile= '.
The profiles are stored in a file called profiles.json in the user's
home directory, for example:
C:\Users\<username>\AppData\Local\Lncli\profiles.json on Windows
~/.lncli/profiles.json on Linux
~/Library/Application Support/Lncli/profiles.json on MacOS
`,
Subcommands: []cli.Command{
profileListCommand,
profileAddCommand,
profileRemoveCommand,
profileSetDefaultCommand,
profileUnsetDefaultCommand,
profileAddMacaroonCommand,
},
}
var profileListCommand = cli.Command{
Name: "list",
Usage: "Lists all lncli profiles",
Action: profileList,
}
func profileList(_ *cli.Context) error {
f, err := loadProfileFile(defaultProfileFile)
if err != nil {
return err
}
printJSON(f)
return nil
}
var profileAddCommand = cli.Command{
Name: "add",
Usage: "Add a new profile.",
ArgsUsage: "name",
Description: `
Add a new named profile to the main profiles.json. All global options
(see 'lncli --help') passed into this command are stored in that named
profile.
`,
Flags: []cli.Flag{
cli.StringFlag{
Name: "name",
Usage: "the name of the new profile",
},
cli.BoolFlag{
Name: "default",
Usage: "set the new profile to be the default profile",
},
},
Action: profileAdd,
}
func profileAdd(ctx *cli.Context) error {
if ctx.NArg() == 0 && ctx.NumFlags() == 0 {
return cli.ShowCommandHelp(ctx, "add")
}
// Load the default profile file or create a new one if it doesn't exist
// yet.
f, err := loadProfileFile(defaultProfileFile)
switch {
case err == errNoProfileFile:
f = &profileFile{}
_ = os.MkdirAll(path.Dir(defaultProfileFile), 0700)
case err != nil:
return err
}
// Create a profile struct from all the global options.
profile, err := profileFromContext(ctx, true, false)
if err != nil {
return fmt.Errorf("could not load global options: %w", err)
}
// Finally, all that's left is to get the profile name from either
// positional argument or flag.
args := ctx.Args()
switch {
case ctx.IsSet("name"):
profile.Name = ctx.String("name")
case args.Present():
profile.Name = args.First()
default:
return fmt.Errorf("name argument missing")
}
// Is there already a profile with that name?
for _, p := range f.Profiles {
if p.Name == profile.Name {
return fmt.Errorf("a profile with the name %s already "+
"exists", profile.Name)
}
}
// Do we need to update the default entry to be this one?
if ctx.Bool("default") {
f.Default = profile.Name
}
// All done, store the updated profile file.
f.Profiles = append(f.Profiles, profile)
if err = saveProfileFile(defaultProfileFile, f); err != nil {
return fmt.Errorf("error writing profile file %s: %w",
defaultProfileFile, err)
}
fmt.Printf("Profile %s added to file %s.\n", profile.Name,
defaultProfileFile)
return nil
}
var profileRemoveCommand = cli.Command{
Name: "remove",
Usage: "Remove a profile",
ArgsUsage: "name",
Description: `Remove the specified profile from the profile file.`,
Flags: []cli.Flag{
cli.StringFlag{
Name: "name",
Usage: "the name of the profile to delete",
},
},
Action: profileRemove,
}
func profileRemove(ctx *cli.Context) error {
if ctx.NArg() == 0 && ctx.NumFlags() == 0 {
return cli.ShowCommandHelp(ctx, "remove")
}
// Load the default profile file.
f, err := loadProfileFile(defaultProfileFile)
if err != nil {
return fmt.Errorf("could not load profile file: %w", err)
}
// Get the profile name from either positional argument or flag.
var (
args = ctx.Args()
name string
found = false
)
switch {
case ctx.IsSet("name"):
name = ctx.String("name")
case args.Present():
name = args.First()
default:
return fmt.Errorf("name argument missing")
}
if len(f.Profiles) == 0 {
return fmt.Errorf("there are no existing profiles")
}
// Create a copy of all profiles but don't include the one to delete.
newProfiles := make([]*profileEntry, 0, len(f.Profiles)-1)
for _, p := range f.Profiles {
// Skip the one we want to delete.
if p.Name == name {
found = true
if p.Name == f.Default {
fmt.Println("Warning: removing default profile.")
}
continue
}
// Keep all others.
newProfiles = append(newProfiles, p)
}
// If what we were looking for didn't exist in the first place, there's
// no need for updating the file.
if !found {
return fmt.Errorf("profile with name %s not found in file",
name)
}
// Great, everything updated, now let's save the file.
f.Profiles = newProfiles
return saveProfileFile(defaultProfileFile, f)
}
var profileSetDefaultCommand = cli.Command{
Name: "setdefault",
Usage: "Set the default profile.",
ArgsUsage: "name",
Description: `
Set a specified profile to be used as the default profile.
WARNING: Setting a default profile changes the default behavior of
lncli! To disable the use of the default profile for a single command,
set '--profile= '.
`,
Flags: []cli.Flag{
cli.StringFlag{
Name: "name",
Usage: "the name of the profile to set as default",
},
},
Action: profileSetDefault,
}
func profileSetDefault(ctx *cli.Context) error {
if ctx.NArg() == 0 && ctx.NumFlags() == 0 {
return cli.ShowCommandHelp(ctx, "setdefault")
}
// Load the default profile file.
f, err := loadProfileFile(defaultProfileFile)
if err != nil {
return fmt.Errorf("could not load profile file: %w", err)
}
// Get the profile name from either positional argument or flag.
var (
args = ctx.Args()
name string
found = false
)
switch {
case ctx.IsSet("name"):
name = ctx.String("name")
case args.Present():
name = args.First()
default:
return fmt.Errorf("name argument missing")
}
// Make sure the new default profile actually exists.
for _, p := range f.Profiles {
if p.Name == name {
found = true
f.Default = p.Name
break
}
}
// If the default profile doesn't exist, there's no need for updating
// the file.
if !found {
return fmt.Errorf("profile with name %s not found in file",
name)
}
// Great, everything updated, now let's save the file.
return saveProfileFile(defaultProfileFile, f)
}
var profileUnsetDefaultCommand = cli.Command{
Name: "unsetdefault",
Usage: "Unsets the default profile.",
Description: `
Disables the use of a default profile and restores lncli to its original
behavior.
`,
Action: profileUnsetDefault,
}
func profileUnsetDefault(_ *cli.Context) error {
// Load the default profile file.
f, err := loadProfileFile(defaultProfileFile)
if err != nil {
return fmt.Errorf("could not load profile file: %w", err)
}
// Save the file with the flag disabled.
f.Default = ""
return saveProfileFile(defaultProfileFile, f)
}
var profileAddMacaroonCommand = cli.Command{
Name: "addmacaroon",
Usage: "Add a macaroon to a profile's macaroon jar.",
ArgsUsage: "macaroon-name",
Description: `
Add an additional macaroon specified by the global option --macaroonpath
to an existing profile's macaroon jar.
If no profile is selected, the macaroon is added to the default profile
(if one exists). To add a macaroon to a specific profile, use the global
--profile=myprofile option.
If multiple macaroons exist in a profile's macaroon jar, the one to use
can be specified with the global option --macfromjar=xyz.
`,
Flags: []cli.Flag{
cli.StringFlag{
Name: "name",
Usage: "the name of the macaroon",
},
cli.BoolFlag{
Name: "default",
Usage: "set the new macaroon to be the default " +
"macaroon in the jar",
},
},
Action: profileAddMacaroon,
}
func profileAddMacaroon(ctx *cli.Context) error {
if ctx.NArg() == 0 && ctx.NumFlags() == 0 {
return cli.ShowCommandHelp(ctx, "addmacaroon")
}
// Load the default profile file or create a new one if it doesn't exist
// yet.
f, err := loadProfileFile(defaultProfileFile)
if err != nil {
return fmt.Errorf("could not load profile file: %w", err)
}
// Finally, all that's left is to get the profile name from either
// positional argument or flag.
var (
args = ctx.Args()
profileName string
macName string
)
switch {
case ctx.IsSet("name"):
macName = ctx.String("name")
case args.Present():
macName = args.First()
default:
return fmt.Errorf("name argument missing")
}
// Make sure the user actually set a macaroon path to use.
if !ctx.GlobalIsSet("macaroonpath") {
return fmt.Errorf("macaroonpath global option missing")
}
// Find out which profile we should add the macaroon. The global flag
// takes precedence over the default profile.
if f.Default != "" {
profileName = f.Default
}
if ctx.GlobalIsSet("profile") {
profileName = ctx.GlobalString("profile")
}
if len(strings.TrimSpace(profileName)) == 0 {
return fmt.Errorf("no profile specified and no default " +
"profile exists")
}
// Is there a profile with that name?
var selectedProfile *profileEntry
for _, p := range f.Profiles {
if p.Name == profileName {
selectedProfile = p
break
}
}
if selectedProfile == nil {
return fmt.Errorf("profile with name %s not found", profileName)
}
// Does a macaroon with that name already exist?
for _, m := range selectedProfile.Macaroons.Jar {
if m.Name == macName {
return fmt.Errorf("a macaroon with the name %s "+
"already exists", macName)
}
}
// Do we need to update the default entry to be this one?
if ctx.Bool("default") {
selectedProfile.Macaroons.Default = macName
}
// Now load and possibly encrypt the macaroon file.
macPath := lncfg.CleanAndExpandPath(ctx.GlobalString("macaroonpath"))
macBytes, err := os.ReadFile(macPath)
if err != nil {
return fmt.Errorf("unable to read macaroon path: %w", err)
}
mac := &macaroon.Macaroon{}
if err = mac.UnmarshalBinary(macBytes); err != nil {
return fmt.Errorf("unable to decode macaroon: %w", err)
}
macEntry := &macaroonEntry{
Name: macName,
}
if err = macEntry.storeMacaroon(mac, nil); err != nil {
return fmt.Errorf("unable to store macaroon: %w", err)
}
// All done, store the updated profile file.
selectedProfile.Macaroons.Jar = append(
selectedProfile.Macaroons.Jar, macEntry,
)
if err = saveProfileFile(defaultProfileFile, f); err != nil {
return fmt.Errorf("error writing profile file %s: %w",
defaultProfileFile, err)
}
fmt.Printf("Macaroon %s added to profile %s in file %s.\n", macName,
selectedProfile.Name, defaultProfileFile)
return nil
}
+47
View File
@@ -0,0 +1,47 @@
package commands
import (
"context"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/urfave/cli"
)
var getStateCommand = cli.Command{
Name: "state",
Category: "Startup",
Usage: "Get the current state of the wallet and RPC",
Description: `
Get the current state of the wallet. The possible states are:
- WAITING_TO_START: node is waiting to become the leader in a cluster
and is not started yet.
- NON_EXISTING: wallet has not yet been initialized.
- LOCKED: wallet is locked.
- UNLOCKED: wallet was unlocked successfully, but RPC server isn't ready.
- RPC_ACTIVE: RPC server is active but not fully ready for calls.
- SERVER_ACTIVE: RPC server is available and ready to accept calls.
`,
Flags: []cli.Flag{},
Action: actionDecorator(getState),
}
func getState(ctx *cli.Context) error {
ctxb := context.Background()
client, cleanUp := getStateServiceClient(ctx)
defer cleanUp()
req := &lnrpc.SubscribeStateRequest{}
stream, err := client.SubscribeState(ctxb, req)
if err != nil {
return err
}
// Get a single state, then exit.
resp, err := stream.Recv()
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
+98
View File
@@ -0,0 +1,98 @@
package commands
import (
"errors"
"github.com/lightningnetwork/lnd/lnrpc/routerrpc"
"github.com/urfave/cli"
)
var updateChanStatusCommand = cli.Command{
Name: "updatechanstatus",
Category: "Channels",
Usage: "Set the status of an existing channel on the network.",
Description: `
Set the status of an existing channel on the network. The actions can
be "enable", "disable", or "auto". If the action changes the status, a
message will be broadcast over the network.
Note that enabling / disabling a channel using this command ONLY affects
what's advertised over the network. For example, disabling a channel
using this command does not close it.
If a channel is manually disabled, automatic / background requests to
re-enable the channel will be ignored. However, if a channel is
manually enabled, automatic / background requests to disable the
channel will succeed (such requests are usually made on channel close
or when the peer is down).
The "auto" action restores automatic channel state management. Per
the behavior described above, it's only needed to undo the effect of
a prior "disable" action, and will be a no-op otherwise.`,
ArgsUsage: "funding_txid [output_index] action",
Flags: []cli.Flag{
cli.StringFlag{
Name: "funding_txid",
Usage: "the txid of the channel's funding transaction",
},
cli.IntFlag{
Name: "output_index",
Usage: "the output index for the funding output of " +
"the funding transaction",
},
cli.StringFlag{
Name: "chan_point",
Usage: "the channel whose status should be updated. " +
"Takes the form of: txid:output_index",
},
cli.StringFlag{
Name: "action",
Usage: `the action to take: must be one of "enable", ` +
`"disable", or "auto"`,
},
},
Action: actionDecorator(updateChanStatus),
}
func updateChanStatus(ctx *cli.Context) error {
ctxc := getContext()
conn := getClientConn(ctx, false)
defer conn.Close()
if ctx.NArg() == 0 && ctx.NumFlags() == 0 {
_ = cli.ShowCommandHelp(ctx, "updatechanstatus")
return nil
}
channelPoint, err := parseChannelPoint(ctx)
if err != nil {
return err
}
var action routerrpc.ChanStatusAction
switch ctx.String("action") {
case "enable":
action = routerrpc.ChanStatusAction_ENABLE
case "disable":
action = routerrpc.ChanStatusAction_DISABLE
case "auto":
action = routerrpc.ChanStatusAction_AUTO
default:
return errors.New(`action must be one of "enable", "disable", ` +
`or "auto"`)
}
req := &routerrpc.UpdateChanStatusRequest{
ChanPoint: channelPoint,
Action: action,
}
client := routerrpc.NewRouterClient(conn)
resp, err := client.UpdateChanStatus(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
+53
View File
@@ -0,0 +1,53 @@
package commands
import (
"fmt"
"github.com/lightningnetwork/lnd/build"
"github.com/lightningnetwork/lnd/lnrpc/lnclipb"
"github.com/lightningnetwork/lnd/lnrpc/verrpc"
"github.com/urfave/cli"
)
var versionCommand = cli.Command{
Name: "version",
Usage: "Display lncli and lnd version info.",
Description: `
Returns version information about both lncli and lnd. If lncli is unable
to connect to lnd, the command fails but still prints the lncli version.
`,
Action: actionDecorator(version),
}
func version(ctx *cli.Context) error {
ctxc := getContext()
conn := getClientConn(ctx, false)
defer conn.Close()
versions := &lnclipb.VersionResponse{
Lncli: &verrpc.Version{
Commit: build.Commit,
CommitHash: build.CommitHash,
Version: build.Version(),
AppMajor: uint32(build.AppMajor),
AppMinor: uint32(build.AppMinor),
AppPatch: uint32(build.AppPatch),
AppPreRelease: build.AppPreRelease,
BuildTags: build.Tags(),
GoVersion: build.GoVersion,
},
}
client := verrpc.NewVersionerClient(conn)
lndVersion, err := client.GetVersion(ctxc, &verrpc.VersionRequest{})
if err != nil {
printRespJSON(versions)
return fmt.Errorf("unable fetch version from lnd: %w", err)
}
versions.Lnd = lndVersion
printRespJSON(versions)
return nil
}
+891
View File
@@ -0,0 +1,891 @@
package commands
import (
"bufio"
"bytes"
"encoding/hex"
"fmt"
"os"
"strconv"
"strings"
"github.com/lightningnetwork/lnd/lncfg"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
"github.com/lightningnetwork/lnd/walletunlocker"
"github.com/urfave/cli"
)
var (
statelessInitFlag = cli.BoolFlag{
Name: "stateless_init",
Usage: "do not create any macaroon files in the file " +
"system of the daemon",
}
saveToFlag = cli.StringFlag{
Name: "save_to",
Usage: "save returned admin macaroon to this file",
}
)
var createCommand = cli.Command{
Name: "create",
Category: "Startup",
Usage: "Initialize a wallet when starting lnd for the first time.",
Description: `
The create command is used to initialize an lnd wallet from scratch for
the very first time. This is interactive command with one required
argument (the password), and one optional argument (the mnemonic
passphrase).
The first argument (the password) is required and MUST be greater than
8 characters. This will be used to encrypt the wallet within lnd. This
MUST be remembered as it will be required to fully start up the daemon.
The second argument is an optional 24-word mnemonic derived from BIP
39. If provided, then the internal wallet will use the seed derived
from this mnemonic to generate all keys.
This command returns a 24-word seed in the scenario that NO mnemonic
was provided by the user. This should be written down as it can be used
to potentially recover all on-chain funds, and most off-chain funds as
well.
If the --stateless_init flag is set, no macaroon files are created by
the daemon. Instead, the binary serialized admin macaroon is returned
in the answer. This answer MUST be stored somewhere, otherwise all
access to the RPC server will be lost and the wallet must be recreated
to re-gain access.
If the --save_to parameter is set, the macaroon is saved to this file,
otherwise it is printed to standard out.
Finally, it's also possible to use this command and a set of static
channel backups to trigger a recover attempt for the provided Static
Channel Backups. Only one of the three parameters will be accepted. See
the restorechanbackup command for further details w.r.t the format
accepted.
`,
Flags: []cli.Flag{
cli.StringFlag{
Name: "single_backup",
Usage: "a hex encoded single channel backup obtained " +
"from exportchanbackup",
},
cli.StringFlag{
Name: "multi_backup",
Usage: "a hex encoded multi-channel backup obtained " +
"from exportchanbackup",
},
cli.StringFlag{
Name: "multi_file",
Usage: "the path to a multi-channel back up file",
},
statelessInitFlag,
saveToFlag,
},
Action: actionDecorator(create),
}
// monowidthColumns takes a set of words, and the number of desired columns,
// and returns a new set of words that have had white space appended to the
// word in order to create a mono-width column.
func monowidthColumns(words []string, ncols int) []string {
// Determine max size of words in each column.
colWidths := make([]int, ncols)
for i, word := range words {
col := i % ncols
curWidth := colWidths[col]
if len(word) > curWidth {
colWidths[col] = len(word)
}
}
// Append whitespace to each word to make columns mono-width.
finalWords := make([]string, len(words))
for i, word := range words {
col := i % ncols
width := colWidths[col]
diff := width - len(word)
finalWords[i] = word + strings.Repeat(" ", diff)
}
return finalWords
}
func create(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getWalletUnlockerClient(ctx)
defer cleanUp()
var (
chanBackups *lnrpc.ChanBackupSnapshot
// We use var restoreSCB to track if we will be including an SCB
// recovery in the init wallet request.
restoreSCB = false
)
backups, err := parseChanBackups(ctx)
// We'll check to see if the user provided any static channel backups (SCB),
// if so, we will warn the user that SCB recovery closes all open channels
// and ask them to confirm their intention.
// If the user agrees, we'll add the SCB recovery onto the final init wallet
// request.
switch {
// parseChanBackups returns an errMissingBackup error (which we ignore) if
// the user did not request a SCB recovery.
case err == errMissingChanBackup:
// Passed an invalid channel backup file.
case err != nil:
return fmt.Errorf("unable to parse chan backups: %w", err)
// We have an SCB recovery option with a valid backup file.
default:
warningLoop:
for {
fmt.Println()
fmt.Printf("WARNING: You are attempting to restore from a " +
"static channel backup (SCB) file.\nThis action will CLOSE " +
"all currently open channels, and you will pay on-chain fees." +
"\n\nAre you sure you want to recover funds from a" +
" static channel backup? (Enter y/n): ")
reader := bufio.NewReader(os.Stdin)
answer, err := reader.ReadString('\n')
if err != nil {
return err
}
answer = strings.TrimSpace(answer)
answer = strings.ToLower(answer)
switch answer {
case "y":
restoreSCB = true
break warningLoop
case "n":
fmt.Println("Aborting SCB recovery")
return nil
}
}
}
// Proceed with SCB recovery.
if restoreSCB {
fmt.Println("Static Channel Backup (SCB) recovery selected!")
if backups != nil {
switch {
case backups.GetChanBackups() != nil:
singleBackup := backups.GetChanBackups()
chanBackups = &lnrpc.ChanBackupSnapshot{
SingleChanBackups: singleBackup,
}
case backups.GetMultiChanBackup() != nil:
multiBackup := backups.GetMultiChanBackup()
chanBackups = &lnrpc.ChanBackupSnapshot{
MultiChanBackup: &lnrpc.MultiChanBackup{
MultiChanBackup: multiBackup,
},
}
}
}
}
// Should the daemon be initialized stateless? Then we expect an answer
// with the admin macaroon later. Because the --save_to is related to
// stateless init, it doesn't make sense to be set on its own.
statelessInit := ctx.Bool(statelessInitFlag.Name)
if !statelessInit && ctx.IsSet(saveToFlag.Name) {
return fmt.Errorf("cannot set save_to parameter without " +
"stateless_init")
}
walletPassword, err := capturePassword(
"Input wallet password: ", false, walletunlocker.ValidatePassword,
)
if err != nil {
return err
}
// Next, we'll see if the user has 24-word mnemonic they want to use to
// derive a seed within the wallet or if they want to specify an
// extended master root key (xprv) directly.
var (
hasMnemonic bool
hasXprv bool
)
mnemonicCheck:
for {
fmt.Println()
fmt.Printf("Do you have an existing cipher seed " +
"mnemonic or extended master root key you want to " +
"use?\nEnter 'y' to use an existing cipher seed " +
"mnemonic, 'x' to use an extended master root key " +
"\nor 'n' to create a new seed (Enter y/x/n): ")
reader := bufio.NewReader(os.Stdin)
answer, err := reader.ReadString('\n')
if err != nil {
return err
}
fmt.Println()
answer = strings.TrimSpace(answer)
answer = strings.ToLower(answer)
switch answer {
case "y":
hasMnemonic = true
break mnemonicCheck
case "x":
hasXprv = true
break mnemonicCheck
case "n":
break mnemonicCheck
}
}
// If the user *does* have an existing seed or root key they want to
// use, then we'll read that in directly from the terminal.
var (
cipherSeedMnemonic []string
aezeedPass []byte
extendedRootKey string
extendedRootKeyBirthday uint64
recoveryWindow int32
)
switch {
// Use an existing cipher seed mnemonic in the aezeed format.
case hasMnemonic:
// We'll now prompt the user to enter in their 24-word
// mnemonic.
fmt.Printf("Input your 24-word mnemonic separated by spaces: ")
reader := bufio.NewReader(os.Stdin)
mnemonic, err := reader.ReadString('\n')
if err != nil {
return err
}
// We'll trim off extra spaces, and ensure the mnemonic is all
// lower case, then populate our request.
mnemonic = strings.TrimSpace(mnemonic)
mnemonic = strings.ToLower(mnemonic)
cipherSeedMnemonic = strings.Split(mnemonic, " ")
fmt.Println()
if len(cipherSeedMnemonic) != 24 {
return fmt.Errorf("wrong cipher seed mnemonic "+
"length: got %v words, expecting %v words",
len(cipherSeedMnemonic), 24)
}
// Additionally, the user may have a passphrase, that will also
// need to be provided so the daemon can properly decipher the
// cipher seed.
aezeedPass, err = readPassword("Input your cipher seed " +
"passphrase (press enter if your seed doesn't have a " +
"passphrase): ")
if err != nil {
return err
}
recoveryWindow, err = askRecoveryWindow()
if err != nil {
return err
}
// Use an existing extended master root key to create the wallet.
case hasXprv:
// We'll now prompt the user to enter in their extended master
// root key.
fmt.Printf("Input your extended master root key (usually " +
"starting with xprv... on mainnet): ")
reader := bufio.NewReader(os.Stdin)
extendedRootKey, err = reader.ReadString('\n')
if err != nil {
return err
}
extendedRootKey = strings.TrimSpace(extendedRootKey)
extendedRootKeyBirthday, err = askBirthdayTimestamp()
if err != nil {
return err
}
recoveryWindow, err = askRecoveryWindow()
if err != nil {
return err
}
// Neither a seed nor a master root key was specified, the user wants
// to create a new seed.
default:
// Otherwise, if the user doesn't have a mnemonic that they
// want to use, we'll generate a fresh one with the GenSeed
// command.
fmt.Println("Your cipher seed can optionally be encrypted.")
instruction := "Input your passphrase if you wish to encrypt it " +
"(or press enter to proceed without a cipher seed " +
"passphrase): "
aezeedPass, err = capturePassword(
instruction, true, func(_ []byte) error { return nil },
)
if err != nil {
return err
}
fmt.Println()
fmt.Println("Generating fresh cipher seed...")
fmt.Println()
genSeedReq := &lnrpc.GenSeedRequest{
AezeedPassphrase: aezeedPass,
}
seedResp, err := client.GenSeed(ctxc, genSeedReq)
if err != nil {
return fmt.Errorf("unable to generate seed: %w", err)
}
cipherSeedMnemonic = seedResp.CipherSeedMnemonic
}
// Before we initialize the wallet, we'll display the cipher seed to
// the user so they can write it down.
if len(cipherSeedMnemonic) > 0 {
printCipherSeedWords(cipherSeedMnemonic)
}
// With either the user's prior cipher seed, or a newly generated one,
// we'll go ahead and initialize the wallet.
req := &lnrpc.InitWalletRequest{
WalletPassword: walletPassword,
CipherSeedMnemonic: cipherSeedMnemonic,
AezeedPassphrase: aezeedPass,
ExtendedMasterKey: extendedRootKey,
ExtendedMasterKeyBirthdayTimestamp: extendedRootKeyBirthday,
RecoveryWindow: recoveryWindow,
ChannelBackups: chanBackups,
StatelessInit: statelessInit,
}
response, err := client.InitWallet(ctxc, req)
if err != nil {
return err
}
fmt.Println("\nlnd successfully initialized!")
if statelessInit {
return storeOrPrintAdminMac(ctx, response.AdminMacaroon)
}
return nil
}
// capturePassword returns a password value that has been entered twice by the
// user, to ensure that the user knows what password they have entered. The user
// will be prompted to retry until the passwords match. If the optional param is
// true, the function may return an empty byte array if the user opts against
// using a password.
func capturePassword(instruction string, optional bool,
validate func([]byte) error) ([]byte, error) {
for {
password, err := readPassword(instruction)
if err != nil {
return nil, err
}
// Do not require users to repeat password if
// it is optional and they are not using one.
if len(password) == 0 && optional {
return nil, nil
}
// If the password provided is not valid, restart
// password capture process from the beginning.
if err := validate(password); err != nil {
fmt.Println(err.Error())
fmt.Println()
continue
}
passwordConfirmed, err := readPassword("Confirm password: ")
if err != nil {
return nil, err
}
if bytes.Equal(password, passwordConfirmed) {
return password, nil
}
fmt.Println("Passwords don't match, please try again")
fmt.Println()
}
}
var unlockCommand = cli.Command{
Name: "unlock",
Category: "Startup",
Usage: "Unlock an encrypted wallet at startup.",
Description: `
The unlock command is used to decrypt lnd's wallet state in order to
start up. This command MUST be run after booting up lnd before it's
able to carry out its duties. An exception is if a user is running with
--noseedbackup, then a default passphrase will be used.
If the --stateless_init flag is set, no macaroon files are created by
the daemon. This should be set for every unlock if the daemon was
initially initialized stateless. Otherwise the daemon will create
unencrypted macaroon files which could leak information to the system
that the daemon runs on.
`,
Flags: []cli.Flag{
cli.IntFlag{
Name: "recovery_window",
Usage: "address lookahead to resume recovery rescan, " +
"value should be non-zero -- To recover all " +
"funds, this should be greater than the " +
"maximum number of consecutive, unused " +
"addresses ever generated by the wallet.",
},
cli.BoolFlag{
Name: "stdin",
Usage: "read password from standard input instead of " +
"prompting for it. THIS IS CONSIDERED TO " +
"BE DANGEROUS if the password is located in " +
"a file that can be read by another user. " +
"This flag should only be used in " +
"combination with some sort of password " +
"manager or secrets vault.",
},
statelessInitFlag,
},
Action: actionDecorator(unlock),
}
func unlock(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getWalletUnlockerClient(ctx)
defer cleanUp()
var (
pw []byte
err error
)
switch {
// Read the password from standard in as if it were a file. This should
// only be used if the password is piped into lncli from some sort of
// password manager. If the user types the password instead, it will be
// echoed in the console.
case ctx.IsSet("stdin"):
reader := bufio.NewReader(os.Stdin)
pw, err = reader.ReadBytes('\n')
// Remove carriage return and newline characters.
pw = bytes.Trim(pw, "\r\n")
// Read the password from a terminal by default. This requires the
// terminal to be a real tty and will fail if a string is piped into
// lncli.
default:
pw, err = readPassword("Input wallet password: ")
}
if err != nil {
return err
}
args := ctx.Args()
// Parse the optional recovery window if it is specified. By default,
// the recovery window will be 0, indicating no lookahead should be
// used.
var recoveryWindow int32
switch {
case ctx.IsSet("recovery_window"):
recoveryWindow = int32(ctx.Int64("recovery_window"))
case args.Present():
window, err := strconv.ParseInt(args.First(), 10, 64)
if err != nil {
return err
}
recoveryWindow = int32(window)
}
req := &lnrpc.UnlockWalletRequest{
WalletPassword: pw,
RecoveryWindow: recoveryWindow,
StatelessInit: ctx.Bool(statelessInitFlag.Name),
}
_, err = client.UnlockWallet(ctxc, req)
if err != nil {
return err
}
fmt.Println("\nlnd successfully unlocked!")
// TODO(roasbeef): add ability to accept hex single and multi backups
return nil
}
var changePasswordCommand = cli.Command{
Name: "changepassword",
Category: "Startup",
Usage: "Change an encrypted wallet's password at startup.",
Description: `
The changepassword command is used to Change lnd's encrypted wallet's
password. It will automatically unlock the daemon if the password change
is successful.
If one did not specify a password for their wallet (running lnd with
--noseedbackup), one must restart their daemon without
--noseedbackup and use this command. The "current password" field
should be left empty.
If the daemon was originally initialized stateless, then the
--stateless_init flag needs to be set for the change password request
as well! Otherwise the daemon will generate unencrypted macaroon files
in its file system again and possibly leak sensitive information.
Changing the password will by default not change the macaroon root key
(just re-encrypt the macaroon database with the new password). So all
macaroons will still be valid.
If one wants to make sure that all previously created macaroons are
invalidated, a new macaroon root key can be generated by using the
--new_mac_root_key flag.
After a successful password change with the --stateless_init flag set,
the current or new admin macaroon is returned binary serialized in the
answer. This answer MUST then be stored somewhere, otherwise
all access to the RPC server will be lost and the wallet must be re-
created to re-gain access. If the --save_to parameter is set, the
macaroon is saved to this file, otherwise it is printed to standard out.
`,
Flags: []cli.Flag{
statelessInitFlag,
saveToFlag,
cli.BoolFlag{
Name: "new_mac_root_key",
Usage: "rotate the macaroon root key resulting in " +
"all previously created macaroons to be " +
"invalidated",
},
},
Action: actionDecorator(changePassword),
}
func changePassword(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getWalletUnlockerClient(ctx)
defer cleanUp()
currentPw, err := readPassword("Input current wallet password: ")
if err != nil {
return err
}
newPw, err := readPassword("Input new wallet password: ")
if err != nil {
return err
}
confirmPw, err := readPassword("Confirm new wallet password: ")
if err != nil {
return err
}
if !bytes.Equal(newPw, confirmPw) {
return fmt.Errorf("passwords don't match")
}
// Should the daemon be initialized stateless? Then we expect an answer
// with the admin macaroon later. Because the --save_to is related to
// stateless init, it doesn't make sense to be set on its own.
statelessInit := ctx.Bool(statelessInitFlag.Name)
if !statelessInit && ctx.IsSet(saveToFlag.Name) {
return fmt.Errorf("cannot set save_to parameter without " +
"stateless_init")
}
req := &lnrpc.ChangePasswordRequest{
CurrentPassword: currentPw,
NewPassword: newPw,
StatelessInit: statelessInit,
NewMacaroonRootKey: ctx.Bool("new_mac_root_key"),
}
response, err := client.ChangePassword(ctxc, req)
if err != nil {
return err
}
if statelessInit {
return storeOrPrintAdminMac(ctx, response.AdminMacaroon)
}
return nil
}
var createWatchOnlyCommand = cli.Command{
Name: "createwatchonly",
Category: "Startup",
ArgsUsage: "accounts-json-file",
Usage: "Initialize a watch-only wallet after starting lnd for the " +
"first time.",
Description: `
The create command is used to initialize an lnd wallet from scratch for
the very first time, in watch-only mode. Watch-only means, there will be
no private keys in lnd's wallet. This is only useful in combination with
a remote signer or when lnd should be used as an on-chain wallet with
PSBT interaction only.
This is an interactive command that takes a JSON file as its first and
only argument. The JSON is in the same format as the output of the
'lncli wallet accounts list' command. This makes it easy to initialize
the remote signer with the seed, then export the extended public account
keys (xpubs) to import the watch-only wallet.
Example JSON (non-mandatory or ignored fields are omitted):
{
"accounts": [
{
"extended_public_key": "upub5Eep7....",
"derivation_path": "m/49'/0'/0'"
},
{
"extended_public_key": "vpub5ZU1PH...",
"derivation_path": "m/84'/0'/0'"
},
{
"extended_public_key": "tpubDDXFH...",
"derivation_path": "m/1017'/1'/0'"
},
...
{
"extended_public_key": "tpubDDXFH...",
"derivation_path": "m/1017'/1'/9'"
}
]
}
There must be an account for each of the existing key families that lnd
uses internally (currently 0-9, see keychain/derivation.go).
Read the documentation under docs/remote-signing.md for more information
on how to set up a remote signing node over RPC.
`,
Flags: []cli.Flag{
statelessInitFlag,
saveToFlag,
},
Action: actionDecorator(createWatchOnly),
}
func createWatchOnly(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getWalletUnlockerClient(ctx)
defer cleanUp()
if ctx.NArg() != 1 {
return cli.ShowCommandHelp(ctx, "createwatchonly")
}
// Should the daemon be initialized stateless? Then we expect an answer
// with the admin macaroon later. Because the --save_to is related to
// stateless init, it doesn't make sense to be set on its own.
statelessInit := ctx.Bool(statelessInitFlag.Name)
if !statelessInit && ctx.IsSet(saveToFlag.Name) {
return fmt.Errorf("cannot set save_to parameter without " +
"stateless_init")
}
jsonFile := lncfg.CleanAndExpandPath(ctx.Args().First())
jsonBytes, err := os.ReadFile(jsonFile)
if err != nil {
return fmt.Errorf("error reading JSON from file %v: %v",
jsonFile, err)
}
jsonAccts := &walletrpc.ListAccountsResponse{}
err = lnrpc.ProtoJSONUnmarshalOpts.Unmarshal(jsonBytes, jsonAccts)
if err != nil {
return fmt.Errorf("error parsing JSON: %w", err)
}
if len(jsonAccts.Accounts) == 0 {
return fmt.Errorf("cannot import empty account list")
}
walletPassword, err := capturePassword(
"Input wallet password: ", false,
walletunlocker.ValidatePassword,
)
if err != nil {
return err
}
extendedRootKeyBirthday, err := askBirthdayTimestamp()
if err != nil {
return err
}
recoveryWindow, err := askRecoveryWindow()
if err != nil {
return err
}
rpcAccounts, err := walletrpc.AccountsToWatchOnly(jsonAccts.Accounts)
if err != nil {
return err
}
rpcResp := &lnrpc.WatchOnly{
MasterKeyBirthdayTimestamp: extendedRootKeyBirthday,
Accounts: rpcAccounts,
}
// We assume that all accounts were exported from the same master root
// key. So if one is set, we just forward that. If other accounts should
// be watched later on, they should be imported into the watch-only
// node, that then also forwards the import request to the remote
// signer.
for _, acct := range jsonAccts.Accounts {
if len(acct.MasterKeyFingerprint) > 0 {
rpcResp.MasterKeyFingerprint = acct.MasterKeyFingerprint
}
}
initResp, err := client.InitWallet(ctxc, &lnrpc.InitWalletRequest{
WalletPassword: walletPassword,
WatchOnly: rpcResp,
RecoveryWindow: recoveryWindow,
StatelessInit: statelessInit,
})
if err != nil {
return err
}
if statelessInit {
return storeOrPrintAdminMac(ctx, initResp.AdminMacaroon)
}
return nil
}
// storeOrPrintAdminMac either stores the admin macaroon to a file specified or
// prints it to standard out, depending on the user flags set.
func storeOrPrintAdminMac(ctx *cli.Context, adminMac []byte) error {
// The user specified the optional --save_to parameter. We'll save the
// macaroon to that file.
if ctx.IsSet(saveToFlag.Name) {
macSavePath := lncfg.CleanAndExpandPath(ctx.String(
saveToFlag.Name,
))
err := os.WriteFile(macSavePath, adminMac, 0644)
if err != nil {
_ = os.Remove(macSavePath)
return err
}
fmt.Printf("Admin macaroon saved to %s\n", macSavePath)
return nil
}
// Otherwise we just print it. The user MUST store this macaroon
// somewhere so we either save it to a provided file path or just print
// it to standard output.
fmt.Printf("Admin macaroon: %s\n", hex.EncodeToString(adminMac))
return nil
}
func askRecoveryWindow() (int32, error) {
for {
fmt.Println()
fmt.Printf("Input an optional address look-ahead used to scan "+
"for used keys (default %d): ", defaultRecoveryWindow)
reader := bufio.NewReader(os.Stdin)
answer, err := reader.ReadString('\n')
if err != nil {
return 0, err
}
fmt.Println()
answer = strings.TrimSpace(answer)
if len(answer) == 0 {
return defaultRecoveryWindow, nil
}
lookAhead, err := strconv.ParseInt(answer, 10, 32)
if err != nil {
fmt.Printf("Unable to parse recovery window: %v\n", err)
continue
}
return int32(lookAhead), nil
}
}
func askBirthdayTimestamp() (uint64, error) {
for {
fmt.Println()
fmt.Printf("Input an optional wallet birthday unix timestamp " +
"of first block to start scanning from (default 0): ")
reader := bufio.NewReader(os.Stdin)
answer, err := reader.ReadString('\n')
if err != nil {
return 0, err
}
fmt.Println()
answer = strings.TrimSpace(answer)
if len(answer) == 0 {
return 0, nil
}
birthdayTimestamp, err := strconv.ParseUint(answer, 10, 64)
if err != nil {
fmt.Printf("Unable to parse birthday timestamp: %v\n",
err)
continue
}
return birthdayTimestamp, nil
}
}
func printCipherSeedWords(mnemonicWords []string) {
fmt.Println("!!!YOU MUST WRITE DOWN THIS SEED TO BE ABLE TO " +
"RESTORE THE WALLET!!!")
fmt.Println()
fmt.Println("---------------BEGIN LND CIPHER SEED---------------")
numCols := 4
colWords := monowidthColumns(mnemonicWords, numCols)
for i := 0; i < len(colWords); i += numCols {
fmt.Printf("%2d. %3s %2d. %3s %2d. %3s %2d. %3s\n",
i+1, colWords[i], i+2, colWords[i+1], i+3,
colWords[i+2], i+4, colWords[i+3])
}
fmt.Println("---------------END LND CIPHER SEED-----------------")
fmt.Println("\n!!!YOU MUST WRITE DOWN THIS SEED TO BE ABLE TO " +
"RESTORE THE WALLET!!!")
}
File diff suppressed because it is too large. Load diff
+201
View File
@@ -0,0 +1,201 @@
package commands
import (
"encoding/hex"
"fmt"
"math"
"strconv"
"testing"
"github.com/stretchr/testify/require"
)
// TestParseChanPoint tests parseChanPoint with various
// valid and invalid input values and verifies the output.
func TestParseChanPoint(t *testing.T) {
testCases := []struct {
channelPoinStr string
channelPointIsNil bool
outputIndex uint32
err error
}{
{
"24581424081379576b4a7580ace91db10925d996a2a8d45c8034" +
"3a5a467dc0bc:0",
false,
0,
nil,
}, {
"24581424081379576b4a7580ace91db10925d996a2a8d45c8034" +
"3a5a467dc0bc:4",
false,
4,
nil,
}, {
":",
true,
0,
errBadChanPoint,
}, {
":0",
true,
0,
errBadChanPoint,
}, {
"24581424081379576b4a7580ace91db10925d996a2a8d45c8034" +
"3a5a467dc0bc:",
true,
0,
errBadChanPoint,
}, {
"24581424081379576b4a7580ace91db10925d996a2a8d45c8034" +
"3a5a467dc0bc:string",
true,
0,
strconv.ErrSyntax,
}, {
"not_hex:0",
true,
0,
hex.InvalidByteError('n'),
},
}
for _, tc := range testCases {
cp, err := parseChanPoint(tc.channelPoinStr)
require.ErrorIs(t, err, tc.err)
require.Equal(t, tc.channelPointIsNil, cp == nil)
if !tc.channelPointIsNil {
require.Equal(t, tc.outputIndex, cp.OutputIndex)
}
}
}
// TestParseTimeLockDelta tests parseTimeLockDelta with various
// valid and invalid input values and verifies the output.
func TestParseTimeLockDelta(t *testing.T) {
t.Parallel()
testCases := []struct {
timeLockDeltaStr string
expectedTimeLockDelta uint16
expectErr bool
expectedErrContent string
}{
{
timeLockDeltaStr: "-1",
expectErr: true,
expectedErrContent: fmt.Sprintf(
"failed to parse time_lock_delta: %d "+
"to uint64", -1,
),
},
{
timeLockDeltaStr: "0",
},
{
timeLockDeltaStr: "3",
expectedTimeLockDelta: 3,
},
{
timeLockDeltaStr: strconv.FormatUint(
uint64(math.MaxUint16), 10,
),
expectedTimeLockDelta: math.MaxUint16,
},
{
timeLockDeltaStr: "18446744073709551616",
expectErr: true,
expectedErrContent: fmt.Sprint(
"failed to parse time_lock_delta:" +
" 18446744073709551616 to uint64"),
},
}
for _, tc := range testCases {
timeLockDelta, err := parseTimeLockDelta(tc.timeLockDeltaStr)
require.Equal(t, tc.expectedTimeLockDelta, timeLockDelta)
if tc.expectErr {
require.ErrorContains(t, err, tc.expectedErrContent)
} else {
require.NoError(t, err)
}
}
}
// TestReplaceCustomData tests that hex encoded custom data can be formatted as
// JSON in the console output.
func TestReplaceCustomData(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
data string
replaceData string
expected string
expectedErr string
}{
{
name: "no replacement necessary",
data: "foo",
expected: "foo",
},
{
name: "valid json with replacement",
data: "{\"foo\":\"bar\",\"custom_channel_data\":\"" +
hex.EncodeToString([]byte(
"{\"bar\":\"baz\"}",
)) + "\"}",
expected: `{
"foo": "bar",
"custom_channel_data": {
"bar": "baz"
}
}`,
},
{
name: "valid json with replacement and space",
data: "{\"foo\":\"bar\",\"custom_channel_data\": \"" +
hex.EncodeToString([]byte(
"{\"bar\":\"baz\"}",
)) + "\"}",
expected: `{
"foo": "bar",
"custom_channel_data": {
"bar": "baz"
}
}`,
},
{
name: "doesn't match pattern, returned identical",
data: "this ain't even json, and no custom data " +
"either",
expected: "this ain't even json, and no custom data " +
"either",
},
{
name: "invalid json",
data: "this ain't json, " +
"\"custom_channel_data\":\"a\"",
expectedErr: "invalid character 'h' in literal true",
},
{
name: "valid json, invalid hex, just formatted",
data: "{\"custom_channel_data\":\"f\"}",
expected: "{\n \"custom_channel_data\": \"f\"\n}",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := replaceCustomData([]byte(tc.data))
if tc.expectedErr != "" {
require.ErrorContains(t, err, tc.expectedErr)
return
}
require.NoError(t, err)
require.Equal(t, tc.expected, string(result))
})
}
}
+62
View File
@@ -0,0 +1,62 @@
//go:build dev
// +build dev
package commands
import (
"fmt"
"os"
"github.com/lightningnetwork/lnd/lncfg"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnrpc/devrpc"
"github.com/urfave/cli"
)
// devCommands will return the set of commands to enable for devrpc builds.
func devCommands() []cli.Command {
return []cli.Command{
{
Name: "importgraph",
Category: "Development",
Description: "Imports graph from describegraph JSON",
Usage: "Import the network graph.",
ArgsUsage: "graph-json-file",
Action: actionDecorator(importGraph),
},
}
}
func getDevClient(ctx *cli.Context) (devrpc.DevClient, func()) {
conn := getClientConn(ctx, false)
cleanUp := func() {
conn.Close()
}
return devrpc.NewDevClient(conn), cleanUp
}
func importGraph(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getDevClient(ctx)
defer cleanUp()
jsonFile := lncfg.CleanAndExpandPath(ctx.Args().First())
jsonBytes, err := os.ReadFile(jsonFile)
if err != nil {
return fmt.Errorf("error reading JSON from file %v: %v",
jsonFile, err)
}
jsonGraph := &lnrpc.ChannelGraph{}
err = lnrpc.ProtoJSONUnmarshalOpts.Unmarshal(jsonBytes, jsonGraph)
if err != nil {
return fmt.Errorf("error parsing JSON: %w", err)
}
res, err := client.ImportGraph(ctxc, jsonGraph)
if err != nil {
return err
}
printRespJSON(res)
return nil
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !dev
// +build !dev
package commands
import "github.com/urfave/cli"
// devCommands will return nil for non-devrpc builds.
func devCommands() []cli.Command {
return nil
}
+264
View File
@@ -0,0 +1,264 @@
//go:build invoicesrpc
// +build invoicesrpc
package commands
import (
"encoding/hex"
"fmt"
"strconv"
"github.com/lightningnetwork/lnd/lnrpc/invoicesrpc"
"github.com/urfave/cli"
)
// invoicesCommands will return nil for non-invoicesrpc builds.
func invoicesCommands() []cli.Command {
return []cli.Command{
cancelInvoiceCommand,
addHoldInvoiceCommand,
settleInvoiceCommand,
}
}
func getInvoicesClient(ctx *cli.Context) (invoicesrpc.InvoicesClient, func()) {
conn := getClientConn(ctx, false)
cleanUp := func() {
conn.Close()
}
return invoicesrpc.NewInvoicesClient(conn), cleanUp
}
var settleInvoiceCommand = cli.Command{
Name: "settleinvoice",
Category: "Invoices",
Usage: "Reveal a preimage and use it to settle the corresponding invoice.",
Description: `
Todo.`,
ArgsUsage: "preimage",
Flags: []cli.Flag{
cli.StringFlag{
Name: "preimage",
Usage: "the hex-encoded preimage (32 byte) which will " +
"allow settling an incoming HTLC payable to this " +
"preimage.",
},
},
Action: actionDecorator(settleInvoice),
}
func settleInvoice(ctx *cli.Context) error {
var (
preimage []byte
err error
)
ctxc := getContext()
client, cleanUp := getInvoicesClient(ctx)
defer cleanUp()
args := ctx.Args()
switch {
case ctx.IsSet("preimage"):
preimage, err = hex.DecodeString(ctx.String("preimage"))
case args.Present():
preimage, err = hex.DecodeString(args.First())
}
if err != nil {
return fmt.Errorf("unable to parse preimage: %w", err)
}
invoice := &invoicesrpc.SettleInvoiceMsg{
Preimage: preimage,
}
resp, err := client.SettleInvoice(ctxc, invoice)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var cancelInvoiceCommand = cli.Command{
Name: "cancelinvoice",
Category: "Invoices",
Usage: "Cancels a (hold) invoice.",
Description: `
Todo.`,
ArgsUsage: "paymenthash",
Flags: []cli.Flag{
cli.StringFlag{
Name: "paymenthash",
Usage: "the hex-encoded payment hash (32 byte) for which the " +
"corresponding invoice will be canceled.",
},
},
Action: actionDecorator(cancelInvoice),
}
func cancelInvoice(ctx *cli.Context) error {
var (
paymentHash []byte
err error
)
ctxc := getContext()
client, cleanUp := getInvoicesClient(ctx)
defer cleanUp()
args := ctx.Args()
switch {
case ctx.IsSet("paymenthash"):
paymentHash, err = hex.DecodeString(ctx.String("paymenthash"))
case args.Present():
paymentHash, err = hex.DecodeString(args.First())
}
if err != nil {
return fmt.Errorf("unable to parse preimage: %w", err)
}
invoice := &invoicesrpc.CancelInvoiceMsg{
PaymentHash: paymentHash,
}
resp, err := client.CancelInvoice(ctxc, invoice)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var addHoldInvoiceCommand = cli.Command{
Name: "addholdinvoice",
Category: "Invoices",
Usage: "Add a new hold invoice.",
Description: `
Add a new invoice, expressing intent for a future payment.
Invoices without an amount can be created by not supplying any
parameters or providing an amount of 0. These invoices allow the payer
to specify the amount of satoshis they wish to send.`,
ArgsUsage: "hash [amt]",
Flags: []cli.Flag{
cli.StringFlag{
Name: "memo",
Usage: "a description of the payment to attach along " +
"with the invoice (default=\"\")",
},
cli.Int64Flag{
Name: "amt",
Usage: "the amt of satoshis in this invoice",
},
cli.Int64Flag{
Name: "amt_msat",
Usage: "the amt of millisatoshis in this invoice",
},
cli.StringFlag{
Name: "description_hash",
Usage: "SHA-256 hash of the description of the payment. " +
"Used if the purpose of payment cannot naturally " +
"fit within the memo. If provided this will be " +
"used instead of the description(memo) field in " +
"the encoded invoice.",
},
cli.StringFlag{
Name: "fallback_addr",
Usage: "fallback on-chain address that can be used in " +
"case the lightning payment fails",
},
cli.Int64Flag{
Name: "expiry",
Usage: "the invoice's expiry time in seconds. If not " +
"specified, an expiry of " +
"86400 seconds (24 hours) is implied.",
},
cli.Uint64Flag{
Name: "cltv_expiry_delta",
Usage: "The minimum CLTV delta to use for the final " +
"hop. If this is set to 0, the default value " +
"is used. The default value for " +
"cltv_expiry_delta is configured by the " +
"'bitcoin.timelockdelta' option.",
},
cli.BoolFlag{
Name: "private",
Usage: "encode routing hints in the invoice with " +
"private channels in order to assist the " +
"payer in reaching you",
},
},
Action: actionDecorator(addHoldInvoice),
}
func addHoldInvoice(ctx *cli.Context) error {
var (
descHash []byte
err error
)
ctxc := getContext()
client, cleanUp := getInvoicesClient(ctx)
defer cleanUp()
args := ctx.Args()
if ctx.NArg() == 0 {
cli.ShowCommandHelp(ctx, "addholdinvoice")
return nil
}
hash, err := hex.DecodeString(args.First())
if err != nil {
return fmt.Errorf("unable to parse hash: %w", err)
}
args = args.Tail()
amt := ctx.Int64("amt")
amtMsat := ctx.Int64("amt_msat")
if !ctx.IsSet("amt") && !ctx.IsSet("amt_msat") && args.Present() {
amt, err = strconv.ParseInt(args.First(), 10, 64)
if err != nil {
return fmt.Errorf("unable to decode amt argument: %w",
err)
}
}
descHash, err = hex.DecodeString(ctx.String("description_hash"))
if err != nil {
return fmt.Errorf("unable to parse description_hash: %w", err)
}
invoice := &invoicesrpc.AddHoldInvoiceRequest{
Memo: ctx.String("memo"),
Hash: hash,
Value: amt,
ValueMsat: amtMsat,
DescriptionHash: descHash,
FallbackAddr: ctx.String("fallback_addr"),
Expiry: ctx.Int64("expiry"),
CltvExpiry: ctx.Uint64("cltv_expiry_delta"),
Private: ctx.Bool("private"),
}
resp, err := client.AddHoldInvoice(ctxc, invoice)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !invoicesrpc
// +build !invoicesrpc
package commands
import "github.com/urfave/cli"
// invoicesCommands will return nil for non-invoicesrpc builds.
func invoicesCommands() []cli.Command {
return nil
}
+162
View File
@@ -0,0 +1,162 @@
package commands
import (
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
"github.com/btcsuite/btcwallet/snacl"
"gopkg.in/macaroon.v2"
)
const (
encryptionPrefix = "snacl:"
)
// getPasswordFn is a function that asks the user to type a password after
// presenting it the given prompt.
type getPasswordFn func(prompt string) ([]byte, error)
// macaroonJar is a struct that represents all macaroons of a profile.
type macaroonJar struct {
Default string `json:"default,omitempty"`
Timeout int64 `json:"timeout,omitempty"`
IP string `json:"ip,omitempty"`
Jar []*macaroonEntry `json:"jar"`
}
// macaroonEntry is a struct that represents a single macaroon. Its content can
// either be cleartext (hex encoded) or encrypted (snacl secretbox).
type macaroonEntry struct {
Name string `json:"name"`
Data string `json:"data"`
}
// loadMacaroon returns the fully usable macaroon instance from the entry. This
// detects whether the macaroon needs to be decrypted and does so if necessary.
// An encrypted macaroon that needs to be decrypted will prompt for the user's
// password by calling the provided password callback. Normally that should
// result in the user being prompted for the password in the terminal.
func (e *macaroonEntry) loadMacaroon(
pwCallback getPasswordFn) (*macaroon.Macaroon, error) {
if len(strings.TrimSpace(e.Data)) == 0 {
return nil, fmt.Errorf("macaroon data is empty")
}
var (
macBytes []byte
err error
)
// Either decrypt or simply decode the macaroon data.
if strings.HasPrefix(e.Data, encryptionPrefix) {
parts := strings.Split(e.Data, ":")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid encrypted macaroon " +
"format, expected 'snacl:<key_base64>:" +
"<encrypted_macaroon_base64>'")
}
pw, err := pwCallback("Enter macaroon encryption password: ")
if err != nil {
return nil, fmt.Errorf("could not read password from "+
"terminal: %v", err)
}
macBytes, err = decryptMacaroon(parts[1], parts[2], pw)
if err != nil {
return nil, fmt.Errorf("unable to decrypt macaroon: %w",
err)
}
} else {
macBytes, err = hex.DecodeString(e.Data)
if err != nil {
return nil, fmt.Errorf("unable to hex decode "+
"macaroon: %v", err)
}
}
// Parse the macaroon data into its native struct.
mac := &macaroon.Macaroon{}
if err := mac.UnmarshalBinary(macBytes); err != nil {
return nil, fmt.Errorf("unable to decode macaroon: %w", err)
}
return mac, nil
}
// storeMacaroon stores a native macaroon instance to the entry. If a non-nil
// password is provided, then the macaroon is encrypted with that password. If
// not, the macaroon is stored as plain text.
func (e *macaroonEntry) storeMacaroon(mac *macaroon.Macaroon, pw []byte) error {
// First of all, make sure we can serialize the macaroon.
macBytes, err := mac.MarshalBinary()
if err != nil {
return fmt.Errorf("unable to marshal macaroon: %w", err)
}
if len(pw) == 0 {
e.Data = hex.EncodeToString(macBytes)
return nil
}
// The user did set a password. Let's derive an encryption key from it.
key, err := snacl.NewSecretKey(
&pw, snacl.DefaultN, snacl.DefaultR, snacl.DefaultP,
)
if err != nil {
return fmt.Errorf("unable to create encryption key: %w", err)
}
// Encrypt the macaroon data with the derived key and store it in the
// human readable format snacl:<key_base64>:<encrypted_macaroon_base64>.
encryptedMac, err := key.Encrypt(macBytes)
if err != nil {
return fmt.Errorf("unable to encrypt macaroon: %w", err)
}
keyB64 := base64.StdEncoding.EncodeToString(key.Marshal())
dataB64 := base64.StdEncoding.EncodeToString(encryptedMac)
e.Data = fmt.Sprintf("%s%s:%s", encryptionPrefix, keyB64, dataB64)
return nil
}
// decryptMacaroon decrypts the cipher text macaroon by using the serialized
// encryption key and the password.
func decryptMacaroon(keyB64, dataB64 string, pw []byte) ([]byte, error) {
// Base64 decode both the marshalled encryption key and macaroon data.
keyData, err := base64.StdEncoding.DecodeString(keyB64)
if err != nil {
return nil, fmt.Errorf("could not base64 decode encryption "+
"key: %v", err)
}
encryptedMac, err := base64.StdEncoding.DecodeString(dataB64)
if err != nil {
return nil, fmt.Errorf("could not base64 decode macaroon "+
"data: %v", err)
}
// Unmarshal the encryption key and ask the user for the password.
key := &snacl.SecretKey{}
err = key.Unmarshal(keyData)
if err != nil {
return nil, fmt.Errorf("could not unmarshal encryption key: %w",
err)
}
// Derive the final encryption key and then decrypt the macaroon with
// it.
err = key.DeriveKey(&pw)
if err != nil {
return nil, fmt.Errorf("could not derive encryption key, "+
"possibly due to incorrect password: %v", err)
}
macBytes, err := key.Decrypt(encryptedMac)
if err != nil {
return nil, fmt.Errorf("could not decrypt macaroon data: %w",
err)
}
return macBytes, nil
}
+102
View File
@@ -0,0 +1,102 @@
package commands
import (
"encoding/hex"
"testing"
"github.com/stretchr/testify/require"
"gopkg.in/macaroon.v2"
)
var (
dummyMacStr = "0201047465737402067788991234560000062052d26ed139ea5af8" +
"3e675500c4ccb2471f62191b745bab820f129e5588a255d2"
dummyMac, _ = hex.DecodeString(dummyMacStr)
encryptedEntry = &macaroonEntry{
Name: "encryptedMac",
Data: "snacl:exX8xbUOb6Gih88ybL2jZGo+DBDPU2tYKkvo0eVVmbDGDoFP" +
"zlv5xvqNK5eml0LKLcB8LdZRw43qXK1W2OLs/gBAAAAAAAAACAAA" +
"AAAAAAABAAAAAAAAAA==:C8TN/aDOvSLiBCX+IdoPTx+UUWhVdGj" +
"NQvbcaWp+KXQWqPfpRZpjJQ6B2PDx5mJxImcezJGPx8ShAqMdxWe" +
"l2precU+1cOjk7HQFkYuu943eJ00s6JerAY+ssg==",
}
plaintextEntry = &macaroonEntry{
Name: "plaintextMac",
Data: dummyMacStr,
}
testPassword = []byte("S3curePazzw0rd")
pwCallback = func(string) ([]byte, error) {
return testPassword, nil
}
noPwCallback = func(string) ([]byte, error) {
return nil, nil
}
)
// TestMacaroonJarEncrypted tests that a macaroon can be stored and retrieved
// safely by encrypting/decrypting it with a password.
func TestMacaroonJarEncrypted(t *testing.T) {
// Create a new macaroon entry from the dummy macaroon and encrypt it
// with the test password.
newEntry := &macaroonEntry{
Name: "encryptedMac",
}
err := newEntry.storeMacaroon(toMacaroon(t, dummyMac), testPassword)
require.NoError(t, err)
// Now decrypt it again and make sure we get the same content back.
mac, err := newEntry.loadMacaroon(pwCallback)
require.NoError(t, err)
macBytes, err := mac.MarshalBinary()
require.NoError(t, err)
require.Equal(t, dummyMac, macBytes)
// The encrypted data of the entry we just created shouldn't be the
// same as our test entry because of the salt snacl uses.
require.NotEqual(t, encryptedEntry.Data, newEntry.Data)
// Decrypt the hard coded test entry and make sure the decrypted content
// matches our created entry.
mac, err = encryptedEntry.loadMacaroon(pwCallback)
require.NoError(t, err)
macBytes, err = mac.MarshalBinary()
require.NoError(t, err)
require.Equal(t, dummyMac, macBytes)
}
// TestMacaroonJarPlaintext tests that a macaroon can be stored and retrieved
// as plaintext as well.
func TestMacaroonJarPlaintext(t *testing.T) {
// Create a new macaroon entry from the dummy macaroon and encrypt it
// with the test password.
newEntry := &macaroonEntry{
Name: "plaintextMac",
}
err := newEntry.storeMacaroon(toMacaroon(t, dummyMac), nil)
require.NoError(t, err)
// Now decrypt it again and make sure we get the same content back.
mac, err := newEntry.loadMacaroon(noPwCallback)
require.NoError(t, err)
macBytes, err := mac.MarshalBinary()
require.NoError(t, err)
require.Equal(t, dummyMac, macBytes)
require.Equal(t, plaintextEntry.Data, newEntry.Data)
// Load the hard coded plaintext test entry and make sure the loaded
// content matches our created entry.
mac, err = plaintextEntry.loadMacaroon(noPwCallback)
require.NoError(t, err)
macBytes, err = mac.MarshalBinary()
require.NoError(t, err)
require.Equal(t, dummyMac, macBytes)
}
func toMacaroon(t *testing.T, macData []byte) *macaroon.Macaroon {
mac := &macaroon.Macaroon{}
err := mac.UnmarshalBinary(macData)
require.NoError(t, err)
return mac
}
+601
View File
@@ -0,0 +1,601 @@
// Copyright (c) 2013-2017 The btcsuite developers
// Copyright (c) 2015-2016 The Decred developers
// Copyright (C) 2015-2024 The Lightning Network Developers
package commands
import (
"context"
"crypto/tls"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"syscall"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
"github.com/lightningnetwork/lnd"
"github.com/lightningnetwork/lnd/build"
"github.com/lightningnetwork/lnd/lncfg"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/macaroons"
"github.com/lightningnetwork/lnd/tor"
"github.com/urfave/cli"
"golang.org/x/term"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
)
const (
defaultDataDir = "data"
defaultChainSubDir = "chain"
defaultTLSCertFilename = "tls.cert"
defaultMacaroonFilename = "admin.macaroon"
defaultRPCPort = "10009"
defaultRPCHostPort = "localhost:" + defaultRPCPort
envVarRPCServer = "LNCLI_RPCSERVER"
envVarLNDDir = "LNCLI_LNDDIR"
envVarSOCKSProxy = "LNCLI_SOCKSPROXY"
envVarTLSCertPath = "LNCLI_TLSCERTPATH"
envVarChain = "LNCLI_CHAIN"
envVarNetwork = "LNCLI_NETWORK"
envVarMacaroonPath = "LNCLI_MACAROONPATH"
envVarMacaroonTimeout = "LNCLI_MACAROONTIMEOUT"
envVarMacaroonIP = "LNCLI_MACAROONIP"
envVarProfile = "LNCLI_PROFILE"
envVarMacFromJar = "LNCLI_MACFROMJAR"
)
var (
DefaultLndDir = btcutil.AppDataDir("lnd", false)
defaultTLSCertPath = filepath.Join(
DefaultLndDir, defaultTLSCertFilename,
)
// maxMsgRecvSize is the largest message our client will receive. We
// set this to 200MiB atm.
maxMsgRecvSize = grpc.MaxCallRecvMsgSize(lnrpc.MaxGrpcMsgSize)
)
func fatal(err error) {
fmt.Fprintf(os.Stderr, "[lncli] %v\n", err)
os.Exit(1)
}
func getWalletUnlockerClient(ctx *cli.Context) (lnrpc.WalletUnlockerClient,
func()) {
conn := getClientConn(ctx, true)
cleanUp := func() {
conn.Close()
}
return lnrpc.NewWalletUnlockerClient(conn), cleanUp
}
func getStateServiceClient(ctx *cli.Context) (lnrpc.StateClient, func()) {
conn := getClientConn(ctx, true)
cleanUp := func() {
conn.Close()
}
return lnrpc.NewStateClient(conn), cleanUp
}
func getClient(ctx *cli.Context) (lnrpc.LightningClient, func()) {
conn := getClientConn(ctx, false)
cleanUp := func() {
conn.Close()
}
return lnrpc.NewLightningClient(conn), cleanUp
}
func getClientConn(ctx *cli.Context, skipMacaroons bool) *grpc.ClientConn {
// First, we'll get the selected stored profile or an ephemeral one
// created from the global options in the CLI context.
profile, err := getGlobalOptions(ctx, skipMacaroons)
if err != nil {
fatal(fmt.Errorf("could not load global options: %w", err))
}
// Create a dial options array.
opts := []grpc.DialOption{
grpc.WithUnaryInterceptor(
addMetadataUnaryInterceptor(profile.Metadata),
),
grpc.WithStreamInterceptor(
addMetaDataStreamInterceptor(profile.Metadata),
),
}
if profile.Insecure {
opts = append(opts, grpc.WithInsecure())
} else {
// Load the specified TLS certificate.
certPool, err := profile.cert()
if err != nil {
fatal(fmt.Errorf("could not create cert pool: %w", err))
}
// Build transport credentials from the certificate pool. If
// there is no certificate pool, we expect the server to use a
// non-self-signed certificate such as a certificate obtained
// from Let's Encrypt.
var creds credentials.TransportCredentials
if certPool != nil {
creds = credentials.NewClientTLSFromCert(certPool, "")
} else {
// Fallback to the system pool. Using an empty tls
// config is an alternative to x509.SystemCertPool().
// That call is not supported on Windows.
creds = credentials.NewTLS(&tls.Config{})
}
opts = append(opts, grpc.WithTransportCredentials(creds))
}
// Only process macaroon credentials if --no-macaroons isn't set and
// if we're not skipping macaroon processing.
if !profile.NoMacaroons && !skipMacaroons {
// Find out which macaroon to load.
macName := profile.Macaroons.Default
if ctx.GlobalIsSet("macfromjar") {
macName = ctx.GlobalString("macfromjar")
}
var macEntry *macaroonEntry
for _, entry := range profile.Macaroons.Jar {
if entry.Name == macName {
macEntry = entry
break
}
}
if macEntry == nil {
fatal(fmt.Errorf("macaroon with name '%s' not found "+
"in profile", macName))
}
// Get and possibly decrypt the specified macaroon.
//
// TODO(guggero): Make it possible to cache the password so we
// don't need to ask for it every time.
mac, err := macEntry.loadMacaroon(readPassword)
if err != nil {
fatal(fmt.Errorf("could not load macaroon: %w", err))
}
macConstraints := []macaroons.Constraint{
// We add a time-based constraint to prevent replay of
// the macaroon. It's good for 60 seconds by default to
// make up for any discrepancy between client and server
// clocks, but leaking the macaroon before it becomes
// invalid makes it possible for an attacker to reuse
// the macaroon. In addition, the validity time of the
// macaroon is extended by the time the server clock is
// behind the client clock, or shortened by the time the
// server clock is ahead of the client clock (or invalid
// altogether if, in the latter case, this time is more
// than 60 seconds).
// TODO(aakselrod): add better anti-replay protection.
macaroons.TimeoutConstraint(profile.Macaroons.Timeout),
// Lock macaroon down to a specific IP address.
macaroons.IPLockConstraint(profile.Macaroons.IP),
// ... Add more constraints if needed.
}
// Apply constraints to the macaroon.
constrainedMac, err := macaroons.AddConstraints(
mac, macConstraints...,
)
if err != nil {
fatal(err)
}
// Now we append the macaroon credentials to the dial options.
cred, err := macaroons.NewMacaroonCredential(constrainedMac)
if err != nil {
fatal(fmt.Errorf("error cloning mac: %w", err))
}
opts = append(opts, grpc.WithPerRPCCredentials(cred))
}
// If a socksproxy server is specified we use a tor dialer
// to connect to the grpc server.
if ctx.GlobalIsSet("socksproxy") {
socksProxy := ctx.GlobalString("socksproxy")
torDialer := func(_ context.Context, addr string) (net.Conn,
error) {
return tor.Dial(
addr, socksProxy, false, false,
tor.DefaultConnTimeout,
)
}
opts = append(opts, grpc.WithContextDialer(torDialer))
} else {
// We need to use a custom dialer so we can also connect to
// unix sockets and not just TCP addresses.
genericDialer := lncfg.ClientAddressDialer(defaultRPCPort)
opts = append(opts, grpc.WithContextDialer(genericDialer))
}
opts = append(opts, grpc.WithDefaultCallOptions(maxMsgRecvSize))
conn, err := grpc.Dial(profile.RPCServer, opts...)
if err != nil {
fatal(fmt.Errorf("unable to connect to RPC server: %w", err))
}
return conn
}
// addMetadataUnaryInterceptor returns a grpc client side interceptor that
// appends any key-value metadata strings to the outgoing context of a grpc
// unary call.
func addMetadataUnaryInterceptor(
md map[string]string) grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply interface{},
cc *grpc.ClientConn, invoker grpc.UnaryInvoker,
opts ...grpc.CallOption) error {
outCtx := contextWithMetadata(ctx, md)
return invoker(outCtx, method, req, reply, cc, opts...)
}
}
// addMetaDataStreamInterceptor returns a grpc client side interceptor that
// appends any key-value metadata strings to the outgoing context of a grpc
// stream call.
func addMetaDataStreamInterceptor(
md map[string]string) grpc.StreamClientInterceptor {
return func(ctx context.Context, desc *grpc.StreamDesc,
cc *grpc.ClientConn, method string, streamer grpc.Streamer,
opts ...grpc.CallOption) (grpc.ClientStream, error) {
outCtx := contextWithMetadata(ctx, md)
return streamer(outCtx, desc, cc, method, opts...)
}
}
// contextWithMetaData appends the given metadata key-value pairs to the given
// context.
func contextWithMetadata(ctx context.Context,
md map[string]string) context.Context {
kvPairs := make([]string, 0, 2*len(md))
for k, v := range md {
kvPairs = append(kvPairs, k, v)
}
return metadata.AppendToOutgoingContext(ctx, kvPairs...)
}
// extractPathArgs parses the TLS certificate and macaroon paths from the
// command.
func extractPathArgs(ctx *cli.Context) (string, string, error) {
network := strings.ToLower(ctx.GlobalString("network"))
switch network {
case "mainnet", "testnet", "regtest", "simnet", "signet":
default:
return "", "", fmt.Errorf("unknown network: %v", network)
}
// We'll now fetch the lnddir so we can make a decision on how to
// properly read the macaroons (if needed) and also the cert. This will
// either be the default, or will have been overwritten by the end
// user.
lndDir := lncfg.CleanAndExpandPath(ctx.GlobalString("lnddir"))
// If the macaroon path as been manually provided, then we'll only
// target the specified file.
var macPath string
if ctx.GlobalString("macaroonpath") != "" {
macPath = lncfg.CleanAndExpandPath(ctx.GlobalString(
"macaroonpath",
))
} else {
// Otherwise, we'll go into the path:
// lnddir/data/chain/<chain>/<network> in order to fetch the
// macaroon that we need.
macPath = filepath.Join(
lndDir, defaultDataDir, defaultChainSubDir,
lnd.BitcoinChainName, network, defaultMacaroonFilename,
)
}
tlsCertPath := lncfg.CleanAndExpandPath(ctx.GlobalString("tlscertpath"))
// If a custom lnd directory was set, we'll also check if custom paths
// for the TLS cert and macaroon file were set as well. If not, we'll
// override their paths so they can be found within the custom lnd
// directory set. This allows us to set a custom lnd directory, along
// with custom paths to the TLS cert and macaroon file.
if lndDir != DefaultLndDir {
tlsCertPath = filepath.Join(lndDir, defaultTLSCertFilename)
}
return tlsCertPath, macPath, nil
}
// checkNotBothSet accepts two flag names, a and b, and checks that only flag a
// or flag b can be set, but not both. It returns the name of the flag or an
// error.
func checkNotBothSet(ctx *cli.Context, a, b string) (string, error) {
if ctx.IsSet(a) && ctx.IsSet(b) {
return "", fmt.Errorf(
"either %s or %s should be set, but not both", a, b,
)
}
if ctx.IsSet(a) {
return a, nil
}
return b, nil
}
func Main() {
app := cli.NewApp()
app.Name = "lncli"
app.Version = build.Version() + " commit=" + build.Commit
app.Usage = "control plane for your Lightning Network Daemon (lnd)"
app.Flags = []cli.Flag{
cli.StringFlag{
Name: "rpcserver",
Value: defaultRPCHostPort,
Usage: "The host:port of LN daemon.",
EnvVar: envVarRPCServer,
},
cli.StringFlag{
Name: "lnddir",
Value: DefaultLndDir,
Usage: "The path to lnd's base directory.",
TakesFile: true,
EnvVar: envVarLNDDir,
},
cli.StringFlag{
Name: "socksproxy",
Usage: "The host:port of a SOCKS proxy through " +
"which all connections to the LN " +
"daemon will be established over.",
EnvVar: envVarSOCKSProxy,
},
cli.StringFlag{
Name: "tlscertpath",
Value: defaultTLSCertPath,
Usage: "The path to lnd's TLS certificate.",
TakesFile: true,
EnvVar: envVarTLSCertPath,
},
cli.StringFlag{
Name: "chain, c",
Usage: "The chain lnd is running on, e.g. bitcoin.",
Value: "bitcoin",
EnvVar: envVarChain,
},
cli.StringFlag{
Name: "network, n",
Usage: "The network lnd is running on, e.g. mainnet, " +
"testnet, etc.",
Value: "mainnet",
EnvVar: envVarNetwork,
},
cli.BoolFlag{
Name: "no-macaroons",
Usage: "Disable macaroon authentication.",
},
cli.StringFlag{
Name: "macaroonpath",
Usage: "The path to macaroon file.",
TakesFile: true,
EnvVar: envVarMacaroonPath,
},
cli.Int64Flag{
Name: "macaroontimeout",
Value: 60,
Usage: "Anti-replay macaroon validity time in " +
"seconds.",
EnvVar: envVarMacaroonTimeout,
},
cli.StringFlag{
Name: "macaroonip",
Usage: "If set, lock macaroon to specific IP address.",
EnvVar: envVarMacaroonIP,
},
cli.StringFlag{
Name: "profile, p",
Usage: "Instead of reading settings from command " +
"line parameters or using the default " +
"profile, use a specific profile. If " +
"a default profile is set, this flag can be " +
"set to an empty string to disable reading " +
"values from the profiles file.",
EnvVar: envVarProfile,
},
cli.StringFlag{
Name: "macfromjar",
Usage: "Use this macaroon from the profile's " +
"macaroon jar instead of the default one. " +
"Can only be used if profiles are defined.",
EnvVar: envVarMacFromJar,
},
cli.StringSliceFlag{
Name: "metadata",
Usage: "This flag can be used to specify a key-value " +
"pair that should be appended to the " +
"outgoing context before the request is sent " +
"to lnd. This flag may be specified multiple " +
"times. The format is: \"key:value\".",
},
cli.BoolFlag{
Name: "insecure",
Usage: "Connect to the rpc server without TLS " +
"authentication",
Hidden: true,
},
}
app.Commands = []cli.Command{
createCommand,
createWatchOnlyCommand,
unlockCommand,
changePasswordCommand,
newAddressCommand,
estimateFeeCommand,
sendManyCommand,
sendCoinsCommand,
listUnspentCommand,
connectCommand,
disconnectCommand,
openChannelCommand,
batchOpenChannelCommand,
closeChannelCommand,
closeAllChannelsCommand,
abandonChannelCommand,
listPeersCommand,
walletBalanceCommand,
ChannelBalanceCommand,
getInfoCommand,
getDebugInfoCommand,
encryptDebugPackageCommand,
decryptDebugPackageCommand,
getRecoveryInfoCommand,
pendingChannelsCommand,
SendPaymentCommand,
payInvoiceCommand,
sendToRouteCommand,
AddInvoiceCommand,
lookupInvoiceCommand,
listInvoicesCommand,
ListChannelsCommand,
closedChannelsCommand,
listPaymentsCommand,
describeGraphCommand,
getNodeMetricsCommand,
getChanInfoCommand,
getNodeInfoCommand,
queryRoutesCommand,
getNetworkInfoCommand,
debugLevelCommand,
decodePayReqCommand,
listChainTxnsCommand,
stopCommand,
signMessageCommand,
verifyMessageCommand,
feeReportCommand,
updateChannelPolicyCommand,
forwardingHistoryCommand,
exportChanBackupCommand,
verifyChanBackupCommand,
restoreChanBackupCommand,
bakeMacaroonCommand,
listMacaroonIDsCommand,
deleteMacaroonIDCommand,
listPermissionsCommand,
printMacaroonCommand,
constrainMacaroonCommand,
trackPaymentCommand,
versionCommand,
profileSubCommand,
getStateCommand,
deletePaymentsCommand,
sendCustomCommand,
subscribeCustomCommand,
fishCompletionCommand,
listAliasesCommand,
estimateRouteFeeCommand,
generateManPageCommand,
}
// Add any extra commands determined by build flags.
app.Commands = append(app.Commands, autopilotCommands()...)
app.Commands = append(app.Commands, invoicesCommands()...)
app.Commands = append(app.Commands, neutrinoCommands()...)
app.Commands = append(app.Commands, routerCommands()...)
app.Commands = append(app.Commands, walletCommands()...)
app.Commands = append(app.Commands, watchtowerCommands()...)
app.Commands = append(app.Commands, wtclientCommands()...)
app.Commands = append(app.Commands, devCommands()...)
app.Commands = append(app.Commands, peersCommands()...)
app.Commands = append(app.Commands, chainCommands()...)
if err := app.Run(os.Args); err != nil {
fatal(err)
}
}
// readPassword reads a password from the terminal. This requires there to be an
// actual TTY so passing in a password from stdin won't work.
func readPassword(text string) ([]byte, error) {
fmt.Print(text)
// The variable syscall.Stdin is of a different type in the Windows API
// that's why we need the explicit cast. And of course the linter
// doesn't like it either.
pw, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
fmt.Println()
return pw, err
}
// networkParams parses the global network flag into a chaincfg.Params.
func networkParams(ctx *cli.Context) (*chaincfg.Params, error) {
network := strings.ToLower(ctx.GlobalString("network"))
switch network {
case "mainnet":
return &chaincfg.MainNetParams, nil
case "testnet":
return &chaincfg.TestNet3Params, nil
case "regtest":
return &chaincfg.RegressionNetParams, nil
case "simnet":
return &chaincfg.SimNetParams, nil
case "signet":
return &chaincfg.SigNetParams, nil
default:
return nil, fmt.Errorf("unknown network: %v", network)
}
}
// parseCoinSelectionStrategy parses a coin selection strategy string
// from the CLI to its lnrpc.CoinSelectionStrategy counterpart proto type.
func parseCoinSelectionStrategy(ctx *cli.Context) (
lnrpc.CoinSelectionStrategy, error) {
strategy := ctx.String(coinSelectionStrategyFlag.Name)
if !ctx.IsSet(coinSelectionStrategyFlag.Name) {
return lnrpc.CoinSelectionStrategy_STRATEGY_USE_GLOBAL_CONFIG,
nil
}
switch strategy {
case "global-config":
return lnrpc.CoinSelectionStrategy_STRATEGY_USE_GLOBAL_CONFIG,
nil
case "largest":
return lnrpc.CoinSelectionStrategy_STRATEGY_LARGEST, nil
case "random":
return lnrpc.CoinSelectionStrategy_STRATEGY_RANDOM, nil
default:
return 0, fmt.Errorf("unknown coin selection strategy "+
"%v", strategy)
}
}
+247
View File
@@ -0,0 +1,247 @@
//go:build neutrinorpc
// +build neutrinorpc
package commands
import (
"github.com/lightningnetwork/lnd/lnrpc/neutrinorpc"
"github.com/urfave/cli"
)
func getNeutrinoKitClient(ctx *cli.Context) (neutrinorpc.NeutrinoKitClient, func()) {
conn := getClientConn(ctx, false)
cleanUp := func() {
conn.Close()
}
return neutrinorpc.NewNeutrinoKitClient(conn), cleanUp
}
var getNeutrinoStatusCommand = cli.Command{
Name: "status",
Usage: "Returns the status of the running neutrino instance.",
Category: "Neutrino",
Description: "Returns the status of the light client neutrino " +
"instance, along with height and hash of the best block, and " +
"a list of connected peers.",
Action: actionDecorator(getNeutrinoStatus),
}
func getNeutrinoStatus(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getNeutrinoKitClient(ctx)
defer cleanUp()
req := &neutrinorpc.StatusRequest{}
resp, err := client.Status(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var addPeerCommand = cli.Command{
Name: "addpeer",
Usage: "Add a peer.",
Category: "Neutrino",
Description: "Adds a new peer that has already been connected to the " +
"server.",
ArgsUsage: "address",
Action: actionDecorator(addNeutrinoPeer),
}
func addNeutrinoPeer(ctx *cli.Context) error {
ctxc := getContext()
// Display the command's help message if we do not have the expected
// number of arguments/flags.
if ctx.NArg() != 1 || ctx.NumFlags() > 0 {
return cli.ShowCommandHelp(ctx, "addpeer")
}
client, cleanUp := getNeutrinoKitClient(ctx)
defer cleanUp()
req := &neutrinorpc.AddPeerRequest{
PeerAddrs: ctx.Args().First(),
}
// Add a peer to the neutrino server.
resp, err := client.AddPeer(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var disconnectPeerCommand = cli.Command{
Name: "disconnectpeer",
Usage: "Disconnect a peer.",
Category: "Neutrino",
Description: "Disconnects a peer by target address. Both outbound and" +
"inbound nodes will be searched for the target node. An error " +
"message will be returned if the peer was not found.",
ArgsUsage: "address",
Action: actionDecorator(disconnectNeutrinoPeer),
}
func disconnectNeutrinoPeer(ctx *cli.Context) error {
ctxc := getContext()
// Display the command's help message if we do not have the expected
// number of arguments/flags.
if ctx.NArg() != 1 || ctx.NumFlags() > 0 {
return cli.ShowCommandHelp(ctx, "disconnectpeer")
}
client, cleanUp := getNeutrinoKitClient(ctx)
defer cleanUp()
req := &neutrinorpc.DisconnectPeerRequest{
PeerAddrs: ctx.Args().First(),
}
// Disconnect a peer to the neutrino server.
resp, err := client.DisconnectPeer(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var isBannedCommand = cli.Command{
Name: "isbanned",
Usage: "Get ban status.",
Category: "Neutrino",
Description: "Returns true if the peer is banned, otherwise false.",
ArgsUsage: "address",
Action: actionDecorator(isBanned),
}
func isBanned(ctx *cli.Context) error {
ctxc := getContext()
// Display the command's help message if we do not have the expected
// number of arguments/flags.
if ctx.NArg() != 1 {
return cli.ShowCommandHelp(ctx, "isbanned")
}
client, cleanUp := getNeutrinoKitClient(ctx)
defer cleanUp()
req := &neutrinorpc.IsBannedRequest{
PeerAddrs: ctx.Args().First(),
}
// Check if the peer is banned.
resp, err := client.IsBanned(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var getBlockHeaderNeutrinoCommand = cli.Command{
Name: "getblockheader",
Usage: "Get a block header.",
Category: "Neutrino",
Description: "Returns a block header with a particular block hash.",
ArgsUsage: "hash",
Action: actionDecorator(getBlockHeaderNeutrino),
}
func getBlockHeaderNeutrino(ctx *cli.Context) error {
ctxc := getContext()
args := ctx.Args()
// Display the command's help message if we do not have the expected
// number of arguments/flags.
if !args.Present() {
return cli.ShowCommandHelp(ctx, "getblockheader")
}
client, cleanUp := getNeutrinoKitClient(ctx)
defer cleanUp()
req := &neutrinorpc.GetBlockHeaderRequest{
Hash: ctx.Args().First(),
}
resp, err := client.GetBlockHeader(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var getCFilterCommand = cli.Command{
Name: "getcfilter",
Usage: "Get a compact filter.",
Category: "Neutrino",
Description: "Returns a compact filter of a particular block.",
ArgsUsage: "hash",
Action: actionDecorator(getCFilter),
}
func getCFilter(ctx *cli.Context) error {
ctxc := getContext()
args := ctx.Args()
// Display the command's help message if we do not have the expected
// number of arguments/flags.
if !args.Present() {
return cli.ShowCommandHelp(ctx, "getcfilter")
}
client, cleanUp := getNeutrinoKitClient(ctx)
defer cleanUp()
req := &neutrinorpc.GetCFilterRequest{Hash: args.First()}
resp, err := client.GetCFilter(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
// neutrinoCommands will return the set of commands to enable for neutrinorpc
// builds.
func neutrinoCommands() []cli.Command {
return []cli.Command{
{
Name: "neutrino",
Category: "Neutrino",
Usage: "Interact with a running neutrino instance.",
Description: "",
Subcommands: []cli.Command{
getNeutrinoStatusCommand,
addPeerCommand,
disconnectPeerCommand,
isBannedCommand,
getBlockHeaderNeutrinoCommand,
getCFilterCommand,
},
},
}
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !neutrinorpc
// +build !neutrinorpc
package commands
import "github.com/urfave/cli"
// neutrinoCommands will return nil for non-neutrinorpc builds.
func neutrinoCommands() []cli.Command {
return nil
}
+162
View File
@@ -0,0 +1,162 @@
//go:build peersrpc
// +build peersrpc
package commands
import (
"fmt"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnrpc/peersrpc"
"github.com/urfave/cli"
)
// peersCommands will return the set of commands to enable for peersrpc
// builds.
func peersCommands() []cli.Command {
return []cli.Command{
{
Name: "peers",
Category: "Peers",
Usage: "Interacts with the other nodes of the " +
"network",
Subcommands: []cli.Command{
updateNodeAnnouncementCommand,
},
},
}
}
func getPeersClient(ctx *cli.Context) (peersrpc.PeersClient, func()) {
conn := getClientConn(ctx, false)
cleanUp := func() {
conn.Close()
}
return peersrpc.NewPeersClient(conn), cleanUp
}
var updateNodeAnnouncementCommand = cli.Command{
Name: "updatenodeannouncement",
Category: "Peers",
Usage: "update and broadcast a new node announcement",
Description: `
Update the node's information and broadcast a new node announcement.
Add or remove addresses where your node can be reached at, change the
alias/color of the node or enable/disable supported feature bits without
restarting the node. A node announcement with the new information will
be created and brodcasted to the network.`,
ArgsUsage: "[--address_add=] [--address_remove=] [--alias=] " +
"[--color=] [--feature_bit_add=] [--feature_bit_remove=]",
Flags: []cli.Flag{
cli.StringSliceFlag{
Name: "address_add",
Usage: "a new address that should be added to the " +
"set of URIs of this node. Can be set " +
"multiple times in the same command",
},
cli.StringSliceFlag{
Name: "address_remove",
Usage: "an address that needs to be removed from the " +
"set of URIs of this node. Can be set " +
"multiple times in the same command",
},
cli.StringFlag{
Name: "alias",
Usage: "the new alias for this node, e.g. \"bob\"",
},
cli.StringFlag{
Name: "color",
Usage: "the new color for this node, e.g. #c42a81",
},
cli.Int64SliceFlag{
Name: "feature_bit_add",
Usage: "a feature bit index that needs to be enabled. " +
"Can be set multiple times in the same command",
},
cli.Int64SliceFlag{
Name: "feature_bit_remove",
Usage: "a feature bit that needs to be disabled. " +
"Can be set multiple times in the same command",
},
},
Action: actionDecorator(updateNodeAnnouncement),
}
func updateNodeAnnouncement(ctx *cli.Context) error {
ctxc := getContext()
client, cleanUp := getPeersClient(ctx)
defer cleanUp()
change := false
req := &peersrpc.NodeAnnouncementUpdateRequest{}
if ctx.IsSet("address_add") {
change = true
for _, addr := range ctx.StringSlice("address_add") {
action := &peersrpc.UpdateAddressAction{
Action: peersrpc.UpdateAction_ADD,
Address: addr,
}
req.AddressUpdates = append(req.AddressUpdates, action)
}
}
if ctx.IsSet("address_remove") {
change = true
for _, addr := range ctx.StringSlice("address_remove") {
action := &peersrpc.UpdateAddressAction{
Action: peersrpc.UpdateAction_REMOVE,
Address: addr,
}
req.AddressUpdates = append(req.AddressUpdates, action)
}
}
if ctx.IsSet("alias") {
change = true
req.Alias = ctx.String("alias")
}
if ctx.IsSet("color") {
change = true
req.Color = ctx.String("color")
}
if ctx.IsSet("feature_bit_add") {
change = true
for _, feature := range ctx.Int64Slice("feature_bit_add") {
action := &peersrpc.UpdateFeatureAction{
Action: peersrpc.UpdateAction_ADD,
FeatureBit: lnrpc.FeatureBit(feature),
}
req.FeatureUpdates = append(req.FeatureUpdates, action)
}
}
if ctx.IsSet("feature_bit_remove") {
change = true
for _, feature := range ctx.Int64Slice("feature_bit_remove") {
action := &peersrpc.UpdateFeatureAction{
Action: peersrpc.UpdateAction_REMOVE,
FeatureBit: lnrpc.FeatureBit(feature),
}
req.FeatureUpdates = append(req.FeatureUpdates, action)
}
}
if !change {
return fmt.Errorf("no changes for the node information " +
"detected")
}
resp, err := client.UpdateNodeAnnouncement(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !peersrpc
// +build !peersrpc
package commands
import "github.com/urfave/cli"
// peersCommands will return nil for non-peersrpc builds.
func peersCommands() []cli.Command {
return nil
}
+277
View File
@@ -0,0 +1,277 @@
package commands
import (
"bytes"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"os"
"path"
"strings"
"github.com/lightningnetwork/lnd/lncfg"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/walletunlocker"
"github.com/urfave/cli"
"gopkg.in/macaroon.v2"
)
var (
errNoProfileFile = errors.New("no profile file found")
)
// profileEntry is a struct that represents all settings for one specific
// profile.
type profileEntry struct {
Name string `json:"name"`
RPCServer string `json:"rpcserver"`
LndDir string `json:"lnddir"`
Network string `json:"network"`
NoMacaroons bool `json:"no-macaroons,omitempty"` // nolint:tagliatelle
TLSCert string `json:"tlscert"`
Macaroons *macaroonJar `json:"macaroons"`
Metadata map[string]string `json:"metadata,omitempty"`
Insecure bool `json:"insecure,omitempty"`
}
// cert returns the profile's TLS certificate as a x509 certificate pool.
func (e *profileEntry) cert() (*x509.CertPool, error) {
if e.TLSCert == "" {
return nil, nil
}
cp := x509.NewCertPool()
if !cp.AppendCertsFromPEM([]byte(e.TLSCert)) {
return nil, fmt.Errorf("credentials: failed to append " +
"certificate")
}
return cp, nil
}
// getGlobalOptions returns the global connection options. If a profile file
// exists, these global options might be read from a predefined profile. If no
// profile exists, the global options from the command line are returned as an
// ephemeral profile entry.
func getGlobalOptions(ctx *cli.Context, skipMacaroons bool) (*profileEntry,
error) {
var profileName string
// Try to load the default profile file and depending on its existence
// what profile to use.
f, err := loadProfileFile(defaultProfileFile)
switch {
// The legacy case where no profile file exists and the user also didn't
// request to use one. We only consider the global options here.
case err == errNoProfileFile && !ctx.GlobalIsSet("profile"):
return profileFromContext(ctx, false, skipMacaroons)
// The file doesn't exist but the user specified an explicit profile.
case err == errNoProfileFile && ctx.GlobalIsSet("profile"):
return nil, fmt.Errorf("profile file %s does not exist",
defaultProfileFile)
// There is a file but we couldn't read/parse it.
case err != nil:
return nil, fmt.Errorf("could not read profile file %s: "+
"%v", defaultProfileFile, err)
// The user explicitly disabled the use of profiles for this command by
// setting the flag to an empty string. We fall back to the default/old
// behavior.
case ctx.GlobalIsSet("profile") && ctx.GlobalString("profile") == "":
return profileFromContext(ctx, false, skipMacaroons)
// There is a file, but no default profile is specified. The user also
// didn't specify a profile to use so we fall back to the default/old
// behavior.
case !ctx.GlobalIsSet("profile") && len(f.Default) == 0:
return profileFromContext(ctx, false, skipMacaroons)
// The user didn't specify a profile but there is a default one defined.
case !ctx.GlobalIsSet("profile") && len(f.Default) > 0:
profileName = f.Default
// The user specified a specific profile to use.
case ctx.GlobalIsSet("profile"):
profileName = ctx.GlobalString("profile")
}
// If we got to here, we do have a profile file and know the name of the
// profile to use. Now we just need to make sure it does exist.
for _, prof := range f.Profiles {
if prof.Name == profileName {
return prof, nil
}
}
return nil, fmt.Errorf("profile '%s' not found in file %s", profileName,
defaultProfileFile)
}
// profileFromContext creates an ephemeral profile entry from the global options
// set in the CLI context.
func profileFromContext(ctx *cli.Context, store, skipMacaroons bool) (
*profileEntry, error) {
// Parse the paths of the cert and macaroon. This will validate the
// chain and network value as well.
tlsCertPath, macPath, err := extractPathArgs(ctx)
if err != nil {
return nil, err
}
insecure := ctx.GlobalBool("insecure")
// Load the certificate file now, if specified. We store it as plain PEM
// directly.
var tlsCert []byte
if tlsCertPath != "" && !insecure {
var err error
tlsCert, err = os.ReadFile(tlsCertPath)
if err != nil {
return nil, fmt.Errorf("could not load TLS cert "+
"file: %v", err)
}
}
metadata := make(map[string]string)
for _, m := range ctx.GlobalStringSlice("metadata") {
pair := strings.Split(m, ":")
if len(pair) != 2 {
return nil, fmt.Errorf("invalid format for metadata " +
"flag; expected \"key:value\"")
}
metadata[pair[0]] = pair[1]
}
entry := &profileEntry{
RPCServer: ctx.GlobalString("rpcserver"),
LndDir: lncfg.CleanAndExpandPath(
ctx.GlobalString("lnddir"),
),
Network: ctx.GlobalString("network"),
NoMacaroons: ctx.GlobalBool("no-macaroons"),
TLSCert: string(tlsCert),
Metadata: metadata,
Insecure: insecure,
}
// If we aren't using macaroons in general (flag --no-macaroons) or
// don't need macaroons for this command (wallet unlocker), we can now
// return already.
if skipMacaroons || ctx.GlobalBool("no-macaroons") {
return entry, nil
}
// Now load and possibly encrypt the macaroon file.
macBytes, err := os.ReadFile(macPath)
if err != nil {
return nil, fmt.Errorf("unable to read macaroon path (check "+
"the network setting!): %v", err)
}
mac := &macaroon.Macaroon{}
if err = mac.UnmarshalBinary(macBytes); err != nil {
return nil, fmt.Errorf("unable to decode macaroon: %w", err)
}
var pw []byte
if store {
// Read a password from the terminal. If it's empty, we won't
// encrypt the macaroon and store it plaintext.
pw, err = capturePassword(
"Enter password to encrypt macaroon with or leave "+
"blank to store in plaintext: ", true,
walletunlocker.ValidatePassword,
)
if err != nil {
return nil, fmt.Errorf("unable to get encryption "+
"password: %v", err)
}
}
macEntry := &macaroonEntry{}
if err = macEntry.storeMacaroon(mac, pw); err != nil {
return nil, fmt.Errorf("unable to store macaroon: %w", err)
}
// We determine the name of the macaroon from the file itself but cut
// off the ".macaroon" at the end.
macEntry.Name = path.Base(macPath)
if path.Ext(macEntry.Name) == "macaroon" {
macEntry.Name = strings.TrimSuffix(macEntry.Name, ".macaroon")
}
// Now that we have the macaroon jar as well, let's return the entry
// with all the values populated.
entry.Macaroons = &macaroonJar{
Default: macEntry.Name,
Timeout: ctx.GlobalInt64("macaroontimeout"),
IP: ctx.GlobalString("macaroonip"),
Jar: []*macaroonEntry{macEntry},
}
return entry, nil
}
// loadProfileFile tries to load the file specified and JSON deserialize it into
// the profile file struct.
func loadProfileFile(file string) (*profileFile, error) {
if !lnrpc.FileExists(file) {
return nil, errNoProfileFile
}
content, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("could not load profile file %s: %w",
file, err)
}
f := &profileFile{}
err = f.unmarshalJSON(content)
if err != nil {
return nil, fmt.Errorf("could not unmarshal profile file %s: "+
"%v", file, err)
}
return f, nil
}
// saveProfileFile stores the given profile file struct in the specified file,
// overwriting it if it already existed.
func saveProfileFile(file string, f *profileFile) error {
content, err := f.marshalJSON()
if err != nil {
return fmt.Errorf("could not marshal profile: %w", err)
}
return os.WriteFile(file, content, 0644)
}
// profileFile is a struct that represents the whole content of a profile file.
type profileFile struct {
Default string `json:"default,omitempty"`
Profiles []*profileEntry `json:"profiles"`
}
// unmarshalJSON tries to parse the given JSON and unmarshal it into the
// receiving instance.
func (f *profileFile) unmarshalJSON(content []byte) error {
return json.Unmarshal(content, f)
}
// marshalJSON serializes the receiving instance to formatted/indented JSON.
func (f *profileFile) marshalJSON() ([]byte, error) {
b, err := json.Marshal(f)
if err != nil {
return nil, fmt.Errorf("error JSON marshalling profile: %w",
err)
}
var out bytes.Buffer
err = json.Indent(&out, b, "", " ")
if err != nil {
return nil, fmt.Errorf("error indenting profile JSON: %w", err)
}
out.WriteString("\n")
return out.Bytes(), nil
}
+17
View File
@@ -0,0 +1,17 @@
package commands
import "github.com/urfave/cli"
// routerCommands returns a list of routerrpc commands.
func routerCommands() []cli.Command {
return []cli.Command{
queryMissionControlCommand,
importMissionControlCommand,
queryProbCommand,
resetMissionControlCommand,
buildRouteCommand,
getCfgCommand,
setCfgCommand,
updateChanStatusCommand,
}
}
+106
View File
@@ -0,0 +1,106 @@
package commands
import (
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/lightningnetwork/lnd/lnrpc"
)
// OutPoint displays an outpoint string in the form "<txid>:<output-index>".
type OutPoint string
// NewOutPointFromProto formats the lnrpc.OutPoint into an OutPoint for display.
func NewOutPointFromProto(op *lnrpc.OutPoint) OutPoint {
var hash chainhash.Hash
copy(hash[:], op.TxidBytes)
return OutPoint(fmt.Sprintf("%v:%d", hash, op.OutputIndex))
}
// NewProtoOutPoint parses an OutPoint into its corresponding lnrpc.OutPoint
// type.
func NewProtoOutPoint(op string) (*lnrpc.OutPoint, error) {
parts := strings.Split(op, ":")
if len(parts) != 2 {
return nil, errors.New("outpoint should be of the form txid:index")
}
txid := parts[0]
if hex.DecodedLen(len(txid)) != chainhash.HashSize {
return nil, fmt.Errorf("invalid hex-encoded txid %v", txid)
}
outputIndex, err := strconv.Atoi(parts[1])
if err != nil {
return nil, fmt.Errorf("invalid output index: %w", err)
}
return &lnrpc.OutPoint{
TxidStr: txid,
OutputIndex: uint32(outputIndex),
}, nil
}
// Utxo displays information about an unspent output, including its address,
// amount, pkscript, and confirmations.
type Utxo struct {
Type lnrpc.AddressType `json:"address_type"`
Address string `json:"address"`
AmountSat int64 `json:"amount_sat"`
PkScript string `json:"pk_script"`
OutPoint OutPoint `json:"outpoint"`
Confirmations int64 `json:"confirmations"`
}
// NewUtxoFromProto creates a display Utxo from the Utxo proto. This filters out
// the raw txid bytes from the provided outpoint, which will otherwise be
// printed in base64.
func NewUtxoFromProto(utxo *lnrpc.Utxo) *Utxo {
return &Utxo{
Type: utxo.AddressType,
Address: utxo.Address,
AmountSat: utxo.AmountSat,
PkScript: utxo.PkScript,
OutPoint: NewOutPointFromProto(utxo.Outpoint),
Confirmations: utxo.Confirmations,
}
}
// FailedUpdate displays information about a failed update, including its
// address, reason and update error.
type FailedUpdate struct {
OutPoint OutPoint `json:"outpoint"`
Reason string `json:"reason"`
UpdateError string `json:"update_error"`
}
// NewFailedUpdateFromProto creates a display from the FailedUpdate
// proto. This filters out the raw txid bytes from the provided outpoint,
// which will otherwise be printed in base64.
func NewFailedUpdateFromProto(update *lnrpc.FailedUpdate) *FailedUpdate {
return &FailedUpdate{
OutPoint: NewOutPointFromProto(update.Outpoint),
Reason: update.Reason.String(),
UpdateError: update.UpdateError,
}
}
// UtxosToOutpoints converts a slice of UTXO strings into a slice of OutPoint
// protobuf objects. It returns an error if no UTXOs are specified or if any
// UTXO string cannot be parsed into an OutPoint.
func UtxosToOutpoints(utxos []string) ([]*lnrpc.OutPoint, error) {
var outpoints []*lnrpc.OutPoint
if len(utxos) == 0 {
return nil, fmt.Errorf("no utxos specified")
}
for _, utxo := range utxos {
outpoint, err := NewProtoOutPoint(utxo)
if err != nil {
return nil, err
}
outpoints = append(outpoints, outpoint)
}
return outpoints, nil
}
File diff suppressed because it is too large. Load diff
+11
View File
@@ -0,0 +1,11 @@
//go:build !walletrpc
// +build !walletrpc
package commands
import "github.com/urfave/cli"
// walletCommands will return nil for non-walletrpc builds.
func walletCommands() []cli.Command {
return nil
}
+42
View File
@@ -0,0 +1,42 @@
package commands
import "github.com/lightningnetwork/lnd/lnrpc/walletrpc"
// PendingSweep is a CLI-friendly type of the walletrpc.PendingSweep proto. We
// use this to show more useful string versions of byte slices and enums.
type PendingSweep struct {
OutPoint OutPoint `json:"outpoint"`
WitnessType string `json:"witness_type"`
AmountSat uint32 `json:"amount_sat"`
SatPerVByte uint32 `json:"sat_per_vbyte"`
BroadcastAttempts uint32 `json:"broadcast_attempts"`
RequestedSatPerVByte uint32 `json:"requested_sat_per_vbyte"`
Immediate bool `json:"immediate"`
Budget uint64 `json:"budget"`
DeadlineHeight uint32 `json:"deadline_height"`
NextBroadcastHeight uint32 `json:"next_broadcast_height"`
RequestedConfTarget uint32 `json:"requested_conf_target"`
Force bool `json:"force"`
}
// NewPendingSweepFromProto converts the walletrpc.PendingSweep proto type into
// its corresponding CLI-friendly type.
func NewPendingSweepFromProto(pendingSweep *walletrpc.PendingSweep) *PendingSweep {
return &PendingSweep{
OutPoint: NewOutPointFromProto(pendingSweep.Outpoint),
WitnessType: pendingSweep.WitnessType.String(),
AmountSat: pendingSweep.AmountSat,
SatPerVByte: uint32(pendingSweep.SatPerVbyte),
BroadcastAttempts: pendingSweep.BroadcastAttempts,
RequestedSatPerVByte: uint32(pendingSweep.RequestedSatPerVbyte),
Immediate: pendingSweep.Immediate,
Budget: pendingSweep.Budget,
DeadlineHeight: pendingSweep.DeadlineHeight,
// Deprecated fields.
NextBroadcastHeight: pendingSweep.NextBroadcastHeight,
RequestedConfTarget: pendingSweep.RequestedConfTarget,
Force: pendingSweep.Force,
}
}
+56
View File
@@ -0,0 +1,56 @@
//go:build watchtowerrpc
// +build watchtowerrpc
package commands
import (
"github.com/lightningnetwork/lnd/lnrpc/watchtowerrpc"
"github.com/urfave/cli"
)
func watchtowerCommands() []cli.Command {
return []cli.Command{
{
Name: "tower",
Usage: "Interact with the watchtower.",
Category: "Watchtower",
Subcommands: []cli.Command{
towerInfoCommand,
},
},
}
}
func getWatchtowerClient(ctx *cli.Context) (watchtowerrpc.WatchtowerClient, func()) {
conn := getClientConn(ctx, false)
cleanup := func() {
conn.Close()
}
return watchtowerrpc.NewWatchtowerClient(conn), cleanup
}
var towerInfoCommand = cli.Command{
Name: "info",
Usage: "Returns basic information related to the active watchtower.",
Action: actionDecorator(towerInfo),
}
func towerInfo(ctx *cli.Context) error {
ctxc := getContext()
if ctx.NArg() != 0 || ctx.NumFlags() > 0 {
return cli.ShowCommandHelp(ctx, "info")
}
client, cleanup := getWatchtowerClient(ctx)
defer cleanup()
req := &watchtowerrpc.GetInfoRequest{}
resp, err := client.GetInfo(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !watchtowerrpc
// +build !watchtowerrpc
package commands
import "github.com/urfave/cli"
// watchtowerCommands will return nil for non-watchtowerrpc builds.
func watchtowerCommands() []cli.Command {
return nil
}
+420
View File
@@ -0,0 +1,420 @@
package commands
import (
"encoding/hex"
"errors"
"fmt"
"strings"
"github.com/lightningnetwork/lnd/lnrpc/wtclientrpc"
"github.com/urfave/cli"
)
// wtclientCommands is a list of commands that can be used to interact with the
// watchtower client.
func wtclientCommands() []cli.Command {
return []cli.Command{
{
Name: "wtclient",
Usage: "Interact with the watchtower client.",
Category: "Watchtower",
Subcommands: []cli.Command{
addTowerCommand,
removeTowerCommand,
deactivateTowerCommand,
listTowersCommand,
getTowerCommand,
statsCommand,
policyCommand,
sessionCommands,
},
},
}
}
// getWtclient initializes a connection to the watchtower client RPC in order to
// interact with it.
func getWtclient(ctx *cli.Context) (wtclientrpc.WatchtowerClientClient, func()) {
conn := getClientConn(ctx, false)
cleanUp := func() {
conn.Close()
}
return wtclientrpc.NewWatchtowerClientClient(conn), cleanUp
}
var addTowerCommand = cli.Command{
Name: "add",
Usage: "Register a watchtower to use for future sessions/backups.",
Description: "If the watchtower has already been registered, then " +
"this command serves as a way of updating the watchtower " +
"with new addresses it is reachable over.",
ArgsUsage: "pubkey@address",
Action: actionDecorator(addTower),
}
func addTower(ctx *cli.Context) error {
ctxc := getContext()
// Display the command's help message if the number of arguments/flags
// is not what we expect.
if ctx.NArg() != 1 || ctx.NumFlags() > 0 {
return cli.ShowCommandHelp(ctx, "add")
}
parts := strings.Split(ctx.Args().First(), "@")
if len(parts) != 2 {
return errors.New("expected tower of format pubkey@address")
}
pubKey, err := hex.DecodeString(parts[0])
if err != nil {
return fmt.Errorf("invalid public key: %w", err)
}
address := parts[1]
client, cleanUp := getWtclient(ctx)
defer cleanUp()
req := &wtclientrpc.AddTowerRequest{
Pubkey: pubKey,
Address: address,
}
resp, err := client.AddTower(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var deactivateTowerCommand = cli.Command{
Name: "deactivate",
Usage: "Deactivate a watchtower to temporarily prevent its use for " +
"sessions/backups.",
ArgsUsage: "pubkey",
Action: actionDecorator(deactivateTower),
}
func deactivateTower(ctx *cli.Context) error {
ctxc := getContext()
// Display the command's help message if the number of arguments/flags
// is not what we expect.
if ctx.NArg() != 1 || ctx.NumFlags() > 0 {
return cli.ShowCommandHelp(ctx, "deactivate")
}
pubKey, err := hex.DecodeString(ctx.Args().First())
if err != nil {
return fmt.Errorf("invalid public key: %w", err)
}
client, cleanUp := getWtclient(ctx)
defer cleanUp()
req := &wtclientrpc.DeactivateTowerRequest{
Pubkey: pubKey,
}
resp, err := client.DeactivateTower(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var removeTowerCommand = cli.Command{
Name: "remove",
Usage: "Remove a watchtower to prevent its use for future " +
"sessions/backups.",
Description: "An optional address can be provided to remove, " +
"indicating that the watchtower is no longer reachable at " +
"this address. If an address isn't provided, then the " +
"watchtower will no longer be used for future sessions/backups.",
ArgsUsage: "pubkey | pubkey@address",
Action: actionDecorator(removeTower),
}
func removeTower(ctx *cli.Context) error {
ctxc := getContext()
// Display the command's help message if the number of arguments/flags
// is not what we expect.
if ctx.NArg() != 1 || ctx.NumFlags() > 0 {
return cli.ShowCommandHelp(ctx, "remove")
}
// The command can have only one argument, but it can be interpreted in
// either of the following formats:
//
// pubkey or pubkey@address
//
// The hex-encoded public key of the watchtower is always required,
// while the second is an optional address we'll remove from the
// watchtower's database record.
parts := strings.Split(ctx.Args().First(), "@")
if len(parts) > 2 {
return errors.New("expected tower of format pubkey@address")
}
pubKey, err := hex.DecodeString(parts[0])
if err != nil {
return fmt.Errorf("invalid public key: %w", err)
}
var address string
if len(parts) == 2 {
address = parts[1]
}
client, cleanUp := getWtclient(ctx)
defer cleanUp()
req := &wtclientrpc.RemoveTowerRequest{
Pubkey: pubKey,
Address: address,
}
resp, err := client.RemoveTower(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var listTowersCommand = cli.Command{
Name: "towers",
Usage: "Display information about all registered watchtowers.",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "include_sessions",
Usage: "include sessions with the watchtower in the " +
"response",
},
cli.BoolFlag{
Name: "exclude_exhausted_sessions",
Usage: "Whether to exclude exhausted sessions in " +
"the response info. This option is only " +
"meaningful if include_sessions is true",
},
},
Action: actionDecorator(listTowers),
}
func listTowers(ctx *cli.Context) error {
ctxc := getContext()
// Display the command's help message if the number of arguments/flags
// is not what we expect.
if ctx.NArg() > 0 || ctx.NumFlags() > 2 {
return cli.ShowCommandHelp(ctx, "towers")
}
client, cleanUp := getWtclient(ctx)
defer cleanUp()
req := &wtclientrpc.ListTowersRequest{
IncludeSessions: ctx.Bool("include_sessions"),
ExcludeExhaustedSessions: ctx.Bool(
"exclude_exhausted_sessions",
),
}
resp, err := client.ListTowers(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var getTowerCommand = cli.Command{
Name: "tower",
Usage: "Display information about a specific registered watchtower.",
ArgsUsage: "pubkey",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "include_sessions",
Usage: "include sessions with the watchtower in the " +
"response",
},
cli.BoolFlag{
Name: "exclude_exhausted_sessions",
Usage: "Whether to exclude exhausted sessions in " +
"the response info. This option is only " +
"meaningful if include_sessions is true",
},
},
Action: actionDecorator(getTower),
}
func getTower(ctx *cli.Context) error {
ctxc := getContext()
// Display the command's help message if the number of arguments/flags
// is not what we expect.
if ctx.NArg() != 1 || ctx.NumFlags() > 2 {
return cli.ShowCommandHelp(ctx, "tower")
}
// The command only has one argument, which we expect to be the
// hex-encoded public key of the watchtower we'll display information
// about.
pubKey, err := hex.DecodeString(ctx.Args().Get(0))
if err != nil {
return fmt.Errorf("invalid public key: %w", err)
}
client, cleanUp := getWtclient(ctx)
defer cleanUp()
req := &wtclientrpc.GetTowerInfoRequest{
Pubkey: pubKey,
IncludeSessions: ctx.Bool("include_sessions"),
ExcludeExhaustedSessions: ctx.Bool(
"exclude_exhausted_sessions",
),
}
resp, err := client.GetTowerInfo(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var statsCommand = cli.Command{
Name: "stats",
Usage: "Display the session stats of the watchtower client.",
Action: actionDecorator(stats),
}
func stats(ctx *cli.Context) error {
ctxc := getContext()
// Display the command's help message if the number of arguments/flags
// is not what we expect.
if ctx.NArg() > 0 || ctx.NumFlags() > 0 {
return cli.ShowCommandHelp(ctx, "stats")
}
client, cleanUp := getWtclient(ctx)
defer cleanUp()
req := &wtclientrpc.StatsRequest{}
resp, err := client.Stats(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var policyCommand = cli.Command{
Name: "policy",
Usage: "Display the active watchtower client policy configuration.",
Action: actionDecorator(policy),
Flags: []cli.Flag{
cli.BoolFlag{
Name: "legacy",
Usage: "Retrieve the legacy tower client's current " +
"policy. (default)",
},
cli.BoolFlag{
Name: "anchor",
Usage: "Retrieve the anchor tower client's current " +
"policy.",
},
cli.BoolFlag{
Name: "taproot",
Usage: "Retrieve the taproot tower client's current " +
"policy.",
},
},
}
func policy(ctx *cli.Context) error {
ctxc := getContext()
// Display the command's help message if the number of arguments/flags
// is not what we expect.
if ctx.NArg() > 0 || ctx.NumFlags() > 1 {
return cli.ShowCommandHelp(ctx, "policy")
}
var policyType wtclientrpc.PolicyType
switch {
case ctx.Bool("anchor"):
policyType = wtclientrpc.PolicyType_ANCHOR
case ctx.Bool("legacy"):
policyType = wtclientrpc.PolicyType_LEGACY
case ctx.Bool("taproot"):
policyType = wtclientrpc.PolicyType_TAPROOT
// For backwards compatibility with original rpc behavior.
default:
policyType = wtclientrpc.PolicyType_LEGACY
}
client, cleanUp := getWtclient(ctx)
defer cleanUp()
req := &wtclientrpc.PolicyRequest{
PolicyType: policyType,
}
resp, err := client.Policy(ctxc, req)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}
var sessionCommands = cli.Command{
Name: "session",
Subcommands: []cli.Command{
terminateSessionCommand,
},
}
var terminateSessionCommand = cli.Command{
Name: "terminate",
ArgsUsage: "id",
Action: actionDecorator(terminateSession),
}
func terminateSession(ctx *cli.Context) error {
ctxc := getContext()
// Display the command's help message if the number of arguments/flags
// is not what we expect.
if ctx.NArg() > 1 || ctx.NumFlags() != 0 {
return cli.ShowCommandHelp(ctx, "terminate")
}
client, cleanUp := getWtclient(ctx)
defer cleanUp()
sessionID, err := hex.DecodeString(ctx.Args().First())
if err != nil {
return fmt.Errorf("invalid session ID: %w", err)
}
resp, err := client.TerminateSession(
ctxc, &wtclientrpc.TerminateSessionRequest{
SessionId: sessionID,
},
)
if err != nil {
return err
}
printRespJSON(resp)
return nil
}