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"
|
||||
|
||||
"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
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
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 (
|
||||
"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
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