Merge pull request #1324 from Cyberguru1/bake-super-macaroon-startup

terminal: auto-bake super macaroon on startup
This commit is contained in:
Viktor Torstensson 2026-07-27 15:48:08 +02:00 committed by GitHub
commit 0bd5af814c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 600 additions and 0 deletions

View file

@ -27,6 +27,7 @@ import (
"github.com/lightninglabs/lightning-terminal/db/sqlc"
"github.com/lightninglabs/lightning-terminal/firewall"
"github.com/lightninglabs/lightning-terminal/firewalldb"
"github.com/lightninglabs/lightning-terminal/macaroons"
mid "github.com/lightninglabs/lightning-terminal/rpcmiddleware"
"github.com/lightninglabs/lightning-terminal/session"
"github.com/lightninglabs/lightning-terminal/subservers"
@ -96,6 +97,22 @@ const (
// autogenerated lit macaroon.
DefaultMacaroonFilename = "lit.macaroon"
// DefaultSuperMacaroonFilename is the default file name for the
// autogenerated super macaroon.
DefaultSuperMacaroonFilename = "super.macaroon"
// noneChoice is the none choice for the bake-super-macaroon
// configuration option.
noneChoice = "none"
// readOnlyChoice is the read-only choice for the bake-super-macaroon
// configuration option.
readOnlyChoice = "read-only"
// defaultBakeSuperMacaroon is the default value for the
// bake-super-macaroon configuration option.
defaultBakeSuperMacaroon = noneChoice
defaultFirstLNCConnTimeout = 10 * time.Minute
// DatabaseBackendSqlite is the name of the SQLite database backend.
@ -169,6 +186,12 @@ var (
defaultSqliteDatabasePath = filepath.Join(
DefaultLitDir, DefaultNetwork, defaultSqliteDatabaseFileName,
)
// DefaultSuperMacaroonPath is the default full path of the super
// macaroon.
DefaultSuperMacaroonPath = filepath.Join(
DefaultLitDir, DefaultNetwork, DefaultSuperMacaroonFilename,
)
)
// Config is the main configuration struct of lightning-terminal. It contains
@ -203,6 +226,9 @@ type Config struct {
MacaroonPath string `long:"macaroonpath" description:"Path to write the macaroon for litd's RPC and REST services if it doesn't exist."`
BakeSuperMacaroon string `long:"bake-super-macaroon" description:"Bake a super macaroon on startup if it doesn't exist." choice:"none" choice:"read-only" choice:"read-write"`
SuperMacaroonPath string `long:"super-macaroon-path" description:"Path to write the auto-baked super macaroon. This must include both the directory and the name of the macaroon file itself (which must end with the .macaroon suffix)."`
FirstLNCConnDeadline time.Duration `long:"firstlncconndeadline" description:"The duration after a new LNC session will be revoked if no connection is made with it. This only applies for the first connection which is made using the pairing phrase. "`
// DatabaseBackend is the database backend we will use for storing all
@ -543,6 +569,8 @@ func defaultConfig() *Config {
LetsEncryptListen: defaultLetsEncryptListen,
LetsEncryptDir: defaultLetsEncryptDir,
MacaroonPath: DefaultMacaroonPath,
SuperMacaroonPath: DefaultSuperMacaroonPath,
BakeSuperMacaroon: defaultBakeSuperMacaroon,
DatabaseBackend: DatabaseBackendSqlite,
Sqlite: &db.SqliteConfig{
DatabaseFileName: defaultSqliteDatabasePath,
@ -732,6 +760,32 @@ func loadAndValidateConfig(ctx context.Context,
return nil, err
}
if cfg.BakeSuperMacaroon != defaultBakeSuperMacaroon {
if cfg.SuperMacaroonPath == DefaultSuperMacaroonPath {
cfg.SuperMacaroonPath = filepath.Join(
litDir, cfg.Network,
DefaultSuperMacaroonFilename,
)
}
err = macaroons.HasMacaroonSuffix(
cfg.SuperMacaroonPath,
)
if err != nil {
return nil, err
}
// Clean and expand the super macaroon path
cfg.SuperMacaroonPath = lncfg.CleanAndExpandPath(
cfg.SuperMacaroonPath,
)
dir := filepath.Dir(cfg.SuperMacaroonPath)
if err := makeDirectories(dir); err != nil {
return nil, fmt.Errorf("unable to create super "+
"macaroon directory %v: %w", dir, err)
}
}
err = cfg.DevConfig.Validate()
if err != nil {
return nil, err

View file

@ -39,6 +39,15 @@
an account, supporting pagination (sorted in ascending lexicographical
order of their payment hash) and counting of total payments.
* [Auto-bake super macaroon on startup](https://github.com/lightninglabs/lightning-terminal/pull/1324):
Added config options `--bake-super-macaroon` (choice: `none`, `read-only`,
`read-write`) and `--super-macaroon-path` to automatically bake a super
macaroon on startup and keep its permissions in sync. When set to `read-only`
or `read-write`, the daemon will automatically bake a super macaroon
containing read-only or read-write permissions, respectively, for all active
sub-servers on startup. If the macaroon already exists but has different
permissions, it will be automatically regenerated.
### Technical and Architectural Updates
* [Report litd's own version for `litd

View file

@ -11,6 +11,7 @@ import (
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
@ -1530,3 +1531,217 @@ func bakeSuperMacaroon(t *testing.T, cfg *LitNodeConfig,
return tempFile.Name()
}
// testSuperMacaroonOnStartup tests that the super macaroon is successfully
// baked on startup if configured.
func testSuperMacaroonOnStartup(ctx context.Context, net *NetworkHarness,
t *harnessTest) {
superMacPath := filepath.Join(
net.Alice.Cfg.LitDir, "startup-super.macaroon",
)
verifyMacaroonPermissions := func(path string, expectWrite bool) {
ctxTimeout, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()
rawConn, err := connectRPC(
ctxTimeout, net.Alice.Cfg.LitAddr(),
net.Alice.Cfg.LitTLSCertPath,
)
require.NoError(t.t, err)
defer rawConn.Close()
macBytes, err := os.ReadFile(path)
require.NoError(t.t, err)
ctxm := macaroonContext(ctxTimeout, macBytes)
lnrpcConn := lnrpc.NewLightningClient(rawConn)
_, err = lnrpcConn.GetInfo(ctxm, &lnrpc.GetInfoRequest{})
require.NoError(t.t, err)
_, err = lnrpcConn.NewAddress(ctxm, &lnrpc.NewAddressRequest{
Type: lnrpc.AddressType_WITNESS_PUBKEY_HASH,
})
if expectWrite {
require.NoError(t.t, err)
} else {
require.Error(t.t, err)
require.Contains(t.t, err.Error(), "permission denied")
}
}
verifyMacaroonSubserverPermissions := func(path string,
expectSubservers bool) {
macBytes, err := os.ReadFile(path)
require.NoError(t.t, err)
mac := &macaroon.Macaroon{}
err = mac.UnmarshalBinary(macBytes)
require.NoError(t.t, err)
rawID := mac.Id()
require.NotEmpty(t.t, rawID)
decodedID := &lnrpc.MacaroonId{}
err = proto.Unmarshal(rawID[1:], decodedID)
require.NoError(t.t, err)
hasSubserver := false
for _, op := range decodedID.Ops {
if op == nil {
continue
}
if op.Entity == "loop" || op.Entity == "pool" ||
op.Entity == "faraday" {
hasSubserver = true
}
}
if expectSubservers {
require.True(
t.t, hasSubserver,
"expected macaroon to contain "+
"subserver permissions",
)
} else {
require.False(
t.t, hasSubserver,
"expected macaroon NOT to contain "+
"subserver permissions",
)
}
}
// Ensure any old file is removed first.
_ = os.Remove(superMacPath)
// Test that starting Alice with an invalid super-macaroon-path (not
// ending with .macaroon) fails.
invalidMacPath := filepath.Join(
net.Alice.Cfg.LitDir, "invalid-path.mac",
)
err := net.RestartNode(
net.Alice, nil, []LitArgOption{
WithLitArg("bake-super-macaroon", "read-only"),
WithLitArg("super-macaroon-path", invalidMacPath),
},
)
require.Error(t.t, err)
// Drain the expected process exit error from the error channel to
// prevent it from failing the test runner at the end of the test.
select {
case <-net.lndErrorChan:
case <-time.After(defaultTimeout):
t.t.Fatalf("expected process exit error in lndErrorChan")
}
// Restart Alice with read-only super macaroon baking enabled.
err = net.RestartNode(
net.Alice, nil, []LitArgOption{
WithLitArg("bake-super-macaroon", "read-only"),
WithLitArg("super-macaroon-path", superMacPath),
},
)
require.NoError(t.t, err)
// Verify that the super macaroon was created on startup.
require.FileExists(t.t, superMacPath)
// Verify permissions: write should be blocked.
verifyMacaroonPermissions(superMacPath, false)
// Restart Alice with a read-write super macaroon, WITHOUT
// deleting the file. This will test the overwrite behavior when
// permissions differ.
err = net.RestartNode(
net.Alice, nil, []LitArgOption{
WithLitArg("bake-super-macaroon", "read-write"),
WithLitArg("super-macaroon-path", superMacPath),
},
)
require.NoError(t.t, err)
require.FileExists(t.t, superMacPath)
// Verify permissions: write should succeed.
verifyMacaroonPermissions(superMacPath, true)
// Restart Alice back with a read-only super macaroon, WITHOUT
// deleting the file. This will test the overwrite behavior when
// switching back to read-only.
err = net.RestartNode(
net.Alice, nil, []LitArgOption{
WithLitArg("bake-super-macaroon", "read-only"),
WithLitArg("super-macaroon-path", superMacPath),
},
)
require.NoError(t.t, err)
require.FileExists(t.t, superMacPath)
// Verify permissions: write should be blocked again.
verifyMacaroonPermissions(superMacPath, false)
// Restart Alice with a sub-server disabled to bake a super macaroon
// with a subset of permissions.
err = net.RestartNode(
net.Alice, nil, []LitArgOption{
WithLitArg("bake-super-macaroon", "read-write"),
WithLitArg("super-macaroon-path", superMacPath),
WithLitArg("loop-mode", "disable"),
WithLitArg("pool-mode", "disable"),
WithLitArg("faraday-mode", "disable"),
},
)
require.NoError(t.t, err)
require.FileExists(t.t, superMacPath)
verifyMacaroonSubserverPermissions(superMacPath, false)
// Restart Alice with the sub-servers re-enabled. This will add
// permissions and trigger macaroon regeneration on startup.
err = net.RestartNode(
net.Alice, nil, []LitArgOption{
WithLitArg("bake-super-macaroon", "read-write"),
WithLitArg("super-macaroon-path", superMacPath),
},
)
require.NoError(t.t, err)
require.FileExists(t.t, superMacPath)
verifyMacaroonSubserverPermissions(superMacPath, true)
// Verify permissions: write should succeed.
verifyMacaroonPermissions(superMacPath, true)
// Clean up the super macaroon file.
_ = os.Remove(superMacPath)
// Restart Alice with super macaroon baking disabled and verify that
// no super macaroon is created.
err = net.RestartNode(
net.Alice, nil, []LitArgOption{
WithLitArg("bake-super-macaroon", "none"),
WithLitArg("super-macaroon-path", superMacPath),
},
)
require.NoError(t.t, err)
_, err = os.Stat(superMacPath)
require.True(t.t, os.IsNotExist(err))
// Clean up after ourselves.
err = net.RestartNode(
net.Alice, nil, []LitArgOption{
WithoutLitArg("bake-super-macaroon"),
WithoutLitArg("super-macaroon-path"),
},
)
require.NoError(t.t, err)
net.ConnectNodes(t.t, net.Alice, net.Bob)
}

View file

@ -31,4 +31,8 @@ var allTestCases = []*testCase{
name: "kvdb to sql migration",
test: testKvdbSQLMigration,
},
{
name: "terminal super macaroon on startup",
test: testSuperMacaroonOnStartup,
},
}

View file

@ -6,8 +6,12 @@ import (
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"os"
"strings"
"github.com/lightningnetwork/lnd/lnrpc"
"google.golang.org/protobuf/proto"
"gopkg.in/macaroon-bakery.v2/bakery"
"gopkg.in/macaroon.v2"
)
@ -118,3 +122,127 @@ func BakeSuperMacaroon(ctx context.Context, lnd lnrpc.LightningClient,
return hex.EncodeToString(macBytes), err
}
// SuperMacaroonExists determines whether a macaroon file exists at the given
// path.
func SuperMacaroonExists(path string) bool {
if _, err := os.Stat(path); err != nil {
return false
}
return true
}
// MacaroonMatchesPermissions checks if the macaroon at the given path contains
// exactly the expected permissions (no more and no less).
func MacaroonMatchesPermissions(path string,
expectedPerms []bakery.Op) (bool, error) {
macBytes, err := os.ReadFile(path)
if err != nil {
return false, err
}
mac := &macaroon.Macaroon{}
if err := mac.UnmarshalBinary(macBytes); err != nil {
return false, err
}
rawID := mac.Id()
if len(rawID) == 0 || rawID[0] != byte(bakery.LatestVersion) {
return false, errors.New("invalid macaroon version")
}
decodedID := &lnrpc.MacaroonId{}
if err := proto.Unmarshal(rawID[1:], decodedID); err != nil {
return false, err
}
// Map expected permissions for easy lookup: entity -> action -> true.
expectedMap := make(map[string]map[string]bool)
for _, op := range expectedPerms {
if expectedMap[op.Entity] == nil {
expectedMap[op.Entity] = make(map[string]bool)
}
expectedMap[op.Entity][op.Action] = true
}
// Map actual permissions from decoded macaroon ID:
// entity -> action -> true.
actualMap := make(map[string]map[string]bool)
for _, op := range decodedID.Ops {
if op == nil {
continue
}
if actualMap[op.Entity] == nil {
actualMap[op.Entity] = make(map[string]bool)
}
for _, action := range op.Actions {
actualMap[op.Entity][action] = true
}
}
// Compare the mapped sets for exact equality.
if len(expectedMap) != len(actualMap) {
return false, nil
}
for entity, actions := range expectedMap {
actualActions, ok := actualMap[entity]
if !ok || len(actions) != len(actualActions) {
return false, nil
}
for action := range actions {
if !actualActions[action] {
return false, nil
}
}
}
return true, nil
}
// BakeAndWriteSuperMacaroon bakes a super macaroon and writes it to disk.
func BakeAndWriteSuperMacaroon(ctx context.Context, lnd lnrpc.LightningClient,
path string, perms []bakery.Op) error {
var suffixBytes [4]byte
rootKeyID := NewSuperMacaroonRootKeyID(suffixBytes)
superMacHex, err := BakeSuperMacaroon(
ctx, lnd, rootKeyID, perms, nil,
)
if err != nil {
return fmt.Errorf("unable to bake super macaroon: %w", err)
}
superMacBytes, err := hex.DecodeString(superMacHex)
if err != nil {
return fmt.Errorf("unable to decode baked "+
"super macaroon: %w", err)
}
if err := os.WriteFile(path, superMacBytes, 0600); err != nil {
return fmt.Errorf("unable to write super macaroon to %v: %w",
path, err)
}
return nil
}
// HasMacaroonSuffix checks that the super macaroon path is not empty and ends
// with the expected suffix.
func HasMacaroonSuffix(path string) error {
if path == "" {
return fmt.Errorf("super-macaroon-path cannot be empty")
}
if !strings.HasSuffix(path, ".macaroon") {
return fmt.Errorf("super-macaroon-path must end with the " +
".macaroon suffix")
}
return nil
}

View file

@ -1,9 +1,13 @@
package macaroons
import (
"encoding/hex"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"gopkg.in/macaroon-bakery.v2/bakery"
)
var (
@ -43,3 +47,121 @@ func TestIsSuperMacaroon(t *testing.T) {
require.True(t, IsSuperMacaroon(testMacHex))
}
// TestSuperMacaroonHelpers tests that SuperMacaroonExists and
// MacaroonMatchesPermissions behave correctly.
func TestSuperMacaroonHelpers(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
path := filepath.Join(tempDir, "test.macaroon")
// Verify that it doesn't exist yet.
require.False(t, SuperMacaroonExists(path))
// Write the test macaroon.
macBytes, err := hex.DecodeString(testMacHex)
require.NoError(t, err)
err = os.WriteFile(path, macBytes, 0600)
require.NoError(t, err)
// Now it should exist.
require.True(t, SuperMacaroonExists(path))
// The macaroon matches this expected list of permissions.
expectedPerms := []bakery.Op{
{Entity: "account", Action: "read"},
{Entity: "auction", Action: "read"},
{Entity: "audit", Action: "read"},
{Entity: "auth", Action: "read"},
{Entity: "info", Action: "read"},
{Entity: "insights", Action: "read"},
{Entity: "invoices", Action: "read"},
{Entity: "loop", Action: "in"},
{Entity: "loop", Action: "out"},
{Entity: "macaroon", Action: "read"},
{Entity: "message", Action: "read"},
{Entity: "offchain", Action: "read"},
{Entity: "onchain", Action: "read"},
{Entity: "order", Action: "read"},
{Entity: "peers", Action: "read"},
{Entity: "rates", Action: "read"},
{Entity: "recommendation", Action: "read"},
{Entity: "report", Action: "read"},
{Entity: "suggestions", Action: "read"},
{Entity: "swap", Action: "read"},
{Entity: "terms", Action: "read"},
}
matches, err := MacaroonMatchesPermissions(path, expectedPerms)
require.NoError(t, err)
require.True(t, matches)
// A subset of permissions should NOT match exactly.
matches, err = MacaroonMatchesPermissions(path, expectedPerms[:5])
require.NoError(t, err)
require.False(t, matches)
// Extra/different permissions should NOT match exactly.
differentPerms := append(
expectedPerms,
bakery.Op{Entity: "invalid", Action: "write"},
)
matches, err = MacaroonMatchesPermissions(path, differentPerms)
require.NoError(t, err)
require.False(t, matches)
}
// TestHasMacaroonSuffix tests that HasMacaroonSuffix correctly
// checks the suffix of the super macaroon path.
func TestHasMacaroonSuffix(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
wantErr bool
errStr string
}{
{
name: "empty path",
path: "",
wantErr: true,
errStr: "super-macaroon-path cannot be empty",
},
{
name: "valid path",
path: "/tmp/test.macaroon",
wantErr: false,
},
{
name: "invalid path - missing suffix",
path: "/tmp/test.mac",
wantErr: true,
errStr: "super-macaroon-path must end " +
"with the .macaroon suffix",
},
{
name: "invalid path - wrong suffix",
path: "/tmp/test.macaroon.tmp",
wantErr: true,
errStr: "super-macaroon-path must end " +
"with the .macaroon suffix",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := HasMacaroonSuffix(tt.path)
if tt.wantErr {
require.Error(t, err)
require.Contains(t, err.Error(), tt.errStr)
} else {
require.NoError(t, err)
}
})
}
}

View file

@ -822,6 +822,13 @@ func (g *LightningTerminal) start(ctx context.Context) error {
return fmt.Errorf("could not start litd sub-servers: %v", err)
}
// Bake the super macaroon on startup if configured, now that all local
// and remote sub-servers have been started and active permissions are
// fully known.
if err := g.setupSuperMacaroon(ctx); err != nil {
return fmt.Errorf("could not setup super macaroon: %w", err)
}
// We can now set the status of LiT as running.
g.statusMgr.SetRunning(subservers.LIT)
@ -2192,3 +2199,64 @@ func randId(n int) string {
return string(b)
}
// setupSuperMacaroon bakes a super macaroon and writes it to disk if needed.
func (g *LightningTerminal) setupSuperMacaroon(ctx context.Context) error {
// If the bake-super-macaroon option is set to none, we don't bake a
// macaroon.
if g.cfg.BakeSuperMacaroon == noneChoice {
return nil
}
// If the super macaroon baking option is enabled, we cannot run in
// stateless initialization mode as it won't write any macaroons to the
// filesystem.
if g.cfg.statelessInitMode {
return fmt.Errorf("cannot use bake-super-macaroon " +
"with stateless-init mode")
}
path := g.cfg.SuperMacaroonPath
readOnly := g.cfg.BakeSuperMacaroon == readOnlyChoice
activePerms := g.permsMgr.ActivePermissions(readOnly)
if litmac.SuperMacaroonExists(path) {
matches, err := litmac.MacaroonMatchesPermissions(
path, activePerms,
)
if err == nil && matches {
log.Debugf("Super macaroon already exists at "+
"%v and matches configuration, "+
"skipping bake", path)
return nil
}
if err != nil {
return fmt.Errorf(
"unable to verify super macaroon "+
"permissions at %v, please delete "+
"it if the issue persists: %w",
path, err,
)
}
log.Infof("Super macaroon permissions " +
"differ from configuration, " +
"regenerating...")
}
log.Infof("Baking super macaroon on startup...")
// Bake the super macaroon and write it to disk.
if err := litmac.BakeAndWriteSuperMacaroon(
ctx, g.basicClient, path, activePerms,
); err != nil {
return err
}
log.Infof("Successfully baked and wrote super macaroon to %v", path)
return nil
}