From ba3688c3b9382db7244d94a41eb9d7555498a8f9 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 12 May 2020 17:05:18 -0700 Subject: [PATCH] netann: add new HostAnnouncer to support dynamic IPs via domains In this commit, we add a new sub-system, then `HostAnnouncer` which allows a users without a static IP address to ensure that lnd always announces the most up to date address based on a domain name. A new command line flag `--external-hosts` has been added which allows a user to specify one or most hosts that should be periodically resolved to update any advertised IPs the node has. Fixes #1624. --- config.go | 10 ++ netann/host_ann.go | 198 ++++++++++++++++++++++++++++ netann/host_ann_test.go | 276 ++++++++++++++++++++++++++++++++++++++++ server.go | 36 ++++++ 4 files changed, 520 insertions(+) create mode 100644 netann/host_ann.go create mode 100644 netann/host_ann_test.go diff --git a/config.go b/config.go index fff67e47..abd27733 100644 --- a/config.go +++ b/config.go @@ -80,6 +80,11 @@ const ( defaultAlias = "" defaultColor = "#3399FF" + + // defaultHostSampleInterval is the default amount of time that the + // HostAnnouncer will wait between DNS resolutions to check if the + // backing IP of a host has changed. + defaultHostSampleInterval = time.Minute * 5 ) var ( @@ -157,6 +162,7 @@ type Config struct { 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:"A set of hosts that should be periodically resolved to announce IPs for"` 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 \"*\"."` @@ -659,6 +665,10 @@ func ValidateConfig(cfg Config, usageMessage string) (*Config, error) { return nil, errors.New("NAT traversal cannot be used when " + "listening is disabled") } + if cfg.NAT && len(cfg.ExternalHosts) != 0 { + return nil, errors.New("NAT support and externalhosts are " + + "mutually exclusive, only one should be selected") + } // Determine the active chain configuration and its parameters. switch { diff --git a/netann/host_ann.go b/netann/host_ann.go new file mode 100644 index 00000000..48189cfe --- /dev/null +++ b/netann/host_ann.go @@ -0,0 +1,198 @@ +package netann + +import ( + "net" + "sync" + + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/ticker" +) + +// HostAnnouncerConfig is the main config for the HostAnnouncer. +type HostAnnouncerConfig struct { + // Hosts is the set of hosts we should watch for IP changes. + Hosts []string + + // RefreshTicker ticks each time we should check for any address + // changes. + RefreshTicker ticker.Ticker + + // LookupHost performs DNS resolution on a given host and returns its + // addresses. + LookupHost func(string) (net.Addr, error) + + // AdvertisedIPs is the set of IPs that we've already announced with + // our current NodeAnnouncement. This set will be constructed to avoid + // unnecessary node NodeAnnouncement updates. + AdvertisedIPs map[string]struct{} + + // AnnounceNewIPs announces a new set of IP addresses for the backing + // Lightning node. The first set of addresses is the new set of + // addresses that we should advertise, while the other set are the + // stale addresses that we should no longer advertise. + AnnounceNewIPs func([]net.Addr, map[string]struct{}) error +} + +// HostAnnouncer is a sub-system that allows a user to specify a set of hosts +// for lnd that will be continually resolved to notice any IP address changes. +// If the target IP address for a host changes, then we'll generate a new +// NodeAnnouncement that includes these new IPs. +type HostAnnouncer struct { + cfg HostAnnouncerConfig + + quit chan struct{} + wg sync.WaitGroup + + startOnce sync.Once + stopOnce sync.Once +} + +// NewHostAnnouncer returns a new instance of the HostAnnouncer. +func NewHostAnnouncer(cfg HostAnnouncerConfig) *HostAnnouncer { + return &HostAnnouncer{ + cfg: cfg, + quit: make(chan struct{}), + } +} + +// Start starts the HostAnnouncer. +func (h *HostAnnouncer) Start() error { + h.startOnce.Do(func() { + h.wg.Add(1) + go h.hostWatcher() + }) + + return nil +} + +// Stop signals the HostAnnouncer for a graceful stop. +func (h *HostAnnouncer) Stop() error { + h.stopOnce.Do(func() { + close(h.quit) + h.wg.Wait() + }) + + return nil +} + +// hostWatcher periodically attempts to resolve the IP for each host, updating +// them if they change within the interval. +func (h *HostAnnouncer) hostWatcher() { + defer h.wg.Done() + + ipMapping := make(map[string]net.Addr) + refreshHosts := func() { + + // We'll now run through each of our hosts to check if they had + // their backing IPs changed. If so, we'll want to re-announce + // them. + var addrsToUpdate []net.Addr + addrsToRemove := make(map[string]struct{}) + for _, host := range h.cfg.Hosts { + newAddr, err := h.cfg.LookupHost(host) + if err != nil { + log.Warnf("unable to resolve IP for "+ + "host %v: %v", host, err) + continue + } + + // If nothing has changed since the last time we + // checked, then we don't need to do any updates. + oldAddr, oldAddrFound := ipMapping[host] + if oldAddrFound && oldAddr.String() == newAddr.String() { + continue + } + + // Update the IP mapping now, as if this is the first + // time then we don't need to send a new announcement. + ipMapping[host] = newAddr + + // If this IP has already been announced, then we'll + // skip it to avoid triggering an unnecessary node + // announcement update. + _, ipAnnounced := h.cfg.AdvertisedIPs[newAddr.String()] + if ipAnnounced { + continue + } + + // If we've reached this point, then the old address + // was found, and the new address we just looked up + // differs from the old one. + log.Debugf("IP change detected! %v: %v -> %v", host, + oldAddr, newAddr) + + // If we had already advertised an addr for this host, + // then we'll need to remove that old stale address. + if oldAddr != nil { + addrsToRemove[oldAddr.String()] = struct{}{} + } + + addrsToUpdate = append(addrsToUpdate, newAddr) + } + + // If we don't have any addresses to update, then we can skip + // things around until the next round. + if len(addrsToUpdate) == 0 { + log.Debugf("No IP changes detected for hosts: %v", + h.cfg.Hosts) + return + } + + // Now that we know the set of IPs we need to update, we'll do + // them all in a single batch. + err := h.cfg.AnnounceNewIPs(addrsToUpdate, addrsToRemove) + if err != nil { + log.Warnf("unable to announce new IPs: %v", err) + } + } + + refreshHosts() + + h.cfg.RefreshTicker.Resume() + + for { + select { + case <-h.cfg.RefreshTicker.Ticks(): + log.Debugf("HostAnnouncer checking for any IP " + + "changes...") + + refreshHosts() + + case <-h.quit: + return + } + } +} + +// NodeAnnUpdater describes a function that's able to update our current node +// announcement on disk. It returns the updated node announcement given a set +// of updates to be applied to the current node announcement. +type NodeAnnUpdater func(refresh bool, modifier ...NodeAnnModifier, +) (lnwire.NodeAnnouncement, error) + +// IPAnnouncer is a factory function that generates a new function that uses +// the passed annUpdater function to to announce new IP changes for a given +// host. +func IPAnnouncer(annUpdater NodeAnnUpdater) func([]net.Addr, map[string]struct{}) error { + return func(newAddrs []net.Addr, oldAddrs map[string]struct{}) error { + _, err := annUpdater(true, func(currentNodeAnn *lnwire.NodeAnnouncement) { + // To ensure we don't duplicate any addresses, we'll + // filter out the same of addresses we should no longer + // advertise. + filteredAddrs := make( + []net.Addr, 0, len(currentNodeAnn.Addresses), + ) + for _, addr := range currentNodeAnn.Addresses { + if _, ok := oldAddrs[addr.String()]; ok { + continue + } + + filteredAddrs = append(filteredAddrs, addr) + } + + filteredAddrs = append(filteredAddrs, newAddrs...) + currentNodeAnn.Addresses = filteredAddrs + }) + return err + } +} diff --git a/netann/host_ann_test.go b/netann/host_ann_test.go new file mode 100644 index 00000000..4f6573f5 --- /dev/null +++ b/netann/host_ann_test.go @@ -0,0 +1,276 @@ +package netann + +import ( + "net" + "testing" + "time" + + "github.com/lightningnetwork/lnd/ticker" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHostAnnouncerUpdates tests that the HostAnnouncer will properly announce +// a new set of addresses each time a target host changes and will noop if not +// change happens during an interval. +func TestHostAnnouncerUpdates(t *testing.T) { + t.Parallel() + + hosts := []string{"test.com", "example.com"} + startingAddrs := []net.Addr{ + &net.TCPAddr{ + IP: net.ParseIP("1.1.1.1"), + }, + &net.TCPAddr{ + IP: net.ParseIP("8.8.8.8"), + }, + } + + ticker := ticker.NewForce(time.Hour * 24) + + testTimeout := time.Millisecond * 200 + + type annReq struct { + newAddrs []net.Addr + removedAddrs map[string]struct{} + } + + testCases := []struct { + preAdvertisedIPs map[string]struct{} + startingAddrs []net.Addr + + preTickHosts map[string]net.Addr + postTickHosts map[string]net.Addr + + updateTriggered bool + + newAddrs []net.Addr + removedAddrs map[string]struct{} + }{ + // The set of addresses are the same before and after a tick we + // expect no change. + { + preTickHosts: map[string]net.Addr{ + "test.com": &net.TCPAddr{ + IP: net.ParseIP("1.1.1.1"), + }, + "example.com": &net.TCPAddr{ + IP: net.ParseIP("8.8.8.8"), + }, + }, + startingAddrs: startingAddrs, + + postTickHosts: map[string]net.Addr{ + "test.com": &net.TCPAddr{ + IP: net.ParseIP("1.1.1.1"), + }, + "example.com": &net.TCPAddr{ + IP: net.ParseIP("8.8.8.8"), + }, + }, + + updateTriggered: false, + }, + + // Half of the addresses are changed out, the new one should be + // added with the old one forgotten. + { + preTickHosts: map[string]net.Addr{ + "test.com": &net.TCPAddr{ + IP: net.ParseIP("1.1.1.1"), + }, + "example.com": &net.TCPAddr{ + IP: net.ParseIP("8.8.8.8"), + }, + }, + startingAddrs: startingAddrs, + + postTickHosts: map[string]net.Addr{ + "test.com": &net.TCPAddr{ + IP: net.ParseIP("1.1.1.1"), + }, + "example.com": &net.TCPAddr{ + IP: net.ParseIP("9.9.9.9"), + }, + }, + + updateTriggered: true, + newAddrs: []net.Addr{ + &net.TCPAddr{ + IP: net.ParseIP("9.9.9.9"), + }, + }, + removedAddrs: map[string]struct{}{ + "8.8.8.8:0": {}, + }, + }, + + // All addresses change, they should all be refreshed. + { + preTickHosts: map[string]net.Addr{ + "test.com": &net.TCPAddr{ + IP: net.ParseIP("1.1.1.1"), + }, + "example.com": &net.TCPAddr{ + IP: net.ParseIP("8.8.8.8"), + }, + }, + startingAddrs: startingAddrs, + + postTickHosts: map[string]net.Addr{ + "test.com": &net.TCPAddr{ + IP: net.ParseIP("2.2.2.2"), + }, + "example.com": &net.TCPAddr{ + IP: net.ParseIP("9.9.9.9"), + }, + }, + + updateTriggered: true, + newAddrs: []net.Addr{ + &net.TCPAddr{ + IP: net.ParseIP("2.2.2.2"), + }, + &net.TCPAddr{ + IP: net.ParseIP("9.9.9.9"), + }, + }, + removedAddrs: map[string]struct{}{ + "8.8.8.8:0": {}, + "1.1.1.1:0": {}, + }, + }, + + // Two addresses, one has already been advertised on start up, + // so we only expect one of them to be announced again. After + // the tick we don't expect an update trigger since nothing. + // changed. + { + preAdvertisedIPs: map[string]struct{}{ + "1.1.1.1:0": {}, + }, + startingAddrs: []net.Addr{ + &net.TCPAddr{ + IP: net.ParseIP("8.8.8.8"), + }, + }, + preTickHosts: map[string]net.Addr{ + "test.com": &net.TCPAddr{ + IP: net.ParseIP("1.1.1.1"), + }, + "example.com": &net.TCPAddr{ + IP: net.ParseIP("8.8.8.8"), + }, + }, + postTickHosts: map[string]net.Addr{ + "test.com": &net.TCPAddr{ + IP: net.ParseIP("1.1.1.1"), + }, + "example.com": &net.TCPAddr{ + IP: net.ParseIP("8.8.8.8"), + }, + }, + + updateTriggered: false, + }, + } + for idx, testCase := range testCases { + hostResps := make(chan net.Addr) + annReqs := make(chan annReq) + hostAnncer := NewHostAnnouncer(HostAnnouncerConfig{ + Hosts: hosts, + AdvertisedIPs: testCase.preAdvertisedIPs, + RefreshTicker: ticker, + LookupHost: func(str string) (net.Addr, error) { + return <-hostResps, nil + }, + AnnounceNewIPs: func(newAddrs []net.Addr, + removeAddrs map[string]struct{}) error { + + annReqs <- annReq{ + newAddrs: newAddrs, + removedAddrs: removeAddrs, + } + + return nil + }, + }) + if err := hostAnncer.Start(); err != nil { + t.Fatalf("unable to start announcer: %v", err) + } + + // As soon as the announcer starts, it'll try to query for the + // state of the hosts. We'll return the preTick state for all + // hosts. + for i := 0; i < len(hosts); i++ { + hostResps <- testCase.preTickHosts[hosts[i]] + } + + // Since this is the first time the announcer is starting up, + // we expect it to advertise the hosts as they exist before any + // updates. + select { + case addrUpdate := <-annReqs: + assert.Equal( + t, testCase.startingAddrs, addrUpdate.newAddrs, + "addresses should match", + ) + assert.Empty( + t, addrUpdate.removedAddrs, + "removed addrs should match", + ) + + case <-time.After(testTimeout): + t.Fatalf("#%v: no addr update sent", idx) + } + + // We'll now force a tick which'll force another query. This + // time we'll respond with the set of the hosts as they should + // be post-tick. + ticker.Force <- time.Time{} + + for i := 0; i < len(hosts); i++ { + hostResps <- testCase.postTickHosts[hosts[i]] + } + + // If we expect an update, then we'll assert that we received + // the proper set of modified addresses. + if testCase.updateTriggered { + + select { + // The receive update should match exactly what the + // test case dictates. + case addrUpdate := <-annReqs: + require.Equal( + t, testCase.newAddrs, addrUpdate.newAddrs, + "addresses should match", + ) + + require.Equal( + t, testCase.removedAddrs, addrUpdate.removedAddrs, + "removed addrs should match", + ) + + case <-time.After(testTimeout): + t.Fatalf("#%v: no addr update set", idx) + } + + if err := hostAnncer.Stop(); err != nil { + t.Fatalf("unable to stop announcer: %v", err) + } + continue + } + + // Otherwise, no updates should be sent since nothing changed. + select { + case <-annReqs: + t.Fatalf("#%v: expected no call to AnnounceNewIPs", idx) + + case <-time.After(testTimeout): + } + + if err := hostAnncer.Stop(); err != nil { + t.Fatalf("unable to stop announcer: %v", err) + } + } +} diff --git a/server.go b/server.go index 3cd357a0..7b3c5815 100644 --- a/server.go +++ b/server.go @@ -271,6 +271,8 @@ type server struct { // provide insights into their health and performance. chanEventStore *chanfitness.ChannelEventStore + hostAnn *netann.HostAnnouncer + quit chan struct{} wg sync.WaitGroup @@ -1231,6 +1233,26 @@ func newServer(cfg *Config, listenAddrs []net.Addr, chanDB *channeldb.DB, } } + if len(cfg.ExternalHosts) != 0 { + advertisedIPs := make(map[string]struct{}) + for _, addr := range s.currentNodeAnn.Addresses { + advertisedIPs[addr.String()] = struct{}{} + } + + s.hostAnn = netann.NewHostAnnouncer(netann.HostAnnouncerConfig{ + Hosts: cfg.ExternalHosts, + RefreshTicker: ticker.New(defaultHostSampleInterval), + LookupHost: func(host string) (net.Addr, error) { + return lncfg.ParseAddressString( + host, strconv.Itoa(defaultPeerPort), + cfg.net.ResolveTCPAddr, + ) + }, + AdvertisedIPs: advertisedIPs, + AnnounceNewIPs: netann.IPAnnouncer(s.genNodeAnnouncement), + }) + } + // Create the connection manager which will be responsible for // maintaining persistent outbound connections and also accepting new // incoming connections @@ -1274,6 +1296,13 @@ func (s *server) Start() error { go s.watchExternalIP() } + if s.hostAnn != nil { + if err := s.hostAnn.Start(); err != nil { + startErr = err + return + } + } + // Start the notification server. This is used so channel // management goroutines can be notified when a funding // transaction reaches a sufficient number of confirmations, or @@ -1492,6 +1521,13 @@ func (s *server) Stop() error { s.towerClient.Stop() } + if s.hostAnn != nil { + if err := s.hostAnn.Stop(); err != nil { + srvrLog.Warnf("unable to shut down host "+ + "annoucner: %v", err) + } + } + // Wait for all lingering goroutines to quit. s.wg.Wait()