Merge pull request #4310 from guggero/lncli-profiles
lncli: add profiles for easy multi-node management
This commit is contained in:
commit
8668248d96
@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/golang/protobuf/proto"
|
"github.com/golang/protobuf/proto"
|
||||||
|
"github.com/lightningnetwork/lnd/lncfg"
|
||||||
"github.com/lightningnetwork/lnd/lnrpc"
|
"github.com/lightningnetwork/lnd/lnrpc"
|
||||||
"github.com/lightningnetwork/lnd/macaroons"
|
"github.com/lightningnetwork/lnd/macaroons"
|
||||||
"github.com/urfave/cli"
|
"github.com/urfave/cli"
|
||||||
@ -93,7 +94,7 @@ func bakeMacaroon(ctx *cli.Context) error {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if ctx.String("save_to") != "" {
|
if ctx.String("save_to") != "" {
|
||||||
savePath = cleanAndExpandPath(ctx.String("save_to"))
|
savePath = lncfg.CleanAndExpandPath(ctx.String("save_to"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx.IsSet("timeout") {
|
if ctx.IsSet("timeout") {
|
||||||
@ -349,7 +350,7 @@ func printMacaroon(ctx *cli.Context) error {
|
|||||||
)
|
)
|
||||||
switch {
|
switch {
|
||||||
case ctx.IsSet("macaroon_file"):
|
case ctx.IsSet("macaroon_file"):
|
||||||
macPath := cleanAndExpandPath(ctx.String("macaroon_file"))
|
macPath := lncfg.CleanAndExpandPath(ctx.String("macaroon_file"))
|
||||||
|
|
||||||
// Load the specified macaroon file.
|
// Load the specified macaroon file.
|
||||||
macBytes, err = ioutil.ReadFile(macPath)
|
macBytes, err = ioutil.ReadFile(macPath)
|
||||||
|
449
cmd/lncli/cmd_profile.go
Normal file
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"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||||
@ -26,7 +25,6 @@ import (
|
|||||||
"github.com/lightningnetwork/lnd/routing/route"
|
"github.com/lightningnetwork/lnd/routing/route"
|
||||||
"github.com/lightningnetwork/lnd/walletunlocker"
|
"github.com/lightningnetwork/lnd/walletunlocker"
|
||||||
"github.com/urfave/cli"
|
"github.com/urfave/cli"
|
||||||
"golang.org/x/crypto/ssh/terminal"
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
@ -1340,15 +1338,13 @@ mnemonicCheck:
|
|||||||
// Additionally, the user may have a passphrase, that will also
|
// Additionally, the user may have a passphrase, that will also
|
||||||
// need to be provided so the daemon can properly decipher the
|
// need to be provided so the daemon can properly decipher the
|
||||||
// cipher seed.
|
// cipher seed.
|
||||||
fmt.Printf("Input your cipher seed passphrase (press enter if " +
|
aezeedPass, err = readPassword("Input your cipher seed " +
|
||||||
"your seed doesn't have a passphrase): ")
|
"passphrase (press enter if your seed doesn't have a " +
|
||||||
passphrase, err := terminal.ReadPassword(int(syscall.Stdin))
|
"passphrase): ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
aezeedPass = []byte(passphrase)
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf("Input an optional address look-ahead "+
|
fmt.Printf("Input an optional address look-ahead "+
|
||||||
@ -1460,12 +1456,10 @@ func capturePassword(instruction string, optional bool,
|
|||||||
validate func([]byte) error) ([]byte, error) {
|
validate func([]byte) error) ([]byte, error) {
|
||||||
|
|
||||||
for {
|
for {
|
||||||
fmt.Printf(instruction)
|
password, err := readPassword(instruction)
|
||||||
password, err := terminal.ReadPassword(int(syscall.Stdin))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// Do not require users to repeat password if
|
// Do not require users to repeat password if
|
||||||
// it is optional and they are not using one.
|
// it is optional and they are not using one.
|
||||||
@ -1481,21 +1475,16 @@ func capturePassword(instruction string, optional bool,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Confirm password: ")
|
passwordConfirmed, err := readPassword("Confirm password: ")
|
||||||
passwordConfirmed, err := terminal.ReadPassword(
|
|
||||||
int(syscall.Stdin),
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
if bytes.Equal(password, passwordConfirmed) {
|
if bytes.Equal(password, passwordConfirmed) {
|
||||||
return password, nil
|
return password, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Passwords don't match, " +
|
fmt.Println("Passwords don't match, please try again")
|
||||||
"please try again")
|
|
||||||
fmt.Println()
|
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
|
// terminal to be a real tty and will fail if a string is piped into
|
||||||
// lncli.
|
// lncli.
|
||||||
default:
|
default:
|
||||||
fmt.Printf("Input wallet password: ")
|
pw, err = readPassword("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()
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -1625,26 +1608,20 @@ func changePassword(ctx *cli.Context) error {
|
|||||||
client, cleanUp := getWalletUnlockerClient(ctx)
|
client, cleanUp := getWalletUnlockerClient(ctx)
|
||||||
defer cleanUp()
|
defer cleanUp()
|
||||||
|
|
||||||
fmt.Printf("Input current wallet password: ")
|
currentPw, err := readPassword("Input current wallet password: ")
|
||||||
currentPw, err := terminal.ReadPassword(int(syscall.Stdin))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
fmt.Printf("Input new wallet password: ")
|
newPw, err := readPassword("Input new wallet password: ")
|
||||||
newPw, err := terminal.ReadPassword(int(syscall.Stdin))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
fmt.Printf("Confirm new wallet password: ")
|
confirmPw, err := readPassword("Confirm new wallet password: ")
|
||||||
confirmPw, err := terminal.ReadPassword(int(syscall.Stdin))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
if !bytes.Equal(newPw, confirmPw) {
|
if !bytes.Equal(newPw, confirmPw) {
|
||||||
return fmt.Errorf("passwords don't match")
|
return fmt.Errorf("passwords don't match")
|
||||||
|
162
cmd/lncli/macaroon_jar.go
Normal file
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
|
||||||
|
}
|
102
cmd/lncli/macaroon_jar_test.go
Normal file
102
cmd/lncli/macaroon_jar_test.go
Normal file
@ -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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
"os"
|
||||||
"os/user"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
macaroon "gopkg.in/macaroon.v2"
|
|
||||||
|
|
||||||
"github.com/btcsuite/btcutil"
|
"github.com/btcsuite/btcutil"
|
||||||
"github.com/lightningnetwork/lnd/build"
|
"github.com/lightningnetwork/lnd/build"
|
||||||
@ -21,6 +18,7 @@ import (
|
|||||||
"github.com/lightningnetwork/lnd/macaroons"
|
"github.com/lightningnetwork/lnd/macaroons"
|
||||||
"github.com/urfave/cli"
|
"github.com/urfave/cli"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh/terminal"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"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 {
|
func getClientConn(ctx *cli.Context, skipMacaroons bool) *grpc.ClientConn {
|
||||||
// First, we'll parse the args from the command.
|
// First, we'll get the selected stored profile or an ephemeral one
|
||||||
tlsCertPath, macPath, err := extractPathArgs(ctx)
|
// created from the global options in the CLI context.
|
||||||
|
profile, err := getGlobalOptions(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatal(err)
|
fatal(fmt.Errorf("could not load global options: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the specified TLS certificate and build transport credentials
|
// Load the specified TLS certificate and build transport credentials
|
||||||
// with it.
|
// with it.
|
||||||
creds, err := credentials.NewClientTLSFromFile(tlsCertPath, "")
|
certPool, err := profile.cert()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatal(err)
|
fatal(fmt.Errorf("could not create cert pool: %v", err))
|
||||||
}
|
}
|
||||||
|
creds := credentials.NewClientTLSFromCert(certPool, "")
|
||||||
|
|
||||||
// Create a dial options array.
|
// Create a dial options array.
|
||||||
opts := []grpc.DialOption{
|
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
|
// Only process macaroon credentials if --no-macaroons isn't set and
|
||||||
// if we're not skipping macaroon processing.
|
// if we're not skipping macaroon processing.
|
||||||
if !ctx.GlobalBool("no-macaroons") && !skipMacaroons {
|
if !profile.NoMacaroons && !skipMacaroons {
|
||||||
// Load the specified macaroon file.
|
// Find out which macaroon to load.
|
||||||
macBytes, err := ioutil.ReadFile(macPath)
|
macName := profile.Macaroons.Default
|
||||||
if err != nil {
|
if ctx.GlobalIsSet("macfromjar") {
|
||||||
fatal(fmt.Errorf("unable to read macaroon path (check "+
|
macName = ctx.GlobalString("macfromjar")
|
||||||
"the network setting!): %v", err))
|
}
|
||||||
|
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{}
|
// Get and possibly decrypt the specified macaroon.
|
||||||
if err = mac.UnmarshalBinary(macBytes); err != nil {
|
//
|
||||||
fatal(fmt.Errorf("unable to decode macaroon: %v", err))
|
// 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{
|
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
|
// altogether if, in the latter case, this time is more than 60
|
||||||
// seconds).
|
// seconds).
|
||||||
// TODO(aakselrod): add better anti-replay protection.
|
// 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.
|
// Lock macaroon down to a specific IP address.
|
||||||
macaroons.IPLockConstraint(ctx.GlobalString("macaroonip")),
|
macaroons.IPLockConstraint(profile.Macaroons.IP),
|
||||||
|
|
||||||
// ... Add more constraints if needed.
|
// ... Add more constraints if needed.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply constraints to the macaroon.
|
// Apply constraints to the macaroon.
|
||||||
constrainedMac, err := macaroons.AddConstraints(mac, macConstraints...)
|
constrainedMac, err := macaroons.AddConstraints(
|
||||||
|
mac, macConstraints...,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatal(err)
|
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.WithContextDialer(genericDialer))
|
||||||
opts = append(opts, grpc.WithDefaultCallOptions(maxMsgRecvSize))
|
opts = append(opts, grpc.WithDefaultCallOptions(maxMsgRecvSize))
|
||||||
|
|
||||||
conn, err := grpc.Dial(ctx.GlobalString("rpcserver"), opts...)
|
conn, err := grpc.Dial(profile.RPCServer, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatal(fmt.Errorf("unable to connect to RPC server: %v", err))
|
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
|
// properly read the macaroons (if needed) and also the cert. This will
|
||||||
// either be the default, or will have been overwritten by the end
|
// either be the default, or will have been overwritten by the end
|
||||||
// user.
|
// user.
|
||||||
lndDir := cleanAndExpandPath(ctx.GlobalString("lnddir"))
|
lndDir := lncfg.CleanAndExpandPath(ctx.GlobalString("lnddir"))
|
||||||
|
|
||||||
// If the macaroon path as been manually provided, then we'll only
|
// If the macaroon path as been manually provided, then we'll only
|
||||||
// target the specified file.
|
// target the specified file.
|
||||||
var macPath string
|
var macPath string
|
||||||
if ctx.GlobalString("macaroonpath") != "" {
|
if ctx.GlobalString("macaroonpath") != "" {
|
||||||
macPath = cleanAndExpandPath(ctx.GlobalString("macaroonpath"))
|
macPath = lncfg.CleanAndExpandPath(ctx.GlobalString("macaroonpath"))
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, we'll go into the path:
|
// Otherwise, we'll go into the path:
|
||||||
// lnddir/data/chain/<chain>/<network> in order to fetch the
|
// 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
|
// 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
|
// for the TLS cert and macaroon file were set as well. If not, we'll
|
||||||
@ -211,45 +227,60 @@ func main() {
|
|||||||
cli.StringFlag{
|
cli.StringFlag{
|
||||||
Name: "rpcserver",
|
Name: "rpcserver",
|
||||||
Value: defaultRPCHostPort,
|
Value: defaultRPCHostPort,
|
||||||
Usage: "host:port of ln daemon",
|
Usage: "The host:port of LN daemon.",
|
||||||
},
|
},
|
||||||
cli.StringFlag{
|
cli.StringFlag{
|
||||||
Name: "lnddir",
|
Name: "lnddir",
|
||||||
Value: defaultLndDir,
|
Value: defaultLndDir,
|
||||||
Usage: "path to lnd's base directory",
|
Usage: "The path to lnd's base directory.",
|
||||||
},
|
},
|
||||||
cli.StringFlag{
|
cli.StringFlag{
|
||||||
Name: "tlscertpath",
|
Name: "tlscertpath",
|
||||||
Value: defaultTLSCertPath,
|
Value: defaultTLSCertPath,
|
||||||
Usage: "path to TLS certificate",
|
Usage: "The path to lnd's TLS certificate.",
|
||||||
},
|
},
|
||||||
cli.StringFlag{
|
cli.StringFlag{
|
||||||
Name: "chain, c",
|
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",
|
Value: "bitcoin",
|
||||||
},
|
},
|
||||||
cli.StringFlag{
|
cli.StringFlag{
|
||||||
Name: "network, n",
|
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.",
|
"testnet, etc.",
|
||||||
Value: "mainnet",
|
Value: "mainnet",
|
||||||
},
|
},
|
||||||
cli.BoolFlag{
|
cli.BoolFlag{
|
||||||
Name: "no-macaroons",
|
Name: "no-macaroons",
|
||||||
Usage: "disable macaroon authentication",
|
Usage: "Disable macaroon authentication.",
|
||||||
},
|
},
|
||||||
cli.StringFlag{
|
cli.StringFlag{
|
||||||
Name: "macaroonpath",
|
Name: "macaroonpath",
|
||||||
Usage: "path to macaroon file",
|
Usage: "The path to macaroon file.",
|
||||||
},
|
},
|
||||||
cli.Int64Flag{
|
cli.Int64Flag{
|
||||||
Name: "macaroontimeout",
|
Name: "macaroontimeout",
|
||||||
Value: 60,
|
Value: 60,
|
||||||
Usage: "anti-replay macaroon validity time in seconds",
|
Usage: "Anti-replay macaroon validity time in seconds.",
|
||||||
},
|
},
|
||||||
cli.StringFlag{
|
cli.StringFlag{
|
||||||
Name: "macaroonip",
|
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{
|
app.Commands = []cli.Command{
|
||||||
@ -307,6 +338,7 @@ func main() {
|
|||||||
printMacaroonCommand,
|
printMacaroonCommand,
|
||||||
trackPaymentCommand,
|
trackPaymentCommand,
|
||||||
versionCommand,
|
versionCommand,
|
||||||
|
profileSubCommand,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add any extra commands determined by build flags.
|
// Add any extra commands determined by build flags.
|
||||||
@ -322,28 +354,15 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanAndExpandPath expands environment variables and leading ~ in the
|
// readPassword reads a password from the terminal. This requires there to be an
|
||||||
// passed path, cleans the result, and returns it.
|
// actual TTY so passing in a password from stdin won't work.
|
||||||
// This function is taken from https://github.com/btcsuite/btcd
|
func readPassword(text string) ([]byte, error) {
|
||||||
func cleanAndExpandPath(path string) string {
|
fmt.Print(text)
|
||||||
if path == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expand initial ~ to OS specific home directory.
|
// The variable syscall.Stdin is of a different type in the Windows API
|
||||||
if strings.HasPrefix(path, "~") {
|
// that's why we need the explicit cast. And of course the linter
|
||||||
var homeDir string
|
// doesn't like it either.
|
||||||
user, err := user.Current()
|
pw, err := terminal.ReadPassword(int(syscall.Stdin)) // nolint:unconvert
|
||||||
if err == nil {
|
fmt.Println()
|
||||||
homeDir = user.HomeDir
|
return pw, err
|
||||||
} 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))
|
|
||||||
}
|
}
|
||||||
|
236
cmd/lncli/profile.go
Normal file
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
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user