TUN-7259: Add warning for missing ingress rules

Providing no ingress rules in the configuration file or via the CLI will now provide a warning and return 502 for all incoming HTTP requests.
This commit is contained in:
Devin Carr 2023-03-09 15:23:11 -08:00
parent ede3c8e056
commit 7b8b3f73e7
13 changed files with 255 additions and 39 deletions

View File

@ -1,3 +1,7 @@
## 2023.3.1
### Breaking Change
- Running a tunnel without ingress rules defined in configuration file nor from the CLI flags will no longer provide a default ingress rule to localhost:8080 and instead will return HTTP response code 502 for all incoming HTTP requests.
## 2023.2.2 ## 2023.2.2
### Notices ### Notices
- Legacy tunnels were officially deprecated on December 1, 2022. Starting with this version, cloudflared no longer supports connecting legacy tunnels. - Legacy tunnels were officially deprecated on December 1, 2022. Starting with this version, cloudflared no longer supports connecting legacy tunnels.

View File

@ -227,22 +227,10 @@ func prepareTunnelConfig(
Arch: info.OSArch(), Arch: info.OSArch(),
} }
cfg := config.GetConfiguration() cfg := config.GetConfiguration()
ingressRules, err := ingress.ParseIngress(cfg) ingressRules, err := ingress.ParseIngressFromConfigAndCLI(cfg, c, log)
if err != nil && err != ingress.ErrNoIngressRules {
return nil, nil, err
}
if c.IsSet("url") || c.IsSet(ingress.HelloWorldFlag) || c.IsSet(config.BastionFlag) {
// Ingress rules cannot be provided with --url, --hello-world or --bastion flag
if !ingressRules.IsEmpty() {
return nil, nil, ingress.ErrURLIncompatibleWithIngress
}
// Only for quick or adhoc tunnels will we attempt to parse:
// --url, --hello-world, --bastion, or --unix-socket flag for a tunnel ingress rule
ingressRules, err = ingress.NewSingleOrigin(c, false)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
}
protocolSelector, err := connection.NewProtocolSelector(transportProtocol, namedTunnel.Credentials.AccountTag, c.IsSet(TunnelTokenFlag), c.Bool("post-quantum"), edgediscovery.ProtocolPercentage, connection.ResolveTTL, log) protocolSelector, err := connection.NewProtocolSelector(transportProtocol, namedTunnel.Credentials.AccountTag, c.IsSet(TunnelTokenFlag), c.Bool("post-quantum"), edgediscovery.ProtocolPercentage, connection.ResolveTTL, log)
if err != nil { if err != nil {

View File

@ -30,6 +30,7 @@ class NamedTunnelBaseConfig(BaseConfig):
tunnel: str = None tunnel: str = None
credentials_file: str = None credentials_file: str = None
ingress: list = None ingress: list = None
hostname: str = None
def __post_init__(self): def __post_init__(self):
if self.tunnel is None: if self.tunnel is None:
@ -63,7 +64,7 @@ class NamedTunnelConfig(NamedTunnelBaseConfig):
self.merge_config(additional_config)) self.merge_config(additional_config))
def get_url(self): def get_url(self):
return "https://" + self.ingress[0]['hostname'] return "https://" + self.hostname
def base_config(self): def base_config(self):
config = self.full_config.copy() config = self.full_config.copy()

View File

@ -26,7 +26,7 @@ def component_tests_config():
config = yaml.safe_load(stream) config = yaml.safe_load(stream)
LOGGER.info(f"component tests base config {config}") LOGGER.info(f"component tests base config {config}")
def _component_tests_config(additional_config={}, cfd_mode=CfdModes.NAMED, run_proxy_dns=True): def _component_tests_config(additional_config={}, cfd_mode=CfdModes.NAMED, run_proxy_dns=True, provide_ingress=True):
if run_proxy_dns: if run_proxy_dns:
# Regression test for TUN-4177, running with proxy-dns should not prevent tunnels from running. # Regression test for TUN-4177, running with proxy-dns should not prevent tunnels from running.
# So we run all tests with it. # So we run all tests with it.
@ -36,12 +36,21 @@ def component_tests_config():
additional_config.pop("proxy-dns", None) additional_config.pop("proxy-dns", None)
additional_config.pop("proxy-dns-port", None) additional_config.pop("proxy-dns-port", None)
# Allows the ingress rules to be omitted from the provided config
ingress = []
if provide_ingress:
ingress = config['ingress']
# Provide the hostname to allow routing to the tunnel even if the ingress rule isn't defined in the config
hostname = config['ingress'][0]['hostname']
if cfd_mode is CfdModes.NAMED: if cfd_mode is CfdModes.NAMED:
return NamedTunnelConfig(additional_config=additional_config, return NamedTunnelConfig(additional_config=additional_config,
cloudflared_binary=config['cloudflared_binary'], cloudflared_binary=config['cloudflared_binary'],
tunnel=config['tunnel'], tunnel=config['tunnel'],
credentials_file=config['credentials_file'], credentials_file=config['credentials_file'],
ingress=config['ingress']) ingress=ingress,
hostname=hostname)
elif cfd_mode is CfdModes.PROXY_DNS: elif cfd_mode is CfdModes.PROXY_DNS:
return ProxyDnsConfig(cloudflared_binary=config['cloudflared_binary']) return ProxyDnsConfig(cloudflared_binary=config['cloudflared_binary'])
elif cfd_mode is CfdModes.QUICK: elif cfd_mode is CfdModes.QUICK:

View File

@ -1,10 +1,9 @@
#!/usr/bin/env python #!/usr/bin/env python
import requests
from conftest import CfdModes from conftest import CfdModes
from constants import METRICS_PORT from constants import METRICS_PORT
from util import LOGGER, start_cloudflared, wait_tunnel_ready, get_quicktunnel_url, send_requests from util import LOGGER, start_cloudflared, wait_tunnel_ready, get_quicktunnel_url, send_requests
class TestCLI: class TestQuickTunnels:
def test_quick_tunnel(self, tmp_path, component_tests_config): def test_quick_tunnel(self, tmp_path, component_tests_config):
config = component_tests_config(cfd_mode=CfdModes.QUICK, run_proxy_dns=False) config = component_tests_config(cfd_mode=CfdModes.QUICK, run_proxy_dns=False)
LOGGER.debug(config) LOGGER.debug(config)

View File

@ -0,0 +1,43 @@
#!/usr/bin/env python
import requests
from conftest import CfdModes
from constants import METRICS_PORT, MAX_RETRIES, BACKOFF_SECS
from retrying import retry
from util import LOGGER, start_cloudflared, wait_tunnel_ready, send_requests
class TestTunnel:
'''Test tunnels with no ingress rules from config.yaml but ingress rules from CLI only'''
def test_tunnel_hello_world(self, tmp_path, component_tests_config):
config = component_tests_config(cfd_mode=CfdModes.NAMED, run_proxy_dns=False, provide_ingress=False)
LOGGER.debug(config)
with start_cloudflared(tmp_path, config, cfd_args=["run", "--hello-world"], new_process=True):
wait_tunnel_ready(tunnel_url=config.get_url(),
require_min_connections=4)
def test_tunnel_url(self, tmp_path, component_tests_config):
config = component_tests_config(cfd_mode=CfdModes.NAMED, run_proxy_dns=False, provide_ingress=False)
LOGGER.debug(config)
with start_cloudflared(tmp_path, config, cfd_args=["run", "--url", f"http://localhost:{METRICS_PORT}/"], new_process=True):
wait_tunnel_ready(require_min_connections=4)
send_requests(config.get_url()+"/ready", 3, True)
def test_tunnel_no_ingress(self, tmp_path, component_tests_config):
'''
Running a tunnel with no ingress rules provided from either config.yaml or CLI will still work but return 502
for all incoming requests.
'''
config = component_tests_config(cfd_mode=CfdModes.NAMED, run_proxy_dns=False, provide_ingress=False)
LOGGER.debug(config)
with start_cloudflared(tmp_path, config, cfd_args=["run"], new_process=True):
wait_tunnel_ready(require_min_connections=4)
resp = send_request(config.get_url()+"/")
assert resp.status_code == 502, "Expected cloudflared to return 502 for all requests with no ingress defined"
resp = send_request(config.get_url()+"/test")
assert resp.status_code == 502, "Expected cloudflared to return 502 for all requests with no ingress defined"
@retry(stop_max_attempt_number=MAX_RETRIES, wait_fixed=BACKOFF_SECS * 1000)
def send_request(url):
with requests.Session() as s:
return s.get(url, timeout=BACKOFF_SECS)

View File

@ -113,7 +113,7 @@ func (rc *RemoteConfig) UnmarshalJSON(b []byte) error {
return nil return nil
} }
func originRequestFromSingeRule(c *cli.Context) OriginRequestConfig { func originRequestFromSingleRule(c *cli.Context) OriginRequestConfig {
var connectTimeout = defaultHTTPConnectTimeout var connectTimeout = defaultHTTPConnectTimeout
var tlsTimeout = defaultTLSTimeout var tlsTimeout = defaultTLSTimeout
var tcpKeepAlive = defaultTCPKeepAlive var tcpKeepAlive = defaultTCPKeepAlive

View File

@ -411,7 +411,7 @@ func TestDefaultConfigFromCLI(t *testing.T) {
KeepAliveTimeout: defaultKeepAliveTimeout, KeepAliveTimeout: defaultKeepAliveTimeout,
ProxyAddress: defaultProxyAddress, ProxyAddress: defaultProxyAddress,
} }
actual := originRequestFromSingeRule(c) actual := originRequestFromSingleRule(c)
require.Equal(t, expected, actual) require.Equal(t, expected, actual)
} }

View File

@ -20,6 +20,7 @@ import (
var ( var (
ErrNoIngressRules = errors.New("The config file doesn't contain any ingress rules") ErrNoIngressRules = errors.New("The config file doesn't contain any ingress rules")
ErrNoIngressRulesCLI = errors.New("No ingress rules were defined in provided config (if any) nor from the cli, cloudflared will return 502 for all incoming HTTP requests")
errLastRuleNotCatchAll = errors.New("The last ingress rule must match all URLs (i.e. it should not have a hostname or path filter)") errLastRuleNotCatchAll = errors.New("The last ingress rule must match all URLs (i.e. it should not have a hostname or path filter)")
errBadWildcard = errors.New("Hostname patterns can have at most one wildcard character (\"*\") and it can only be used for subdomains, e.g. \"*.example.com\"") errBadWildcard = errors.New("Hostname patterns can have at most one wildcard character (\"*\") and it can only be used for subdomains, e.g. \"*.example.com\"")
errHostnameContainsPort = errors.New("Hostname cannot contain a port") errHostnameContainsPort = errors.New("Hostname cannot contain a port")
@ -70,16 +71,52 @@ type Ingress struct {
Defaults OriginRequestConfig `json:"originRequest"` Defaults OriginRequestConfig `json:"originRequest"`
} }
// NewSingleOrigin constructs an Ingress set with only one rule, constructed from // ParseIngress parses ingress rules, but does not send HTTP requests to the origins.
// CLI parameters for quick tunnels like --url or --no-chunked-encoding. func ParseIngress(conf *config.Configuration) (Ingress, error) {
func NewSingleOrigin(c *cli.Context, allowURLFromArgs bool) (Ingress, error) { if len(conf.Ingress) == 0 {
return Ingress{}, ErrNoIngressRules
}
return validateIngress(conf.Ingress, originRequestFromConfig(conf.OriginRequest))
}
// ParseIngressFromConfigAndCLI will parse the configuration rules from config files for ingress
// rules and then attempt to parse CLI for ingress rules.
// Will always return at least one valid ingress rule. If none are provided by the user, the default
// will be to return 502 status code for all incoming requests.
func ParseIngressFromConfigAndCLI(conf *config.Configuration, c *cli.Context, log *zerolog.Logger) (Ingress, error) {
// Attempt to parse ingress rules from configuration
ingressRules, err := ParseIngress(conf)
if err == nil && !ingressRules.IsEmpty() {
return ingressRules, nil
}
if err != ErrNoIngressRules {
return Ingress{}, err
}
// Attempt to parse ingress rules from CLI:
// --url or --unix-socket flag for a tunnel HTTP ingress
// --hello-world for a basic HTTP ingress self-served
// --bastion for ssh bastion service
ingressRules, err = parseCLIIngress(c, false)
if errors.Is(err, ErrNoIngressRulesCLI) {
log.Warn().Msgf(ErrNoIngressRulesCLI.Error())
return newDefaultOrigin(c, log), nil
}
if err != nil {
return Ingress{}, err
}
return ingressRules, nil
}
// parseCLIIngress constructs an Ingress set with only one rule constructed from
// CLI parameters: --url, --hello-world, --bastion, or --unix-socket
func parseCLIIngress(c *cli.Context, allowURLFromArgs bool) (Ingress, error) {
service, err := parseSingleOriginService(c, allowURLFromArgs) service, err := parseSingleOriginService(c, allowURLFromArgs)
if err != nil { if err != nil {
return Ingress{}, err return Ingress{}, err
} }
// Construct an Ingress with the single rule. // Construct an Ingress with the single rule.
defaults := originRequestFromSingeRule(c) defaults := originRequestFromSingleRule(c)
ing := Ingress{ ing := Ingress{
Rules: []Rule{ Rules: []Rule{
{ {
@ -92,6 +129,22 @@ func NewSingleOrigin(c *cli.Context, allowURLFromArgs bool) (Ingress, error) {
return ing, err return ing, err
} }
// newDefaultOrigin always returns a 502 response code to help indicate that there are no ingress
// rules setup, but the tunnel is reachable.
func newDefaultOrigin(c *cli.Context, log *zerolog.Logger) Ingress {
noRulesService := newDefaultStatusCode(log)
defaults := originRequestFromSingleRule(c)
ingress := Ingress{
Rules: []Rule{
{
Service: &noRulesService,
},
},
Defaults: defaults,
}
return ingress
}
// WarpRoutingService starts a tcp stream between the origin and requests from // WarpRoutingService starts a tcp stream between the origin and requests from
// warp clients. // warp clients.
type WarpRoutingService struct { type WarpRoutingService struct {
@ -137,8 +190,7 @@ func parseSingleOriginService(c *cli.Context, allowURLFromArgs bool) (OriginServ
} }
return &unixSocketPath{path: path, scheme: "http"}, nil return &unixSocketPath{path: path, scheme: "http"}, nil
} }
u, err := url.Parse("http://localhost:8080") return nil, ErrNoIngressRulesCLI
return &httpService{url: u}, err
} }
// IsEmpty checks if there are any ingress rules. // IsEmpty checks if there are any ingress rules.
@ -335,14 +387,6 @@ func (e errRuleShouldNotBeCatchAll) Error() string {
"will never be triggered.", e.index+1, e.hostname) "will never be triggered.", e.index+1, e.hostname)
} }
// ParseIngress parses ingress rules, but does not send HTTP requests to the origins.
func ParseIngress(conf *config.Configuration) (Ingress, error) {
if len(conf.Ingress) == 0 {
return Ingress{}, ErrNoIngressRules
}
return validateIngress(conf.Ingress, originRequestFromConfig(conf.OriginRequest))
}
func isHTTPService(url *url.URL) bool { func isHTTPService(url *url.URL) bool {
return url.Scheme == "http" || url.Scheme == "https" || url.Scheme == "ws" || url.Scheme == "wss" return url.Scheme == "http" || url.Scheme == "https" || url.Scheme == "ws" || url.Scheme == "wss"
} }

View File

@ -43,7 +43,7 @@ ingress:
require.Equal(t, "https", s.scheme) require.Equal(t, "https", s.scheme)
} }
func Test_parseIngress(t *testing.T) { func TestParseIngress(t *testing.T) {
localhost8000 := MustParseURL(t, "https://localhost:8000") localhost8000 := MustParseURL(t, "https://localhost:8000")
localhost8001 := MustParseURL(t, "https://localhost:8001") localhost8001 := MustParseURL(t, "https://localhost:8001")
fourOhFour := newStatusCode(404) fourOhFour := newStatusCode(404)
@ -517,7 +517,7 @@ func TestSingleOriginSetsConfig(t *testing.T) {
allowURLFromArgs := false allowURLFromArgs := false
require.NoError(t, err) require.NoError(t, err)
ingress, err := NewSingleOrigin(cliCtx, allowURLFromArgs) ingress, err := parseCLIIngress(cliCtx, allowURLFromArgs)
require.NoError(t, err) require.NoError(t, err)
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.ConnectTimeout)
@ -537,6 +537,119 @@ func TestSingleOriginSetsConfig(t *testing.T) {
assert.Equal(t, socksProxy, ingress.Rules[0].Config.ProxyType) assert.Equal(t, socksProxy, ingress.Rules[0].Config.ProxyType)
} }
func TestSingleOriginServices(t *testing.T) {
host := "://localhost:8080"
httpURL := urlMustParse("http" + host)
tcpURL := urlMustParse("tcp" + host)
unix := "unix://service"
newCli := func(params ...string) *cli.Context {
flagSet := flag.NewFlagSet(t.Name(), flag.PanicOnError)
flagSet.Bool("hello-world", false, "")
flagSet.Bool("bastion", false, "")
flagSet.String("url", "", "")
flagSet.String("unix-socket", "", "")
cliCtx := cli.NewContext(cli.NewApp(), flagSet, nil)
for i := 0; i < len(params); i += 2 {
cliCtx.Set(params[i], params[i+1])
}
return cliCtx
}
tests := []struct {
name string
cli *cli.Context
expectedService OriginService
err error
}{
{
name: "Valid hello-world",
cli: newCli("hello-world", "true"),
expectedService: &helloWorld{},
},
{
name: "Valid bastion",
cli: newCli("bastion", "true"),
expectedService: newBastionService(),
},
{
name: "Valid http url",
cli: newCli("url", httpURL.String()),
expectedService: &httpService{url: httpURL},
},
{
name: "Valid tcp url",
cli: newCli("url", tcpURL.String()),
expectedService: newTCPOverWSService(tcpURL),
},
{
name: "Valid unix-socket",
cli: newCli("unix-socket", unix),
expectedService: &unixSocketPath{path: unix, scheme: "http"},
},
{
name: "No origins defined",
cli: newCli(),
err: ErrNoIngressRulesCLI,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ingress, err := parseCLIIngress(test.cli, false)
require.Equal(t, err, test.err)
if test.err != nil {
return
}
require.Equal(t, 1, len(ingress.Rules))
rule := ingress.Rules[0]
require.Equal(t, test.expectedService, rule.Service)
})
}
}
func urlMustParse(s string) *url.URL {
u, err := url.Parse(s)
if err != nil {
panic(err)
}
return u
}
func TestSingleOriginServices_URL(t *testing.T) {
host := "://localhost:8080"
newCli := func(param string, value string) *cli.Context {
flagSet := flag.NewFlagSet(t.Name(), flag.PanicOnError)
flagSet.String("url", "", "")
cliCtx := cli.NewContext(cli.NewApp(), flagSet, nil)
cliCtx.Set(param, value)
return cliCtx
}
httpTests := []string{"http", "https"}
for _, test := range httpTests {
t.Run(test, func(t *testing.T) {
url := urlMustParse(test + host)
ingress, err := parseCLIIngress(newCli("url", url.String()), false)
require.NoError(t, err)
require.Equal(t, 1, len(ingress.Rules))
rule := ingress.Rules[0]
require.Equal(t, &httpService{url: url}, rule.Service)
})
}
tcpTests := []string{"ssh", "rdp", "smb", "tcp"}
for _, test := range tcpTests {
t.Run(test, func(t *testing.T) {
url := urlMustParse(test + host)
ingress, err := parseCLIIngress(newCli("url", url.String()), false)
require.NoError(t, err)
require.Equal(t, 1, len(ingress.Rules))
rule := ingress.Rules[0]
require.Equal(t, newTCPOverWSService(url), rule.Service)
})
}
}
func TestFindMatchingRule(t *testing.T) { func TestFindMatchingRule(t *testing.T) {
ingress := Ingress{ ingress := Ingress{
Rules: []Rule{ Rules: []Rule{

View File

@ -44,6 +44,9 @@ func (o *httpService) RoundTrip(req *http.Request) (*http.Response, error) {
} }
func (o *statusCode) RoundTrip(_ *http.Request) (*http.Response, error) { func (o *statusCode) RoundTrip(_ *http.Request) (*http.Response, error) {
if o.defaultResp {
o.log.Warn().Msgf(ErrNoIngressRulesCLI.Error())
}
resp := &http.Response{ resp := &http.Response{
StatusCode: o.code, StatusCode: o.code,
Status: fmt.Sprintf("%d %s", o.code, http.StatusText(o.code)), Status: fmt.Sprintf("%d %s", o.code, http.StatusText(o.code)),

View File

@ -247,13 +247,26 @@ func (o helloWorld) MarshalJSON() ([]byte, error) {
// Typical use-case is "user wants the catch-all rule to just respond 404". // Typical use-case is "user wants the catch-all rule to just respond 404".
type statusCode struct { type statusCode struct {
code int code int
// Set only when the user has not defined any ingress rules
defaultResp bool
log *zerolog.Logger
} }
func newStatusCode(status int) statusCode { func newStatusCode(status int) statusCode {
return statusCode{code: status} return statusCode{code: status}
} }
// default status code (502) that is returned for requests to cloudflared that don't have any ingress rules setup
func newDefaultStatusCode(log *zerolog.Logger) statusCode {
return statusCode{code: 502, defaultResp: true, log: log}
}
func (o *statusCode) String() string { func (o *statusCode) String() string {
// returning a different service name can help with identifying users via config that don't setup ingress rules
if o.defaultResp {
return "default_http_status:502"
}
return fmt.Sprintf("http_status:%d", o.code) return fmt.Sprintf("http_status:%d", o.code)
} }

View File

@ -149,8 +149,7 @@ func TestProxySingleOrigin(t *testing.T) {
err := cliCtx.Set("hello-world", "true") err := cliCtx.Set("hello-world", "true")
require.NoError(t, err) require.NoError(t, err)
allowURLFromArgs := false ingressRule, err := ingress.ParseIngressFromConfigAndCLI(&config.Configuration{}, cliCtx, &log)
ingressRule, err := ingress.NewSingleOrigin(cliCtx, allowURLFromArgs)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, ingressRule.StartOrigins(&log, ctx.Done())) require.NoError(t, ingressRule.StartOrigins(&log, ctx.Done()))