diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9e0501d677f..b08cb01068d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,16 +109,17 @@ jobs: - name: Build AvalancheGo Binary shell: bash run: ./scripts/build.sh - - name: Run e2e tests - shell: bash - run: ./scripts/tests.upgrade.sh - - name: Upload tmpnet network dir - uses: actions/upload-artifact@v3 - if: always() - with: - name: upgrade-tmpnet-data - path: ${{ env.tmpnet_data_path }} - if-no-files-found: error + # TODO: re-activate this test after there is a compatible tag to use + # - name: Run e2e tests + # shell: bash + # run: ./scripts/tests.upgrade.sh + # - name: Upload tmpnet network dir + # uses: actions/upload-artifact@v3 + # if: always() + # with: + # name: upgrade-tmpnet-data + # path: ${{ env.tmpnet_data_path }} + # if-no-files-found: error Lint: runs-on: ubuntu-latest steps: diff --git a/app/app.go b/app/app.go index c2da07b416a8..26043ff449da 100644 --- a/app/app.go +++ b/app/app.go @@ -14,30 +14,20 @@ import ( "golang.org/x/sync/errgroup" - "github.com/ava-labs/avalanchego/nat" "github.com/ava-labs/avalanchego/node" - "github.com/ava-labs/avalanchego/utils/constants" - "github.com/ava-labs/avalanchego/utils/ips" "github.com/ava-labs/avalanchego/utils/logging" "github.com/ava-labs/avalanchego/utils/perms" "github.com/ava-labs/avalanchego/utils/ulimit" ) -const ( - Header = ` _____ .__ .__ +const Header = ` _____ .__ .__ / _ \___ _______ | | _____ ____ ____ | |__ ____ ,_ o / /_\ \ \/ /\__ \ | | \__ \ / \_/ ___\| | \_/ __ \ / //\, / | \ / / __ \| |__/ __ \| | \ \___| Y \ ___/ \>> | \____|__ /\_/ (____ /____(____ /___| /\___ >___| /\___ > \\ \/ \/ \/ \/ \/ \/ \/` -) - -var ( - stakingPortName = fmt.Sprintf("%s-staking", constants.AppName) - httpPortName = fmt.Sprintf("%s-http", constants.AppName) - _ App = (*app)(nil) -) +var _ App = (*app)(nil) type App interface { // Start kicks off the application and returns immediately. @@ -88,7 +78,6 @@ func New(config node.Config) (App, error) { } return &app{ - config: config, node: n, log: log, logFactory: logFactory, @@ -133,7 +122,6 @@ func Run(app App) int { // app is a wrapper around a node that runs in this process type app struct { - config node.Config node *node.Node log logging.Logger logFactory logging.Factory @@ -144,88 +132,6 @@ type app struct { // Does not block until the node is done. Errors returned from this method // are not logged. func (a *app) Start() error { - // Track if sybil control is enforced - if !a.config.SybilProtectionEnabled { - a.log.Warn("sybil control is not enforced") - } - - // TODO move this to config - // SupportsNAT() for NoRouter is false. - // Which means we tried to perform a NAT activity but we were not successful. - if a.config.AttemptedNATTraversal && !a.config.Nat.SupportsNAT() { - a.log.Warn("UPnP and NAT-PMP router attach failed, " + - "you may not be listening publicly. " + - "Please confirm the settings in your router") - } - - if ip := a.config.IPPort.IPPort().IP; ip.IsLoopback() || ip.IsPrivate() { - a.log.Warn("P2P IP is private, you will not be publicly discoverable", - zap.Stringer("ip", ip), - ) - } - - // An empty host is treated as a wildcard to match all addresses, so it is - // considered public. - hostIsPublic := a.config.HTTPHost == "" - if !hostIsPublic { - ip, err := ips.Lookup(a.config.HTTPHost) - if err != nil { - a.log.Fatal("failed to lookup HTTP host", - zap.String("host", a.config.HTTPHost), - zap.Error(err), - ) - a.logFactory.Close() - return err - } - hostIsPublic = !ip.IsLoopback() && !ip.IsPrivate() - - a.log.Debug("finished HTTP host lookup", - zap.String("host", a.config.HTTPHost), - zap.Stringer("ip", ip), - zap.Bool("isPublic", hostIsPublic), - ) - } - - mapper := nat.NewPortMapper(a.log, a.config.Nat) - - // Open staking port we want for NAT traversal to have the external port - // (config.IP.Port) to connect to our internal listening port - // (config.InternalStakingPort) which should be the same in most cases. - if port := a.config.IPPort.IPPort().Port; port != 0 { - mapper.Map( - port, - port, - stakingPortName, - a.config.IPPort, - a.config.IPResolutionFreq, - ) - } - - // Don't open the HTTP port if the HTTP server is private - if hostIsPublic { - a.log.Warn("HTTP server is binding to a potentially public host. "+ - "You may be vulnerable to a DoS attack if your HTTP port is publicly accessible", - zap.String("host", a.config.HTTPHost), - ) - - // For NAT traversal we want to route from the external port - // (config.ExternalHTTPPort) to our internal port (config.HTTPPort). - if a.config.HTTPPort != 0 { - mapper.Map( - a.config.HTTPPort, - a.config.HTTPPort, - httpPortName, - nil, - a.config.IPResolutionFreq, - ) - } - } - - // Regularly update our public IP. - // Note that if the node config said to not dynamically resolve and - // update our public IP, [p.config.IPUdater] is a no-op implementation. - go a.config.IPUpdater.Dispatch(a.log) - // [p.ExitCode] will block until [p.exitWG.Done] is called a.exitWG.Add(1) go func() { @@ -238,9 +144,6 @@ func (a *app) Start() error { a.exitWG.Done() }() defer func() { - mapper.UnmapAllPorts() - a.config.IPUpdater.Stop() - // If [p.node.Dispatch()] panics, then we should log the panic and // then re-raise the panic. This is why the above defer is broken // into two parts. diff --git a/config/config.go b/config/config.go index 297d46255077..e4ae0816c043 100644 --- a/config/config.go +++ b/config/config.go @@ -4,7 +4,6 @@ package config import ( - "context" "crypto/tls" "encoding/base64" "encoding/json" @@ -12,7 +11,6 @@ import ( "fmt" "io/fs" "math" - "net" "os" "path/filepath" "strings" @@ -25,7 +23,6 @@ import ( "github.com/ava-labs/avalanchego/genesis" "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/ipcs" - "github.com/ava-labs/avalanchego/nat" "github.com/ava-labs/avalanchego/network" "github.com/ava-labs/avalanchego/network/dialer" "github.com/ava-labs/avalanchego/network/throttling" @@ -40,7 +37,6 @@ import ( "github.com/ava-labs/avalanchego/utils/compression" "github.com/ava-labs/avalanchego/utils/constants" "github.com/ava-labs/avalanchego/utils/crypto/bls" - "github.com/ava-labs/avalanchego/utils/dynamicip" "github.com/ava-labs/avalanchego/utils/ips" "github.com/ava-labs/avalanchego/utils/logging" "github.com/ava-labs/avalanchego/utils/password" @@ -58,7 +54,6 @@ const ( chainConfigFileName = "config" chainUpgradeFileName = "upgrade" subnetConfigFileExt = ".json" - ipResolutionTimeout = 30 * time.Second ipcDeprecationMsg = "IPC API is deprecated" keystoreDeprecationMsg = "keystore API is deprecated" @@ -617,64 +612,19 @@ func getBootstrapConfig(v *viper.Viper, networkID uint32) (node.BootstrapConfig, } func getIPConfig(v *viper.Viper) (node.IPConfig, error) { - ipResolutionService := v.GetString(PublicIPResolutionServiceKey) - ipResolutionFreq := v.GetDuration(PublicIPResolutionFreqKey) - if ipResolutionFreq <= 0 { - return node.IPConfig{}, fmt.Errorf("%q must be > 0", PublicIPResolutionFreqKey) - } - - stakingPort := uint16(v.GetUint(StakingPortKey)) - publicIP := v.GetString(PublicIPKey) - if publicIP != "" && ipResolutionService != "" { - return node.IPConfig{}, fmt.Errorf("only one of --%s and --%s can be given", PublicIPKey, PublicIPResolutionServiceKey) - } - - // Define default configuration ipConfig := node.IPConfig{ - IPUpdater: dynamicip.NewNoUpdater(), - IPResolutionFreq: ipResolutionFreq, - Nat: nat.NewNoRouter(), - ListenHost: v.GetString(StakingHostKey), - } - - if publicIP != "" { - // User specified a specific public IP to use. - ip := net.ParseIP(publicIP) - if ip == nil { - return node.IPConfig{}, fmt.Errorf("invalid IP Address %s", publicIP) - } - ipConfig.IPPort = ips.NewDynamicIPPort(ip, stakingPort) - return ipConfig, nil + PublicIP: v.GetString(PublicIPKey), + PublicIPResolutionService: v.GetString(PublicIPResolutionServiceKey), + PublicIPResolutionFreq: v.GetDuration(PublicIPResolutionFreqKey), + ListenHost: v.GetString(StakingHostKey), + ListenPort: uint16(v.GetUint(StakingPortKey)), } - if ipResolutionService != "" { - // User specified to use dynamic IP resolution. - resolver, err := dynamicip.NewResolver(ipResolutionService) - if err != nil { - return node.IPConfig{}, fmt.Errorf("couldn't create IP resolver: %w", err) - } - - // Use that to resolve our public IP. - ctx, cancel := context.WithTimeout(context.Background(), ipResolutionTimeout) - defer cancel() - ip, err := resolver.Resolve(ctx) - if err != nil { - return node.IPConfig{}, fmt.Errorf("couldn't resolve public IP: %w", err) - } - ipConfig.IPPort = ips.NewDynamicIPPort(ip, stakingPort) - ipConfig.IPUpdater = dynamicip.NewUpdater(ipConfig.IPPort, resolver, ipResolutionFreq) - return ipConfig, nil + if ipConfig.PublicIPResolutionFreq <= 0 { + return node.IPConfig{}, fmt.Errorf("%q must be > 0", PublicIPResolutionFreqKey) } - - // User didn't specify a public IP to use, and they didn't specify a public IP resolution - // service to use. Try to resolve public IP with NAT traversal. - nat := nat.GetRouter() - ip, err := nat.ExternalIP() - if err != nil { - return node.IPConfig{}, fmt.Errorf("public IP / IP resolution service not given and failed to resolve IP with NAT: %w", err) + if ipConfig.PublicIP != "" && ipConfig.PublicIPResolutionService != "" { + return node.IPConfig{}, fmt.Errorf("only one of --%s and --%s can be given", PublicIPKey, PublicIPResolutionServiceKey) } - ipConfig.IPPort = ips.NewDynamicIPPort(ip, stakingPort) - ipConfig.Nat = nat - ipConfig.AttemptedNATTraversal = true return ipConfig, nil } diff --git a/config/flags.go b/config/flags.go index d8b013edc4a6..e98f132da6a5 100644 --- a/config/flags.go +++ b/config/flags.go @@ -21,6 +21,7 @@ import ( "github.com/ava-labs/avalanchego/trace" "github.com/ava-labs/avalanchego/utils/compression" "github.com/ava-labs/avalanchego/utils/constants" + "github.com/ava-labs/avalanchego/utils/dynamicip" "github.com/ava-labs/avalanchego/utils/ulimit" "github.com/ava-labs/avalanchego/utils/units" ) @@ -133,9 +134,9 @@ func addNodeFlags(fs *pflag.FlagSet) { fs.Duration(NetworkPeerListGossipFreqKey, constants.DefaultNetworkPeerListGossipFreq, "Frequency to gossip peers to other nodes") // Public IP Resolution - fs.String(PublicIPKey, "", "Public IP of this node for P2P communication. If empty, try to discover with NAT") + fs.String(PublicIPKey, "", "Public IP of this node for P2P communication") fs.Duration(PublicIPResolutionFreqKey, 5*time.Minute, "Frequency at which this node resolves/updates its public IP and renew NAT mappings, if applicable") - fs.String(PublicIPResolutionServiceKey, "", fmt.Sprintf("Only acceptable values are 'ifconfigco', 'opendns' or 'ifconfigme'. When provided, the node will use that service to periodically resolve/update its public IP. Ignored if %s is set", PublicIPKey)) + fs.String(PublicIPResolutionServiceKey, "", fmt.Sprintf("Only acceptable values are %q, %q or %q. When provided, the node will use that service to periodically resolve/update its public IP", dynamicip.OpenDNSName, dynamicip.IFConfigCoName, dynamicip.IFConfigMeName)) // Inbound Connection Throttling fs.Duration(NetworkInboundConnUpgradeThrottlerCooldownKey, constants.DefaultInboundConnUpgradeThrottlerCooldown, "Upgrade an inbound connection from a given IP at most once per this duration. If 0, don't rate-limit inbound connection upgrades") diff --git a/nat/nat.go b/nat/nat.go index 20af4fe27ea9..a6e37078e7a6 100644 --- a/nat/nat.go +++ b/nat/nat.go @@ -53,8 +53,8 @@ type Mapper struct { } // NewPortMapper returns an initialized mapper -func NewPortMapper(log logging.Logger, r Router) Mapper { - return Mapper{ +func NewPortMapper(log logging.Logger, r Router) *Mapper { + return &Mapper{ log: log, r: r, closer: make(chan struct{}), diff --git a/network/dialer_test.go b/network/dialer_test.go index ecc506ba2927..7a60d056d66d 100644 --- a/network/dialer_test.go +++ b/network/dialer_test.go @@ -33,7 +33,7 @@ func (d *testDialer) NewListener() (ips.DynamicIPPort, *testListener) { // Uses a private IP to easily enable testing AllowPrivateIPs ip := ips.NewDynamicIPPort( net.IPv4(10, 0, 0, 0), - uint16(len(d.listeners)), + uint16(len(d.listeners)+1), ) staticIP := ip.IPPort() listener := newTestListener(staticIP) @@ -55,22 +55,22 @@ func (d *testDialer) Dial(ctx context.Context, ip ips.IPPort) (net.Conn, error) Conn: serverConn, localAddr: &net.TCPAddr{ IP: net.IPv6loopback, - Port: 0, + Port: 1, }, remoteAddr: &net.TCPAddr{ IP: net.IPv6loopback, - Port: 1, + Port: 2, }, } client := &testConn{ Conn: clientConn, localAddr: &net.TCPAddr{ IP: net.IPv6loopback, - Port: 2, + Port: 3, }, remoteAddr: &net.TCPAddr{ IP: net.IPv6loopback, - Port: 3, + Port: 4, }, } select { diff --git a/network/network.go b/network/network.go index 2dc6b2bbfdcb..2d178cb93488 100644 --- a/network/network.go +++ b/network/network.go @@ -1068,6 +1068,10 @@ func (n *network) peerIPStatus(nodeID ids.NodeID, ip *ips.ClaimedIPPort) (*ips.C // there is a randomized exponential backoff to avoid spamming connection // attempts. func (n *network) dial(nodeID ids.NodeID, ip *trackedIP) { + n.peerConfig.Log.Verbo("attempting to dial node", + zap.Stringer("nodeID", nodeID), + zap.Stringer("ip", ip.ip), + ) go func() { n.metrics.numTracked.Inc() defer n.metrics.numTracked.Dec() @@ -1141,7 +1145,7 @@ func (n *network) dial(nodeID ids.NodeID, ip *trackedIP) { n.peerConfig.Log.Verbo("skipping connection dial", zap.String("reason", "outbound connections to private IPs are prohibited"), zap.Stringer("nodeID", nodeID), - zap.Stringer("peerIP", ip.ip.IP), + zap.Stringer("peerIP", ip.ip), zap.Duration("delay", ip.delay), ) continue @@ -1151,7 +1155,8 @@ func (n *network) dial(nodeID ids.NodeID, ip *trackedIP) { if err != nil { n.peerConfig.Log.Verbo( "failed to reach peer, attempting again", - zap.Stringer("peerIP", ip.ip.IP), + zap.Stringer("nodeID", nodeID), + zap.Stringer("peerIP", ip.ip), zap.Duration("delay", ip.delay), ) continue @@ -1159,14 +1164,16 @@ func (n *network) dial(nodeID ids.NodeID, ip *trackedIP) { n.peerConfig.Log.Verbo("starting to upgrade connection", zap.String("direction", "outbound"), - zap.Stringer("peerIP", ip.ip.IP), + zap.Stringer("nodeID", nodeID), + zap.Stringer("peerIP", ip.ip), ) err = n.upgrade(conn, n.clientUpgrader) if err != nil { n.peerConfig.Log.Verbo( "failed to upgrade, attempting again", - zap.Stringer("peerIP", ip.ip.IP), + zap.Stringer("nodeID", nodeID), + zap.Stringer("peerIP", ip.ip), zap.Duration("delay", ip.delay), ) continue diff --git a/network/peer/peer.go b/network/peer/peer.go index 013b9d8ec3ea..16b5d3ab3090 100644 --- a/network/peer/peer.go +++ b/network/peer/peer.go @@ -500,6 +500,13 @@ func (p *peer) writeMessages() { ) return } + if mySignedIP.Port == 0 { + p.Log.Error("signed IP has invalid port", + zap.Stringer("nodeID", p.id), + zap.Uint16("port", mySignedIP.Port), + ) + return + } myVersion := p.VersionCompatibility.Version() legacyApplication := &version.Application{ @@ -996,6 +1003,16 @@ func (p *peer) handleHandshake(msg *p2p.Handshake) { p.StartClose() return } + if msg.IpPort == 0 { + p.Log.Debug("message with invalid field", + zap.Stringer("nodeID", p.id), + zap.Stringer("messageOp", message.HandshakeOp), + zap.String("field", "Port"), + zap.Uint32("port", msg.IpPort), + ) + p.StartClose() + return + } p.ip = &SignedIP{ UnsignedIP: UnsignedIP{ @@ -1086,6 +1103,16 @@ func (p *peer) handlePeerList(msg *p2p.PeerList) { p.StartClose() return } + if claimedIPPort.IpPort == 0 { + p.Log.Debug("message with invalid field", + zap.Stringer("nodeID", p.id), + zap.Stringer("messageOp", message.PeerListOp), + zap.String("field", "Port"), + zap.Uint32("port", claimedIPPort.IpPort), + ) + // TODO: After v1.11.x is activated, close the peer here. + continue + } txID, err := ids.ToID(claimedIPPort.TxId) if err != nil { diff --git a/network/peer/peer_test.go b/network/peer/peer_test.go index 77dd47e73f2c..797a9634a863 100644 --- a/network/peer/peer_test.go +++ b/network/peer/peer_test.go @@ -112,7 +112,7 @@ func makeRawTestPeers(t *testing.T, trackedSubnets set.Set[ids.ID]) (*rawTestPee peerConfig0 := sharedConfig peerConfig1 := sharedConfig - ip0 := ips.NewDynamicIPPort(net.IPv6loopback, 0) + ip0 := ips.NewDynamicIPPort(net.IPv6loopback, 1) tls0 := tlsCert0.PrivateKey.(crypto.Signer) peerConfig0.IPSigner = NewIPSigner(ip0, tls0) @@ -122,7 +122,7 @@ func makeRawTestPeers(t *testing.T, trackedSubnets set.Set[ids.ID]) (*rawTestPee inboundMsgChan0 <- msg }) - ip1 := ips.NewDynamicIPPort(net.IPv6loopback, 1) + ip1 := ips.NewDynamicIPPort(net.IPv6loopback, 2) tls1 := tlsCert1.PrivateKey.(crypto.Signer) peerConfig1.IPSigner = NewIPSigner(ip1, tls1) diff --git a/network/peer/test_peer.go b/network/peer/test_peer.go index 74627930c952..7bd58344ab86 100644 --- a/network/peer/test_peer.go +++ b/network/peer/test_peer.go @@ -102,7 +102,7 @@ func StartTestPeer( return nil, err } - signerIP := ips.NewDynamicIPPort(net.IPv6zero, 0) + signerIP := ips.NewDynamicIPPort(net.IPv6zero, 1) tls := tlsCert.PrivateKey.(crypto.Signer) peer := Start( diff --git a/network/test_network.go b/network/test_network.go index e1c76e92774a..0811875b4c43 100644 --- a/network/test_network.go +++ b/network/test_network.go @@ -66,7 +66,7 @@ func (l *noopListener) Close() error { func (*noopListener) Addr() net.Addr { return &net.TCPAddr{ IP: net.IPv4zero, - Port: 0, + Port: 1, } } @@ -225,7 +225,7 @@ func NewTestNetwork( networkConfig.ResourceTracker.DiskTracker(), ) - networkConfig.MyIPPort = ips.NewDynamicIPPort(net.IPv4zero, 0) + networkConfig.MyIPPort = ips.NewDynamicIPPort(net.IPv4zero, 1) networkConfig.GossipTracker, err = peer.NewGossipTracker(metrics, "") if err != nil { diff --git a/node/config.go b/node/config.go index 768f23cab3e9..a26ec4806fc6 100644 --- a/node/config.go +++ b/node/config.go @@ -11,7 +11,6 @@ import ( "github.com/ava-labs/avalanchego/chains" "github.com/ava-labs/avalanchego/genesis" "github.com/ava-labs/avalanchego/ids" - "github.com/ava-labs/avalanchego/nat" "github.com/ava-labs/avalanchego/network" "github.com/ava-labs/avalanchego/snow/networking/benchlist" "github.com/ava-labs/avalanchego/snow/networking/router" @@ -19,7 +18,6 @@ import ( "github.com/ava-labs/avalanchego/subnets" "github.com/ava-labs/avalanchego/trace" "github.com/ava-labs/avalanchego/utils/crypto/bls" - "github.com/ava-labs/avalanchego/utils/dynamicip" "github.com/ava-labs/avalanchego/utils/ips" "github.com/ava-labs/avalanchego/utils/logging" "github.com/ava-labs/avalanchego/utils/profiler" @@ -74,19 +72,16 @@ type APIConfig struct { } type IPConfig struct { - IPPort ips.DynamicIPPort `json:"ip"` - IPUpdater dynamicip.Updater `json:"-"` - IPResolutionFreq time.Duration `json:"ipResolutionFrequency"` - // True if we attempted NAT traversal - AttemptedNATTraversal bool `json:"attemptedNATTraversal"` - // Tries to perform network address translation - Nat nat.Router `json:"-"` + PublicIP string `json:"publicIP"` + PublicIPResolutionService string `json:"publicIPResolutionService"` + PublicIPResolutionFreq time.Duration `json:"publicIPResolutionFreq"` // The host portion of the address to listen on. The port to // listen on will be sourced from IPPort. // // - If empty, listen on all interfaces (both ipv4 and ipv6). // - If populated, listen only on the specified address. ListenHost string `json:"listenHost"` + ListenPort uint16 `json:"listenPort"` } type StakingConfig struct { diff --git a/node/node.go b/node/node.go index 45e2f6a506eb..8fce5df75550 100644 --- a/node/node.go +++ b/node/node.go @@ -48,6 +48,7 @@ import ( "github.com/ava-labs/avalanchego/indexer" "github.com/ava-labs/avalanchego/ipcs" "github.com/ava-labs/avalanchego/message" + "github.com/ava-labs/avalanchego/nat" "github.com/ava-labs/avalanchego/network" "github.com/ava-labs/avalanchego/network/dialer" "github.com/ava-labs/avalanchego/network/peer" @@ -64,6 +65,7 @@ import ( "github.com/ava-labs/avalanchego/utils" "github.com/ava-labs/avalanchego/utils/constants" "github.com/ava-labs/avalanchego/utils/crypto/bls" + "github.com/ava-labs/avalanchego/utils/dynamicip" "github.com/ava-labs/avalanchego/utils/filesystem" "github.com/ava-labs/avalanchego/utils/hashing" "github.com/ava-labs/avalanchego/utils/ips" @@ -91,6 +93,13 @@ import ( platformconfig "github.com/ava-labs/avalanchego/vms/platformvm/config" ) +const ( + stakingPortName = constants.AppName + "-staking" + httpPortName = constants.AppName + "-http" + + ipResolutionTimeout = 30 * time.Second +) + var ( genesisHashKey = []byte("genesisID") ungracefulShutdown = []byte("ungracefulShutdown") @@ -156,7 +165,7 @@ func New( } n.initMetrics() - + n.initNAT() if err := n.initAPIServer(); err != nil { // Start the API Server return nil, fmt.Errorf("couldn't initialize API server: %w", err) } @@ -193,6 +202,7 @@ func New( n.vdrs = validators.NewManager() if !n.Config.SybilProtectionEnabled { + logger.Warn("sybil control is not enforced") n.vdrs = newOverriddenManager(constants.PrimaryNetworkID, n.vdrs) } if err := n.initResourceManager(n.MetricsRegisterer); err != nil { @@ -266,6 +276,10 @@ type Node struct { // Storage for this node DB database.Database + router nat.Router + portMapper *nat.Mapper + ipUpdater dynamicip.Updater + // Profiles the process. Nil if continuous profiling is disabled. profiler profiler.ContinuousProfiler @@ -380,8 +394,6 @@ type Node struct { // Initialize the networking layer. // Assumes [n.vdrs], [n.CPUTracker], and [n.CPUTargeter] have been initialized. func (n *Node) initNetworking() error { - currentIPPort := n.Config.IPPort.IPPort() - // Providing either loopback address - `::1` for ipv6 and `127.0.0.1` for ipv4 - as the listen // host will avoid the need for a firewall exception on recent MacOS: // @@ -399,8 +411,7 @@ func (n *Node) initNetworking() error { // // 1: https://apple.stackexchange.com/questions/393715/do-you-want-the-application-main-to-accept-incoming-network-connections-pop // 2: https://github.com/golang/go/issues/56998 - listenAddress := net.JoinHostPort(n.Config.ListenHost, strconv.FormatUint(uint64(currentIPPort.Port), 10)) - + listenAddress := net.JoinHostPort(n.Config.ListenHost, strconv.FormatUint(uint64(n.Config.ListenPort), 10)) listener, err := net.Listen(constants.NetworkType, listenAddress) if err != nil { return err @@ -408,23 +419,67 @@ func (n *Node) initNetworking() error { // Wrap listener so it will only accept a certain number of incoming connections per second listener = throttling.NewThrottledListener(listener, n.Config.NetworkConfig.ThrottlerConfig.MaxInboundConnsPerSec) - ipPort, err := ips.ToIPPort(listener.Addr().String()) + // Record the bound address to enable inclusion in process context file. + n.stakingAddress = listener.Addr().String() + ipPort, err := ips.ToIPPort(n.stakingAddress) if err != nil { - n.Log.Info("initializing networking", - zap.Stringer("currentNodeIP", currentIPPort), - ) - } else { - ipPort = ips.IPPort{ - IP: currentIPPort.IP, - Port: ipPort.Port, + return err + } + + var dynamicIP ips.DynamicIPPort + switch { + case n.Config.PublicIP != "": + // Use the specified public IP. + ipPort.IP = net.ParseIP(n.Config.PublicIP) + if ipPort.IP == nil { + return fmt.Errorf("invalid IP Address: %s", n.Config.PublicIP) } - n.Log.Info("initializing networking", - zap.Stringer("currentNodeIP", ipPort), + dynamicIP = ips.NewDynamicIPPort(ipPort.IP, ipPort.Port) + n.ipUpdater = dynamicip.NewNoUpdater() + case n.Config.PublicIPResolutionService != "": + // Use dynamic IP resolution. + resolver, err := dynamicip.NewResolver(n.Config.PublicIPResolutionService) + if err != nil { + return fmt.Errorf("couldn't create IP resolver: %w", err) + } + + // Use that to resolve our public IP. + ctx, cancel := context.WithTimeout(context.Background(), ipResolutionTimeout) + ipPort.IP, err = resolver.Resolve(ctx) + cancel() + if err != nil { + return fmt.Errorf("couldn't resolve public IP: %w", err) + } + dynamicIP = ips.NewDynamicIPPort(ipPort.IP, ipPort.Port) + n.ipUpdater = dynamicip.NewUpdater(dynamicIP, resolver, n.Config.PublicIPResolutionFreq) + default: + ipPort.IP, err = n.router.ExternalIP() + if err != nil { + return fmt.Errorf("public IP / IP resolution service not given and failed to resolve IP with NAT: %w", err) + } + dynamicIP = ips.NewDynamicIPPort(ipPort.IP, ipPort.Port) + n.ipUpdater = dynamicip.NewNoUpdater() + } + + if ipPort.IP.IsLoopback() || ipPort.IP.IsPrivate() { + n.Log.Warn("P2P IP is private, you will not be publicly discoverable", + zap.Stringer("ip", ipPort), ) } - // Record the bound address to enable inclusion in process context file. - n.stakingAddress = listener.Addr().String() + // Regularly update our public IP and port mappings. + n.portMapper.Map( + ipPort.Port, + ipPort.Port, + stakingPortName, + dynamicIP, + n.Config.PublicIPResolutionFreq, + ) + go n.ipUpdater.Dispatch(n.Log) + + n.Log.Info("initializing networking", + zap.Stringer("ip", ipPort), + ) tlsKey, ok := n.Config.StakingTLSCert.PrivateKey.(crypto.Signer) if !ok { @@ -543,7 +598,7 @@ func (n *Node) initNetworking() error { // add node configs to network config n.Config.NetworkConfig.Namespace = n.networkNamespace n.Config.NetworkConfig.MyNodeID = n.ID - n.Config.NetworkConfig.MyIPPort = n.Config.IPPort + n.Config.NetworkConfig.MyIPPort = dynamicIP n.Config.NetworkConfig.NetworkID = n.Config.NetworkID n.Config.NetworkConfig.Validators = n.vdrs n.Config.NetworkConfig.Beacons = n.bootstrappers @@ -861,16 +916,76 @@ func (n *Node) initMetrics() { n.MetricsGatherer = metrics.NewMultiGatherer() } +func (n *Node) initNAT() { + n.Log.Info("initializing NAT") + + if n.Config.PublicIP == "" && n.Config.PublicIPResolutionService == "" { + n.router = nat.GetRouter() + if !n.router.SupportsNAT() { + n.Log.Warn("UPnP and NAT-PMP router attach failed, " + + "you may not be listening publicly. " + + "Please confirm the settings in your router") + } + } else { + n.router = nat.NewNoRouter() + } + + n.portMapper = nat.NewPortMapper(n.Log, n.router) +} + // initAPIServer initializes the server that handles HTTP calls func (n *Node) initAPIServer() error { n.Log.Info("initializing API server") + // An empty host is treated as a wildcard to match all addresses, so it is + // considered public. + hostIsPublic := n.Config.HTTPHost == "" + if !hostIsPublic { + ip, err := ips.Lookup(n.Config.HTTPHost) + if err != nil { + n.Log.Fatal("failed to lookup HTTP host", + zap.String("host", n.Config.HTTPHost), + zap.Error(err), + ) + return err + } + hostIsPublic = !ip.IsLoopback() && !ip.IsPrivate() + + n.Log.Debug("finished HTTP host lookup", + zap.String("host", n.Config.HTTPHost), + zap.Stringer("ip", ip), + zap.Bool("isPublic", hostIsPublic), + ) + } + listenAddress := net.JoinHostPort(n.Config.HTTPHost, strconv.FormatUint(uint64(n.Config.HTTPPort), 10)) listener, err := net.Listen("tcp", listenAddress) if err != nil { return err } + addr := listener.Addr().String() + ipPort, err := ips.ToIPPort(addr) + if err != nil { + return err + } + + // Don't open the HTTP port if the HTTP server is private + if hostIsPublic { + n.Log.Warn("HTTP server is binding to a potentially public host. "+ + "You may be vulnerable to a DoS attack if your HTTP port is publicly accessible", + zap.String("host", n.Config.HTTPHost), + ) + + n.portMapper.Map( + ipPort.Port, + ipPort.Port, + httpPortName, + nil, + n.Config.PublicIPResolutionFreq, + ) + } + protocol := "http" if n.Config.HTTPSEnabled { cert, err := tls.X509KeyPair(n.Config.HTTPSCert, n.Config.HTTPSKey) @@ -1585,6 +1700,8 @@ func (n *Node) shutdown() { zap.Error(err), ) } + n.portMapper.UnmapAllPorts() + n.ipUpdater.Stop() if err := n.indexer.Close(); err != nil { n.Log.Debug("error closing tx indexer", zap.Error(err),