package ingress import ( "fmt" "net" "net/url" "regexp" "strconv" "strings" "github.com/pkg/errors" "github.com/rs/zerolog" "github.com/urfave/cli/v2" "golang.org/x/net/idna" "github.com/cloudflare/cloudflared/config" "github.com/cloudflare/cloudflared/ingress/middleware" "github.com/cloudflare/cloudflared/ipaccess" ) var ( 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 503 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)") 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") ErrURLIncompatibleWithIngress = errors.New("You can't set the --url flag (or $TUNNEL_URL) when using multiple-origin ingress rules") ) const ( ServiceBastion = "bastion" ServiceSocksProxy = "socks-proxy" ServiceWarpRouting = "warp-routing" ) // FindMatchingRule returns the index of the Ingress Rule which matches the given // hostname and path. This function assumes the last rule matches everything, // which is the case if the rules were instantiated via the ingress#Validate method. // // Negative index rule signifies local cloudflared rules (not-user defined). func (ing Ingress) FindMatchingRule(hostname, path string) (*Rule, int) { // The hostname might contain port. We only want to compare the host part with the rule host, _, err := net.SplitHostPort(hostname) if err == nil { hostname = host } for i, rule := range ing.InternalRules { if rule.Matches(hostname, path) { // Local rule matches return a negative rule index to distiguish local rules from user-defined rules in logs // Full range would be [-1 .. ) return &rule, -1 - i } } for i, rule := range ing.Rules { if rule.Matches(hostname, path) { return &rule, i } } i := len(ing.Rules) - 1 return &ing.Rules[i], i } func matchHost(ruleHost, reqHost string) bool { if ruleHost == reqHost { return true } // Validate hostnames that use wildcards at the start if strings.HasPrefix(ruleHost, "*.") { toMatch := strings.TrimPrefix(ruleHost, "*") return strings.HasSuffix(reqHost, toMatch) } return false } // Ingress maps eyeball requests to origins. type Ingress struct { // Set of ingress rules that are not added to remote config, e.g. management InternalRules []Rule // Rules that are provided by the user from remote or local configuration Rules []Rule `json:"ingress"` Defaults OriginRequestConfig `json:"originRequest"` } // ParseIngress parses ingress rules, but does not send HTTP requests to the origins. func ParseIngress(conf *config.Configuration) (Ingress, error) { if conf == nil || 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 503 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) { // If no token is provided, the probability of NOT being a remotely managed tunnel is higher. // So, we should warn the user that no ingress rules were found, because remote configuration will most likely not exist. if !c.IsSet("token") { 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) if err != nil { return Ingress{}, err } // Construct an Ingress with the single rule. defaults := originRequestFromSingleRule(c) ing := Ingress{ Rules: []Rule{ { Service: service, Config: setConfig(defaults, config.OriginRequestConfig{}), }, }, Defaults: defaults, } return ing, err } // newDefaultOrigin always returns a 503 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 { defaultRule := GetDefaultIngressRules(log) defaults := originRequestFromSingleRule(c) ingress := Ingress{ Rules: defaultRule, Defaults: defaults, } return ingress } // Get a single origin service from the CLI/config. func parseSingleOriginService(c *cli.Context, allowURLFromArgs bool) (OriginService, error) { if c.IsSet(HelloWorldFlag) { return new(helloWorld), nil } if c.IsSet(config.BastionFlag) { return newBastionService(), nil } if c.IsSet("url") { originURL, err := config.ValidateUrl(c, allowURLFromArgs) if err != nil { return nil, errors.Wrap(err, "Error validating origin URL") } if isHTTPService(originURL) { return &httpService{ url: originURL, }, nil } return newTCPOverWSService(originURL), nil } if c.IsSet("unix-socket") { path, err := config.ValidateUnixSocket(c) if err != nil { return nil, errors.Wrap(err, "Error validating --unix-socket") } return &unixSocketPath{path: path, scheme: "http"}, nil } return nil, ErrNoIngressRulesCLI } // IsEmpty checks if there are any ingress rules. func (ing Ingress) IsEmpty() bool { return len(ing.Rules) == 0 } // IsSingleRule checks if the user only specified a single ingress rule. func (ing Ingress) IsSingleRule() bool { return len(ing.Rules) == 1 } // StartOrigins will start any origin services managed by cloudflared, e.g. proxy servers or Hello World. func (ing Ingress) StartOrigins( log *zerolog.Logger, shutdownC <-chan struct{}, ) error { for _, rule := range ing.Rules { if err := rule.Service.start(log, shutdownC, rule.Config); err != nil { return errors.Wrapf(err, "Error starting local service %s", rule.Service) } } return nil } // CatchAll returns the catch-all rule (i.e. the last rule) func (ing Ingress) CatchAll() *Rule { return &ing.Rules[len(ing.Rules)-1] } // Gets the default ingress rule that will be return 503 status // code for all incoming requests. func GetDefaultIngressRules(log *zerolog.Logger) []Rule { noRulesService := newDefaultStatusCode(log) return []Rule{ { Service: &noRulesService, }, } } func validateAccessConfiguration(cfg *config.AccessConfig) error { if !cfg.Required { return nil } // we allow for an initial setup where user can force Access but not configure the rest of the keys. // however, if the user specified audTags but forgot teamName, we should alert it. if cfg.TeamName == "" && len(cfg.AudTag) > 0 { return errors.New("access.TeamName cannot be blank when access.audTags are present") } return nil } func validateIngress(ingress []config.UnvalidatedIngressRule, defaults OriginRequestConfig) (Ingress, error) { rules := make([]Rule, len(ingress)) for i, r := range ingress { cfg := setConfig(defaults, r.OriginRequest) var service OriginService if prefix := "unix:"; strings.HasPrefix(r.Service, prefix) { // No validation necessary for unix socket filepath services path := strings.TrimPrefix(r.Service, prefix) service = &unixSocketPath{path: path, scheme: "http"} } else if prefix := "unix+tls:"; strings.HasPrefix(r.Service, prefix) { path := strings.TrimPrefix(r.Service, prefix) service = &unixSocketPath{path: path, scheme: "https"} } else if prefix := "http_status:"; strings.HasPrefix(r.Service, prefix) { statusCode, err := strconv.Atoi(strings.TrimPrefix(r.Service, prefix)) if err != nil { return Ingress{}, errors.Wrap(err, "invalid HTTP status code") } if statusCode < 100 || statusCode > 999 { return Ingress{}, fmt.Errorf("invalid HTTP status code: %d", statusCode) } srv := newStatusCode(statusCode) service = &srv } else if r.Service == HelloWorldFlag || r.Service == HelloWorldService { service = new(helloWorld) } else if r.Service == ServiceSocksProxy { rules := make([]ipaccess.Rule, len(r.OriginRequest.IPRules)) for i, ipRule := range r.OriginRequest.IPRules { rule, err := ipaccess.NewRuleByCIDR(ipRule.Prefix, ipRule.Ports, ipRule.Allow) if err != nil { return Ingress{}, fmt.Errorf("unable to create ip rule for %s: %s", r.Service, err) } rules[i] = rule } accessPolicy, err := ipaccess.NewPolicy(false, rules) if err != nil { return Ingress{}, fmt.Errorf("unable to create ip access policy for %s: %s", r.Service, err) } service = newSocksProxyOverWSService(accessPolicy) } else if r.Service == ServiceBastion || cfg.BastionMode { // Bastion mode will always start a Websocket proxy server, which will // overwrite the localService.URL field when `start` is called. So, // leave the URL field empty for now. cfg.BastionMode = true service = newBastionService() } else { // Validate URL services u, err := url.Parse(r.Service) if err != nil { return Ingress{}, err } if u.Scheme == "" || u.Hostname() == "" { return Ingress{}, fmt.Errorf("%s is an invalid address, please make sure it has a scheme and a hostname", r.Service) } if u.Path != "" { return Ingress{}, fmt.Errorf("%s is an invalid address, ingress rules don't support proxying to a different path on the origin service. The path will be the same as the eyeball request's path", r.Service) } if isHTTPService(u) { service = &httpService{url: u} } else { service = newTCPOverWSService(u) } } var handlers []middleware.Handler if access := r.OriginRequest.Access; access != nil { if err := validateAccessConfiguration(access); err != nil { return Ingress{}, err } if access.Required { verifier := middleware.NewJWTValidator(access.TeamName, "", access.AudTag) handlers = append(handlers, verifier) } } if err := validateHostname(r, i, len(ingress)); err != nil { return Ingress{}, err } isCatchAllRule := (r.Hostname == "" || r.Hostname == "*") && r.Path == "" punycodeHostname := "" if !isCatchAllRule { punycode, err := idna.Lookup.ToASCII(r.Hostname) // Don't provide the punycode hostname if it is the same as the original hostname if err == nil && punycode != r.Hostname { punycodeHostname = punycode } } var pathRegexp *Regexp if r.Path != "" { var err error 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, punycodeHostname: punycodeHostname, Service: service, Path: pathRegexp, Handlers: handlers, Config: cfg, } } return Ingress{Rules: rules, Defaults: defaults}, nil } func validateHostname(r config.UnvalidatedIngressRule, ruleIndex, totalRules int) error { // Ensure that the hostname doesn't contain port _, _, err := net.SplitHostPort(r.Hostname) if err == nil { return errHostnameContainsPort } // Ensure that there are no wildcards anywhere except the first character // of the hostname. if strings.LastIndex(r.Hostname, "*") > 0 { return errBadWildcard } // The last rule should catch all hostnames. isCatchAllRule := (r.Hostname == "" || r.Hostname == "*") && r.Path == "" isLastRule := ruleIndex == totalRules-1 if isLastRule && !isCatchAllRule { return errLastRuleNotCatchAll } // ONLY the last rule should catch all hostnames. if !isLastRule && isCatchAllRule { return errRuleShouldNotBeCatchAll{index: ruleIndex, hostname: r.Hostname} } return nil } type errRuleShouldNotBeCatchAll struct { index int hostname string } func (e errRuleShouldNotBeCatchAll) Error() string { return fmt.Sprintf("Rule #%d is matching the hostname '%s', but "+ "this will match every hostname, meaning the rules which follow it "+ "will never be triggered.", e.index+1, e.hostname) } func isHTTPService(url *url.URL) bool { return url.Scheme == "http" || url.Scheme == "https" || url.Scheme == "ws" || url.Scheme == "wss" }