From e2a8302bbca9804507e64d77feeecb04298260b5 Mon Sep 17 00:00:00 2001 From: Devin Carr Date: Mon, 14 Mar 2022 10:51:10 -0700 Subject: [PATCH] TUN-5869: Add configuration endpoint in metrics server --- cmd/cloudflared/proxydns/cmd.go | 2 +- cmd/cloudflared/tunnel/cmd.go | 12 ++--- config/configuration.go | 2 +- ingress/config.go | 80 +++++++++++++++--------------- ingress/config_test.go | 26 +++++----- ingress/ingress.go | 17 ++++--- ingress/ingress_test.go | 14 +++--- ingress/origin_service.go | 53 +++++++++++++++++--- ingress/rule.go | 27 +++++++--- ingress/rule_test.go | 78 ++++++++++++++++++++++++++++- metrics/metrics.go | 26 +++++++++- orchestration/orchestrator.go | 27 ++++++++++ orchestration/orchestrator_test.go | 7 +-- 13 files changed, 275 insertions(+), 96 deletions(-) diff --git a/cmd/cloudflared/proxydns/cmd.go b/cmd/cloudflared/proxydns/cmd.go index bd6c0e25..ae03d8c6 100644 --- a/cmd/cloudflared/proxydns/cmd.go +++ b/cmd/cloudflared/proxydns/cmd.go @@ -73,7 +73,7 @@ func Run(c *cli.Context) error { log.Fatal().Err(err).Msg("Failed to open the metrics listener") } - go metrics.ServeMetrics(metricsListener, nil, nil, "", log) + go metrics.ServeMetrics(metricsListener, nil, nil, "", nil, log) listener, err := tunneldns.CreateListener( c.String("address"), diff --git a/cmd/cloudflared/tunnel/cmd.go b/cmd/cloudflared/tunnel/cmd.go index 43eee046..b6895967 100644 --- a/cmd/cloudflared/tunnel/cmd.go +++ b/cmd/cloudflared/tunnel/cmd.go @@ -340,6 +340,11 @@ func StartServer( return err } + orchestrator, err := orchestration.NewOrchestrator(ctx, dynamicConfig, tunnelConfig.Tags, tunnelConfig.Log) + if err != nil { + return err + } + metricsListener, err := listeners.Listen("tcp", c.String("metrics")) if err != nil { log.Err(err).Msg("Error opening metrics server listener") @@ -351,14 +356,9 @@ func StartServer( defer wg.Done() readinessServer := metrics.NewReadyServer(log) observer.RegisterSink(readinessServer) - errC <- metrics.ServeMetrics(metricsListener, ctx.Done(), readinessServer, quickTunnelURL, log) + errC <- metrics.ServeMetrics(metricsListener, ctx.Done(), readinessServer, quickTunnelURL, orchestrator, log) }() - orchestrator, err := orchestration.NewOrchestrator(ctx, dynamicConfig, tunnelConfig.Tags, tunnelConfig.Log) - if err != nil { - return err - } - reconnectCh := make(chan supervisor.ReconnectSignal, 1) if c.IsSet("stdin-control") { log.Info().Msg("Enabling control through stdin") diff --git a/config/configuration.go b/config/configuration.go index ffd3eb7c..c73a3a46 100644 --- a/config/configuration.go +++ b/config/configuration.go @@ -411,7 +411,7 @@ type CustomDuration struct { time.Duration } -func (s *CustomDuration) MarshalJSON() ([]byte, error) { +func (s CustomDuration) MarshalJSON() ([]byte, error) { return json.Marshal(s.Duration.Seconds()) } diff --git a/ingress/config.go b/ingress/config.go index f8284b43..0b59ecff 100644 --- a/ingress/config.go +++ b/ingress/config.go @@ -11,14 +11,16 @@ import ( "github.com/cloudflare/cloudflared/tlsconfig" ) -const ( - defaultConnectTimeout = 30 * time.Second - defaultTLSTimeout = 10 * time.Second - defaultTCPKeepAlive = 30 * time.Second - defaultKeepAliveConnections = 100 - defaultKeepAliveTimeout = 90 * time.Second - defaultProxyAddress = "127.0.0.1" +var ( + defaultConnectTimeout = config.CustomDuration{Duration: 30 * time.Second} + defaultTLSTimeout = config.CustomDuration{Duration: 10 * time.Second} + defaultTCPKeepAlive = config.CustomDuration{Duration: 30 * time.Second} + defaultKeepAliveTimeout = config.CustomDuration{Duration: 90 * time.Second} +) +const ( + defaultProxyAddress = "127.0.0.1" + defaultKeepAliveConnections = 100 SSHServerFlag = "ssh-server" Socks5Flag = "socks5" ProxyConnectTimeoutFlag = "proxy-connect-timeout" @@ -68,12 +70,12 @@ func (rc *RemoteConfig) UnmarshalJSON(b []byte) error { } func originRequestFromSingeRule(c *cli.Context) OriginRequestConfig { - var connectTimeout time.Duration = defaultConnectTimeout - var tlsTimeout time.Duration = defaultTLSTimeout - var tcpKeepAlive time.Duration = defaultTCPKeepAlive + var connectTimeout config.CustomDuration = defaultConnectTimeout + var tlsTimeout config.CustomDuration = defaultTLSTimeout + var tcpKeepAlive config.CustomDuration = defaultTCPKeepAlive var noHappyEyeballs bool var keepAliveConnections int = defaultKeepAliveConnections - var keepAliveTimeout time.Duration = defaultKeepAliveTimeout + var keepAliveTimeout config.CustomDuration = defaultKeepAliveTimeout var httpHostHeader string var originServerName string var caPool string @@ -84,13 +86,13 @@ func originRequestFromSingeRule(c *cli.Context) OriginRequestConfig { var proxyPort uint var proxyType string if flag := ProxyConnectTimeoutFlag; c.IsSet(flag) { - connectTimeout = c.Duration(flag) + connectTimeout = config.CustomDuration{Duration: c.Duration(flag)} } if flag := ProxyTLSTimeoutFlag; c.IsSet(flag) { - tlsTimeout = c.Duration(flag) + tlsTimeout = config.CustomDuration{Duration: c.Duration(flag)} } if flag := ProxyTCPKeepAliveFlag; c.IsSet(flag) { - tcpKeepAlive = c.Duration(flag) + tcpKeepAlive = config.CustomDuration{Duration: c.Duration(flag)} } if flag := ProxyNoHappyEyeballsFlag; c.IsSet(flag) { noHappyEyeballs = c.Bool(flag) @@ -99,7 +101,7 @@ func originRequestFromSingeRule(c *cli.Context) OriginRequestConfig { keepAliveConnections = c.Int(flag) } if flag := ProxyKeepAliveTimeoutFlag; c.IsSet(flag) { - keepAliveTimeout = c.Duration(flag) + keepAliveTimeout = config.CustomDuration{Duration: c.Duration(flag)} } if flag := HTTPHostHeaderFlag; c.IsSet(flag) { httpHostHeader = c.String(flag) @@ -158,13 +160,13 @@ func originRequestFromConfig(c config.OriginRequestConfig) OriginRequestConfig { ProxyAddress: defaultProxyAddress, } if c.ConnectTimeout != nil { - out.ConnectTimeout = c.ConnectTimeout.Duration + out.ConnectTimeout = *c.ConnectTimeout } if c.TLSTimeout != nil { - out.TLSTimeout = c.TLSTimeout.Duration + out.TLSTimeout = *c.TLSTimeout } if c.TCPKeepAlive != nil { - out.TCPKeepAlive = c.TCPKeepAlive.Duration + out.TCPKeepAlive = *c.TCPKeepAlive } if c.NoHappyEyeballs != nil { out.NoHappyEyeballs = *c.NoHappyEyeballs @@ -173,7 +175,7 @@ func originRequestFromConfig(c config.OriginRequestConfig) OriginRequestConfig { out.KeepAliveConnections = *c.KeepAliveConnections } if c.KeepAliveTimeout != nil { - out.KeepAliveTimeout = c.KeepAliveTimeout.Duration + out.KeepAliveTimeout = *c.KeepAliveTimeout } if c.HTTPHostHeader != nil { out.HTTPHostHeader = *c.HTTPHostHeader @@ -218,52 +220,52 @@ func originRequestFromConfig(c config.OriginRequestConfig) OriginRequestConfig { // Note: To specify a time.Duration in go-yaml, use e.g. "3s" or "24h". type OriginRequestConfig struct { // HTTP proxy timeout for establishing a new connection - ConnectTimeout time.Duration `yaml:"connectTimeout"` + ConnectTimeout config.CustomDuration `yaml:"connectTimeout" json:"connectTimeout"` // HTTP proxy timeout for completing a TLS handshake - TLSTimeout time.Duration `yaml:"tlsTimeout"` + TLSTimeout config.CustomDuration `yaml:"tlsTimeout" json:"tlsTimeout"` // HTTP proxy TCP keepalive duration - TCPKeepAlive time.Duration `yaml:"tcpKeepAlive"` + TCPKeepAlive config.CustomDuration `yaml:"tcpKeepAlive" json:"tcpKeepAlive"` // HTTP proxy should disable "happy eyeballs" for IPv4/v6 fallback - NoHappyEyeballs bool `yaml:"noHappyEyeballs"` + NoHappyEyeballs bool `yaml:"noHappyEyeballs" json:"noHappyEyeballs"` // HTTP proxy timeout for closing an idle connection - KeepAliveTimeout time.Duration `yaml:"keepAliveTimeout"` + KeepAliveTimeout config.CustomDuration `yaml:"keepAliveTimeout" json:"keepAliveTimeout"` // HTTP proxy maximum keepalive connection pool size - KeepAliveConnections int `yaml:"keepAliveConnections"` + KeepAliveConnections int `yaml:"keepAliveConnections" json:"keepAliveConnections"` // Sets the HTTP Host header for the local webserver. - HTTPHostHeader string `yaml:"httpHostHeader"` + HTTPHostHeader string `yaml:"httpHostHeader" json:"httpHostHeader"` // Hostname on the origin server certificate. - OriginServerName string `yaml:"originServerName"` + OriginServerName string `yaml:"originServerName" json:"originServerName"` // Path to the CA for the certificate of your origin. // This option should be used only if your certificate is not signed by Cloudflare. - CAPool string `yaml:"caPool"` + CAPool string `yaml:"caPool" json:"caPool"` // Disables TLS verification of the certificate presented by your origin. // Will allow any certificate from the origin to be accepted. // Note: The connection from your machine to Cloudflare's Edge is still encrypted. - NoTLSVerify bool `yaml:"noTLSVerify"` + NoTLSVerify bool `yaml:"noTLSVerify" json:"noTLSVerify"` // Disables chunked transfer encoding. // Useful if you are running a WSGI server. - DisableChunkedEncoding bool `yaml:"disableChunkedEncoding"` + DisableChunkedEncoding bool `yaml:"disableChunkedEncoding" json:"disableChunkedEncoding"` // Runs as jump host - BastionMode bool `yaml:"bastionMode"` + BastionMode bool `yaml:"bastionMode" json:"bastionMode"` // Listen address for the proxy. - ProxyAddress string `yaml:"proxyAddress"` + ProxyAddress string `yaml:"proxyAddress" json:"proxyAddress"` // Listen port for the proxy. - ProxyPort uint `yaml:"proxyPort"` + ProxyPort uint `yaml:"proxyPort" json:"proxyPort"` // What sort of proxy should be started - ProxyType string `yaml:"proxyType"` + ProxyType string `yaml:"proxyType" json:"proxyType"` // IP rules for the proxy service - IPRules []ipaccess.Rule `yaml:"ipRules"` + IPRules []ipaccess.Rule `yaml:"ipRules" json:"ipRules"` } func (defaults *OriginRequestConfig) setConnectTimeout(overrides config.OriginRequestConfig) { if val := overrides.ConnectTimeout; val != nil { - defaults.ConnectTimeout = val.Duration + defaults.ConnectTimeout = *val } } func (defaults *OriginRequestConfig) setTLSTimeout(overrides config.OriginRequestConfig) { if val := overrides.TLSTimeout; val != nil { - defaults.TLSTimeout = val.Duration + defaults.TLSTimeout = *val } } @@ -281,13 +283,13 @@ func (defaults *OriginRequestConfig) setKeepAliveConnections(overrides config.Or func (defaults *OriginRequestConfig) setKeepAliveTimeout(overrides config.OriginRequestConfig) { if val := overrides.KeepAliveTimeout; val != nil { - defaults.KeepAliveTimeout = val.Duration + defaults.KeepAliveTimeout = *val } } func (defaults *OriginRequestConfig) setTCPKeepAlive(overrides config.OriginRequestConfig) { if val := overrides.TCPKeepAlive; val != nil { - defaults.TCPKeepAlive = val.Duration + defaults.TCPKeepAlive = *val } } diff --git a/ingress/config_test.go b/ingress/config_test.go index e520c9bf..55229137 100644 --- a/ingress/config_test.go +++ b/ingress/config_test.go @@ -65,7 +65,7 @@ func TestUnmarshalRemoteConfigOverridesGlobal(t *testing.T) { err := json.Unmarshal(rawConfig, &remoteConfig) require.NoError(t, err) require.True(t, remoteConfig.Ingress.Rules[0].Config.NoTLSVerify) - require.True(t, remoteConfig.Ingress.defaults.NoHappyEyeballs) + require.True(t, remoteConfig.Ingress.Defaults.NoHappyEyeballs) } func TestOriginRequestConfigOverrides(t *testing.T) { @@ -74,11 +74,11 @@ func TestOriginRequestConfigOverrides(t *testing.T) { // root-level configuration. actual0 := ing.Rules[0].Config expected0 := OriginRequestConfig{ - ConnectTimeout: 1 * time.Minute, - TLSTimeout: 1 * time.Second, - TCPKeepAlive: 1 * time.Second, + ConnectTimeout: config.CustomDuration{Duration: 1 * time.Minute}, + TLSTimeout: config.CustomDuration{Duration: 1 * time.Second}, + TCPKeepAlive: config.CustomDuration{Duration: 1 * time.Second}, NoHappyEyeballs: true, - KeepAliveTimeout: 1 * time.Second, + KeepAliveTimeout: config.CustomDuration{Duration: 1 * time.Second}, KeepAliveConnections: 1, HTTPHostHeader: "abc", OriginServerName: "a1", @@ -99,11 +99,11 @@ func TestOriginRequestConfigOverrides(t *testing.T) { // Rule 1 overrode all the root-level config. actual1 := ing.Rules[1].Config expected1 := OriginRequestConfig{ - ConnectTimeout: 2 * time.Minute, - TLSTimeout: 2 * time.Second, - TCPKeepAlive: 2 * time.Second, + ConnectTimeout: config.CustomDuration{Duration: 2 * time.Minute}, + TLSTimeout: config.CustomDuration{Duration: 2 * time.Second}, + TCPKeepAlive: config.CustomDuration{Duration: 2 * time.Second}, NoHappyEyeballs: false, - KeepAliveTimeout: 2 * time.Second, + KeepAliveTimeout: config.CustomDuration{Duration: 2 * time.Second}, KeepAliveConnections: 2, HTTPHostHeader: "def", OriginServerName: "b2", @@ -286,11 +286,11 @@ func TestOriginRequestConfigDefaults(t *testing.T) { // Rule 1 overrode all defaults. actual1 := ing.Rules[1].Config expected1 := OriginRequestConfig{ - ConnectTimeout: 2 * time.Minute, - TLSTimeout: 2 * time.Second, - TCPKeepAlive: 2 * time.Second, + ConnectTimeout: config.CustomDuration{Duration: 2 * time.Minute}, + TLSTimeout: config.CustomDuration{Duration: 2 * time.Second}, + TCPKeepAlive: config.CustomDuration{Duration: 2 * time.Second}, NoHappyEyeballs: false, - KeepAliveTimeout: 2 * time.Second, + KeepAliveTimeout: config.CustomDuration{Duration: 2 * time.Second}, KeepAliveConnections: 2, HTTPHostHeader: "def", OriginServerName: "b2", diff --git a/ingress/ingress.go b/ingress/ingress.go index 801bc551..8d2ec5ba 100644 --- a/ingress/ingress.go +++ b/ingress/ingress.go @@ -64,8 +64,8 @@ func matchHost(ruleHost, reqHost string) bool { // Ingress maps eyeball requests to origins. type Ingress struct { - Rules []Rule - defaults OriginRequestConfig + Rules []Rule `json:"ingress"` + Defaults OriginRequestConfig `json:"originRequest"` } // NewSingleOrigin constructs an Ingress set with only one rule, constructed from @@ -86,7 +86,7 @@ func NewSingleOrigin(c *cli.Context, allowURLFromArgs bool) (Ingress, error) { Config: setConfig(defaults, config.OriginRequestConfig{}), }, }, - defaults: defaults, + Defaults: defaults, } return ing, err } @@ -180,7 +180,7 @@ func validateIngress(ingress []config.UnvalidatedIngressRule, defaults OriginReq } srv := newStatusCode(status) service = &srv - } else if r.Service == "hello_world" || r.Service == "hello-world" || r.Service == "helloworld" { + } else if r.Service == HelloWorldService || r.Service == "hello-world" || r.Service == "helloworld" { service = new(helloWorld) } else if r.Service == ServiceSocksProxy { rules := make([]ipaccess.Rule, len(r.OriginRequest.IPRules)) @@ -230,23 +230,24 @@ func validateIngress(ingress []config.UnvalidatedIngressRule, defaults OriginReq return Ingress{}, err } - var pathRegex *regexp.Regexp + var pathRegexp *Regexp if r.Path != "" { var err error - pathRegex, err = regexp.Compile(r.Path) + regex, err := regexp.Compile(r.Path) if err != nil { return Ingress{}, errors.Wrapf(err, "Rule #%d has an invalid regex", i+1) } + pathRegexp = &Regexp{Regexp: regex} } rules[i] = Rule{ Hostname: r.Hostname, Service: service, - Path: pathRegex, + Path: pathRegexp, Config: cfg, } } - return Ingress{Rules: rules, defaults: defaults}, nil + return Ingress{Rules: rules, Defaults: defaults}, nil } func validateHostname(r config.UnvalidatedIngressRule, ruleIndex, totalRules int) error { diff --git a/ingress/ingress_test.go b/ingress/ingress_test.go index 1e999a4e..7ca8965a 100644 --- a/ingress/ingress_test.go +++ b/ingress/ingress_test.go @@ -482,12 +482,12 @@ func TestSingleOriginSetsConfig(t *testing.T) { ingress, err := NewSingleOrigin(cliCtx, allowURLFromArgs) require.NoError(t, err) - assert.Equal(t, time.Second, ingress.Rules[0].Config.ConnectTimeout) - assert.Equal(t, time.Second, ingress.Rules[0].Config.TLSTimeout) - assert.Equal(t, time.Second, ingress.Rules[0].Config.TCPKeepAlive) + assert.Equal(t, config.CustomDuration{Duration: time.Second}, ingress.Rules[0].Config.ConnectTimeout) + assert.Equal(t, config.CustomDuration{Duration: time.Second}, ingress.Rules[0].Config.TLSTimeout) + assert.Equal(t, config.CustomDuration{Duration: time.Second}, ingress.Rules[0].Config.TCPKeepAlive) assert.True(t, ingress.Rules[0].Config.NoHappyEyeballs) assert.Equal(t, 10, ingress.Rules[0].Config.KeepAliveConnections) - assert.Equal(t, time.Second, ingress.Rules[0].Config.KeepAliveTimeout) + assert.Equal(t, config.CustomDuration{Duration: time.Second}, ingress.Rules[0].Config.KeepAliveTimeout) assert.Equal(t, "example.com:8080", ingress.Rules[0].Config.HTTPHostHeader) assert.Equal(t, "example.com", ingress.Rules[0].Config.OriginServerName) assert.Equal(t, "/etc/certs/ca.pem", ingress.Rules[0].Config.CAPool) @@ -508,7 +508,7 @@ func TestFindMatchingRule(t *testing.T) { }, { Hostname: "tunnel-b.example.com", - Path: mustParsePath(t, "/health"), + Path: MustParsePath(t, "/health"), }, { Hostname: "*", @@ -591,10 +591,10 @@ func TestIsHTTPService(t *testing.T) { } } -func mustParsePath(t *testing.T, path string) *regexp.Regexp { +func MustParsePath(t *testing.T, path string) *Regexp { regexp, err := regexp.Compile(path) assert.NoError(t, err) - return regexp + return &Regexp{Regexp: regexp} } func MustParseURL(t *testing.T, rawURL string) *url.URL { diff --git a/ingress/origin_service.go b/ingress/origin_service.go index c76c98a4..239b6e93 100644 --- a/ingress/origin_service.go +++ b/ingress/origin_service.go @@ -3,6 +3,7 @@ package ingress import ( "context" "crypto/tls" + "encoding/json" "fmt" "io" "net" @@ -20,7 +21,8 @@ import ( ) const ( - HelloWorldService = "Hello World test origin" + HelloWorldService = "hello_world" + HttpStatusService = "http_status" ) // OriginService is something a tunnel can proxy traffic to. @@ -31,6 +33,7 @@ type OriginService interface { // starting the origin service. // Implementor of services managed by cloudflared should terminate the service if shutdownC is closed start(log *zerolog.Logger, shutdownC <-chan struct{}, cfg OriginRequestConfig) error + MarshalJSON() ([]byte, error) } // unixSocketPath is an OriginService representing a unix socket (which accepts HTTP or HTTPS) @@ -41,7 +44,11 @@ type unixSocketPath struct { } func (o *unixSocketPath) String() string { - return "unix socket: " + o.path + scheme := "" + if o.scheme == "https" { + scheme = "+tls" + } + return fmt.Sprintf("unix%s:%s", scheme, o.path) } func (o *unixSocketPath) start(log *zerolog.Logger, _ <-chan struct{}, cfg OriginRequestConfig) error { @@ -53,6 +60,10 @@ func (o *unixSocketPath) start(log *zerolog.Logger, _ <-chan struct{}, cfg Origi return nil } +func (o unixSocketPath) MarshalJSON() ([]byte, error) { + return json.Marshal(o.String()) +} + type httpService struct { url *url.URL hostHeader string @@ -73,6 +84,10 @@ func (o *httpService) String() string { return o.url.String() } +func (o httpService) MarshalJSON() ([]byte, error) { + return json.Marshal(o.String()) +} + // rawTCPService dials TCP to the destination specified by the client // It's used by warp routing type rawTCPService struct { @@ -87,6 +102,10 @@ func (o *rawTCPService) start(log *zerolog.Logger, _ <-chan struct{}, cfg Origin return nil } +func (o rawTCPService) MarshalJSON() ([]byte, error) { + return json.Marshal(o.String()) +} + // tcpOverWSService models TCP origins serving eyeballs connecting over websocket, such as // cloudflared access commands. type tcpOverWSService struct { @@ -153,6 +172,10 @@ func (o *tcpOverWSService) start(log *zerolog.Logger, _ <-chan struct{}, cfg Ori return nil } +func (o tcpOverWSService) MarshalJSON() ([]byte, error) { + return json.Marshal(o.String()) +} + func (o *socksProxyOverWSService) start(log *zerolog.Logger, _ <-chan struct{}, cfg OriginRequestConfig) error { return nil } @@ -161,6 +184,10 @@ func (o *socksProxyOverWSService) String() string { return ServiceSocksProxy } +func (o socksProxyOverWSService) MarshalJSON() ([]byte, error) { + return json.Marshal(o.String()) +} + // HelloWorld is an OriginService for the built-in Hello World server. // Users only use this for testing and experimenting with cloudflared. type helloWorld struct { @@ -197,6 +224,10 @@ func (o *helloWorld) start( return nil } +func (o helloWorld) MarshalJSON() ([]byte, error) { + return json.Marshal(o.String()) +} + // statusCode is an OriginService that just responds with a given HTTP status. // Typical use-case is "user wants the catch-all rule to just respond 404". type statusCode struct { @@ -213,7 +244,7 @@ func newStatusCode(status int) statusCode { } func (o *statusCode) String() string { - return fmt.Sprintf("HTTP %d", o.resp.StatusCode) + return fmt.Sprintf("http_status:%d", o.resp.StatusCode) } func (o *statusCode) start( @@ -224,6 +255,10 @@ func (o *statusCode) start( return nil } +func (o statusCode) MarshalJSON() ([]byte, error) { + return json.Marshal(o.String()) +} + type NopReadCloser struct{} // Read always returns EOF to signal end of input @@ -245,8 +280,8 @@ func newHTTPTransport(service OriginService, cfg OriginRequestConfig, log *zerol Proxy: http.ProxyFromEnvironment, MaxIdleConns: cfg.KeepAliveConnections, MaxIdleConnsPerHost: cfg.KeepAliveConnections, - IdleConnTimeout: cfg.KeepAliveTimeout, - TLSHandshakeTimeout: cfg.TLSTimeout, + IdleConnTimeout: cfg.KeepAliveTimeout.Duration, + TLSHandshakeTimeout: cfg.TLSTimeout.Duration, ExpectContinueTimeout: 1 * time.Second, TLSClientConfig: &tls.Config{RootCAs: originCertPool, InsecureSkipVerify: cfg.NoTLSVerify}, } @@ -255,8 +290,8 @@ func newHTTPTransport(service OriginService, cfg OriginRequestConfig, log *zerol } dialer := &net.Dialer{ - Timeout: cfg.ConnectTimeout, - KeepAlive: cfg.TCPKeepAlive, + Timeout: cfg.ConnectTimeout.Duration, + KeepAlive: cfg.TCPKeepAlive.Duration, } if cfg.NoHappyEyeballs { dialer.FallbackDelay = -1 // As of Golang 1.12, a negative delay disables "happy eyeballs" @@ -296,3 +331,7 @@ func (mos MockOriginHTTPService) String() string { func (mos MockOriginHTTPService) start(log *zerolog.Logger, _ <-chan struct{}, cfg OriginRequestConfig) error { return nil } + +func (mos MockOriginHTTPService) MarshalJSON() ([]byte, error) { + return json.Marshal(mos.String()) +} diff --git a/ingress/rule.go b/ingress/rule.go index e91b4139..08499919 100644 --- a/ingress/rule.go +++ b/ingress/rule.go @@ -1,6 +1,7 @@ package ingress import ( + "encoding/json" "regexp" "strings" ) @@ -9,18 +10,18 @@ import ( // service running on the given URL. type Rule struct { // Requests for this hostname will be proxied to this rule's service. - Hostname string + Hostname string `json:"hostname"` // Path is an optional regex that can specify path-driven ingress rules. - Path *regexp.Regexp + Path *Regexp `json:"path"` // A (probably local) address. Requests for a hostname which matches this // rule's hostname pattern will be proxied to the service running on this // address. - Service OriginService + Service OriginService `json:"service"` // Configure the request cloudflared sends to this specific origin. - Config OriginRequestConfig + Config OriginRequestConfig `json:"originRequest"` } // MultiLineString is for outputting rules in a human-friendly way when Cloudflared @@ -32,9 +33,9 @@ func (r Rule) MultiLineString() string { out.WriteString(r.Hostname) out.WriteRune('\n') } - if r.Path != nil { + if r.Path != nil && r.Path.Regexp != nil { out.WriteString("\tpath: ") - out.WriteString(r.Path.String()) + out.WriteString(r.Path.Regexp.String()) out.WriteRune('\n') } out.WriteString("\tservice: ") @@ -45,6 +46,18 @@ func (r Rule) MultiLineString() string { // Matches checks if the rule matches a given hostname/path combination. func (r *Rule) Matches(hostname, path string) bool { hostMatch := r.Hostname == "" || r.Hostname == "*" || matchHost(r.Hostname, hostname) - pathMatch := r.Path == nil || r.Path.MatchString(path) + pathMatch := r.Path == nil || r.Path.Regexp == nil || r.Path.Regexp.MatchString(path) return hostMatch && pathMatch } + +// Regexp adds unmarshalling from json for regexp.Regexp +type Regexp struct { + *regexp.Regexp +} + +func (r *Regexp) MarshalJSON() ([]byte, error) { + if r.Regexp == nil { + return json.Marshal(nil) + } + return json.Marshal(r.Regexp.String()) +} diff --git a/ingress/rule_test.go b/ingress/rule_test.go index f5bfcd92..7561709a 100644 --- a/ingress/rule_test.go +++ b/ingress/rule_test.go @@ -1,6 +1,7 @@ package ingress import ( + "encoding/json" "io" "net/http/httptest" "net/url" @@ -8,12 +9,14 @@ import ( "testing" "github.com/stretchr/testify/require" + + "github.com/cloudflare/cloudflared/config" ) func Test_rule_matches(t *testing.T) { type fields struct { Hostname string - Path *regexp.Regexp + Path *Regexp Service OriginService } type args struct { @@ -99,13 +102,35 @@ func Test_rule_matches(t *testing.T) { name: "Hostname and path", fields: fields{ Hostname: "*.example.com", - Path: regexp.MustCompile("/static/.*\\.html"), + Path: &Regexp{Regexp: regexp.MustCompile("/static/.*\\.html")}, }, args: args{ requestURL: MustParseURL(t, "https://www.example.com/static/index.html"), }, want: true, }, + { + name: "Hostname and empty Regex", + fields: fields{ + Hostname: "example.com", + Path: &Regexp{}, + }, + args: args{ + requestURL: MustParseURL(t, "https://example.com/"), + }, + want: true, + }, + { + name: "Hostname and nil path", + fields: fields{ + Hostname: "example.com", + Path: nil, + }, + args: args{ + requestURL: MustParseURL(t, "https://example.com/"), + }, + want: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -144,3 +169,52 @@ func TestStaticHTTPStatus(t *testing.T) { sendReq() sendReq() } + +func TestMarshalJSON(t *testing.T) { + localhost8000 := MustParseURL(t, "https://localhost:8000") + defaultConfig := setConfig(originRequestFromConfig(config.OriginRequestConfig{}), config.OriginRequestConfig{}) + tests := []struct { + name string + path *Regexp + expected string + want bool + }{ + { + name: "Nil", + path: nil, + expected: `{"hostname":"example.com","path":null,"service":"https://localhost:8000","originRequest":{"connectTimeout":30,"tlsTimeout":10,"tcpKeepAlive":30,"noHappyEyeballs":false,"keepAliveTimeout":90,"keepAliveConnections":100,"httpHostHeader":"","originServerName":"","caPool":"","noTLSVerify":false,"disableChunkedEncoding":false,"bastionMode":false,"proxyAddress":"127.0.0.1","proxyPort":0,"proxyType":"","ipRules":null}}`, + want: true, + }, + { + name: "Nil regex", + path: &Regexp{Regexp: nil}, + expected: `{"hostname":"example.com","path":null,"service":"https://localhost:8000","originRequest":{"connectTimeout":30,"tlsTimeout":10,"tcpKeepAlive":30,"noHappyEyeballs":false,"keepAliveTimeout":90,"keepAliveConnections":100,"httpHostHeader":"","originServerName":"","caPool":"","noTLSVerify":false,"disableChunkedEncoding":false,"bastionMode":false,"proxyAddress":"127.0.0.1","proxyPort":0,"proxyType":"","ipRules":null}}`, + want: true, + }, + { + name: "Empty", + path: &Regexp{Regexp: regexp.MustCompile("")}, + expected: `{"hostname":"example.com","path":"","service":"https://localhost:8000","originRequest":{"connectTimeout":30,"tlsTimeout":10,"tcpKeepAlive":30,"noHappyEyeballs":false,"keepAliveTimeout":90,"keepAliveConnections":100,"httpHostHeader":"","originServerName":"","caPool":"","noTLSVerify":false,"disableChunkedEncoding":false,"bastionMode":false,"proxyAddress":"127.0.0.1","proxyPort":0,"proxyType":"","ipRules":null}}`, + want: true, + }, + { + name: "Basic", + path: &Regexp{Regexp: regexp.MustCompile("/echo")}, + expected: `{"hostname":"example.com","path":"/echo","service":"https://localhost:8000","originRequest":{"connectTimeout":30,"tlsTimeout":10,"tcpKeepAlive":30,"noHappyEyeballs":false,"keepAliveTimeout":90,"keepAliveConnections":100,"httpHostHeader":"","originServerName":"","caPool":"","noTLSVerify":false,"disableChunkedEncoding":false,"bastionMode":false,"proxyAddress":"127.0.0.1","proxyPort":0,"proxyType":"","ipRules":null}}`, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := Rule{ + Hostname: "example.com", + Service: &httpService{url: localhost8000}, + Path: tt.path, + Config: defaultConfig, + } + bytes, err := json.Marshal(r) + require.NoError(t, err) + require.Equal(t, tt.expected, string(bytes)) + }) + } +} diff --git a/metrics/metrics.go b/metrics/metrics.go index 3210e840..0129c49a 100644 --- a/metrics/metrics.go +++ b/metrics/metrics.go @@ -22,7 +22,16 @@ const ( startupTime = time.Millisecond * 500 ) -func newMetricsHandler(readyServer *ReadyServer, quickTunnelHostname string) *mux.Router { +type orchestrator interface { + GetConfigJSON() ([]byte, error) +} + +func newMetricsHandler( + readyServer *ReadyServer, + quickTunnelHostname string, + orchestrator orchestrator, + log *zerolog.Logger, +) *mux.Router { router := mux.NewRouter() router.PathPrefix("/debug/").Handler(http.DefaultServeMux) @@ -36,6 +45,18 @@ func newMetricsHandler(readyServer *ReadyServer, quickTunnelHostname string) *mu router.HandleFunc("/quicktunnel", func(w http.ResponseWriter, r *http.Request) { _, _ = fmt.Fprintf(w, `{"hostname":"%s"}`, quickTunnelHostname) }) + if orchestrator != nil { + router.HandleFunc("/config", func(w http.ResponseWriter, r *http.Request) { + json, err := orchestrator.GetConfigJSON() + if err != nil { + w.WriteHeader(500) + _, _ = fmt.Fprintf(w, "ERR: %v", err) + log.Err(err).Msg("Failed to serve config") + return + } + _, _ = w.Write(json) + }) + } return router } @@ -45,6 +66,7 @@ func ServeMetrics( shutdownC <-chan struct{}, readyServer *ReadyServer, quickTunnelHostname string, + orchestrator orchestrator, log *zerolog.Logger, ) (err error) { var wg sync.WaitGroup @@ -52,7 +74,7 @@ func ServeMetrics( trace.AuthRequest = func(*http.Request) (bool, bool) { return true, true } // TODO: parameterize ReadTimeout and WriteTimeout. The maximum time we can // profile CPU usage depends on WriteTimeout - h := newMetricsHandler(readyServer, quickTunnelHostname) + h := newMetricsHandler(readyServer, quickTunnelHostname, orchestrator, log) server := &http.Server{ ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, diff --git a/orchestration/orchestrator.go b/orchestration/orchestrator.go index 59d8db57..9206cc21 100644 --- a/orchestration/orchestrator.go +++ b/orchestration/orchestrator.go @@ -10,6 +10,7 @@ import ( "github.com/pkg/errors" "github.com/rs/zerolog" + "github.com/cloudflare/cloudflared/config" "github.com/cloudflare/cloudflared/connection" "github.com/cloudflare/cloudflared/ingress" "github.com/cloudflare/cloudflared/proxy" @@ -130,6 +131,32 @@ func (o *Orchestrator) updateIngress(ingressRules ingress.Ingress, warpRoutingEn return nil } +// GetConfigJSON returns the current version and configuration as JSON +func (o *Orchestrator) GetConfigJSON() ([]byte, error) { + o.lock.RLock() + defer o.lock.RUnlock() + var currentConfiguration = struct { + Version int32 `json:"version"` + Config struct { + Ingress []ingress.Rule `json:"ingress"` + WarpRouting config.WarpRoutingConfig `json:"warp-routing"` + OriginRequest ingress.OriginRequestConfig `json:"originRequest"` + } `json:"config"` + }{ + Version: o.currentVersion, + Config: struct { + Ingress []ingress.Rule `json:"ingress"` + WarpRouting config.WarpRoutingConfig `json:"warp-routing"` + OriginRequest ingress.OriginRequestConfig `json:"originRequest"` + }{ + Ingress: o.config.Ingress.Rules, + WarpRouting: config.WarpRoutingConfig{Enabled: o.config.WarpRoutingEnabled}, + OriginRequest: o.config.Ingress.Defaults, + }, + } + return json.Marshal(currentConfiguration) +} + // GetOriginProxy returns an interface to proxy to origin. It satisfies connection.ConfigManager interface func (o *Orchestrator) GetOriginProxy() (connection.OriginProxy, error) { val := o.proxy.Load() diff --git a/orchestration/orchestrator_test.go b/orchestration/orchestrator_test.go index 78de169a..f3638ea7 100644 --- a/orchestration/orchestrator_test.go +++ b/orchestration/orchestrator_test.go @@ -17,6 +17,7 @@ import ( "github.com/rs/zerolog" "github.com/stretchr/testify/require" + "github.com/cloudflare/cloudflared/config" "github.com/cloudflare/cloudflared/connection" "github.com/cloudflare/cloudflared/ingress" "github.com/cloudflare/cloudflared/proxy" @@ -99,7 +100,7 @@ func TestUpdateConfiguration(t *testing.T) { require.Equal(t, "http://192.16.19.1:443", configV2.Ingress.Rules[0].Service.String()) require.Len(t, configV2.Ingress.Rules, 3) // originRequest of this ingress rule overrides global default - require.Equal(t, time.Second*10, configV2.Ingress.Rules[0].Config.ConnectTimeout) + require.Equal(t, config.CustomDuration{Duration: time.Second * 10}, configV2.Ingress.Rules[0].Config.ConnectTimeout) require.Equal(t, true, configV2.Ingress.Rules[0].Config.NoTLSVerify) // Inherited from global default require.Equal(t, true, configV2.Ingress.Rules[0].Config.NoHappyEyeballs) @@ -108,14 +109,14 @@ func TestUpdateConfiguration(t *testing.T) { require.True(t, configV2.Ingress.Rules[1].Matches("jira.tunnel.org", "/users")) require.Equal(t, "http://172.32.20.6:80", configV2.Ingress.Rules[1].Service.String()) // originRequest of this ingress rule overrides global default - require.Equal(t, time.Second*30, configV2.Ingress.Rules[1].Config.ConnectTimeout) + require.Equal(t, config.CustomDuration{Duration: time.Second * 30}, configV2.Ingress.Rules[1].Config.ConnectTimeout) require.Equal(t, true, configV2.Ingress.Rules[1].Config.NoTLSVerify) // Inherited from global default require.Equal(t, true, configV2.Ingress.Rules[1].Config.NoHappyEyeballs) // Validate ingress rule 2, it's the catch-all rule require.True(t, configV2.Ingress.Rules[2].Matches("blogs.tunnel.io", "/2022/02/10")) // Inherited from global default - require.Equal(t, time.Second*90, configV2.Ingress.Rules[2].Config.ConnectTimeout) + require.Equal(t, config.CustomDuration{Duration: time.Second * 90}, configV2.Ingress.Rules[2].Config.ConnectTimeout) require.Equal(t, false, configV2.Ingress.Rules[2].Config.NoTLSVerify) require.Equal(t, true, configV2.Ingress.Rules[2].Config.NoHappyEyeballs) require.True(t, configV2.WarpRoutingEnabled)