diff --git a/cmd/cloudflared/tunnel/cmd.go b/cmd/cloudflared/tunnel/cmd.go index 315f8c63..4edc0690 100644 --- a/cmd/cloudflared/tunnel/cmd.go +++ b/cmd/cloudflared/tunnel/cmd.go @@ -84,12 +84,12 @@ const ( LogFieldTmpTraceFilename = "tmpTraceFilename" LogFieldTraceOutputFilepath = "traceOutputFilepath" - tunnelCmdErrorMessage = `You did not specify any valid additional argument to the cloudflared tunnel command. + tunnelCmdErrorMessage = `You did not specify any valid additional argument to the cloudflared tunnel command. -If you are trying to run a Quick Tunnel then you need to explicitly pass the --url flag. -Eg. cloudflared tunnel --url localhost:8080/. +If you are trying to run a Quick Tunnel then you need to explicitly pass the --url flag. +Eg. cloudflared tunnel --url localhost:8080/. -Please note that Quick Tunnels are meant to be ephemeral and should only be used for testing purposes. +Please note that Quick Tunnels are meant to be ephemeral and should only be used for testing purposes. For production usage, we recommend creating Named Tunnels. (https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/tunnel-guide/) ` ) @@ -551,11 +551,17 @@ func tunnelFlags(shouldHide bool) []cli.Flag { }), altsrc.NewStringFlag(&cli.StringFlag{ Name: "edge-ip-version", - Usage: "Cloudflare Edge ip address version to connect with. {4, 6, auto}", + Usage: "Cloudflare Edge IP address version to connect with. {4, 6, auto}", EnvVars: []string{"TUNNEL_EDGE_IP_VERSION"}, Value: "4", Hidden: false, }), + altsrc.NewStringFlag(&cli.StringFlag{ + Name: "edge-bind-address", + Usage: "Bind to IP address for outgoing connections to Cloudflare Edge.", + EnvVars: []string{"TUNNEL_EDGE_BIND_ADDRESS"}, + Hidden: false, + }), altsrc.NewStringFlag(&cli.StringFlag{ Name: tlsconfig.CaCertFlag, Usage: "Certificate Authority authenticating connections with Cloudflare's edge network.", diff --git a/cmd/cloudflared/tunnel/configuration.go b/cmd/cloudflared/tunnel/configuration.go index 8012f9af..4f6919c6 100644 --- a/cmd/cloudflared/tunnel/configuration.go +++ b/cmd/cloudflared/tunnel/configuration.go @@ -44,7 +44,7 @@ var ( secretFlags = [2]*altsrc.StringFlag{credentialsContentsFlag, tunnelTokenFlag} defaultFeatures = []string{supervisor.FeatureAllowRemoteConfig, supervisor.FeatureSerializedHeaders, supervisor.FeatureDatagramV2, supervisor.FeatureQUICSupportEOF} - configFlags = []string{"autoupdate-freq", "no-autoupdate", "retries", "protocol", "loglevel", "transport-loglevel", "origincert", "metrics", "metrics-update-freq", "edge-ip-version"} + configFlags = []string{"autoupdate-freq", "no-autoupdate", "retries", "protocol", "loglevel", "transport-loglevel", "origincert", "metrics", "metrics-update-freq", "edge-ip-version", "edge-bind-address"} ) // returns the first path that contains a cert.pem file. If none of the DefaultConfigSearchDirectories @@ -284,6 +284,18 @@ func prepareTunnelConfig( if err != nil { return nil, nil, err } + edgeBindAddr, err := parseConfigBindAddress(c.String("edge-bind-address")) + if err != nil { + return nil, nil, err + } + if err := testIPBindable(edgeBindAddr); err != nil { + return nil, nil, fmt.Errorf("invalid edge-bind-address %s: %v", edgeBindAddr, err) + } + edgeIPVersion, err = adjustIPVersionByBindAddress(edgeIPVersion, edgeBindAddr) + if err != nil { + // This is not a fatal error, we just overrode edgeIPVersion + log.Warn().Str("edgeIPVersion", edgeIPVersion.String()).Err(err).Msg("Overriding edge-ip-version") + } var pqKexIdx int if needPQ { @@ -302,6 +314,7 @@ func prepareTunnelConfig( EdgeAddrs: c.StringSlice("edge"), Region: c.String("region"), EdgeIPVersion: edgeIPVersion, + EdgeBindAddr: edgeBindAddr, HAConnections: c.Int("ha-connections"), IncidentLookup: supervisor.NewIncidentLookup(), IsAutoupdated: c.Bool("is-autoupdated"), @@ -394,6 +407,51 @@ func parseConfigIPVersion(version string) (v allregions.ConfigIPVersion, err err return } +func parseConfigBindAddress(ipstr string) (net.IP, error) { + // Unspecified - it's fine + if ipstr == "" { + return nil, nil + } + ip := net.ParseIP(ipstr) + if ip == nil { + return nil, fmt.Errorf("invalid value for edge-bind-address: %s", ipstr) + } + return ip, nil +} + +func testIPBindable(ip net.IP) error { + // "Unspecified" = let OS choose, so always bindable + if ip == nil { + return nil + } + + addr := &net.UDPAddr{IP: ip, Port: 0} + listener, err := net.ListenUDP("udp", addr) + if err != nil { + return err + } + listener.Close() + return nil +} + +func adjustIPVersionByBindAddress(ipVersion allregions.ConfigIPVersion, ip net.IP) (allregions.ConfigIPVersion, error) { + if ip == nil { + return ipVersion, nil + } + // https://pkg.go.dev/net#IP.To4: "If ip is not an IPv4 address, To4 returns nil." + if ip.To4() != nil { + if ipVersion == allregions.IPv6Only { + return allregions.IPv4Only, fmt.Errorf("IPv4 bind address is specified, but edge-ip-version is IPv6") + } + return allregions.IPv4Only, nil + } else { + if ipVersion == allregions.IPv4Only { + return allregions.IPv6Only, fmt.Errorf("IPv6 bind address is specified, but edge-ip-version is IPv4") + } + return allregions.IPv6Only, nil + } +} + func newPacketConfig(c *cli.Context, logger *zerolog.Logger) (*ingress.GlobalRouterConfig, error) { ipv4Src, err := determineICMPv4Src(c.String("icmpv4-src"), logger) if err != nil { diff --git a/cmd/cloudflared/tunnel/configuration_test.go b/cmd/cloudflared/tunnel/configuration_test.go index b4edf636..237bc829 100644 --- a/cmd/cloudflared/tunnel/configuration_test.go +++ b/cmd/cloudflared/tunnel/configuration_test.go @@ -9,6 +9,7 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/asn1" + "net" "os" "testing" @@ -214,3 +215,23 @@ func getCertPoolSubjects(certPool *x509.CertPool) ([]*pkix.Name, error) { func isUnrecoverableError(err error) bool { return err != nil && err.Error() != "crypto/x509: system root pool is not available on Windows" } + +func TestTestIPBindable(t *testing.T) { + assert.Nil(t, testIPBindable(nil)) + + // Public services - if one of these IPs is on the machine, the test environment is too weird + assert.NotNil(t, testIPBindable(net.ParseIP("8.8.8.8"))) + assert.NotNil(t, testIPBindable(net.ParseIP("1.1.1.1"))) + + addrs, err := net.InterfaceAddrs() + if err != nil { + t.Fatal(err) + } + for i, addr := range addrs { + if i >= 3 { + break + } + ip := addr.(*net.IPNet).IP + assert.Nil(t, testIPBindable(ip)) + } +} diff --git a/connection/quic.go b/connection/quic.go index 3bd49b11..49f77e49 100644 --- a/connection/quic.go +++ b/connection/quic.go @@ -67,6 +67,7 @@ type QUICConnection struct { func NewQUICConnection( quicConfig *quic.Config, edgeAddr net.Addr, + localAddr net.IP, connIndex uint8, tlsConfig *tls.Config, orchestrator Orchestrator, @@ -75,7 +76,7 @@ func NewQUICConnection( logger *zerolog.Logger, packetRouterConfig *ingress.GlobalRouterConfig, ) (*QUICConnection, error) { - udpConn, err := createUDPConnForConnIndex(connIndex, logger) + udpConn, err := createUDPConnForConnIndex(connIndex, localAddr, logger) if err != nil { return nil, err } @@ -563,13 +564,17 @@ func (rp *muxerWrapper) Close() error { return nil } -func createUDPConnForConnIndex(connIndex uint8, logger *zerolog.Logger) (*net.UDPConn, error) { +func createUDPConnForConnIndex(connIndex uint8, localIP net.IP, logger *zerolog.Logger) (*net.UDPConn, error) { portMapMutex.Lock() defer portMapMutex.Unlock() + if localIP == nil { + localIP = net.IPv4zero + } + // if port was not set yet, it will be zero, so bind will randomly allocate one. if port, ok := portForConnIndex[connIndex]; ok { - udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: port}) + udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: localIP, Port: port}) // if there wasn't an error, or if port was 0 (independently of error or not, just return) if err == nil { return udpConn, nil @@ -579,7 +584,7 @@ func createUDPConnForConnIndex(connIndex uint8, logger *zerolog.Logger) (*net.UD } // if we reached here, then there was an error or port as not been allocated it. - udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) + udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: localIP, Port: 0}) if err == nil { udpAddr, ok := (udpConn.LocalAddr()).(*net.UDPAddr) if !ok { diff --git a/connection/quic_test.go b/connection/quic_test.go index d6777506..d1dcaa68 100644 --- a/connection/quic_test.go +++ b/connection/quic_test.go @@ -572,7 +572,7 @@ func TestNopCloserReadWriterCloseAfterEOF(t *testing.T) { func TestCreateUDPConnReuseSourcePort(t *testing.T) { logger := zerolog.Nop() - conn, err := createUDPConnForConnIndex(0, &logger) + conn, err := createUDPConnForConnIndex(0, nil, &logger) require.NoError(t, err) getPortFunc := func(conn *net.UDPConn) int { @@ -586,17 +586,17 @@ func TestCreateUDPConnReuseSourcePort(t *testing.T) { conn.Close() // should get the same port as before. - conn, err = createUDPConnForConnIndex(0, &logger) + conn, err = createUDPConnForConnIndex(0, nil, &logger) require.NoError(t, err) require.Equal(t, initialPort, getPortFunc(conn)) // new index, should get a different port - conn1, err := createUDPConnForConnIndex(1, &logger) + conn1, err := createUDPConnForConnIndex(1, nil, &logger) require.NoError(t, err) require.NotEqual(t, initialPort, getPortFunc(conn1)) // not closing the conn and trying to obtain a new conn for same index should give a different random port - conn, err = createUDPConnForConnIndex(0, &logger) + conn, err = createUDPConnForConnIndex(0, nil, &logger) require.NoError(t, err) require.NotEqual(t, initialPort, getPortFunc(conn)) } @@ -716,6 +716,7 @@ func testQUICConnection(udpListenerAddr net.Addr, t *testing.T, index uint8) *QU qc, err := NewQUICConnection( testQUICConfig, udpListenerAddr, + nil, index, tlsClientConfig, &mockOrchestrator{originProxy: &mockOriginProxyWithRequest{}}, diff --git a/edgediscovery/allregions/discovery.go b/edgediscovery/allregions/discovery.go index dafaac13..e6c6105e 100644 --- a/edgediscovery/allregions/discovery.go +++ b/edgediscovery/allregions/discovery.go @@ -41,6 +41,19 @@ const ( IPv6Only ConfigIPVersion = 6 ) +func (c ConfigIPVersion) String() string { + switch c { + case Auto: + return "auto" + case IPv4Only: + return "4" + case IPv6Only: + return "6" + default: + return "" + } +} + // IPVersion is the IP version of an EdgeAddr type EdgeIPVersion int8 diff --git a/edgediscovery/dial.go b/edgediscovery/dial.go index c8ae632e..675e5dc5 100644 --- a/edgediscovery/dial.go +++ b/edgediscovery/dial.go @@ -15,12 +15,16 @@ func DialEdge( timeout time.Duration, tlsConfig *tls.Config, edgeTCPAddr *net.TCPAddr, + localIP net.IP, ) (net.Conn, error) { // Inherit from parent context so we can cancel (Ctrl-C) while dialing dialCtx, dialCancel := context.WithTimeout(ctx, timeout) defer dialCancel() dialer := net.Dialer{} + if localIP != nil { + dialer.LocalAddr = &net.TCPAddr{IP: localIP, Port: 0} + } edgeConn, err := dialer.DialContext(dialCtx, "tcp", edgeTCPAddr.String()) if err != nil { return nil, newDialError(err, "DialContext error") diff --git a/supervisor/supervisor.go b/supervisor/supervisor.go index 5723a820..fa2ce2cb 100644 --- a/supervisor/supervisor.go +++ b/supervisor/supervisor.go @@ -82,6 +82,7 @@ func NewSupervisor(config *TunnelConfig, orchestrator *orchestration.Orchestrato log := NewConnAwareLogger(config.Log, tracker, config.Observer) edgeAddrHandler := NewIPAddrFallback(config.MaxEdgeAddrRetries) + edgeBindAddr := config.EdgeBindAddr edgeTunnelServer := EdgeTunnelServer{ config: config, @@ -89,6 +90,7 @@ func NewSupervisor(config *TunnelConfig, orchestrator *orchestration.Orchestrato credentialManager: reconnectCredentialManager, edgeAddrs: edgeIPs, edgeAddrHandler: edgeAddrHandler, + edgeBindAddr: edgeBindAddr, tracker: tracker, reconnectCh: reconnectCh, gracefulShutdownC: gracefulShutdownC, diff --git a/supervisor/tunnel.go b/supervisor/tunnel.go index ac659426..636bb3ef 100644 --- a/supervisor/tunnel.go +++ b/supervisor/tunnel.go @@ -49,6 +49,7 @@ type TunnelConfig struct { EdgeAddrs []string Region string EdgeIPVersion allregions.ConfigIPVersion + EdgeBindAddr net.IP HAConnections int IncidentLookup IncidentLookup IsAutoupdated bool @@ -207,6 +208,7 @@ type EdgeTunnelServer struct { credentialManager *reconnectCredentialManager edgeAddrHandler EdgeAddrHandler edgeAddrs *edgediscovery.Edge + edgeBindAddr net.IP reconnectCh chan ReconnectSignal gracefulShutdownC <-chan struct{} tracker *tunnelstate.ConnTracker @@ -497,7 +499,7 @@ func (e *EdgeTunnelServer) serveConnection( connIndex) case connection.HTTP2: - edgeConn, err := edgediscovery.DialEdge(ctx, dialTimeout, e.config.EdgeTLSConfigs[protocol], addr.TCP) + edgeConn, err := edgediscovery.DialEdge(ctx, dialTimeout, e.config.EdgeTLSConfigs[protocol], addr.TCP, e.edgeBindAddr) if err != nil { connLog.ConnAwareLogger().Err(err).Msg("Unable to establish connection with Cloudflare edge") return err, true @@ -516,7 +518,7 @@ func (e *EdgeTunnelServer) serveConnection( } default: - edgeConn, err := edgediscovery.DialEdge(ctx, dialTimeout, e.config.EdgeTLSConfigs[protocol], addr.TCP) + edgeConn, err := edgediscovery.DialEdge(ctx, dialTimeout, e.config.EdgeTLSConfigs[protocol], addr.TCP, e.edgeBindAddr) if err != nil { connLog.ConnAwareLogger().Err(err).Msg("Unable to establish connection with Cloudflare edge") return err, true @@ -672,6 +674,7 @@ func (e *EdgeTunnelServer) serveQUIC( quicConn, err := connection.NewQUICConnection( quicConfig, edgeAddr, + e.edgeBindAddr, connIndex, tlsConfig, e.orchestrator,