diff --git a/config.go b/config.go index aaf748bc0..e3d8850e5 100644 --- a/config.go +++ b/config.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "math" "net" "os" "os/user" @@ -41,6 +42,7 @@ import ( "github.com/lightningnetwork/lnd/lnutils" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/onionmessage" "github.com/lightningnetwork/lnd/routing" "github.com/lightningnetwork/lnd/signal" "github.com/lightningnetwork/lnd/tor" @@ -583,6 +585,43 @@ type GRPCConfig struct { ClientAllowPingWithoutStream bool `long:"client-allow-ping-without-stream" description:"If true, the server allows keepalive pings from the client even when there are no active gRPC streams. This might be useful to keep the underlying HTTP/2 connection open for future requests."` } +// maxOnionMsgWireSize is the largest on-the-wire size in bytes, including +// the 2-byte message type prefix, that an OnionMessage can take. This is +// the value the rate limiter charges via OnionMessage.WireSize() for a +// max-sized message and therefore the tightest meaningful lower bound on +// the configured burst: anything smaller would reject every max-sized +// message even though the configured rate is positive. +const maxOnionMsgWireSize = 2 + lnwire.MaxMsgBody + +// validateOnionMsgLimiter validates a single onion message rate limiter +// kbps/burst-bytes pair. Both zero means "disabled"; both strictly positive +// means "enabled"; a mismatched pair is rejected so that operator typos +// surface at startup instead of silently disabling the limiter via the +// constructor fallback path. When enabled, burst-bytes must also be at +// least maxOnionMsgWireSize so that a single max-sized onion message +// (lnwire.MaxMsgBody bytes of body plus the 2-byte message-type prefix +// that WireSize charges for) can always fit in the token bucket; +// otherwise rate.Limiter.AllowN would reject every call and silently +// disable onion message forwarding. +func validateOnionMsgLimiter(name string, kbps, burstBytes uint64) error { + if (kbps > 0) != (burstBytes > 0) { + return fmt.Errorf("%s kbps and burst-bytes must both be "+ + "positive or both be zero; got kbps=%v "+ + "burst-bytes=%v", name, kbps, burstBytes) + } + if burstBytes > 0 && burstBytes < maxOnionMsgWireSize { + return fmt.Errorf("%s burst-bytes=%v must be at least %v "+ + "so a single max-sized onion message can fit in "+ + "the bucket", name, burstBytes, maxOnionMsgWireSize) + } + if burstBytes > uint64(math.MaxInt) { + return fmt.Errorf("%s burst-bytes=%v exceeds maximum %v", + name, burstBytes, math.MaxInt) + } + + return nil +} + // DefaultConfig returns all default values for the Config struct. // //nolint:ll @@ -727,6 +766,16 @@ func DefaultConfig() Config { Backoff: defaultLeaderCheckBackoff, }, }, + // Only the onion message rate limiter fields are explicitly + // initialized here; all other ProtocolOptions fields rely on + // Go zero values, which happen to be the historical defaults + // for those flags. + ProtocolOptions: &lncfg.ProtocolOptions{ + OnionMsgPeerKbps: onionmessage.DefaultPeerOnionMsgKbps, + OnionMsgPeerBurstBytes: onionmessage.DefaultPeerOnionMsgBurstBytes, + OnionMsgGlobalKbps: onionmessage.DefaultGlobalOnionMsgKbps, + OnionMsgGlobalBurstBytes: onionmessage.DefaultGlobalOnionMsgBurstBytes, + }, Gossip: &lncfg.Gossip{ MaxChannelUpdateBurst: discovery.DefaultMaxChannelUpdateBurst, ChannelUpdateInterval: discovery.DefaultChannelUpdateInterval, @@ -1076,6 +1125,27 @@ func ValidateConfig(cfg Config, interceptor signal.Interceptor, fileParser, return nil, mkErr("error validating autopilot: %v", err) } + // Validate the onion message rate limiter configuration. We reject + // the mismatched case where one of kbps/burst-bytes is strictly + // positive but the other is zero, which would silently disable the + // limiter and leave the operator unprotected. Both zero is fine and + // explicitly means "disabled"; both positive is fine and enables + // the limiter. + if err := validateOnionMsgLimiter( + "protocol.onion-msg-peer", + cfg.ProtocolOptions.OnionMsgPeerKbps, + cfg.ProtocolOptions.OnionMsgPeerBurstBytes, + ); err != nil { + return nil, mkErr("%s", err) + } + if err := validateOnionMsgLimiter( + "protocol.onion-msg-global", + cfg.ProtocolOptions.OnionMsgGlobalKbps, + cfg.ProtocolOptions.OnionMsgGlobalBurstBytes, + ); err != nil { + return nil, mkErr("%s", err) + } + // Ensure that --maxchansize is properly handled when set by user. // For non-Wumbo channels this limit remains 16777215 satoshis by default // as specified in BOLT-02. For wumbo channels this limit is 1,000,000,000. diff --git a/config_onion_ratelimit_test.go b/config_onion_ratelimit_test.go new file mode 100644 index 000000000..b97a75785 --- /dev/null +++ b/config_onion_ratelimit_test.go @@ -0,0 +1,90 @@ +package lnd + +import ( + "testing" + + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" +) + +// TestValidateOnionMsgLimiter exercises every branch of +// validateOnionMsgLimiter: the happy-path cases (both zero, both positive +// with adequate burst) and every rejection branch (mismatched pair and +// undersized burst). Startup config validation is the first line of +// defense against a typo silently disabling the limiter, so every branch +// is exercised explicitly. +func TestValidateOnionMsgLimiter(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + kbps uint64 + burstBytes uint64 + wantErr string + }{ + { + name: "both zero disables", + kbps: 0, + burstBytes: 0, + }, + { + name: "both positive enables", + kbps: 512, + burstBytes: 8 * 32 * 1024, + }, + { + name: "large values pass", + kbps: 1_000_000, + burstBytes: 1_000_000, + }, + { + name: "burst exactly at min allowed", + kbps: 1, + burstBytes: 2 + lnwire.MaxMsgBody, + }, + { + name: "burst one below min max-msg wire size " + + "rejected", + kbps: 1, + burstBytes: 1 + lnwire.MaxMsgBody, + wantErr: "must be at least 65535", + }, + { + name: "positive kbps zero burst rejected", + kbps: 512, + burstBytes: 0, + wantErr: "kbps and burst-bytes must both be " + + "positive or both be zero", + }, + { + name: "zero kbps positive burst rejected", + kbps: 0, + burstBytes: 65_536, + wantErr: "kbps and burst-bytes must both be " + + "positive or both be zero", + }, + { + name: "burst below maxOnionMsgWireSize " + + "rejected", + kbps: 512, + burstBytes: 1024, + wantErr: "burst-bytes=1024 must be at least " + + "65535", + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := validateOnionMsgLimiter( + "test", tc.kbps, tc.burstBytes, + ) + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErr) + }) + } +} diff --git a/lncfg/protocol.go b/lncfg/protocol.go index a300b70b4..67a440129 100644 --- a/lncfg/protocol.go +++ b/lncfg/protocol.go @@ -74,6 +74,29 @@ type ProtocolOptions struct { // NoOnionMessagesOption disables onion message forwarding. NoOnionMessagesOption bool `long:"no-onion-messages" description:"disable support for onion messaging"` + // OnionMsgPeerKbps is the maximum sustained onion message ingress + // bandwidth, in decimal kilobits per second (1 Kbps = 1000 bits/s), + // that will be accepted from any single peer. Setting this to zero, + // together with a zero burst, disables the per-peer onion message + // rate limiter. + OnionMsgPeerKbps uint64 `long:"onion-msg-peer-kbps" description:"max onion message ingress rate from a single peer, in decimal kilobits per second; set both this and onion-msg-peer-burst-bytes to 0 to disable the per-peer limiter"` + + // OnionMsgPeerBurstBytes is the token bucket depth, in bytes, used + // by the per-peer onion message rate limiter. A value of zero, + // paired with a zero rate, disables the per-peer limiter. + OnionMsgPeerBurstBytes uint64 `long:"onion-msg-peer-burst-bytes" description:"token bucket burst for the per-peer onion message limiter, in bytes; set both this and onion-msg-peer-kbps to 0 to disable the per-peer limiter"` + + // OnionMsgGlobalKbps is the maximum sustained onion message ingress + // bandwidth, in decimal kilobits per second, that will be accepted + // across all peers combined. Setting this to zero, together with a + // zero burst, disables the global onion message rate limiter. + OnionMsgGlobalKbps uint64 `long:"onion-msg-global-kbps" description:"max onion message ingress rate across all peers combined, in decimal kilobits per second; set both this and onion-msg-global-burst-bytes to 0 to disable the global limiter"` + + // OnionMsgGlobalBurstBytes is the token bucket depth, in bytes, used + // by the global onion message rate limiter. A value of zero, paired + // with a zero rate, disables the global limiter. + OnionMsgGlobalBurstBytes uint64 `long:"onion-msg-global-burst-bytes" description:"token bucket burst for the global onion message limiter, in bytes; set both this and onion-msg-global-kbps to 0 to disable the global limiter"` + // NoExperimentalAccountabilityOption disables experimental accountability. NoExperimentalAccountabilityOption bool `long:"no-experimental-accountability" description:"do not forward experimental accountability signals"` diff --git a/lncfg/protocol_integration.go b/lncfg/protocol_integration.go index 0961b9454..b28b03193 100644 --- a/lncfg/protocol_integration.go +++ b/lncfg/protocol_integration.go @@ -77,6 +77,29 @@ type ProtocolOptions struct { // NoOnionMessagesOption disables onion message forwarding. NoOnionMessagesOption bool `long:"no-onion-messages" description:"disable support for onion messaging"` + // OnionMsgPeerKbps is the maximum sustained onion message ingress + // bandwidth, in decimal kilobits per second (1 Kbps = 1000 bits/s), + // that will be accepted from any single peer. Setting this to zero, + // together with a zero burst, disables the per-peer onion message + // rate limiter. + OnionMsgPeerKbps uint64 `long:"onion-msg-peer-kbps" description:"max onion message ingress rate from a single peer, in decimal kilobits per second; set both this and onion-msg-peer-burst-bytes to 0 to disable the per-peer limiter"` + + // OnionMsgPeerBurstBytes is the token bucket depth, in bytes, used + // by the per-peer onion message rate limiter. A value of zero, + // paired with a zero rate, disables the per-peer limiter. + OnionMsgPeerBurstBytes uint64 `long:"onion-msg-peer-burst-bytes" description:"token bucket burst for the per-peer onion message limiter, in bytes; set both this and onion-msg-peer-kbps to 0 to disable the per-peer limiter"` + + // OnionMsgGlobalKbps is the maximum sustained onion message ingress + // bandwidth, in decimal kilobits per second, that will be accepted + // across all peers combined. Setting this to zero, together with a + // zero burst, disables the global onion message rate limiter. + OnionMsgGlobalKbps uint64 `long:"onion-msg-global-kbps" description:"max onion message ingress rate across all peers combined, in decimal kilobits per second; set both this and onion-msg-global-burst-bytes to 0 to disable the global limiter"` + + // OnionMsgGlobalBurstBytes is the token bucket depth, in bytes, used + // by the global onion message rate limiter. A value of zero, paired + // with a zero rate, disables the global limiter. + OnionMsgGlobalBurstBytes uint64 `long:"onion-msg-global-burst-bytes" description:"token bucket burst for the global onion message limiter, in bytes; set both this and onion-msg-global-kbps to 0 to disable the global limiter"` + // NoExperimentalAccountabilityOption disables experimental accountability. NoExperimentalAccountabilityOption bool `long:"no-experimental-accountability" description:"do not forward experimental accountability signals"`