From 05e4370ebde76231468ee093a8282641606e0029 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 14 Aug 2025 10:14:48 +0000 Subject: [PATCH 01/14] lnwire: remove `AddrLen` helper In this commit, we remove `AddrLen` as prepration step before adding DNS address type which will have a var length. Co-authored-by: Elle Mouton --- lnwire/lnwire.go | 46 ++++++++++++++++++++++------------------------ 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/lnwire/lnwire.go b/lnwire/lnwire.go index 7d686658297..1b47e5778c3 100644 --- a/lnwire/lnwire.go +++ b/lnwire/lnwire.go @@ -28,6 +28,24 @@ const ( MaxMsgBody = 65533 ) +const ( + // tcp4AddrLen is the length of an IPv4 address + // (4 bytes IP + 2 bytes port). + tcp4AddrLen = 6 + + // tcp6AddrLen is the length of an IPv6 address + // (16 bytes IP + 2 bytes port). + tcp6AddrLen = 18 + + // v2OnionAddrLen is the length of a version 2 Tor onion service + // address. + v2OnionAddrLen = 12 + + // v3OnionAddrLen is the length of a version 3 Tor onion service address + // (35 bytes decoded onion + 2 bytes port). + v3OnionAddrLen = 37 +) + // PkScript is simple type definition which represents a raw serialized public // key script. type PkScript []byte @@ -54,25 +72,6 @@ const ( v3OnionAddr addressType = 4 ) -// AddrLen returns the number of bytes that it takes to encode the target -// address. -func (a addressType) AddrLen() uint16 { - switch a { - case noAddr: - return 0 - case tcp4Addr: - return 6 - case tcp6Addr: - return 18 - case v2OnionAddr: - return 12 - case v3OnionAddr: - return 37 - default: - return 0 - } -} - // WriteElement is a one-stop shop to write the big endian representation of // any element which is to be serialized for the wire protocol. // @@ -743,7 +742,6 @@ func ReadElement(r io.Reader, element interface{}) error { var address net.Addr switch aType := addressType(descriptor[0]); aType { case noAddr: - addrBytesRead += aType.AddrLen() continue case tcp4Addr: @@ -761,7 +759,7 @@ func ReadElement(r io.Reader, element interface{}) error { IP: net.IP(ip[:]), Port: int(binary.BigEndian.Uint16(port[:])), } - addrBytesRead += aType.AddrLen() + addrBytesRead += tcp4AddrLen case tcp6Addr: var ip [16]byte @@ -778,7 +776,7 @@ func ReadElement(r io.Reader, element interface{}) error { IP: net.IP(ip[:]), Port: int(binary.BigEndian.Uint16(port[:])), } - addrBytesRead += aType.AddrLen() + addrBytesRead += tcp6AddrLen case v2OnionAddr: var h [tor.V2DecodedLen]byte @@ -799,7 +797,7 @@ func ReadElement(r io.Reader, element interface{}) error { OnionService: onionService, Port: port, } - addrBytesRead += aType.AddrLen() + addrBytesRead += v2OnionAddrLen case v3OnionAddr: var h [tor.V3DecodedLen]byte @@ -820,7 +818,7 @@ func ReadElement(r io.Reader, element interface{}) error { OnionService: onionService, Port: port, } - addrBytesRead += aType.AddrLen() + addrBytesRead += v3OnionAddrLen default: // If we don't understand this address type, From 4d9f884c3f733f7dd97beb5d34002cf818c70e40 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 14 Aug 2025 10:26:07 +0000 Subject: [PATCH 02/14] lnwire: add `DNSAddress` type --- lnwire/dns_addr.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 lnwire/dns_addr.go diff --git a/lnwire/dns_addr.go b/lnwire/dns_addr.go new file mode 100644 index 00000000000..92cbc073af6 --- /dev/null +++ b/lnwire/dns_addr.go @@ -0,0 +1,31 @@ +package lnwire + +import ( + "net" + "strconv" +) + +// DNSAddress is used to represent a DNS address of a node. +type DNSAddress struct { + // Hostname is the DNS hostname of the address. This MUST only contain + // ASCII characters as per Bolt #7. The maximum length that this may + // be is 255 bytes. + Hostname string + + // Port is the port number of the address. + Port uint16 +} + +// A compile-time check to ensure that DNSAddress implements the net.Addr +// interface. +var _ net.Addr = (*DNSAddress)(nil) + +// Network returns the network that this address uses, which is "tcp". +func (d *DNSAddress) Network() string { + return "tcp" +} + +// String returns the address in the form "hostname:port". +func (d *DNSAddress) String() string { + return net.JoinHostPort(d.Hostname, strconv.Itoa(int(d.Port))) +} From 41bd519859d0f360bbcc8245b0ca0a64a0f47334 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 14 Aug 2025 13:11:06 +0000 Subject: [PATCH 03/14] lnwire: add encoding/decoding for DNS addr Co-authored-by: Elle Mouton --- lnwire/lnwire.go | 40 ++++++++++++++++++++++++++++++++++++++++ lnwire/message_test.go | 21 ++++++++++++++++++++- lnwire/writer.go | 29 +++++++++++++++++++++++++++++ lnwire/writer_test.go | 36 ++++++++++++++++++++++++++++-------- 4 files changed, 117 insertions(+), 9 deletions(-) diff --git a/lnwire/lnwire.go b/lnwire/lnwire.go index 1b47e5778c3..86f7caa6173 100644 --- a/lnwire/lnwire.go +++ b/lnwire/lnwire.go @@ -44,6 +44,10 @@ const ( // v3OnionAddrLen is the length of a version 3 Tor onion service address // (35 bytes decoded onion + 2 bytes port). v3OnionAddrLen = 37 + + // dnsAddrOverhead is the fixed overhead for a DNS address: 1 byte for + // the hostname length and 2 bytes for the port. + dnsAddrOverhead = 3 ) // PkScript is simple type definition which represents a raw serialized public @@ -70,6 +74,9 @@ const ( // v3OnionAddr denotes a version 3 Tor (prop224) onion service address. v3OnionAddr addressType = 4 + + // dnsAddr denotes a DNS address. + dnsAddr addressType = 5 ) // WriteElement is a one-stop shop to write the big endian representation of @@ -347,6 +354,11 @@ func WriteElement(w *bytes.Buffer, element interface{}) error { return err } + case *DNSAddress: + if err := WriteDNSAddress(w, e); err != nil { + return err + } + case *OpaqueAddrs: if err := WriteOpaqueAddrs(w, e); err != nil { return err @@ -820,6 +832,34 @@ func ReadElement(r io.Reader, element interface{}) error { } addrBytesRead += v3OnionAddrLen + case dnsAddr: + var hostnameLen [1]byte + _, err := io.ReadFull(addrBuf, hostnameLen[:]) + if err != nil { + return err + } + + hostname := make([]byte, hostnameLen[0]) + _, err = io.ReadFull(addrBuf, hostname) + if err != nil { + return err + } + + var port [2]byte + _, err = io.ReadFull(addrBuf, port[:]) + if err != nil { + return err + } + + address = &DNSAddress{ + Hostname: string(hostname), + Port: binary.BigEndian.Uint16( + port[:], + ), + } + addrBytesRead += dnsAddrOverhead + + uint16(len(hostname)) + default: // If we don't understand this address type, // we just store it along with the remaining diff --git a/lnwire/message_test.go b/lnwire/message_test.go index d42d5791152..4f95cb0efa1 100644 --- a/lnwire/message_test.go +++ b/lnwire/message_test.go @@ -1000,13 +1000,32 @@ func randV3OnionAddr(t testing.TB, r *rand.Rand) *tor.OnionAddr { return &tor.OnionAddr{OnionService: onionService, Port: addrPort} } +// randDNSAddr generates a random DNS address for testing purposes. +func randDNSAddr(t testing.TB, r *rand.Rand) *lnwire.DNSAddress { + t.Helper() + + var domain [1]byte + _, err := r.Read(domain[:]) + require.NoError(t, err) + + var port [2]byte + _, err = r.Read(port[:]) + require.NoError(t, err, "unable to read port") + + return &lnwire.DNSAddress{ + Hostname: string(domain[:]), + Port: uint16(port[0]), + } +} + func randAddrs(t testing.TB, r *rand.Rand) []net.Addr { tcp4Addr := randTCP4Addr(t, r) tcp6Addr := randTCP6Addr(t, r) v2OnionAddr := randV2OnionAddr(t, r) v3OnionAddr := randV3OnionAddr(t, r) + dnsAddr := randDNSAddr(t, r) - return []net.Addr{tcp4Addr, tcp6Addr, v2OnionAddr, v3OnionAddr} + return []net.Addr{tcp4Addr, tcp6Addr, v2OnionAddr, v3OnionAddr, dnsAddr} } func randAlias(r *rand.Rand) lnwire.NodeAlias { diff --git a/lnwire/writer.go b/lnwire/writer.go index fa6247de0b0..ddf67e8a289 100644 --- a/lnwire/writer.go +++ b/lnwire/writer.go @@ -35,6 +35,9 @@ var ( // ErrNilOpaqueAddrs is returned when the supplied address is nil. ErrNilOpaqueAddrs = errors.New("cannot write nil OpaqueAddrs") + // ErrNilDNSAddress is returned when the supplied address is nil. + ErrNilDNSAddress = errors.New("cannot write nil DNS address") + // ErrNilPublicKey is returned when a nil pubkey is used. ErrNilPublicKey = errors.New("cannot write nil pubkey") @@ -364,6 +367,28 @@ func WriteOnionAddr(buf *bytes.Buffer, addr *tor.OnionAddr) error { return WriteUint16(buf, uint16(addr.Port)) } +// WriteDNSAddress appends the DNS address to the provided buffer. +func WriteDNSAddress(buf *bytes.Buffer, addr *DNSAddress) error { + if addr == nil { + return ErrNilDNSAddress + } + + // Write the descriptor, the hostname length, and the hostname. + if _, err := buf.Write([]byte{byte(dnsAddr)}); err != nil { + return err + } + + if err := WriteUint8(buf, uint8(len(addr.Hostname))); err != nil { + return err + } + + if _, err := buf.WriteString(addr.Hostname); err != nil { + return err + } + + return WriteUint16(buf, addr.Port) +} + // WriteOpaqueAddrs appends the payload of the given OpaqueAddrs to buffer. func WriteOpaqueAddrs(buf *bytes.Buffer, addr *OpaqueAddrs) error { if addr == nil { @@ -397,6 +422,10 @@ func WriteNetAddrs(buf *bytes.Buffer, addresses []net.Addr) error { if err := WriteOpaqueAddrs(addrBuf, a); err != nil { return err } + case *DNSAddress: + if err := WriteDNSAddress(addrBuf, a); err != nil { + return err + } default: return ErrNilNetAddress } diff --git a/lnwire/writer_test.go b/lnwire/writer_test.go index 3e2550443e5..bb2bada06ad 100644 --- a/lnwire/writer_test.go +++ b/lnwire/writer_test.go @@ -559,7 +559,8 @@ func TestWriteOnionAddr(t *testing.T) { } func TestWriteNetAddrs(t *testing.T) { - buf := new(bytes.Buffer) + t.Parallel() + tcpAddr := &net.TCPAddr{ IP: net.IP{127, 0, 0, 1}, Port: 8080, @@ -568,6 +569,10 @@ func TestWriteNetAddrs(t *testing.T) { OnionService: "abcdefghijklmnop.onion", Port: 9065, } + dnsAddr := &DNSAddress{ + Hostname: "example.com", + Port: 8080, + } testCases := []struct { name string @@ -587,24 +592,27 @@ func TestWriteNetAddrs(t *testing.T) { { // Check empty address slice. name: "empty address slice", - addr: []net.Addr{}, expectedErr: nil, // Use two bytes to encode the address size. expectedBytes: []byte{0, 0}, }, { // Check a successful writes of a slice of addresses. - name: "two addresses", - addr: []net.Addr{tcpAddr, onionAddr}, + name: "multiple addresses", + addr: []net.Addr{tcpAddr, onionAddr, dnsAddr}, expectedErr: nil, expectedBytes: []byte{ - // 7 bytes for TCP and 13 bytes for onion. - 0x0, 0x14, + // 7 bytes for TCP and 13 bytes for onion, + // 15 bytes for DNS. + 0x0, 0x23, // TCP address. 0x1, 0x7f, 0x0, 0x0, 0x1, 0x1f, 0x90, // Onion address. 0x3, 0x0, 0x44, 0x32, 0x14, 0xc7, 0x42, 0x54, 0xb6, 0x35, 0xcf, 0x23, 0x69, + // DNS address. + 0x5, 0xb, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, + 0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x1f, 0x90, }, }, } @@ -612,13 +620,25 @@ func TestWriteNetAddrs(t *testing.T) { for _, tc := range testCases { tc := tc t.Run(tc.name, func(t *testing.T) { - oldLen := buf.Len() + buf := new(bytes.Buffer) err := WriteNetAddrs(buf, tc.addr) require.Equal(t, tc.expectedErr, err) - bytesWritten := buf.Bytes()[oldLen:buf.Len()] + bytesWritten := buf.Bytes()[:buf.Len()] require.Equal(t, tc.expectedBytes, bytesWritten) + + if tc.expectedErr != nil { + return + } + + // Read the addresses from the buffer and ensure + // they match the original addresses. + var addrs []net.Addr + err = ReadElement(buf, &addrs) + require.NoError(t, err) + + require.Equal(t, tc.addr, addrs) }) } } From a7ae21d685954cf51af1a0f31514d29e9391d5d6 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 14 Aug 2025 13:15:40 +0000 Subject: [PATCH 04/14] netadd: split ValidatenodeAnn into sig and field checks Check that the node ann doesnt contain more than 1 DNS addr. This will ensure that we now start rejecting new node announcements with multiple DNS addrs since this check is called in the gossiper before persisting a node ann to our local graph. It also validates the DNS fields according to BOLT #7 specs. --- lnwire/dns_addr.go | 45 +++++++++++++++++++++++++++++++++++++ netann/node_announcement.go | 40 +++++++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/lnwire/dns_addr.go b/lnwire/dns_addr.go index 92cbc073af6..94f4bd800c5 100644 --- a/lnwire/dns_addr.go +++ b/lnwire/dns_addr.go @@ -1,10 +1,18 @@ package lnwire import ( + "errors" + "fmt" "net" "strconv" ) +// ErrEmptyDNSHostname is returned when a DNS hostname is empty. +var ErrEmptyDNSHostname = errors.New("hostname cannot be empty") + +// ErrZeroPort is returned when a DNS port is zero. +var ErrZeroPort = errors.New("port cannot be zero") + // DNSAddress is used to represent a DNS address of a node. type DNSAddress struct { // Hostname is the DNS hostname of the address. This MUST only contain @@ -29,3 +37,40 @@ func (d *DNSAddress) Network() string { func (d *DNSAddress) String() string { return net.JoinHostPort(d.Hostname, strconv.Itoa(int(d.Port))) } + +// ValidateDNSAddr validates that the DNS hostname is not empty and contains +// only ASCII characters and of max length 255 characters and port is non zero +// according to BOLT #7. +func ValidateDNSAddr(hostname string, port uint16) error { + if hostname == "" { + return ErrEmptyDNSHostname + } + + // Per BOLT 7, ports must not be zero for type 5 address (DNS address). + if port == 0 { + return ErrZeroPort + } + + if len(hostname) > 255 { + return fmt.Errorf("DNS hostname length %d, exceeds limit of "+ + "255 bytes", len(hostname)) + } + + // Check if hostname contains only ASCII characters. + for i, r := range hostname { + // Check for valid hostname characters, excluding ASCII control + // characters (0-31), spaces, underscores, delete character + // (127), and the special characters (like /, \, @, #, $, etc.). + if !((r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '-' || + r == '.') { + + return fmt.Errorf("hostname '%s' contains invalid "+ + "character '%c' at position %d", hostname, r, i) + } + } + + return nil +} diff --git a/netann/node_announcement.go b/netann/node_announcement.go index 71250217057..893e9fc4af7 100644 --- a/netann/node_announcement.go +++ b/netann/node_announcement.go @@ -2,6 +2,7 @@ package netann import ( "bytes" + "errors" "fmt" "image/color" "net" @@ -81,10 +82,45 @@ func SignNodeAnnouncement(signer lnwallet.MessageSigner, return err } -// ValidateNodeAnn validates the node announcement by ensuring that the +// ValidateNodeAnn validates the fields and signature of a node announcement. +func ValidateNodeAnn(a *lnwire.NodeAnnouncement) error { + err := ValidateNodeAnnFields(a) + if err != nil { + return fmt.Errorf("invalid node announcement fields: %w", err) + } + + return ValidateNodeAnnSignature(a) +} + +// ValidateNodeAnnFields validates the fields of a node announcement. +func ValidateNodeAnnFields(a *lnwire.NodeAnnouncement) error { + // Check that it only has at most one DNS address. + hasDNSAddr := false + for _, addr := range a.Addresses { + dnsAddr, ok := addr.(*lnwire.DNSAddress) + if !ok { + continue + } + if hasDNSAddr { + return errors.New("node announcement contains " + + "multiple DNS addresses. Only one is allowed") + } + + hasDNSAddr = true + + err := lnwire.ValidateDNSAddr(dnsAddr.Hostname, dnsAddr.Port) + if err != nil { + return err + } + } + + return nil +} + +// ValidateNodeAnnSignature validates the node announcement by ensuring that the // attached signature is needed a signature of the node announcement under the // specified node public key. -func ValidateNodeAnn(a *lnwire.NodeAnnouncement) error { +func ValidateNodeAnnSignature(a *lnwire.NodeAnnouncement) error { // Reconstruct the data of announcement which should be covered by the // signature so we can verify the signature shortly below data, err := a.DataToSign() From 40a7c3eaa8a1931af06015c8bf42c571bdd597b9 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 6 Aug 2025 13:42:47 +0200 Subject: [PATCH 05/14] discovery+gossip: make sure we dont advertise node anns with bad DNS We may have already persisted node announcements that have multiple DNS addresses since we may have received them before updating our code to check for this. So here we just make sure not to send these on to our peers. --- discovery/chan_series.go | 30 ++++++++++++++++++++++++++---- discovery/gossiper.go | 11 +++++++++-- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/discovery/chan_series.go b/discovery/chan_series.go index a6787edf969..77ae5862500 100644 --- a/discovery/chan_series.go +++ b/discovery/chan_series.go @@ -192,6 +192,13 @@ func (c *ChanSeries) UpdatesInHorizon(chain chainhash.Hash, return nil, err } + if err := netann.ValidateNodeAnnFields(nodeUpdate); err != nil { + log.Debugf("Skipping forwarding invalid node "+ + "announcement %x: %v", nodeAnn.PubKeyBytes, err) + + continue + } + updates = append(updates, nodeUpdate) } @@ -243,6 +250,7 @@ func (c *ChanSeries) FilterChannelRange(_ chainhash.Hash, startHeight, // to reply to a QueryShortChanIDs message sent by a remote peer. The response // will contain a unique set of ChannelAnnouncements, the latest ChannelUpdate // for each of the announcements, and a unique set of NodeAnnouncements. +// Invalid node announcements are skipped and logged for debugging purposes. // // NOTE: This is part of the ChannelGraphTimeSeries interface. func (c *ChanSeries) FetchChanAnns(chain chainhash.Hash, @@ -296,8 +304,15 @@ func (c *ChanSeries) FetchChanAnns(chain chainhash.Hash, return nil, err } - chanAnns = append(chanAnns, nodeAnn) - nodePubsSent[nodePub] = struct{}{} + err = netann.ValidateNodeAnnFields(nodeAnn) + if err != nil { + log.Debugf("Skipping forwarding "+ + "invalid node announcement "+ + "%x: %v", nodeAnn.NodeID, err) + } else { + chanAnns = append(chanAnns, nodeAnn) + nodePubsSent[nodePub] = struct{}{} + } } } if edge2 != nil { @@ -315,8 +330,15 @@ func (c *ChanSeries) FetchChanAnns(chain chainhash.Hash, return nil, err } - chanAnns = append(chanAnns, nodeAnn) - nodePubsSent[nodePub] = struct{}{} + err = netann.ValidateNodeAnnFields(nodeAnn) + if err != nil { + log.Debugf("Skipping forwarding "+ + "invalid node announcement "+ + "%x: %v", nodeAnn.NodeID, err) + } else { + chanAnns = append(chanAnns, nodeAnn) + nodePubsSent[nodePub] = struct{}{} + } } } } diff --git a/discovery/gossiper.go b/discovery/gossiper.go index 2473eda242b..669bfa59885 100644 --- a/discovery/gossiper.go +++ b/discovery/gossiper.go @@ -2220,7 +2220,9 @@ func (d *AuthenticatedGossiper) processZombieUpdate(_ context.Context, } // fetchNodeAnn fetches the latest signed node announcement from our point of -// view for the node with the given public key. +// view for the node with the given public key. It also validates the node +// announcement fields and returns an error if they are invalid to prevent +// forwarding invalid node announcements to our peers. func (d *AuthenticatedGossiper) fetchNodeAnn(ctx context.Context, pubKey [33]byte) (*lnwire.NodeAnnouncement, error) { @@ -2229,7 +2231,12 @@ func (d *AuthenticatedGossiper) fetchNodeAnn(ctx context.Context, return nil, err } - return node.NodeAnnouncement(true) + nodeAnn, err := node.NodeAnnouncement(true) + if err != nil { + return nil, err + } + + return nodeAnn, netann.ValidateNodeAnnFields(nodeAnn) } // isMsgStale determines whether a message retrieved from the backing From ea84c3112e539a0620db7a04ea154d026eaac5b9 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 6 Aug 2025 13:53:44 +0200 Subject: [PATCH 06/14] graph/db: update test opaque addr to be valid The first byte of an opaque addr must be one that we dont understand yet. We do this update in preparation for doing an on-the-fly parse of persisted opaque addrs to see if they contain addrs that we now support. For this to work, the first byte cant be 0x01 since this maps to a known address. --- graph/db/graph_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/graph/db/graph_test.go b/graph/db/graph_test.go index b9d972b3b08..2662d60c129 100644 --- a/graph/db/graph_test.go +++ b/graph/db/graph_test.go @@ -39,7 +39,10 @@ var ( "[2001:db8:85a3:0:0:8a2e:370:7334]:80") testAddrs = []net.Addr{testAddr, anotherAddr} testOpaqueAddr = &lnwire.OpaqueAddrs{ - Payload: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, + // NOTE: the first byte is a protocol level address type. So + // for we set it to 0xff to guarantee that we do not know this + // type yet. + Payload: []byte{0xff, 0x02, 0x03, 0x04, 0x05, 0x06}, } testRBytes, _ = hex.DecodeString("8ce2bc69281ce27da07e6683571319d18" + From b4a8b2447f9147ee6cc79290abc06c26f5b70750 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 7 Aug 2025 12:25:44 +0200 Subject: [PATCH 07/14] graph/db+chanbackup: dns add encoding/decoding for persistence --- chanbackup/backup_test.go | 1 + chanbackup/multi_test.go | 2 +- chanbackup/single_test.go | 10 +++++--- graph/db/addr.go | 54 +++++++++++++++++++++++++++++++++++++++ graph/db/addr_test.go | 19 ++++++++++++++ graph/db/graph_test.go | 10 +++----- graph/db/sql_store.go | 22 ++++++++++++++++ 7 files changed, 107 insertions(+), 11 deletions(-) diff --git a/chanbackup/backup_test.go b/chanbackup/backup_test.go index d73b3769f08..f2417a78343 100644 --- a/chanbackup/backup_test.go +++ b/chanbackup/backup_test.go @@ -161,6 +161,7 @@ func TestFetchStaticChanBackups(t *testing.T) { chanSource.addAddrsForNode(randomChan2.IdentityPub, []net.Addr{addr2}) chanSource.addAddrsForNode(randomChan2.IdentityPub, []net.Addr{addr3}) chanSource.addAddrsForNode(randomChan2.IdentityPub, []net.Addr{addr4}) + chanSource.addAddrsForNode(randomChan2.IdentityPub, []net.Addr{addr5}) // With the channel source populated, we'll now attempt to create a set // of backups for all the channels. This should succeed, as all items diff --git a/chanbackup/multi_test.go b/chanbackup/multi_test.go index 3350c773cf4..d84ca2f111b 100644 --- a/chanbackup/multi_test.go +++ b/chanbackup/multi_test.go @@ -23,7 +23,7 @@ func TestMultiPackUnpack(t *testing.T) { } single := NewSingle( - channel, []net.Addr{addr1, addr2, addr3, addr4}, + channel, []net.Addr{addr1, addr2, addr3, addr4, addr5}, ) originalSingles = append(originalSingles, single) diff --git a/chanbackup/single_test.go b/chanbackup/single_test.go index b3c7471a36b..f1f805c1435 100644 --- a/chanbackup/single_test.go +++ b/chanbackup/single_test.go @@ -41,7 +41,11 @@ var ( OnionService: "3g2upl4pq6kufc4m.onion", Port: 9735, } - addr4 = &lnwire.OpaqueAddrs{ + addr4 = &lnwire.DNSAddress{ + Hostname: "example.com", + Port: 8080, + } + addr5 = &lnwire.OpaqueAddrs{ // The first byte must be an address type we are not yet aware // of for it to be a valid OpaqueAddrs. Payload: []byte{math.MaxUint8, 1, 2, 3, 4}, @@ -320,7 +324,7 @@ func TestSinglePackUnpack(t *testing.T) { require.NoError(t, err, "unable to gen open channel") singleChanBackup := NewSingle( - channel, []net.Addr{addr1, addr2, addr3, addr4}, + channel, []net.Addr{addr1, addr2, addr3, addr4, addr5}, ) keyRing := &lnencrypt.MockKeyRing{} @@ -647,7 +651,7 @@ func TestSingleUnconfirmedChannel(t *testing.T) { channel.FundingBroadcastHeight = fundingBroadcastHeight singleChanBackup := NewSingle( - channel, []net.Addr{addr1, addr2, addr3, addr4}, + channel, []net.Addr{addr1, addr2, addr3, addr4, addr5}, ) keyRing := &lnencrypt.MockKeyRing{} diff --git a/graph/db/addr.go b/graph/db/addr.go index c68039a2624..836d516b000 100644 --- a/graph/db/addr.go +++ b/graph/db/addr.go @@ -31,8 +31,36 @@ const ( // opaqueAddrs denotes an address (or a set of addresses) that LND was // not able to parse since LND is not yet aware of the address type. opaqueAddrs addressType = 4 + + // dnsAddr denotes a DNS address type. + dnsAddr addressType = 5 ) +// encodeDNSAddr encodes a DNS address. +func encodeDNSAddr(w io.Writer, addr *lnwire.DNSAddress) error { + if _, err := w.Write([]byte{byte(dnsAddr)}); err != nil { + return err + } + + // Write the length of the hostname. + hostLen := len(addr.Hostname) + if _, err := w.Write([]byte{byte(hostLen)}); err != nil { + return err + } + + if _, err := w.Write([]byte(addr.Hostname)); err != nil { + return err + } + + var port [2]byte + byteOrder.PutUint16(port[:], addr.Port) + if _, err := w.Write(port[:]); err != nil { + return err + } + + return nil +} + // encodeTCPAddr serializes a TCP address into its compact raw bytes // representation. func encodeTCPAddr(w io.Writer, addr *net.TCPAddr) error { @@ -230,6 +258,30 @@ func DeserializeAddr(r io.Reader) (net.Addr, error) { Port: port, } + case dnsAddr: + // Read the length of the hostname. + var hostLen [1]byte + if _, err := r.Read(hostLen[:]); err != nil { + return nil, err + } + + // Read the hostname. + hostname := make([]byte, hostLen[0]) + if _, err := r.Read(hostname); err != nil { + return nil, err + } + + // Read the port. + var port [2]byte + if _, err := r.Read(port[:]); err != nil { + return nil, err + } + + address = &lnwire.DNSAddress{ + Hostname: string(hostname), + Port: binary.BigEndian.Uint16(port[:]), + } + case opaqueAddrs: // Read the length of the payload. var l [2]byte @@ -264,6 +316,8 @@ func SerializeAddr(w io.Writer, address net.Addr) error { return encodeOnionAddr(w, addr) case *lnwire.OpaqueAddrs: return encodeOpaqueAddrs(w, addr) + case *lnwire.DNSAddress: + return encodeDNSAddr(w, addr) default: return ErrUnknownAddressType } diff --git a/graph/db/addr_test.go b/graph/db/addr_test.go index b3dbea8cba1..d3c3700a33d 100644 --- a/graph/db/addr_test.go +++ b/graph/db/addr_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/tor" "github.com/stretchr/testify/require" ) @@ -38,6 +39,18 @@ var ( OnionService: "vww6ybal4bd7szmgncyruucpgfkqahzddi37ktceo3ah7ngmcopnpyyd.onion", //nolint:ll Port: 80, } + + testOpaqueAddr = &lnwire.OpaqueAddrs{ + // NOTE: the first byte is a protocol level address type. So + // for we set it to 0xff to guarantee that we do not know this + // type yet. + Payload: []byte{0xff, 0x02, 0x03, 0x04, 0x05, 0x06}, + } + + testDNSAddr = &lnwire.DNSAddress{ + Hostname: "example.com", + Port: 8080, + } ) var addrTests = []struct { @@ -57,6 +70,12 @@ var addrTests = []struct { { expAddr: testOnionV3Addr, }, + { + expAddr: testOpaqueAddr, + }, + { + expAddr: testDNSAddr, + }, // Invalid addresses. { diff --git a/graph/db/graph_test.go b/graph/db/graph_test.go index 2662d60c129..94af5197ee4 100644 --- a/graph/db/graph_test.go +++ b/graph/db/graph_test.go @@ -37,13 +37,7 @@ var ( Port: 9000} anotherAddr, _ = net.ResolveTCPAddr("tcp", "[2001:db8:85a3:0:0:8a2e:370:7334]:80") - testAddrs = []net.Addr{testAddr, anotherAddr} - testOpaqueAddr = &lnwire.OpaqueAddrs{ - // NOTE: the first byte is a protocol level address type. So - // for we set it to 0xff to guarantee that we do not know this - // type yet. - Payload: []byte{0xff, 0x02, 0x03, 0x04, 0x05, 0x06}, - } + testAddrs = []net.Addr{testAddr, anotherAddr} testRBytes, _ = hex.DecodeString("8ce2bc69281ce27da07e6683571319d18" + "e949ddfa2965fb6caa1bf0314f882d7") @@ -211,6 +205,8 @@ func TestNodeInsertionAndDeletion(t *testing.T) { // Add one v2 and one v3 onion address. testOnionV2Addr, testOnionV3Addr, + // Add a DNS host address. + testDNSAddr, // Make sure to also test the opaque address type. testOpaqueAddr, } diff --git a/graph/db/sql_store.go b/graph/db/sql_store.go index 956b8406d2d..8463fc9b8fd 100644 --- a/graph/db/sql_store.go +++ b/graph/db/sql_store.go @@ -3520,6 +3520,7 @@ const ( addressTypeIPv6 dbAddressType = 2 addressTypeTorV2 dbAddressType = 3 addressTypeTorV3 dbAddressType = 4 + addressTypeDNS dbAddressType = 5 addressTypeOpaque dbAddressType = math.MaxInt8 ) @@ -3547,6 +3548,7 @@ func upsertNodeAddresses(ctx context.Context, db SQLQueries, nodeID int64, addressTypeIPv6: {}, addressTypeTorV2: {}, addressTypeTorV3: {}, + addressTypeDNS: {}, addressTypeOpaque: {}, } addAddr := func(t dbAddressType, addr net.Addr) { @@ -3576,6 +3578,9 @@ func upsertNodeAddresses(ctx context.Context, db SQLQueries, nodeID int64, "address") } + case *lnwire.DNSAddress: + addAddr(addressTypeDNS, addr) + case *lnwire.OpaqueAddrs: addAddr(addressTypeOpaque, addr) @@ -4650,6 +4655,23 @@ func parseAddress(addrType dbAddressType, address string) (net.Addr, error) { Port: port, }, nil + case addressTypeDNS: + hostname, portStr, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("unable to split DNS "+ + "address: %v", address) + } + + port, err := strconv.Atoi(portStr) + if err != nil { + return nil, err + } + + return &lnwire.DNSAddress{ + Hostname: hostname, + Port: uint16(port), + }, nil + case addressTypeOpaque: opaque, err := hex.DecodeString(address) if err != nil { From efbe61e94166816e9dcc3f196037bdeb428764c1 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 14 Aug 2025 13:28:03 +0000 Subject: [PATCH 08/14] lnwire: extract addr descriptor reading logic cause we will need to be able to deserialise any persisted OpaqueAddrs which will have a Payload that we should be able to decode with this lnwire method to extract any new addresses that we now support. Co-authored-by: Elle Mouton --- lnwire/lnwire.go | 348 ++++++++++++++++++++++++----------------------- 1 file changed, 180 insertions(+), 168 deletions(-) diff --git a/lnwire/lnwire.go b/lnwire/lnwire.go index 86f7caa6173..e77bafaf35b 100644 --- a/lnwire/lnwire.go +++ b/lnwire/lnwire.go @@ -719,174 +719,9 @@ func ReadElement(r io.Reader, element interface{}) error { } case *[]net.Addr: - // First, we'll read the number of total bytes that have been - // used to encode the set of addresses. - var numAddrsBytes [2]byte - if _, err = io.ReadFull(r, numAddrsBytes[:]); err != nil { - return err - } - addrsLen := binary.BigEndian.Uint16(numAddrsBytes[:]) - - // With the number of addresses, read, we'll now pull in the - // buffer of the encoded addresses into memory. - addrs := make([]byte, addrsLen) - if _, err := io.ReadFull(r, addrs[:]); err != nil { - return err - } - addrBuf := bytes.NewReader(addrs) - - // Finally, we'll parse the remaining address payload in - // series, using the first byte to denote how to decode the - // address itself. - var ( - addresses []net.Addr - addrBytesRead uint16 - ) - - for addrBytesRead < addrsLen { - var descriptor [1]byte - if _, err = io.ReadFull(addrBuf, descriptor[:]); err != nil { - return err - } - - addrBytesRead++ - - var address net.Addr - switch aType := addressType(descriptor[0]); aType { - case noAddr: - continue - - case tcp4Addr: - var ip [4]byte - if _, err := io.ReadFull(addrBuf, ip[:]); err != nil { - return err - } - - var port [2]byte - if _, err := io.ReadFull(addrBuf, port[:]); err != nil { - return err - } - - address = &net.TCPAddr{ - IP: net.IP(ip[:]), - Port: int(binary.BigEndian.Uint16(port[:])), - } - addrBytesRead += tcp4AddrLen - - case tcp6Addr: - var ip [16]byte - if _, err := io.ReadFull(addrBuf, ip[:]); err != nil { - return err - } - - var port [2]byte - if _, err := io.ReadFull(addrBuf, port[:]); err != nil { - return err - } - - address = &net.TCPAddr{ - IP: net.IP(ip[:]), - Port: int(binary.BigEndian.Uint16(port[:])), - } - addrBytesRead += tcp6AddrLen - - case v2OnionAddr: - var h [tor.V2DecodedLen]byte - if _, err := io.ReadFull(addrBuf, h[:]); err != nil { - return err - } - - var p [2]byte - if _, err := io.ReadFull(addrBuf, p[:]); err != nil { - return err - } - - onionService := tor.Base32Encoding.EncodeToString(h[:]) - onionService += tor.OnionSuffix - port := int(binary.BigEndian.Uint16(p[:])) - - address = &tor.OnionAddr{ - OnionService: onionService, - Port: port, - } - addrBytesRead += v2OnionAddrLen - - case v3OnionAddr: - var h [tor.V3DecodedLen]byte - if _, err := io.ReadFull(addrBuf, h[:]); err != nil { - return err - } - - var p [2]byte - if _, err := io.ReadFull(addrBuf, p[:]); err != nil { - return err - } - - onionService := tor.Base32Encoding.EncodeToString(h[:]) - onionService += tor.OnionSuffix - port := int(binary.BigEndian.Uint16(p[:])) - - address = &tor.OnionAddr{ - OnionService: onionService, - Port: port, - } - addrBytesRead += v3OnionAddrLen - - case dnsAddr: - var hostnameLen [1]byte - _, err := io.ReadFull(addrBuf, hostnameLen[:]) - if err != nil { - return err - } - - hostname := make([]byte, hostnameLen[0]) - _, err = io.ReadFull(addrBuf, hostname) - if err != nil { - return err - } - - var port [2]byte - _, err = io.ReadFull(addrBuf, port[:]) - if err != nil { - return err - } - - address = &DNSAddress{ - Hostname: string(hostname), - Port: binary.BigEndian.Uint16( - port[:], - ), - } - addrBytesRead += dnsAddrOverhead + - uint16(len(hostname)) - - default: - // If we don't understand this address type, - // we just store it along with the remaining - // address bytes as type OpaqueAddrs. We need - // to hold onto the bytes so that we can still - // write them back to the wire when we - // propagate this message. - payloadLen := 1 + addrsLen - addrBytesRead - payload := make([]byte, payloadLen) - - // First write a byte for the address type that - // we already read. - payload[0] = byte(aType) - - // Now append the rest of the address bytes. - _, err := io.ReadFull(addrBuf, payload[1:]) - if err != nil { - return err - } - - address = &OpaqueAddrs{ - Payload: payload, - } - addrBytesRead = addrsLen - } - - addresses = append(addresses, address) + addresses, err := ReadAddresses(r) + if err != nil { + return fmt.Errorf("unable to read addresses: %w", err) } *e = addresses @@ -949,3 +784,180 @@ func ReadElements(r io.Reader, elements ...interface{}) error { } return nil } + +// ReadAddresses reads encoded network addresses. +// +//nolint:funlen +func ReadAddresses(r io.Reader) ([]net.Addr, error) { + // First, we'll read the number of total bytes that have been + // used to encode the set of addresses. + var numAddrsBytes [2]byte + if _, err := io.ReadFull(r, numAddrsBytes[:]); err != nil { + return nil, err + } + addrsLen := binary.BigEndian.Uint16(numAddrsBytes[:]) + + // With the number of addresses, read, we'll now pull in the + // buffer of the encoded addresses into memory. + addrs := make([]byte, addrsLen) + if _, err := io.ReadFull(r, addrs); err != nil { + return nil, err + } + addrBuf := bytes.NewReader(addrs) + + // Finally, we'll parse the remaining address payload in + // series, using the first byte to denote how to decode the + // address itself. + var ( + addresses []net.Addr + addrBytesRead uint16 + ) + + for addrBytesRead < addrsLen { + var descriptor [1]byte + if _, err := io.ReadFull(addrBuf, descriptor[:]); err != nil { + return nil, err + } + + addrBytesRead++ + + var address net.Addr + switch aType := addressType(descriptor[0]); aType { + case noAddr: + continue + + case tcp4Addr: + var ip [4]byte + if _, err := io.ReadFull(addrBuf, ip[:]); err != nil { + return nil, err + } + + var port [2]byte + if _, err := io.ReadFull(addrBuf, port[:]); err != nil { + return nil, err + } + + address = &net.TCPAddr{ + IP: net.IP(ip[:]), + Port: int(binary.BigEndian.Uint16(port[:])), + } + addrBytesRead += tcp4AddrLen + + case tcp6Addr: + var ip [16]byte + if _, err := io.ReadFull(addrBuf, ip[:]); err != nil { + return nil, err + } + + var port [2]byte + if _, err := io.ReadFull(addrBuf, port[:]); err != nil { + return nil, err + } + + address = &net.TCPAddr{ + IP: net.IP(ip[:]), + Port: int(binary.BigEndian.Uint16(port[:])), + } + addrBytesRead += tcp6AddrLen + + case v2OnionAddr: + var h [tor.V2DecodedLen]byte + if _, err := io.ReadFull(addrBuf, h[:]); err != nil { + return nil, err + } + + var p [2]byte + if _, err := io.ReadFull(addrBuf, p[:]); err != nil { + return nil, err + } + + onionService := tor.Base32Encoding.EncodeToString(h[:]) + onionService += tor.OnionSuffix + port := int(binary.BigEndian.Uint16(p[:])) + + address = &tor.OnionAddr{ + OnionService: onionService, + Port: port, + } + addrBytesRead += v2OnionAddrLen + + case v3OnionAddr: + var h [tor.V3DecodedLen]byte + if _, err := io.ReadFull(addrBuf, h[:]); err != nil { + return nil, err + } + + var p [2]byte + if _, err := io.ReadFull(addrBuf, p[:]); err != nil { + return nil, err + } + + onionService := tor.Base32Encoding.EncodeToString(h[:]) + onionService += tor.OnionSuffix + port := int(binary.BigEndian.Uint16(p[:])) + + address = &tor.OnionAddr{ + OnionService: onionService, + Port: port, + } + addrBytesRead += v3OnionAddrLen + + case dnsAddr: + var hostnameLen [1]byte + _, err := io.ReadFull(addrBuf, hostnameLen[:]) + if err != nil { + return nil, err + } + + hostname := make([]byte, hostnameLen[0]) + _, err = io.ReadFull(addrBuf, hostname) + if err != nil { + return nil, err + } + + var port [2]byte + _, err = io.ReadFull(addrBuf, port[:]) + if err != nil { + return nil, err + } + + address = &DNSAddress{ + Hostname: string(hostname), + Port: binary.BigEndian.Uint16( + port[:], + ), + } + addrBytesRead += dnsAddrOverhead + + uint16(len(hostname)) + + default: + // If we don't understand this address type, + // we just store it along with the remaining + // address bytes as type OpaqueAddrs. We need + // to hold onto the bytes so that we can still + // write them back to the wire when we + // propagate this message. + payloadLen := 1 + addrsLen - addrBytesRead + payload := make([]byte, payloadLen) + + // First write a byte for the address type that + // we already read. + payload[0] = byte(aType) + + // Now append the rest of the address bytes. + _, err := io.ReadFull(addrBuf, payload[1:]) + if err != nil { + return nil, err + } + + address = &OpaqueAddrs{ + Payload: payload, + } + addrBytesRead = addrsLen + } + + addresses = append(addresses, address) + } + + return addresses, nil +} From 08a23e7fa9accc23fd15f8cb2949697b0c01d364 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 7 Aug 2025 12:30:26 +0200 Subject: [PATCH 09/14] graph/db: update TestAddrSerialization --- graph/db/addr_test.go | 64 +++++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 36 deletions(-) diff --git a/graph/db/addr_test.go b/graph/db/addr_test.go index d3c3700a33d..ed7bd848d5e 100644 --- a/graph/db/addr_test.go +++ b/graph/db/addr_test.go @@ -3,7 +3,6 @@ package graphdb import ( "bytes" "net" - "strings" "testing" "github.com/lightningnetwork/lnd/lnwire" @@ -54,36 +53,39 @@ var ( ) var addrTests = []struct { + inputAddr net.Addr + // If expAddr is not set, then the test will expect it to be equal + // to inputAddr. expAddr net.Addr serErr string }{ // Valid addresses. { - expAddr: testIPV4Addr, + inputAddr: testIPV4Addr, }, { - expAddr: testIPV6Addr, + inputAddr: testIPV6Addr, }, { - expAddr: testOnionV2Addr, + inputAddr: testOnionV2Addr, }, { - expAddr: testOnionV3Addr, + inputAddr: testOnionV3Addr, }, { - expAddr: testOpaqueAddr, + inputAddr: testOpaqueAddr, }, { - expAddr: testDNSAddr, + inputAddr: testDNSAddr, }, // Invalid addresses. { - expAddr: unknownAddrType{}, - serErr: ErrUnknownAddressType.Error(), + inputAddr: unknownAddrType{}, + serErr: ErrUnknownAddressType.Error(), }, { - expAddr: &net.TCPAddr{ + inputAddr: &net.TCPAddr{ // Remove last byte of IPv4 address. IP: testIP4[:len(testIP4)-1], Port: 12345, @@ -91,7 +93,7 @@ var addrTests = []struct { serErr: "unable to encode", }, { - expAddr: &net.TCPAddr{ + inputAddr: &net.TCPAddr{ // Add an extra byte of IPv4 address. IP: append(testIP4, 0xff), Port: 12345, @@ -99,7 +101,7 @@ var addrTests = []struct { serErr: "unable to encode", }, { - expAddr: &net.TCPAddr{ + inputAddr: &net.TCPAddr{ // Remove last byte of IPv6 address. IP: testIP6[:len(testIP6)-1], Port: 65535, @@ -107,7 +109,7 @@ var addrTests = []struct { serErr: "unable to encode", }, { - expAddr: &net.TCPAddr{ + inputAddr: &net.TCPAddr{ // Add an extra byte to the IPv6 address. IP: append(testIP6, 0xff), Port: 65535, @@ -115,7 +117,7 @@ var addrTests = []struct { serErr: "unable to encode", }, { - expAddr: &tor.OnionAddr{ + inputAddr: &tor.OnionAddr{ // Invalid suffix. OnionService: "vww6ybal4bd7szmgncyruucpgfkqahzddi37ktceo3ah7ngmcopnpyyd.inion", Port: 80, @@ -123,7 +125,7 @@ var addrTests = []struct { serErr: "invalid suffix", }, { - expAddr: &tor.OnionAddr{ + inputAddr: &tor.OnionAddr{ // Invalid length. OnionService: "vww6ybal4bd7szmgncyruucpgfkqahzddi37ktceo3ah7ngmcopnpyy.onion", Port: 80, @@ -131,7 +133,7 @@ var addrTests = []struct { serErr: "unknown onion service length", }, { - expAddr: &tor.OnionAddr{ + inputAddr: &tor.OnionAddr{ // Invalid encoding. OnionService: "vww6ybal4bd7szmgncyruucpgfkqahzddi37ktceo3ah7ngmcopnpyyA.onion", Port: 80, @@ -147,30 +149,20 @@ func TestAddrSerialization(t *testing.T) { var b bytes.Buffer for _, test := range addrTests { - err := SerializeAddr(&b, test.expAddr) - switch { - case err == nil && test.serErr != "": - t.Fatalf("expected serialization err for addr %v", - test.expAddr) - - case err != nil && test.serErr == "": - t.Fatalf("unexpected serialization err for addr %v: %v", - test.expAddr, err) - - case err != nil && !strings.Contains(err.Error(), test.serErr): - t.Fatalf("unexpected serialization err for addr %v, "+ - "want: %v, got %v", test.expAddr, test.serErr, - err) - - case err != nil: + err := SerializeAddr(&b, test.inputAddr) + if test.serErr != "" { + require.ErrorContains(t, err, test.serErr) continue } + require.NoError(t, err) addr, err := DeserializeAddr(&b) - if err != nil { - t.Fatalf("unable to deserialize address: %v", err) - } + require.NoError(t, err) - require.Equal(t, test.expAddr, addr) + if test.expAddr != nil { + require.Equal(t, test.expAddr, addr) + } else { + require.Equal(t, test.inputAddr, addr) + } } } From b57a30858210e30a8b68e92447dde391261b66e1 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Fri, 15 Aug 2025 02:59:22 +0000 Subject: [PATCH 10/14] server_test.go: test existing net addresses In this commit, we test the central `parseAddr` with existing net addresses before support parsing DNS address --- server_test.go | 299 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 server_test.go diff --git a/server_test.go b/server_test.go new file mode 100644 index 00000000000..9c5d0401960 --- /dev/null +++ b/server_test.go @@ -0,0 +1,299 @@ +package lnd + +import ( + "errors" + "net" + "testing" + "time" + + "github.com/lightningnetwork/lnd/tor" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestParseAddress verifies the behavior of parseAddr for a variety of address +// formats and input scenarios. +func TestParseAddress(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + address string + netCfg tor.Net + expected net.Addr + expectErr bool + errMsg string + }{ + { + name: "OnionAddressWithExplicitPortReturnsAddr", + address: "3g2upl4pq6kufc4m.onion:9735", + netCfg: new(MockTorNet), + expected: &tor.OnionAddr{ + OnionService: "3g2upl4pq6kufc4m.onion", + Port: 9735, + }, + expectErr: false, + }, + { + name: "OnionAddressWithoutPortReturnsAddrWithPort", + address: "3g2upl4pq6kufc4m.onion", + netCfg: new(MockTorNet), + expected: &tor.OnionAddr{ + OnionService: "3g2upl4pq6kufc4m.onion", + Port: defaultPeerPort, + }, + expectErr: false, + }, + { + name: "LoopbackAddressWithExplicitPortReturnsAddr", + address: "127.0.0.1:8080", + netCfg: func() tor.Net { + mockNet := new(MockTorNet) + mockExpectation := mockNet.On( + "ResolveTCPAddr", "tcp", + "127.0.0.1:8080", + ) + mockExpectation.Return( + &net.TCPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 8080, + }, + nil, + ) + + return mockNet + }(), + expected: &net.TCPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 8080, + }, + expectErr: false, + }, + { + name: "LoopbackAddressWithoutPortReturnsAddr", + address: "127.0.0.1", + netCfg: func() tor.Net { + mockNet := new(MockTorNet) + mockExpectation := mockNet.On( + "ResolveTCPAddr", "tcp", + "127.0.0.1:9735", + ) + mockExpectation.Return( + &net.TCPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: defaultPeerPort, + }, + nil, + ) + + return mockNet + }(), + expected: &net.TCPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: defaultPeerPort, + }, + expectErr: false, + }, + { + name: "IPv4AddressWithExplicitPortReturnsTCPAddr", + address: "10.1.1.1:9735", + netCfg: func() tor.Net { + mockNet := new(MockTorNet) + mockExpectation := mockNet.On( + "ResolveTCPAddr", "tcp", + "10.1.1.1:9735", + ) + mockExpectation.Return( + &net.TCPAddr{ + IP: net.ParseIP("10.1.1.1"), + Port: 9735, + }, + nil, + ) + + return mockNet + }(), + expected: &net.TCPAddr{ + IP: net.ParseIP("10.1.1.1"), + Port: 9735, + }, + expectErr: false, + }, + { + name: "IPv4AddressWithoutPortReturnsAddrWithPort", + address: "10.1.1.1", + netCfg: func() tor.Net { + mockNet := new(MockTorNet) + mockExpectation := mockNet.On( + "ResolveTCPAddr", "tcp", + "10.1.1.1:9735", + ) + mockExpectation.Return( + &net.TCPAddr{ + IP: net.ParseIP("10.1.1.1"), + Port: defaultPeerPort, + }, + nil, + ) + + return mockNet + }(), + expected: &net.TCPAddr{ + IP: net.ParseIP("10.1.1.1"), + Port: defaultPeerPort, + }, + expectErr: false, + }, + { + name: "IPv6AddressWithExplicitPortReturnsTCPAddr", + address: "[2001::]:9735", + netCfg: func() tor.Net { + mockNet := new(MockTorNet) + mockExpectation := mockNet.On( + "ResolveTCPAddr", "tcp", + "[2001::]:9735", + ) + mockExpectation.Return( + &net.TCPAddr{ + IP: net.ParseIP("2001::"), + Port: 9735, + }, + nil, + ) + + return mockNet + }(), + expected: &net.TCPAddr{ + IP: net.ParseIP("2001::"), + Port: 9735, + }, + expectErr: false, + }, + { + name: "IPv6AddressWithoutPortReturnsAddrWithPort", + address: "2001::", + netCfg: func() tor.Net { + mockNet := new(MockTorNet) + mockExpectation := mockNet.On( + "ResolveTCPAddr", "tcp", + "[2001::]:9735", + ) + mockExpectation.Return( + &net.TCPAddr{ + IP: net.ParseIP("2001::"), + Port: defaultPeerPort, + }, + nil, + ) + + return mockNet + }(), + expected: &net.TCPAddr{ + IP: net.ParseIP("2001::"), + Port: defaultPeerPort, + }, + expectErr: false, + }, + { + name: "IPAddressTCPResolutionFailureReturnsError", + address: "10.1.1.1:9735", + netCfg: func() tor.Net { + mockNet := new(MockTorNet) + mockExpectation := mockNet.On( + "ResolveTCPAddr", "tcp", + "10.1.1.1:9735", + ) + mockExpectation.Return( + &net.TCPAddr{}, + errors.New("resolve error"), + ) + + return mockNet + }(), + expectErr: true, + errMsg: "resolve error", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + addr, err := parseAddr(test.address, test.netCfg) + + if test.expectErr { + require.ErrorContains(t, err, test.errMsg) + return + } + + require.NoError(t, err) + require.Equal(t, test.expected, addr) + + // Verify that all expected mock method calls were made. + if mockNet, ok := test.netCfg.(*MockTorNet); ok { + mockNet.AssertExpectations(t) + } + }) + } +} + +// MockTorNet is a mock implementation of tor.Net. +type MockTorNet struct { + mock.Mock +} + +// Dial implements the tor.Net Dial method for testing. +func (m *MockTorNet) Dial(network, address string, + timeout time.Duration) (net.Conn, error) { + + args := m.Called(network, address, timeout) + arg0, ok := args.Get(0).(net.Conn) + if !ok { + return nil, errors.New("type assertion failed for arg0") + } + + return arg0, args.Error(1) +} + +// LookupHost implements the tor.Net LookupHost method for testing. +func (m *MockTorNet) LookupHost(host string) ([]string, error) { + args := m.Called(host) + arg0, ok := args.Get(0).([]string) + if !ok { + return nil, errors.New("type assertion failed for arg0") + } + + return arg0, args.Error(1) +} + +// LookupSRV implements the tor.Net LookupSRV method for testing. +func (m *MockTorNet) LookupSRV(service, proto, name string, + timeout time.Duration) (string, []*net.SRV, error) { + + args := m.Called(service, proto, name, timeout) + + arg0, ok := args.Get(0).(string) + if !ok { + return "", nil, errors.New("type assertion failed for arg0") + } + + arg1, ok := args.Get(1).([]*net.SRV) + if !ok { + return "", nil, errors.New("type assertion failed for arg1") + } + + return arg0, arg1, args.Error(2) +} + +// ResolveTCPAddr implements the tor.Net ResolveTCPAddr method for testing. +func (m *MockTorNet) ResolveTCPAddr(network, + address string) (*net.TCPAddr, error) { + + args := m.Called(network, address) + arg0, ok := args.Get(0).(*net.TCPAddr) + if !ok { + return nil, errors.New("type assertion failed for arg0") + } + + return arg0, args.Error(1) +} From 0b71bfd32829de9644997ce22dbf42d7dc27c229 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Fri, 15 Aug 2025 03:21:11 +0000 Subject: [PATCH 11/14] server+server_test: parse DNS address type --- server.go | 35 ++++++++++++++++++++++++------ server_test.go | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/server.go b/server.go index f0a618a1b5b..7346b478272 100644 --- a/server.go +++ b/server.go @@ -553,16 +553,39 @@ func parseAddr(address string, netCfg tor.Net) (net.Addr, error) { port = portNum } + // Handle the Onion address type. if tor.IsOnionHost(host) { return &tor.OnionAddr{OnionService: host, Port: port}, nil } - // If the host is part of a TCP address, we'll use the network - // specific ResolveTCPAddr function in order to resolve these - // addresses over Tor in order to prevent leaking your real IP - // address. - hostPort := net.JoinHostPort(host, strconv.Itoa(port)) - return netCfg.ResolveTCPAddr("tcp", hostPort) + // For loopback or IP addresses: Resolve them through Tor or other + // proxies, preventing IP leakage. + if lncfg.IsLoopback(host) || isIP(host) { + hostPort := net.JoinHostPort(host, strconv.Itoa(port)) + return netCfg.ResolveTCPAddr("tcp", hostPort) + } + + // Handle the DNS address type. + addr := &lnwire.DNSAddress{ + Hostname: host, + Port: uint16(port), + } + + // Check if that DNS address resolve to any TCP addresses. + if _, err = netCfg.ResolveTCPAddr( + addr.Network(), addr.String(), + ); err != nil { + return nil, err + } + + return addr, nil +} + +// isIP checks if the provided host is an IP address (IPv4 or IPv6). +func isIP(host string) bool { + // Try parsing the host as an IP address. + ip := net.ParseIP(host) + return ip != nil } // noiseDial is a factory function which creates a connmgr compliant dialing diff --git a/server_test.go b/server_test.go index 9c5d0401960..3f7a940440a 100644 --- a/server_test.go +++ b/server_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/tor" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -213,6 +214,63 @@ func TestParseAddress(t *testing.T) { expectErr: true, errMsg: "resolve error", }, + { + name: "DNSHostnameWithExplicitPortReturnsAddr", + address: "example.com:8080", + netCfg: func() tor.Net { + mockNet := new(MockTorNet) + mockExpectation := mockNet.On( + "ResolveTCPAddr", "tcp", + "example.com:8080", + ) + mockExpectation.Return(&net.TCPAddr{}, nil) + + return mockNet + }(), + expected: &lnwire.DNSAddress{ + Hostname: "example.com", + Port: 8080, + }, + expectErr: false, + }, + { + name: "DNSHostnameWithoutPortReturnsAddrWithPort", + address: "example.com", + netCfg: func() tor.Net { + mockNet := new(MockTorNet) + mockExpectation := mockNet.On( + "ResolveTCPAddr", "tcp", + "example.com:9735", + ) + mockExpectation.Return(&net.TCPAddr{}, nil) + + return mockNet + }(), + expected: &lnwire.DNSAddress{ + Hostname: "example.com", + Port: defaultPeerPort, + }, + expectErr: false, + }, + { + name: "DNSTCPResolutionFailureReturnsError", + address: "example.com:9735", + netCfg: func() tor.Net { + mockNet := new(MockTorNet) + mockExpectation := mockNet.On( + "ResolveTCPAddr", "tcp", + "example.com:9735", + ) + mockExpectation.Return( + &net.TCPAddr{}, + errors.New("resolve error"), + ) + + return mockNet + }(), + expectErr: true, + errMsg: "resolve error", + }, } for _, test := range tests { From 37caedaaaec60e12e5f41b10248d3653e6d0e59b Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Fri, 15 Aug 2025 03:26:36 +0000 Subject: [PATCH 12/14] config+server+itest: add `external-dns-address` --- config.go | 55 +++++++++++++++++++++------------ itest/lnd_channel_graph_test.go | 16 ++++++++++ server.go | 28 ++++++++++++++++- 3 files changed, 78 insertions(+), 21 deletions(-) diff --git a/config.go b/config.go index d5fbe30b89f..bd7332a4d90 100644 --- a/config.go +++ b/config.go @@ -336,26 +336,28 @@ type Config struct { // loadConfig function. We need to expose the 'raw' strings so the // command line library can access them. // Only the parsed net.Addrs should be used! - RawRPCListeners []string `long:"rpclisten" description:"Add an interface/port/socket to listen for RPC connections"` - RawRESTListeners []string `long:"restlisten" description:"Add an interface/port/socket to listen for REST connections"` - RawListeners []string `long:"listen" description:"Add an interface/port to listen for peer connections"` - RawExternalIPs []string `long:"externalip" description:"Add an ip:port to the list of local addresses we claim to listen on to peers. If a port is not specified, the default (9735) will be used regardless of other parameters"` - ExternalHosts []string `long:"externalhosts" description:"Add a hostname:port that should be periodically resolved to announce IPs for. If a port is not specified, the default (9735) will be used."` - RPCListeners []net.Addr - RESTListeners []net.Addr - RestCORS []string `long:"restcors" description:"Add an ip:port/hostname to allow cross origin access from. To allow all origins, set as \"*\"."` - Listeners []net.Addr - ExternalIPs []net.Addr - DisableListen bool `long:"nolisten" description:"Disable listening for incoming peer connections"` - DisableRest bool `long:"norest" description:"Disable REST API"` - DisableRestTLS bool `long:"no-rest-tls" description:"Disable TLS for REST connections"` - WSPingInterval time.Duration `long:"ws-ping-interval" description:"The ping interval for REST based WebSocket connections, set to 0 to disable sending ping messages from the server side"` - WSPongWait time.Duration `long:"ws-pong-wait" description:"The time we wait for a pong response message on REST based WebSocket connections before the connection is closed as inactive"` - NAT bool `long:"nat" description:"Toggle NAT traversal support (using either UPnP or NAT-PMP) to automatically advertise your external IP address to the network -- NOTE this does not support devices behind multiple NATs"` - AddPeers []string `long:"addpeer" description:"Specify peers to connect to first"` - MinBackoff time.Duration `long:"minbackoff" description:"Shortest backoff when reconnecting to persistent peers. Valid time units are {s, m, h}."` - MaxBackoff time.Duration `long:"maxbackoff" description:"Longest backoff when reconnecting to persistent peers. Valid time units are {s, m, h}."` - ConnectionTimeout time.Duration `long:"connectiontimeout" description:"The timeout value for network connections. Valid time units are {ms, s, m, h}."` + RawRPCListeners []string `long:"rpclisten" description:"Add an interface/port/socket to listen for RPC connections"` + RawRESTListeners []string `long:"restlisten" description:"Add an interface/port/socket to listen for REST connections"` + RawListeners []string `long:"listen" description:"Add an interface/port to listen for peer connections"` + RawExternalIPs []string `long:"externalip" description:"Add an ip:port to the list of local addresses we claim to listen on to peers. If a port is not specified, the default (9735) will be used regardless of other parameters"` + RawExternalDNSAddress string `long:"external-dns-address" description:"Specify a DNS hostname for the node's external address. If no port is provided, the default (9735) is used."` + ExternalHosts []string `long:"externalhosts" description:"Add a hostname:port that should be periodically resolved to announce IPs for. If a port is not specified, the default (9735) will be used."` + RPCListeners []net.Addr + RESTListeners []net.Addr + RestCORS []string `long:"restcors" description:"Add an ip:port/hostname to allow cross origin access from. To allow all origins, set as \"*\"."` + Listeners []net.Addr + ExternalIPs []net.Addr + ExternalDNSAddress *lnwire.DNSAddress + DisableListen bool `long:"nolisten" description:"Disable listening for incoming peer connections"` + DisableRest bool `long:"norest" description:"Disable REST API"` + DisableRestTLS bool `long:"no-rest-tls" description:"Disable TLS for REST connections"` + WSPingInterval time.Duration `long:"ws-ping-interval" description:"The ping interval for REST based WebSocket connections, set to 0 to disable sending ping messages from the server side"` + WSPongWait time.Duration `long:"ws-pong-wait" description:"The time we wait for a pong response message on REST based WebSocket connections before the connection is closed as inactive"` + NAT bool `long:"nat" description:"Toggle NAT traversal support (using either UPnP or NAT-PMP) to automatically advertise your external IP address to the network -- NOTE this does not support devices behind multiple NATs"` + AddPeers []string `long:"addpeer" description:"Specify peers to connect to first"` + MinBackoff time.Duration `long:"minbackoff" description:"Shortest backoff when reconnecting to persistent peers. Valid time units are {s, m, h}."` + MaxBackoff time.Duration `long:"maxbackoff" description:"Longest backoff when reconnecting to persistent peers. Valid time units are {s, m, h}."` + ConnectionTimeout time.Duration `long:"connectiontimeout" description:"The timeout value for network connections. Valid time units are {ms, s, m, h}."` DebugLevel string `short:"d" long:"debuglevel" description:"Logging level for all subsystems {trace, debug, info, warn, error, critical} -- You may also specify ,=,=,... to set the log level for individual subsystems -- Use show to list available subsystems"` @@ -1592,6 +1594,7 @@ func ValidateConfig(cfg Config, interceptor signal.Interceptor, fileParser, ltndLog.Infof("Listening on the p2p interface is disabled!") cfg.Listeners = nil cfg.ExternalIPs = nil + cfg.ExternalDNSAddress = nil } else { // Add default port to all listener addresses if needed and remove @@ -1615,6 +1618,18 @@ func ValidateConfig(cfg Config, interceptor signal.Interceptor, fileParser, return nil, err } + // Parse the external DNS address if provided. + if cfg.RawExternalDNSAddress != "" { + addr, err := parseDNSAddr( + cfg.RawExternalDNSAddress, cfg.net, + ) + if err != nil { + return nil, err + } + + cfg.ExternalDNSAddress = addr + } + // For the p2p port it makes no sense to listen to an Unix socket. // Also, we would need to refactor the brontide listener to support // that. diff --git a/itest/lnd_channel_graph_test.go b/itest/lnd_channel_graph_test.go index 397a81e0ed7..950b7c66c68 100644 --- a/itest/lnd_channel_graph_test.go +++ b/itest/lnd_channel_graph_test.go @@ -441,6 +441,7 @@ func testUpdateNodeAnnouncement(ht *lntest.HarnessTest) { for _, addr := range extraAddrs { lndArgs = append(lndArgs, "--externalip="+addr) } + lndArgs = append(lndArgs, "--external-dns-address="+"example.com") dave := ht.NewNode("Dave", lndArgs) // assertNodeAnn is a helper closure that checks a given node update @@ -471,6 +472,21 @@ func testUpdateNodeAnnouncement(ht *lntest.HarnessTest) { // Get dave default information so we can compare it lately with the // broadcast updates. resp := dave.RPC.GetInfo() + + // Assert that the URIs contain example.com as configured via + // --external-dns-address. + foundDNSAddress := false + for _, uri := range resp.GetUris() { + if strings.Contains(uri, "example.com") { + foundDNSAddress = true + break + } + } + require.True( + ht, foundDNSAddress, + "example.com should be present in node URIs", + ) + defaultAddrs := make([]*lnrpc.NodeAddress, 0, len(resp.Uris)) for _, uri := range resp.GetUris() { values := strings.Split(uri, "@") diff --git a/server.go b/server.go index 7346b478272..eee9735f1de 100644 --- a/server.go +++ b/server.go @@ -588,6 +588,23 @@ func isIP(host string) bool { return ip != nil } +// parseDNSAddr parses a raw DNS address and assert it of type DNSAddress. +func parseDNSAddr(rawAddr string, netCfg tor.Net) (*lnwire.DNSAddress, error) { + addr, err := parseAddr(rawAddr, netCfg) + if err != nil { + return nil, err + } + + // Check if the parsed address is a DNS address. + dnsAddr, ok := addr.(*lnwire.DNSAddress) + if !ok { + return nil, fmt.Errorf("expected DNS hostname address, got "+ + "%T", addr) + } + + return dnsAddr, nil +} + // noiseDial is a factory function which creates a connmgr compliant dialing // function by returning a closure which includes the server's identity key. func noiseDial(idKey keychain.SingleKeyECDH, @@ -959,9 +976,18 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, return nil, err } - selfAddrs := make([]net.Addr, 0, len(externalIPs)) + addrsLen := len(externalIPs) + if cfg.ExternalDNSAddress != nil { + addrsLen++ + } + + selfAddrs := make([]net.Addr, 0, addrsLen) selfAddrs = append(selfAddrs, externalIPs...) + if cfg.ExternalDNSAddress != nil { + selfAddrs = append(selfAddrs, cfg.ExternalDNSAddress) + } + // We'll now reconstruct a node announcement based on our current // configuration so we can send it out as a sort of heart beat within // the network. From f7115b82abfffc5c8db6ed627753936b102a68b4 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Fri, 15 Aug 2025 03:28:20 +0000 Subject: [PATCH 13/14] sample-lnd.conf: doc `external-dns-address` field --- sample-lnd.conf | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sample-lnd.conf b/sample-lnd.conf index 22ffc3916a2..045aafd5858 100644 --- a/sample-lnd.conf +++ b/sample-lnd.conf @@ -226,6 +226,13 @@ ; support devices behind multiple NATs. ; nat=false +; Specify a DNS address for the node's external address. If no port is provided, +; the default (9735) is used. +; Default: +; external-dns-address= +; Example: +; external-dns-address=my-node-domain.com + ; Disable REST API. ; norest=false From c32685723c373a1614030847752838a6fb453ea3 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Fri, 15 Aug 2025 03:29:19 +0000 Subject: [PATCH 14/14] docs: update release notes --- docs/release-notes/release-notes-0.20.0.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.0.md b/docs/release-notes/release-notes-0.20.0.md index 83dd1e20b21..4fe81424073 100644 --- a/docs/release-notes/release-notes-0.20.0.md +++ b/docs/release-notes/release-notes-0.20.0.md @@ -198,6 +198,14 @@ reader of a payment request. * [Require invoices to include a payment address or blinded paths](https://github.com/lightningnetwork/lnd/pull/9752) to comply with updated BOLT 11 specifications before sending payments. +* [Support DNS address in + node announcement msg](https://github.com/lightningnetwork/lnd/pull/10159): + This update allows users to announce their LND node using a DNS address in + the node announcement message, by configuring `external-dns-address` field in + the LND config file. + **Note:** According to BOLT 07, only one DNS address may be announced per + node. + ## Testing * Previously, automatic peer bootstrapping was disabled for simnet, signet and