Merge pull request #4310 from guggero/lncli-profiles

lncli: add profiles for easy multi-node management
This commit is contained in:
Oliver Gugger 2020-09-09 09:00:27 +02:00 committed by GitHub
commit 8668248d96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 1038 additions and 92 deletions

@ -11,6 +11,7 @@ import (
"strings"
"github.com/golang/protobuf/proto"
"github.com/lightningnetwork/lnd/lncfg"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/macaroons"
"github.com/urfave/cli"
@ -93,7 +94,7 @@ func bakeMacaroon(ctx *cli.Context) error {
)
if ctx.String("save_to") != "" {
savePath = cleanAndExpandPath(ctx.String("save_to"))
savePath = lncfg.CleanAndExpandPath(ctx.String("save_to"))
}
if ctx.IsSet("timeout") {
@ -349,7 +350,7 @@ func printMacaroon(ctx *cli.Context) error {
)
switch {
case ctx.IsSet("macaroon_file"):
macPath := cleanAndExpandPath(ctx.String("macaroon_file"))
macPath := lncfg.CleanAndExpandPath(ctx.String("macaroon_file"))
// Load the specified macaroon file.
macBytes, err = ioutil.ReadFile(macPath)

449
cmd/lncli/cmd_profile.go Normal file

@ -0,0 +1,449 @@
package main
import (
"fmt"
"io/ioutil"
"os"
"path"
"strings"
"github.com/btcsuite/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)
if err != nil {
return fmt.Errorf("could not load global options: %v", 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: %v",
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: %v", 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")
}
// 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: %v", 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: %v", 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: %v", 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 := ioutil.ReadFile(macPath)
if err != nil {
return fmt.Errorf("unable to read macaroon path: %v", err)
}
mac := &macaroon.Macaroon{}
if err = mac.UnmarshalBinary(macBytes); err != nil {
return fmt.Errorf("unable to decode macaroon: %v", err)
}
macEntry := &macaroonEntry{
Name: macName,
}
if err = macEntry.storeMacaroon(mac, nil); err != nil {
return fmt.Errorf("unable to store macaroon: %v", 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: %v",
defaultProfileFile, err)
}
fmt.Printf("Macaroon %s added to profile %s in file %s.\n", macName,
selectedProfile.Name, defaultProfileFile)
return nil
}

@ -14,7 +14,6 @@ import (
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/btcsuite/btcd/chaincfg/chainhash"
@ -26,7 +25,6 @@ import (
"github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/walletunlocker"
"github.com/urfave/cli"
"golang.org/x/crypto/ssh/terminal"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
@ -1340,15 +1338,13 @@ mnemonicCheck:
// Additionally, the user may have a passphrase, that will also
// need to be provided so the daemon can properly decipher the
// cipher seed.
fmt.Printf("Input your cipher seed passphrase (press enter if " +
"your seed doesn't have a passphrase): ")
passphrase, err := terminal.ReadPassword(int(syscall.Stdin))
aezeedPass, err = readPassword("Input your cipher seed " +
"passphrase (press enter if your seed doesn't have a " +
"passphrase): ")
if err != nil {
return err
}
aezeedPass = []byte(passphrase)
for {
fmt.Println()
fmt.Printf("Input an optional address look-ahead "+
@ -1460,12 +1456,10 @@ func capturePassword(instruction string, optional bool,
validate func([]byte) error) ([]byte, error) {
for {
fmt.Printf(instruction)
password, err := terminal.ReadPassword(int(syscall.Stdin))
password, err := readPassword(instruction)
if err != nil {
return nil, err
}
fmt.Println()
// Do not require users to repeat password if
// it is optional and they are not using one.
@ -1481,21 +1475,16 @@ func capturePassword(instruction string, optional bool,
continue
}
fmt.Printf("Confirm password: ")
passwordConfirmed, err := terminal.ReadPassword(
int(syscall.Stdin),
)
passwordConfirmed, err := readPassword("Confirm password: ")
if err != nil {
return nil, err
}
fmt.Println()
if bytes.Equal(password, passwordConfirmed) {
return password, nil
}
fmt.Println("Passwords don't match, " +
"please try again")
fmt.Println("Passwords don't match, please try again")
fmt.Println()
}
}
@ -1558,13 +1547,7 @@ func unlock(ctx *cli.Context) error {
// terminal to be a real tty and will fail if a string is piped into
// lncli.
default:
fmt.Printf("Input wallet password: ")
// 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 = terminal.ReadPassword(int(syscall.Stdin)) // nolint:unconvert
fmt.Println()
pw, err = readPassword("Input wallet password: ")
}
if err != nil {
return err
@ -1625,26 +1608,20 @@ func changePassword(ctx *cli.Context) error {
client, cleanUp := getWalletUnlockerClient(ctx)
defer cleanUp()
fmt.Printf("Input current wallet password: ")
currentPw, err := terminal.ReadPassword(int(syscall.Stdin))
currentPw, err := readPassword("Input current wallet password: ")
if err != nil {
return err
}
fmt.Println()
fmt.Printf("Input new wallet password: ")
newPw, err := terminal.ReadPassword(int(syscall.Stdin))
newPw, err := readPassword("Input new wallet password: ")
if err != nil {
return err
}
fmt.Println()
fmt.Printf("Confirm new wallet password: ")
confirmPw, err := terminal.ReadPassword(int(syscall.Stdin))
confirmPw, err := readPassword("Confirm new wallet password: ")
if err != nil {
return err
}
fmt.Println()
if !bytes.Equal(newPw, confirmPw) {
return fmt.Errorf("passwords don't match")

162
cmd/lncli/macaroon_jar.go Normal file

@ -0,0 +1,162 @@
package main
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: %v",
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: %v", 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: %v", 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: %v", 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: %v", 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: %v",
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: %v",
err)
}
return macBytes, nil
}

@ -0,0 +1,102 @@
package main
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
}

@ -6,13 +6,10 @@ package main
import (
"fmt"
"io/ioutil"
"os"
"os/user"
"path/filepath"
"strings"
macaroon "gopkg.in/macaroon.v2"
"syscall"
"github.com/btcsuite/btcutil"
"github.com/lightningnetwork/lnd/build"
@ -21,6 +18,7 @@ import (
"github.com/lightningnetwork/lnd/macaroons"
"github.com/urfave/cli"
"golang.org/x/crypto/ssh/terminal"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
@ -69,18 +67,20 @@ func getClient(ctx *cli.Context) (lnrpc.LightningClient, func()) {
}
func getClientConn(ctx *cli.Context, skipMacaroons bool) *grpc.ClientConn {
// First, we'll parse the args from the command.
tlsCertPath, macPath, err := extractPathArgs(ctx)
// 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)
if err != nil {
fatal(err)
fatal(fmt.Errorf("could not load global options: %v", err))
}
// Load the specified TLS certificate and build transport credentials
// with it.
creds, err := credentials.NewClientTLSFromFile(tlsCertPath, "")
certPool, err := profile.cert()
if err != nil {
fatal(err)
fatal(fmt.Errorf("could not create cert pool: %v", err))
}
creds := credentials.NewClientTLSFromCert(certPool, "")
// Create a dial options array.
opts := []grpc.DialOption{
@ -89,17 +89,31 @@ func getClientConn(ctx *cli.Context, skipMacaroons bool) *grpc.ClientConn {
// Only process macaroon credentials if --no-macaroons isn't set and
// if we're not skipping macaroon processing.
if !ctx.GlobalBool("no-macaroons") && !skipMacaroons {
// Load the specified macaroon file.
macBytes, err := ioutil.ReadFile(macPath)
if err != nil {
fatal(fmt.Errorf("unable to read macaroon path (check "+
"the network setting!): %v", err))
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))
}
mac := &macaroon.Macaroon{}
if err = mac.UnmarshalBinary(macBytes); err != nil {
fatal(fmt.Errorf("unable to decode macaroon: %v", err))
// 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: %v", err))
}
macConstraints := []macaroons.Constraint{
@ -114,16 +128,18 @@ func getClientConn(ctx *cli.Context, skipMacaroons bool) *grpc.ClientConn {
// altogether if, in the latter case, this time is more than 60
// seconds).
// TODO(aakselrod): add better anti-replay protection.
macaroons.TimeoutConstraint(ctx.GlobalInt64("macaroontimeout")),
macaroons.TimeoutConstraint(profile.Macaroons.Timeout),
// Lock macaroon down to a specific IP address.
macaroons.IPLockConstraint(ctx.GlobalString("macaroonip")),
macaroons.IPLockConstraint(profile.Macaroons.IP),
// ... Add more constraints if needed.
}
// Apply constraints to the macaroon.
constrainedMac, err := macaroons.AddConstraints(mac, macConstraints...)
constrainedMac, err := macaroons.AddConstraints(
mac, macConstraints...,
)
if err != nil {
fatal(err)
}
@ -139,7 +155,7 @@ func getClientConn(ctx *cli.Context, skipMacaroons bool) *grpc.ClientConn {
opts = append(opts, grpc.WithContextDialer(genericDialer))
opts = append(opts, grpc.WithDefaultCallOptions(maxMsgRecvSize))
conn, err := grpc.Dial(ctx.GlobalString("rpcserver"), opts...)
conn, err := grpc.Dial(profile.RPCServer, opts...)
if err != nil {
fatal(fmt.Errorf("unable to connect to RPC server: %v", err))
}
@ -171,13 +187,13 @@ func extractPathArgs(ctx *cli.Context) (string, string, error) {
// 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 := cleanAndExpandPath(ctx.GlobalString("lnddir"))
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 = cleanAndExpandPath(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
@ -188,7 +204,7 @@ func extractPathArgs(ctx *cli.Context) (string, string, error) {
)
}
tlsCertPath := cleanAndExpandPath(ctx.GlobalString("tlscertpath"))
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
@ -211,45 +227,60 @@ func main() {
cli.StringFlag{
Name: "rpcserver",
Value: defaultRPCHostPort,
Usage: "host:port of ln daemon",
Usage: "The host:port of LN daemon.",
},
cli.StringFlag{
Name: "lnddir",
Value: defaultLndDir,
Usage: "path to lnd's base directory",
Usage: "The path to lnd's base directory.",
},
cli.StringFlag{
Name: "tlscertpath",
Value: defaultTLSCertPath,
Usage: "path to TLS certificate",
Usage: "The path to lnd's TLS certificate.",
},
cli.StringFlag{
Name: "chain, c",
Usage: "the chain lnd is running on e.g. bitcoin",
Usage: "The chain lnd is running on, e.g. bitcoin.",
Value: "bitcoin",
},
cli.StringFlag{
Name: "network, n",
Usage: "the network lnd is running on e.g. mainnet, " +
Usage: "The network lnd is running on, e.g. mainnet, " +
"testnet, etc.",
Value: "mainnet",
},
cli.BoolFlag{
Name: "no-macaroons",
Usage: "disable macaroon authentication",
Usage: "Disable macaroon authentication.",
},
cli.StringFlag{
Name: "macaroonpath",
Usage: "path to macaroon file",
Usage: "The path to macaroon file.",
},
cli.Int64Flag{
Name: "macaroontimeout",
Value: 60,
Usage: "anti-replay macaroon validity time in seconds",
Usage: "Anti-replay macaroon validity time in seconds.",
},
cli.StringFlag{
Name: "macaroonip",
Usage: "if set, lock macaroon to specific IP address",
Usage: "If set, lock macaroon to specific IP address.",
},
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.",
},
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.",
},
}
app.Commands = []cli.Command{
@ -307,6 +338,7 @@ func main() {
printMacaroonCommand,
trackPaymentCommand,
versionCommand,
profileSubCommand,
}
// Add any extra commands determined by build flags.
@ -322,28 +354,15 @@ func main() {
}
}
// cleanAndExpandPath expands environment variables and leading ~ in the
// passed path, cleans the result, and returns it.
// This function is taken from https://github.com/btcsuite/btcd
func cleanAndExpandPath(path string) string {
if path == "" {
return ""
}
// 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)
// Expand initial ~ to OS specific home directory.
if strings.HasPrefix(path, "~") {
var homeDir string
user, err := user.Current()
if err == nil {
homeDir = user.HomeDir
} else {
homeDir = os.Getenv("HOME")
}
path = strings.Replace(path, "~", homeDir, 1)
}
// NOTE: The os.ExpandEnv doesn't work with Windows-style %VARIABLE%,
// but the variables can still be expanded via POSIX-style $VARIABLE.
return filepath.Clean(os.ExpandEnv(path))
// 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 := terminal.ReadPassword(int(syscall.Stdin)) // nolint:unconvert
fmt.Println()
return pw, err
}

236
cmd/lncli/profile.go Normal file

@ -0,0 +1,236 @@
package main
import (
"bytes"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"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"`
Chain string `json:"chain"`
Network string `json:"network"`
NoMacaroons bool `json:"no-macaroons,omitempty"`
TLSCert string `json:"tlscert"`
Macaroons *macaroonJar `json:"macaroons"`
}
// cert returns the profile's TLS certificate as a x509 certificate pool.
func (e *profileEntry) cert() (*x509.CertPool, error) {
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) (*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)
// 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)
// 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)
// 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 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
}
// Load the certificate file now. We store it as plain PEM directly.
tlsCert, err := ioutil.ReadFile(tlsCertPath)
if err != nil {
return nil, fmt.Errorf("could not load TLS cert file %s: %v",
tlsCertPath, err)
}
// Now load and possibly encrypt the macaroon file.
macBytes, err := ioutil.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: %v", 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: %v", 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 complicated arguments behind us, let's return
// the new entry with all the values populated.
return &profileEntry{
RPCServer: ctx.GlobalString("rpcserver"),
LndDir: lncfg.CleanAndExpandPath(ctx.GlobalString("lnddir")),
Chain: ctx.GlobalString("chain"),
Network: ctx.GlobalString("network"),
NoMacaroons: ctx.GlobalBool("no-macaroons"),
TLSCert: string(tlsCert),
Macaroons: &macaroonJar{
Default: macEntry.Name,
Timeout: ctx.GlobalInt64("macaroontimeout"),
IP: ctx.GlobalString("macaroonip"),
Jar: []*macaroonEntry{macEntry},
},
}, 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 := ioutil.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("could not load profile file %s: %v",
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: %v", err)
}
return ioutil.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: %v",
err)
}
var out bytes.Buffer
err = json.Indent(&out, b, "", " ")
if err != nil {
return nil, fmt.Errorf("error indenting profile JSON: %v", err)
}
out.WriteString("\n")
return out.Bytes(), nil
}