Merge pull request #11297 from ViktorT-11/2026-10-remote-signing-renaming-and-docs
What changed, and why it matters
This commit is a documentation and code-comment follow-up to an earlier remote-signing feature. It renames internal variables like 'connectionTimeout' to 'startupTimeout' to make their purpose clearer, expands the user-facing docs to explain the security model, and clarifies that a remote signer protects private keys but does not stop a compromised watch-only node from getting valid signatures. There are no functional code changes that alter behavior.
No security action required for this commit. Treat it as a documentation/UX improvement. Operators using remote signing should read the updated security-model section to ensure they understand the trust assumptions.
Security signals we found
Documentation clarifies that remote signing is blind signing and does not prevent a compromised watch-only node from spending funds
No functional code changes; only comments, variable names, and docs were modified
Security-model warning is informational, not a fix for a newly introduced vulnerability
Evidence from the diff
The patch is purely cosmetic/educational: it updates comments, variable names, configuration descriptions, and the remote-signing documentation. The only code-level modifications are renames (e.g., InboundConnection.connectionTimeout -> startupTimeout, SignCoordinator.connectionTimeout -> startupTimeout) with matching test updates. No logic, validation, timeout values, network behavior, or cryptographic operations are changed. The documentation now explicitly warns that the signer blindly signs whatever the watch-only node requests, so a compromised watch-only node can still move funds by asking for signatures.
Changed components
docs/remote-signing.mdlncfg/remotesigner.golnd.golnwallet/rpcwallet/remote_signer_client.golnwallet/rpcwallet/remote_signer_client_builder.golnwallet/rpcwallet/remote_signer_connection.golnwallet/rpcwallet/remote_signer_connection_builder.golnwallet/rpcwallet/sign_coordinator.golnwallet/rpcwallet/sign_coordinator_test.gosample-lnd.confInspect captured patch +315 / −129
### docs/remote-signing.md
@@ -10,6 +10,10 @@ the **private** keys.
The advantage of such a setup is that the `lnd` instance containing the private
keys (the "signer") can be completely offline except for a single inbound or
outbound gRPC connection.
+Note that this protects the key material itself, but does not prevent a
+compromised watch-only node from obtaining valid signatures. See
+[Security model](#security-model) for what the split does and does not
+guarantee.
The signer instance can run on a different machine with more tightly locked down
network security, optimally only allowing the single gRPC connection to or from
the outside.
@@ -40,12 +44,119 @@ xxx xx
```
When using a remote signer, the "signer" node can be configured to operate in
-one of two modes.
-It can either be configured as an "inbound" remote signer (the default setting)
-or as an "outbound" remote signer. As an "inbound" remote signer, the signer
-node permits a single inbound gRPC connection **from** the watch-only lnd node.
-Conversely, when configured as an "outbound" remote signer, it allows a single
-outbound gRPC connection **to** the watch-only lnd node.
+one of two modes. The only thing that differs between them is **which node
+dials the other**; the signing itself is identical either way.
+
+| Mode | Who dials | Who listens |
+| --- | --- | --- |
+| "inbound" remote signer (default) | The watch-only node dials the signer | The signer |
+| "outbound" remote signer | The signer dials the watch-only node | The watch-only node |
+
+The names describe the connection from the **signer's** point of view: an
+"inbound" remote signer is one that receives an inbound connection, and an
+"outbound" remote signer is one that makes an outbound connection.
+
+Be aware that the same connection has the opposite name on the other side, so
+the labels appear inverted depending on which node you are looking at. A setup
+using an "outbound" remote signer is one where the watch-only node accepts an
+*inbound* connection, which is why the option that selects it is called
+`remotesigner.experimentalallowinboundconnection` and why the watch-only node's
+internals refer to it as an inbound connection. Where it matters, this document
+says which node dials rather than relying on the labels.
+
+## Security model
+
+Remote signing splits key material away from the node that is exposed to the
+internet. It does **not** make the watch-only node harmless if it is
+compromised. Read this section before deploying a remote signer, as the
+distinction decides what the setup actually buys you.
+
+> **Warning:** "watch-only" describes which keys the node holds, not how much
+> damage it can do. The signer does not inspect or validate what it is asked to
+> sign. An attacker who controls the watch-only node can ask the signer to
+> produce valid signatures, including signatures over transactions that send
+> your funds to an address of their choosing. The signer will produce them. In
+> its current form this represents blind signing rather than a spending
+> policy or a hardware-wallet style confirmation step.
+
+### What the signer protects against
+
+* **Key exfiltration from the exposed node.** The watch-only node only ever
+ holds public keys (`xpub`s). An attacker who reads its disk, memory or
+ backups does not obtain the seed or any private key, and cannot sign offline
+ or after losing access.
+* **Blast radius of a host compromise over time.** Access to the watch-only
+ node is only useful for as long as the attacker keeps it and the signer stays
+ reachable. Revoking the connection or stopping the signer immediately removes
+ the ability to produce new signatures.
+* **Network exposure of the key material.** The signer needs no chain backend,
+ no p2p connectivity and no inbound internet access. It can be confined to a
+ separate network zone that permits exactly one gRPC connection, as shown in
+ the example configurations below.
+
+### What the signer does not protect against
+
+The signer forwards every request it receives to its local `signrpc` and
+`walletrpc` sub-servers and returns the result. It does not examine the
+contents of a request, and there is no allow-list of destinations, amounts,
+transaction shapes or key paths.
+
+The following operations are delegated over the connection, and a compromised
+watch-only node can invoke any of them:
+
+| Request | What the signer does with it |
+| --- | --- |
+| `SignPsbt` | Signs the PSBT given, whatever it spends and wherever it pays |
+| `SignMessage` | Signs an arbitrary digest under a requested key locator |
+| `DeriveSharedKey` | Performs ECDH against a supplied public key |
+| `MuSig2*` | Runs the full MuSig2 session lifecycle, incl. partial signing |
+
+Concretely, this means a compromised watch-only node can construct a
+transaction that sweeps the wallet to an attacker-controlled address and obtain
+a valid signature for it. The private keys stay on the signer throughout, and
+the funds still leave.
+
+### Assumptions this setup makes
+
+* The watch-only node is honest, and only asks the signer to sign what the
+ operator intended. The signer has no way to check this.
+* The operator secures the watch-only node's host, credentials and RPC surface
+ to a standard appropriate for a machine that can move funds. Its security is
+ part of the fund-safety perimeter, not outside it.
+* The macaroon used for the connection is a bearer credential and must be
+ protected like a private key, as must the listening node's TLS private key.
+ Note that the macaroon belongs to the node being **dialled**, not the one
+ dialling: when the signer dials the watch-only node it presents the
+ *watch-only node's* macaroon, and when the watch-only node dials the signer
+ it presents the *signer's* macaroon.
+
+ If it is compromised, rotate it on the node that issued it, i.e. on the node
+ being dialled. `lncli changepassword --new_mac_root_key` invalidates every
+ macaroon that node has issued. Deleting its `macaroons.db` has the same effect
+ but also discards any custom root key.
+
+ What a stolen macaroon enables depends on which node issued it:
+
+ * **The signer's macaroon**, used when the watch-only node dials the signer.
+ Whoever holds it can send signing requests to the signer directly, so it is
+ theft-capable. YOU MUST rotate it, and stop the signer until the rotation is
+ done.
+ * **The watch-only node's macaroon**, used when the signer dials the
+ watch-only node. As the registration challenge is not verified yet (see
+ below), whoever holds it can connect to the watch-only node as if it were
+ the signer. The watch-only node accepts only one signer connection at a
+ time, so if the holder connects while the real signer is not connected, the
+ real signer is refused and the holder receives every signing request the
+ watch-only node sends. It cannot produce valid signatures, so the node can
+ no longer sign, and it learns everything the node asks to have signed.
+ Rotate it as soon as possible.
+
+### What this is not (yet)
+
+A validating remote signer, which would enforce a policy on what it is willing
+to sign, is a separate and larger piece of work. Until it exists, the trust
+model above is the one in effect: the signer protects the keys, and the
+watch-only node decides what gets signed.
## Example setups
### lncfg/remotesigner.go
@@ -7,22 +7,27 @@ import (
const (
// DefaultRemoteSignerListenPort is the default port for the dedicated
- // inbound remote signer RPC listeners when no port is specified.
+ // RPC listeners that a remote signer dials, when no port is specified.
DefaultRemoteSignerListenPort = 10019
- // DefaultRemoteSignerRPCTimeout is the default connection timeout
- // that is used when connecting to the remote signer or watch-only node
- // through RPC.
+ // DefaultRemoteSignerRPCTimeout is the default connection timeout,
+ // i.e. the time allowed for a single attempt at establishing the RPC
+ // connection to the remote signer or watch-only node. On a watch-only
+ // node that dials the remote signer, it also bounds each request sent
+ // over that connection.
DefaultRemoteSignerRPCTimeout = 5 * time.Second
- // DefaultRemoteSignerRequestTimeout is the default timeout used for
- // requests to and from the remote signer.
+ // DefaultRemoteSignerRequestTimeout is the default request timeout,
+ // i.e. the time allowed for a single request sent over an already
+ // established remote signer connection to be responded to. It is not
+ // used by a watch-only node that dials the remote signer, which bounds
+ // its requests with the connection timeout instead.
DefaultRemoteSignerRequestTimeout = 5 * time.Second
- // DefaultStartupTimeout is the default startup timeout used when a
- // watch-only node with
- // 'remotesigner.experimentalallowinboundconnection' set to true waits
- // for the remote signer to connect.
+ // DefaultStartupTimeout is the default startup timeout, i.e. the time
+ // a watch-only node with
+ // 'remotesigner.experimentalallowinboundconnection' set to true will
+ // wait for the remote signer to connect to it during startup.
DefaultStartupTimeout = 5 * time.Minute
)
@@ -35,8 +40,9 @@ type RemoteSigner struct {
// setup.
Enable bool `long:"enable" description:"Use a remote signer for signing any on-chain related transactions or messages. Only recommended if local wallet is initialized as watch-only. Remote signer must use the same seed/root key as the local watch-only wallet but must have private keys."`
- // ExperimentalAllowInboundConnection is true if the remote signer node
- // will connect to this node.
+ // ExperimentalAllowInboundConnection is true if the remote signer dials
+ // this node. When false, which is the default, this node dials the
+ // remote signer instead.
ExperimentalAllowInboundConnection bool `long:"experimentalallowinboundconnection" description:"EXPERIMENTAL: Signals that we allow an inbound connection from a remote signer to this node."`
// MigrateWatchOnly migrates the wallet to a watch-only wallet by
@@ -49,9 +55,8 @@ type RemoteSigner struct {
// signer.
ConnectionCfg
- // InboundWatchOnlyCfg holds the configuration options specifically
- // used when the watch-only node expects an inbound connection from
- // the remote signer.
+ // InboundWatchOnlyCfg holds the configuration options used only when
+ // the remote signer dials this watch-only node.
InboundWatchOnlyCfg
}
@@ -101,15 +106,15 @@ func (r *RemoteSigner) Validate() error {
}
}
- // Validate the shared timeout values in both inbound and outbound mode.
+ // Validate the timeout values, which apply regardless of which node
+ // dials.
err := r.ConnectionCfg.validateTimeouts()
if err != nil {
return fmt.Errorf("remotesigner.%w", err)
}
- // The host and credential settings are required only when the
- // watch-only node initiates the outbound connection to the remote
- // signer.
+ // The host and credential settings are required only when this
+ // watch-only node is the side that dials the remote signer.
if r.ExperimentalAllowInboundConnection {
return nil
}
@@ -122,34 +127,34 @@ func (r *RemoteSigner) Validate() error {
return nil
}
-// InboundWatchOnlyCfg holds the configuration options specific for watch-only
-// nodes with the `experimentalallowinboundconnection` option set.
+// InboundWatchOnlyCfg holds the configuration options specific to watch-only
+// nodes that are dialled by the remote signer, i.e. nodes with the
+// `experimentalallowinboundconnection` option set.
//
//nolint:ll
type InboundWatchOnlyCfg struct {
- ExperimentalStartupTimeout time.Duration `long:"experimentalstartuptimeout" description:"EXPERIMENTAL: The time the watch-only node will wait for the remote signer to connect during startup. If the timeout expires before the remote signer connects, the watch-only node will shut down. If set to 0, no timeout will not expire. Valid time units are {s, m, h}."`
+ ExperimentalStartupTimeout time.Duration `long:"experimentalstartuptimeout" description:"EXPERIMENTAL: The startup timeout, i.e. the time the watch-only node will wait for the remote signer to connect to it during startup. This only has an effect when 'remotesigner.experimentalallowinboundconnection' is set, as it is otherwise this node that connects to the remote signer. If the remote signer has not connected once the timeout expires, the watch-only node will shut down. Set this to 0 to wait indefinitely. Note that this is not the timeout for individual requests once the remote signer is connected, which is set with 'remotesigner.experimentalrequesttimeout'. Valid time units are {s, m, h}."`
// RPCListeners is the set of dedicated gRPC listener addresses that
- // serve only the SignCoordinatorStreams RPC for inbound remote signer
- // connections. This must be set when
+ // serve only the SignCoordinatorStreams RPC, which is the endpoint a
+ // remote signer dials to reach this node. This must be set when
// experimentalallowinboundconnection is enabled.
// If a listener omits a port, the default remote signer RPC port is
// used.
- ExperimentalRPCListeners []string `long:"experimentalrpclisten" description:"EXPERIMENTAL: Dedicated RPC listen address(es) for inbound remote signer connections. When experimentalallowinboundconnection is enabled, lnd starts a separate gRPC server on these listeners that serves only the SignCoordinatorStreams RPC. If no port is specified, the default remote signer RPC port 10019 is used."`
+ ExperimentalRPCListeners []string `long:"experimentalrpclisten" description:"EXPERIMENTAL: Dedicated RPC listen address(es) on which this node accepts the connection dialled by a remote signer. When experimentalallowinboundconnection is enabled, lnd starts a separate gRPC server on these listeners that serves only the SignCoordinatorStreams RPC. If no port is specified, the default remote signer RPC port 10019 is used."`
}
// WatchOnlyNode holds the configuration options for how to connect to a watch
// only node. Only a signer node specifies this config.
//
//nolint:ll
type WatchOnlyNode struct {
- // Enable signals if this node a signer node and is expected to connect
- // to a watch-only node.
- ExperimentalEnable bool `long:"experimentalenable" description:"EXPERIMENTAL: Signals that this node functions as a remote signer that will to connect with a watch-only node."`
+ // ExperimentalEnable signals that this node acts as a remote signer
+ // that dials a watch-only node.
+ ExperimentalEnable bool `long:"experimentalenable" description:"EXPERIMENTAL: Signals that this node functions as a remote signer that will connect to a watch-only node."`
- // ConnectionCfg holds the connection configuration options that the
- // remote signer node will use when setting up the connection to the
- // watch-only node.
+ // ConnectionCfg holds the options this signer node uses when it dials
+ // the watch-only node.
ConnectionCfg
}
@@ -175,17 +180,20 @@ func (w *WatchOnlyNode) Validate() error {
return nil
}
-// ConnectionCfg holds the configuration options required when setting up a
-// connection to either a remote signer or watch-only node, depending on which
-// side makes the outbound connection.
+// ConnectionCfg holds the options needed by whichever node dials the other.
+// On a watch-only node that dials the signer it describes the signer, and on a
+// signer node that dials the watch-only node it describes the watch-only node.
+// On a watch-only node that is dialled by the signer, the host, credential and
+// connection timeout settings are unused, but ExperimentalRequestTimeout still
+// bounds each request this node sends to the signer.
//
//nolint:ll
type ConnectionCfg struct {
RPCHost string `long:"rpchost" description:"The RPC host:port of the remote signer or watch-only node. For watch-only nodes with 'remotesigner.experimentalallowinboundconnection' set to false (the default value if not specifically set), this should be set to the remote signer's RPC host:port. For remote signer nodes connecting to a watch-only node with 'remotesigner.experimentalallowinboundconnection' set to true, this should be set to the watch-only node's RPC host:port."`
MacaroonPath string `long:"macaroonpath" description:"The macaroon to use for authenticating with the remote signer or the watch-only node. For watch-only nodes with 'remotesigner.experimentalallowinboundconnection' set to false (the default value if not specifically set), this should be set to the remote signer's macaroon. For remote signer nodes connecting to a watch-only node with 'remotesigner.experimentalallowinboundconnection' set to true, this should be set to the watch-only node's macaroon."`
TLSCertPath string `long:"tlscertpath" description:"The TLS certificate to use for establishing the remote signer's or watch-only node's identity. For watch-only nodes with 'remotesigner.experimentalallowinboundconnection' set to false (the default value if not specifically set), this should be set to the remote signer's TLS certificate. For remote signer nodes connecting to a watch-only node with 'remotesigner.experimentalallowinboundconnection' set to true, this should be set to the watch-only node's TLS certificate."`
- Timeout time.Duration `long:"timeout" description:"The timeout for making the connection to the remote signer or watch-only node, depending on whether the node acts as a watch-only node or a signer. For watch-only nodes with 'remotesigner.experimentalallowinboundconnection' set to true, this timeout value has no effect. Valid time units are {s, m, h}."`
- ExperimentalRequestTimeout time.Duration `long:"experimentalrequesttimeout" description:"EXPERIMENTAL: The time we will wait when making requests to the remote signer or watch-only node, depending on whether the node acts as a watch-only node or a signer. Valid time units are {s, m, h}."`
+ Timeout time.Duration `long:"timeout" description:"The connection timeout, i.e. the time allowed for a single attempt at establishing the connection to the remote signer or watch-only node, depending on whether this node acts as a watch-only node or a signer. For watch-only nodes with 'remotesigner.experimentalallowinboundconnection' set to false (the default value if not specifically set), this is also the time allowed for each request sent to the remote signer over the established connection, and 'remotesigner.experimentalrequesttimeout' has no effect. For watch-only nodes with 'remotesigner.experimentalallowinboundconnection' set to true, this timeout value has no effect, as the remote signer connects to this node rather than the other way around. For remote signer nodes, this covers setting the connection up only, and the time allowed for sending a response is set with 'watchonlynode.experimentalrequesttimeout'. Valid time units are {s, m, h}."`
+ ExperimentalRequestTimeout time.Duration `long:"experimentalrequesttimeout" description:"EXPERIMENTAL: The request timeout, i.e. the time allowed for a single request or response sent over an already established connection. For watch-only nodes with 'remotesigner.experimentalallowinboundconnection' set to true, this is the time we will wait for the remote signer to respond to each request. For remote signer nodes, this is the time we will wait when sending each response to the watch-only node. For watch-only nodes with 'remotesigner.experimentalallowinboundconnection' set to false (the default value if not specifically set), this value has no effect, as requests to the remote signer are bounded by 'remotesigner.timeout' instead. This applies per request, for as long as the connection is up. It is unrelated to the time a watch-only node spends waiting for the remote signer to connect during startup, which is set with 'remotesigner.experimentalstartuptimeout'. Valid time units are {s, m, h}."`
}
// defaultConnectionCfg returns the default ConnectionCfg config.
@@ -206,8 +214,8 @@ func (c *ConnectionCfg) Validate() error {
return c.validateRemoteHostCredentials()
}
-// validateTimeouts checks the timeout values that apply in both inbound and
-// outbound remote signer modes.
+// validateTimeouts checks the timeout values, which apply regardless of which
+// of the two nodes dials the other.
func (c *ConnectionCfg) validateTimeouts() error {
if c.Timeout < time.Millisecond {
return fmt.Errorf("timeout of %v is invalid, cannot be "+
### lnd.go
@@ -515,9 +515,10 @@ func Main(cfg *Config, lisCfg ListenerCfg, implCfg *ImplementationCfg,
activeChainControl := chainControlResult.ChainControl
- // If the chain control returned an inbound remote signer connection,
- // start the dedicated RPC server for it. This RPC is not served by
- // the main RPC server.
+ // If the signer dials this node rather than the other way around, chain
+ // control returned the receiving side of that connection. Start the
+ // dedicated RPC server that accepts it. This RPC is not served by the
+ // main RPC server.
if chainControlResult.InboundRemoteSignerConn != nil {
rsGRPCServer, rsListeners,
rsInterceptor, err := startInboundWatchOnlyRPCServer(
@@ -526,8 +527,8 @@ func Main(cfg *Config, lisCfg ListenerCfg, implCfg *ImplementationCfg,
chainControlResult.InboundRemoteSignerConn,
)
if err != nil {
- return mkErr("error starting inbound remote signer "+
- "RPC server", err)
+ return mkErr("error starting the dedicated RPC server "+
+ "that remote signers dial", err)
}
if rsGRPCServer != nil {
defer rsGRPCServer.Stop()
@@ -1144,8 +1145,9 @@ func startRestProxy(ctx context.Context, cfg *Config, rpcServer *rpcServer,
}
// makeRemoteSignerListeners normalizes and binds the listeners for the
-// dedicated remote signer RPC server. A nil or empty listener set indicates
-// the inbound watch-only signer endpoint is disabled.
+// dedicated remote signer RPC server, which is the endpoint a remote signer
+// dials when it connects to this watch-only node. A nil or empty listener set
+// indicates that the endpoint is disabled.
func makeRemoteSignerListeners(cfg *Config) ([]*ListenerWithSignal, error) {
addrs, err := normalizeRemoteSignerListenAddrs(cfg)
if err != nil {
@@ -1199,10 +1201,11 @@ func normalizeRemoteSignerListenAddrs(cfg *Config) ([]net.Addr, error) {
return addrs, nil
}
-// startInboundWatchOnlyRPCServer starts the dedicated gRPC server that serves
-// the inbound watch-only signer stream. The server uses its own interceptor
-// chain and listeners so remote signing can be exposed independently from the
-// main RPC server while still sharing the main macaroon service.
+// startInboundWatchOnlyRPCServer starts the dedicated gRPC server that accepts
+// the connection a remote signer dials to this watch-only node, and serves the
+// signing stream over it. The server uses its own interceptor chain and
+// listeners so remote signing can be exposed independently from the main RPC
+// server while still sharing the main macaroon service.
func startInboundWatchOnlyRPCServer(cfg *Config,
baseServerOpts []grpc.ServerOption,
serverKeepalive keepalive.ServerParameters,
@@ -1264,9 +1267,9 @@ func startInboundWatchOnlyRPCServer(cfg *Config,
"RPC permission: %w", err)
}
- // This dedicated server is only intended to serve the inbound remote
- // signer stream during startup and runtime, so it can be active
- // immediately.
+ // This dedicated server only ever serves the signing stream that the
+ // remote signer opens when it dials us, during startup and at runtime,
+ // so it can be active immediately.
interceptor.SetRPCActive()
serverOpts := append([]grpc.ServerOption{}, baseServerOpts...)
### lnwallet/rpcwallet/remote_signer_client.go
@@ -299,19 +299,21 @@ type OutboundClient struct {
gManager *fn.GoroutineManager
}
-// NewOutboundClient creates a new instance of the remote signer client.
+// NewOutboundClient creates a new instance of the remote signer client used by
+// a signer node that dials the watch-only node.
// The passed subServers need to include a walletrpc.WalletKitServer and a
// signrpc.SignerServer, or the OutboundClient will be disabled.
-// Note that the client will only fully start if the configuration
-// enables an outbound remote signer.
+// Note that the client will only fully start if this node is configured to act
+// as a remote signer that dials out.
func NewOutboundClient(walletServer walletrpc.WalletKitServer,
signerServer signrpc.SignerServer,
streamFeeder SignCoordinatorStreamFeeder,
requestTimeout time.Duration) (*OutboundClient, error) {
if walletServer == nil || signerServer == nil {
- return nil, errors.New("sub-servers cannot be nil when using " +
- "an outbound remote signer")
+ return nil, errors.New("sub-servers cannot be nil when " +
+ "acting as a remote signer that dials the watch-only " +
+ "node")
}
if streamFeeder == nil {
### lnwallet/rpcwallet/remote_signer_client_builder.go
@@ -21,9 +21,12 @@ func NewRemoteSignerClientBuilder(cfg *lncfg.WatchOnlyNode) *rscBuilder {
return &rscBuilder{cfg}
}
-// Build creates a new RemoteSignerClient instance. If the configuration enables
-// an outbound remote signer, a new OutboundRemoteSignerClient will be returned.
-// Else, a NoOpClient will be returned.
+// Build creates a new RemoteSignerClient instance. If this node is configured
+// to act as a remote signer that dials the watch-only node, an OutboundClient
+// is returned. Otherwise a NoOpClient is returned.
+//
+// NOTE: "outbound" here is from this node's point of view, as it is this node
+// that dials out. The watch-only node calls the resulting connection inbound.
func (b *rscBuilder) Build(subServers []lnrpc.SubServer) (
RemoteSignerClient, error) {
@@ -42,8 +45,9 @@ func (b *rscBuilder) Build(subServers []lnrpc.SubServer) (
}
}
- // Check if we have all servers and if the configuration enables an
- // outbound remote signer. If not, return a NoOpClient.
+ // Check if we have all servers and if this node is configured to act as
+ // a remote signer that dials the watch-only node. If not, return a
+ // NoOpClient.
if walletServer == nil || signerServer == nil {
log.Debugf("Using a No Op remote signer client due to " +
"current sub-server support")
@@ -58,7 +62,8 @@ func (b *rscBuilder) Build(subServers []lnrpc.SubServer) (
return &NoOpClient{}, nil
}
- // An outbound remote signer client is enabled, therefore we create one.
+ // This node is configured to dial the watch-only node and serve signing
+ // requests over that connection, so create the client that does it.
log.Debugf("Using an outbound remote signer client")
streamFeeder := NewStreamFeeder(b.cfg.ConnectionCfg)
### lnwallet/rpcwallet/remote_signer_connection.go
@@ -120,9 +120,16 @@ type RemoteSignerRequests interface {
*signrpc.MuSig2GetCombinedNonceResponse, error)
}
-// OutboundConnection is an abstraction of the outbound connection made to an
-// inbound remote signer. An inbound remote signer is a remote signer that
-// allows the watch-only node to connect to it via an inbound GRPC connection.
+// OutboundConnection is the watch-only node's side of a setup where the
+// watch-only node dials the signer. The connection is outbound as seen from
+// the watch-only node, which is the same connection the signer sees as
+// inbound, and is why such a signer is configured as an "inbound" remote
+// signer.
+//
+// NOTE: Throughout this package, "inbound" and "outbound" describe the
+// direction as seen by the node whose code you are reading, so the same
+// connection carries both labels depending on the side. See InboundConnection
+// for the opposite arrangement.
type OutboundConnection struct {
// Embedded signrpc.SignerClient and walletrpc.WalletKitClient to
// implement the RemoteSigner interface.
@@ -160,9 +167,9 @@ func NewOutboundConnection(ctx context.Context,
//
// NOTE: This is part of the RemoteSignerConnection interface.
func (r *OutboundConnection) Ready(_ context.Context) chan error {
- // The inbound remote signer is ready as soon we have connected to the
- // remote signer node in the constructor. Therefore, we always send
- // nil here to signal that we are ready.
+ // We dialled the signer in the constructor, so it is ready as soon as
+ // this connection exists. Therefore, we always send nil here to signal
+ // that we are ready.
readyChan := make(chan error, 1)
readyChan <- nil
@@ -296,44 +303,56 @@ func (r *OutboundConnection) connectRPC(ctx context.Context,
// RemoteSignerConnection interface.
var _ RemoteSignerConnection = (*OutboundConnection)(nil)
-// InboundRemoteSignerConnection is an interface that abstracts the
-// communication with an outbound remote signer. It extends the
-// RemoteSignerConnection insterface.
+// InboundRemoteSignerConnection abstracts the watch-only node's side of a
+// setup where the signer dials the watch-only node. The connection is inbound
+// as seen from the watch-only node, and such a signer is correspondingly
+// configured as an "outbound" remote signer. It extends the
+// RemoteSignerConnection interface.
type InboundRemoteSignerConnection interface {
RemoteSignerConnection
- // AddConnection feeds the inbound connection handler with the incoming
- // stream set up by an outbound remote signer and then blocks until the
- // stream is closed. Lnd can then send any requests to the remote signer
- // through the stream.
+ // AddConnection hands the connection handler the stream that the signer
+ // opened when it dialled this node, then blocks until that stream is
+ // closed. Lnd can send requests to the signer over the stream for as
+ // long as it is open.
AddConnection(stream StreamServer) error
}
-// InboundConnection is an abstraction that manages the inbound connection that
-// is set up by an outbound remote signer that connects to the watch-only node.
+// InboundConnection is the watch-only node's side of a setup where the signer
+// dials the watch-only node. The connection is inbound as seen from the
+// watch-only node, which is the same connection the signer sees as outbound,
+// and is why such a signer is configured as an "outbound" remote signer.
+//
+// NOTE: See OutboundConnection for the opposite arrangement, and for why the
+// same connection carries both labels.
type InboundConnection struct {
*SignCoordinator
- connectionTimeout time.Duration
+ startupTimeout time.Duration
}
-// NewInboundConnection creates a new InboundConnection instance.
+// NewInboundConnection creates a new InboundConnection instance. The
+// startupTimeout bounds how long we wait for the remote signer to connect to
+// us during startup, where 0 means that we wait indefinitely.
func NewInboundConnection(requestTimeout time.Duration,
- connectionTimeout time.Duration) *InboundConnection {
+ startupTimeout time.Duration) *InboundConnection {
return &InboundConnection{
- connectionTimeout: connectionTimeout,
+ startupTimeout: startupTimeout,
SignCoordinator: NewSignCoordinator(
- requestTimeout, connectionTimeout,
+ requestTimeout, startupTimeout,
),
}
}
-// Timeout returns the set connection timeout for the remote signer.
+// Timeout returns the startup timeout for the remote signer, i.e. how long we
+// wait for it to connect to us during startup. Unlike the outbound case, there
+// is no connection for us to establish, so this is the only connection-related
+// timeout that applies.
//
// NOTE: This is part of the RemoteSignerConnection interface.
func (r *InboundConnection) Timeout() time.Duration {
- return r.connectionTimeout
+ return r.startupTimeout
}
// RequestTimeout returns the timeout that should be used for per-request RPC
@@ -382,10 +401,9 @@ func (r *InboundConnection) Ping(ctx context.Context,
return nil
}
-// AddConnection feeds the inbound connection handler with the incoming stream
-// set up by an outbound remote signer and then blocks until the stream is
-// closed. Lnd can then send any requests to the remote signer through the
-// stream.
+// AddConnection hands the connection handler the stream that the signer opened
+// when it dialled this node, then blocks until that stream is closed. Lnd can
+// send requests to the signer over the stream for as long as it is open.
//
// NOTE: This is part of the InboundRemoteSignerConnection interface.
func (r *InboundConnection) AddConnection(stream StreamServer) error {
### lnwallet/rpcwallet/remote_signer_connection_builder.go
@@ -7,13 +7,23 @@ import (
"github.com/lightningnetwork/lnd/lncfg"
)
-// BuildRemoteSignerConnection creates a new RemoteSignerConnection instance.
-// If the configuration specifies that an inbound remote signer should be used,
-// a new OutboundConnection is created. If the configuration specifies that an
-// outbound remote signer should be used, a new InboundConnection is created.
-// The function returns the created RemoteSignerConnection instance, and a
-// cleanup function that should be called when the RemoteSignerConnection is no
-// longer needed.
+// BuildRemoteSignerConnection creates the watch-only node's side of the
+// connection to the remote signer. Which implementation is returned depends on
+// which of the two nodes dials the other:
+//
+// - The watch-only node dials the signer (the default). The connection is
+// outbound as seen from here, so an OutboundConnection is returned. Such a
+// signer is configured as an "inbound" remote signer, because the same
+// connection is inbound as seen from the signer.
+//
+// - The signer dials the watch-only node, which is what
+// remotesigner.experimentalallowinboundconnection selects. The connection
+// is inbound as seen from here, so an InboundConnection is returned, and
+// such a signer is configured as an "outbound" remote signer.
+//
+// The labels look inverted only because each one describes the direction from
+// the point of view of the node using it, never from a single fixed vantage
+// point.
func BuildRemoteSignerConnection(ctx context.Context,
cfg *lncfg.RemoteSigner) (RemoteSignerConnection, error) {
@@ -23,11 +33,14 @@ func BuildRemoteSignerConnection(ctx context.Context,
"config")
}
- // Create the remote signer based on the configuration.
+ // We dial the signer ourselves, so the connection is outbound from
+ // here.
if !cfg.ExperimentalAllowInboundConnection {
return NewOutboundConnection(ctx, cfg.ConnectionCfg)
}
+ // Otherwise the signer dials us, so we set up the receiving side and
+ // wait for it to connect.
inboundConnection := NewInboundConnection(
cfg.ConnectionCfg.ExperimentalRequestTimeout,
cfg.ExperimentalStartupTimeout,
### lnwallet/rpcwallet/sign_coordinator.go
@@ -120,9 +120,14 @@ type SignCoordinator struct {
// the remote signer.
requestTimeout time.Duration
- // connectionTimeout is the maximum time we will wait for the remote
- // signer to connect.
- connectionTimeout time.Duration
+ // startupTimeout is the maximum time we will wait for the remote
+ // signer to connect to us during startup. A value of 0 means that we
+ // wait indefinitely.
+ //
+ // NOTE: This is the startup timeout, and is distinct from the
+ // connection timeout in lncfg.ConnectionCfg, which bounds a single
+ // attempt at establishing an outbound connection.
+ startupTimeout time.Duration
// sendMu serializes stream send operations, since gRPC stream Send is
// not safe for concurrent use from multiple goroutines. We use a
@@ -141,18 +146,18 @@ var _ RemoteSignerRequests = (*SignCoordinator)(nil)
// NewSignCoordinator creates a new instance of the SignCoordinator.
func NewSignCoordinator(requestTimeout time.Duration,
- connectionTimeout time.Duration) *SignCoordinator {
+ startupTimeout time.Duration) *SignCoordinator {
respsMap := &lnutils.SyncMap[uint64, requestInfo]{}
s := &SignCoordinator{
- responses: respsMap,
- receiveErrChan: make(chan error, 1),
- clientReady: make(chan struct{}),
- clientConnected: false,
- quit: make(chan struct{}),
- requestTimeout: requestTimeout,
- connectionTimeout: connectionTimeout,
+ responses: respsMap,
+ receiveErrChan: make(chan error, 1),
+ clientReady: make(chan struct{}),
+ clientConnected: false,
+ quit: make(chan struct{}),
+ requestTimeout: requestTimeout,
+ startupTimeout: startupTimeout,
// Note that the disconnected channel is not initialized here,
// as no code listens to it until the Run method has been called
// and set the field.
@@ -502,8 +507,9 @@ func (s *SignCoordinator) StartReceiving() {
}
// WaitUntilConnected waits until the remote signer has connected. If the remote
-// signer does not connect within the configured connection timeout, or if the
-// passed context is canceled, an error is returned.
+// signer does not connect within the configured startup timeout, or if the
+// passed context is canceled, an error is returned. A startup timeout of 0
+// means that we wait indefinitely for the remote signer to connect.
func (s *SignCoordinator) WaitUntilConnected(ctx context.Context) error {
// As the Run method will redefine the clientReady channel once it
// returns, we need copy the pointer to the current clientReady channel
@@ -513,9 +519,11 @@ func (s *SignCoordinator) WaitUntilConnected(ctx context.Context) error {
currentClientReady := s.clientReady
s.mu.Unlock()
+ // A startup timeout of 0 means that we wait indefinitely, which we
+ // achieve by leaving the timeout channel nil so that it never fires.
var timeout <-chan time.Time
- if s.connectionTimeout > 0 {
- timer := time.NewTimer(s.connectionTimeout)
+ if s.startupTimeout > 0 {
+ timer := time.NewTimer(s.startupTimeout)
defer timer.Stop()
timeout = timer.C
}
### lnwallet/rpcwallet/sign_coordinator_test.go
@@ -767,7 +767,7 @@ func TestRemoteSignerDisconnects(t *testing.T) {
// Use a timeout longer than the connection timeout so the coordinator
// returns ErrConnectTimeout because of a disconnect, instead of the
// context deadline error for the ping request.
- pingTimeout := coordinator.connectionTimeout + (1 * time.Second)
+ pingTimeout := coordinator.startupTimeout + (1 * time.Second)
startTime := time.Now()
var wg sync.WaitGroup
@@ -819,7 +819,7 @@ func TestRemoteSignerDisconnects(t *testing.T) {
// Ensure that the Ping request goroutine returned after the connection
// timeout, but before the ping timeout was reached, which indicates
// that the request was canceled because the remote signer disconnected.
- require.Greater(t, time.Since(startTime), coordinator.connectionTimeout)
+ require.Greater(t, time.Since(startTime), coordinator.startupTimeout)
require.Less(t, time.Since(startTime), pingTimeout)
// Verify the responses map is empty after all responses are received
@@ -854,7 +854,7 @@ func TestWaitUntilConnectedNoTimeout(t *testing.T) {
waitErrChan <- coordinator.WaitUntilConnected(ctx)
}()
- // With connectionTimeout == 0, there is no internal timeout path, so
+ // With startupTimeout == 0, there is no internal timeout path, so
// WaitUntilConnected must stay blocked until the handshake completes or
// the ctx is canceled.
select {
### sample-lnd.conf
@@ -1884,29 +1884,44 @@
; Example:
; remotesigner.tlscertpath=/path/to/remote/signer/tls.cert
-; The timeout for connecting to the remote signer.
+; The connection timeout, i.e. the time allowed for a single attempt at
+; establishing the connection to the remote signer. As this node dials the
+; remote signer, this is also the time allowed for each request sent to it over
+; that connection, and `remotesigner.experimentalrequesttimeout` has no effect.
+; Has no effect when `remotesigner.experimentalallowinboundconnection` is set,
+; as the remote signer then connects to this node instead.
; Valid time units are {s, m, h}.
; Default:
; remotesigner.timeout=5s
; Example:
; remotesigner.timeout=2m
-; EXPERIMENTAL: The time we will wait when making requests to the remote
-; signer.
+; EXPERIMENTAL: The request timeout, i.e. the time we will wait for the
+; response to a single request sent to the remote signer over an already
+; established connection. This applies per request, for as long as the
+; connection is up. Only has an effect when
+; `remotesigner.experimentalallowinboundconnection` is set, i.e. when the
+; remote signer connects to this node. Otherwise, requests to the remote signer
+; are bounded by `remotesigner.timeout`.
; Valid time units are {s, m, h}.
; Default:
; remotesigner.experimentalrequesttimeout=5s
; Example:
; remotesigner.experimentalrequesttimeout=30s
-; EXPERIMENTAL: The time a node with
+; EXPERIMENTAL: The startup timeout, i.e. the time a node with
; `remotesigner.experimentalallowinboundconnection` set to true will wait for
-; the remote signer to connect.
+; the remote signer to connect to it during startup. If the remote signer has
+; not connected once the timeout expires, the node shuts down. Set this to 0 to
+; wait indefinitely. This is not the per-request timeout, which is set with
+; `remotesigner.experimentalrequesttimeout`.
; Valid time units are {s, m, h}.
; Default:
; remotesigner.experimentalstartuptimeout=5m
; Example:
; remotesigner.experimentalstartuptimeout=1m
+; Example, wait indefinitely:
+; remotesigner.experimentalstartuptimeout=0
; If a wallet with private key material already exists, migrate it into a
; watch-only wallet on first startup.
@@ -1946,15 +1961,18 @@
; Example:
; watchonlynode.tlscertpath=/path/to/watch-only/tls.cert
-; The timeout during a connection attempt to the watch-only node.
+; The connection timeout, i.e. the time allowed for a single attempt at
+; establishing the connection to the watch-only node. This covers setting the
+; connection up only, not any response sent over it.
; Valid time units are {s, m, h}.
; Default:
; watchonlynode.timeout=5s
; Example:
; watchonlynode.timeout=2m
-; EXPERIMENTAL: The time we will wait when when sending responses to the
-; watch-only node.
+; EXPERIMENTAL: The request timeout, i.e. the time we will wait when sending a
+; single response to the watch-only node over an already established
+; connection. This applies per response, for as long as the connection is up.
; Valid time units are {s, m, h}.
; Default:
; watchonlynode.experimentalrequesttimeout=5sWhy this scored 18/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.